Skip to main content

yggdrasil/models/
note.rs

1//! 笔记、不可变版本和有序笔记本的共享模型。
2use chrono::{DateTime, Utc};
3#[cfg(feature = "server")]
4use rmcp::schemars;
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
8#[cfg_attr(feature = "server", derive(rmcp::schemars::JsonSchema))]
9#[serde(rename_all = "snake_case")]
10pub enum NoteKind {
11    #[default]
12    Moment,
13    Topic,
14}
15
16impl NoteKind {
17    pub fn as_str(self) -> &'static str {
18        match self {
19            Self::Moment => "moment",
20            Self::Topic => "topic",
21        }
22    }
23    pub fn label(self) -> &'static str {
24        match self {
25            Self::Moment => "随记",
26            Self::Topic => "主题笔记",
27        }
28    }
29}
30
31#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
32#[cfg_attr(feature = "server", derive(rmcp::schemars::JsonSchema))]
33pub struct NoteDraft {
34    pub id: Option<i32>,
35    /// 更新必须携带读到的版本;过期写入被拒绝。
36    pub expected_version: Option<i32>,
37    #[serde(default)]
38    pub kind: NoteKind,
39    #[serde(default)]
40    pub title: String,
41    pub content_md: String,
42    #[serde(default)]
43    pub tags: Vec<String>,
44    #[serde(default)]
45    pub notebook_ids: Vec<i32>,
46}
47
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
49pub struct Note {
50    pub id: i32,
51    pub slug: String,
52    pub version: i32,
53    pub kind: NoteKind,
54    pub title: String,
55    /// 编辑 Markdown。公开详情端点已改为只返回 [`super::public_note::PublicNote`],
56    /// 不再传输本字段。注意 `#[serde(default)]` 编译在本侧二进制里,保护的是
57    /// **新客户端读旧响应**(滚动部署中旧节点仍发全字段,缺字段才触发默认值);
58    /// 发布切换前已打开的旧标签页(旧二进制、无默认值)反序列化新响应仍会报错,
59    /// 需刷新页面——这是公开响应结构变更的已知残留风险,与方案 §6.7 的
60    /// 「旧标签页跨部署兼容性单独评估」一致。
61    #[serde(default)]
62    pub content_md: String,
63    pub content_html: String,
64    pub toc_html: String,
65    pub summary: String,
66    pub tags: Vec<String>,
67    #[cfg_attr(
68        target_arch = "wasm32",
69        serde(deserialize_with = "crate::models::rfc3339::deserialize")
70    )]
71    pub created_at: DateTime<Utc>,
72    #[cfg_attr(
73        target_arch = "wasm32",
74        serde(deserialize_with = "crate::models::rfc3339::deserialize")
75    )]
76    pub updated_at: DateTime<Utc>,
77    #[cfg_attr(
78        target_arch = "wasm32",
79        serde(
80            default,
81            deserialize_with = "crate::models::rfc3339::deserialize_option"
82        )
83    )]
84    pub published_at: Option<DateTime<Utc>>,
85    /// 仅管理接口填充。公开接口不暴露工作稿或知识库版本。
86    #[serde(default)]
87    pub published_version: Option<i32>,
88    #[serde(default)]
89    pub knowledge_version: Option<i32>,
90    #[serde(default)]
91    #[cfg_attr(
92        target_arch = "wasm32",
93        serde(deserialize_with = "crate::models::rfc3339::deserialize_option")
94    )]
95    pub deleted_at: Option<DateTime<Utc>>,
96    #[serde(default)]
97    pub notebook_ids: Vec<i32>,
98    /// 目录的一级小节数(服务端从 `toc_html` 解析);列表不传 `toc_html` 时仍可显示「N 节」。
99    #[serde(default)]
100    pub sections: u32,
101}
102
103/// 没有标题的笔记(多为随记)取摘要前 48 字,摘要也为空时用类型名。
104pub fn display_title(kind: NoteKind, title: &str, summary: &str) -> String {
105    if title.trim().is_empty() {
106        if summary.trim().is_empty() {
107            kind.label().to_string()
108        } else {
109            summary.chars().take(48).collect()
110        }
111    } else {
112        title.to_string()
113    }
114}
115
116impl Note {
117    pub fn display_title(&self) -> String {
118        display_title(self.kind, &self.title, &self.summary)
119    }
120    pub fn draft(&self) -> NoteDraft {
121        NoteDraft {
122            id: Some(self.id),
123            expected_version: Some(self.version),
124            kind: self.kind,
125            title: self.title.clone(),
126            content_md: self.content_md.clone(),
127            tags: self.tags.clone(),
128            notebook_ids: self.notebook_ids.clone(),
129        }
130    }
131}
132
133#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
134pub struct NotePage {
135    pub notes: Vec<Note>,
136    pub total: i64,
137}
138
139#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
140#[cfg_attr(feature = "server", derive(rmcp::schemars::JsonSchema))]
141pub struct NoteFilter {
142    #[serde(default)]
143    pub query: String,
144    pub kind: Option<NoteKind>,
145    pub notebook_id: Option<i32>,
146    #[serde(default)]
147    pub tag: String,
148    #[serde(default)]
149    pub trash: bool,
150    /// 仅管理接口应用;公开查询不能借此探测私密状态。
151    pub published: Option<bool>,
152    pub knowledge: Option<bool>,
153    #[serde(default)]
154    pub page: i32,
155}
156
157#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
158pub struct Notebook {
159    pub id: i32,
160    pub title: String,
161    pub description: String,
162    pub is_public: bool,
163    #[cfg_attr(
164        target_arch = "wasm32",
165        serde(
166            default,
167            deserialize_with = "crate::models::rfc3339::deserialize_option"
168        )
169    )]
170    pub archived_at: Option<DateTime<Utc>>,
171    pub note_count: i64,
172    #[cfg_attr(
173        target_arch = "wasm32",
174        serde(deserialize_with = "crate::models::rfc3339::deserialize")
175    )]
176    pub updated_at: DateTime<Utc>,
177}
178
179/// 公开笔记的概况:笔记列表左栏的类型计数与常用标签。
180#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
181pub struct NoteStats {
182    pub moments: i64,
183    pub topics: i64,
184    /// 按使用次数降序的 `(标签, 条数)`。
185    pub tags: Vec<(String, i64)>,
186}
187
188impl NoteStats {
189    pub fn total(&self) -> i64 {
190        self.moments + self.topics
191    }
192}
193
194/// 笔记本目录里的一篇。
195#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
196pub struct NoteLink {
197    pub slug: String,
198    pub title: String,
199}
200
201/// 阅读页的笔记本上下文:所在笔记本、本篇序号(从 1 起)与同级已发布笔记。
202#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
203pub struct NoteContext {
204    pub notebook: Notebook,
205    pub position: usize,
206    pub total: usize,
207    pub prev: Option<NoteLink>,
208    pub next: Option<NoteLink>,
209    pub siblings: Vec<NoteLink>,
210}
211
212#[cfg(any(feature = "server", test))]
213impl NoteContext {
214    /// 在有序的同级笔记里定位本篇;本篇不在其中时返回 `None`。
215    pub fn locate(notebook: Notebook, siblings: Vec<NoteLink>, slug: &str) -> Option<Self> {
216        let index = siblings.iter().position(|s| s.slug == slug)?;
217        Some(Self {
218            notebook,
219            position: index + 1,
220            total: siblings.len(),
221            prev: index.checked_sub(1).map(|i| siblings[i].clone()),
222            next: siblings.get(index + 1).cloned(),
223            siblings,
224        })
225    }
226}
227
228/// 阅读页的笔记本:`requested` 是公开候选之一时用它;否则只属于一个公开笔记本时用那一个。
229#[cfg(any(feature = "server", test))]
230pub fn choose_notebook(candidates: &[i32], requested: Option<i32>) -> Option<i32> {
231    match requested {
232        Some(id) if candidates.contains(&id) => Some(id),
233        _ if candidates.len() == 1 => Some(candidates[0]),
234        _ => None,
235    }
236}
237
238/// 笔记本编排里的一篇:标题与访客能否看到。
239#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
240pub struct NotebookEntry {
241    pub id: i32,
242    pub title: String,
243    /// 有公开版。
244    pub published: bool,
245    /// 工作稿比公开版新。
246    pub ahead: bool,
247    /// 在回收站里(仍占目录位置)。
248    pub trashed: bool,
249}
250
251impl NotebookEntry {
252    pub fn state_label(&self) -> &'static str {
253        match (self.trashed, self.published, self.ahead) {
254            (true, _, _) => "在回收站 · 访客不可见",
255            (false, true, true) => "已发布 · 有未发布修改",
256            (false, true, false) => "已发布",
257            (false, false, _) => "私密草稿 · 访客不可见",
258        }
259    }
260}
261
262#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
263pub struct NotebookInput {
264    pub id: Option<i32>,
265    pub title: String,
266    pub description: String,
267    pub is_public: bool,
268}
269
270#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
271pub struct NoteRevision {
272    pub version: i32,
273    pub title: String,
274    #[cfg_attr(
275        target_arch = "wasm32",
276        serde(deserialize_with = "crate::models::rfc3339::deserialize")
277    )]
278    pub created_at: DateTime<Utc>,
279}
280
281#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
282pub struct NoteBatchTarget {
283    pub id: i32,
284    pub expected_version: i32,
285}
286
287#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
288#[serde(rename_all = "snake_case")]
289pub enum NoteAction {
290    Publish,
291    Unpublish,
292    IncludeKnowledge,
293    ExcludeKnowledge,
294    Trash,
295    Restore,
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    fn link(slug: &str) -> NoteLink {
303        NoteLink {
304            slug: slug.into(),
305            title: slug.to_uppercase(),
306        }
307    }
308
309    #[test]
310    fn notebook_entry_label_explains_visitor_visibility() {
311        let entry = |published, ahead, trashed| NotebookEntry {
312            id: 1,
313            title: String::new(),
314            published,
315            ahead,
316            trashed,
317        };
318        assert_eq!(
319            entry(true, true, false).state_label(),
320            "已发布 · 有未发布修改"
321        );
322        assert_eq!(entry(true, false, false).state_label(), "已发布");
323        assert_eq!(
324            entry(false, false, false).state_label(),
325            "私密草稿 · 访客不可见"
326        );
327        assert_eq!(
328            entry(true, false, true).state_label(),
329            "在回收站 · 访客不可见"
330        );
331    }
332
333    #[test]
334    fn requested_notebook_wins_only_when_it_is_a_public_candidate() {
335        assert_eq!(choose_notebook(&[3, 7], Some(7)), Some(7));
336        // 属于多个笔记本又没指明时,不猜。
337        assert_eq!(choose_notebook(&[3, 7], None), None);
338        // 指向不属于、非公开或已归档的笔记本时忽略参数。
339        assert_eq!(choose_notebook(&[3], Some(9)), Some(3));
340        assert_eq!(choose_notebook(&[3, 7], Some(9)), None);
341        assert_eq!(choose_notebook(&[], Some(3)), None);
342    }
343
344    #[test]
345    fn locate_gives_neighbours_and_leaves_the_ends_empty() {
346        let siblings = vec![link("a"), link("b"), link("c")];
347        let first = NoteContext::locate(Notebook::default(), siblings.clone(), "a").unwrap();
348        assert_eq!((first.position, first.total), (1, 3));
349        assert_eq!(first.prev, None);
350        assert_eq!(first.next, Some(link("b")));
351        let last = NoteContext::locate(Notebook::default(), siblings.clone(), "c").unwrap();
352        assert_eq!(last.prev, Some(link("b")));
353        assert_eq!(last.next, None);
354        assert!(NoteContext::locate(Notebook::default(), siblings, "draft").is_none());
355    }
356}