Skip to main content

jasper_core/
library.rs

1//! 内存索引层:把一批条目原始内容解析后组织成可查询的库(纯计算,无 IO)。
2//!
3//! 只读、构建一次即不可变。从存储拉取 + 增量缓存的协调放在 server 的 `indexer`,
4//! 它拿到原始内容后调用 `Library::from_contents`。本模块不引入线程/IO,可编译到 WASM。
5
6use crate::model::*;
7use crate::parser;
8use std::collections::{HashMap, HashSet};
9
10#[derive(Default)]
11pub struct Library {
12    pub folders: HashMap<String, Folder>,
13    pub notes: HashMap<String, Note>,
14    pub resources: HashMap<String, Resource>,
15    pub tags: HashMap<String, Tag>,
16    pub note_tags: Vec<NoteTag>,
17
18    /// folder_id -> 该笔记本下的笔记 id(直属,不含子笔记本)
19    notes_by_folder: HashMap<String, Vec<String>>,
20    /// parent folder_id -> 子笔记本 id(根用空串 "")
21    child_folders: HashMap<String, Vec<String>>,
22
23    /// 笔记的原始 .md 内容(用于保存时保留元数据,避免重新拉取)
24    raw_notes: HashMap<String, String>,
25}
26
27#[derive(Debug, Default, Clone)]
28pub struct BuildStats {
29    pub notes: usize,
30    pub folders: usize,
31    pub resources: usize,
32    pub tags: usize,
33    pub note_tags: usize,
34    pub others: usize,
35    pub encrypted: usize,
36    pub errors: usize,
37    /// 命中增量缓存、免去拉取的条目数。
38    pub cached: usize,
39    /// 本轮实际从数据源拉取的条目数。
40    pub fetched: usize,
41}
42
43impl Library {
44    /// 把一批原始 .md 内容解析、分类、建索引为 Library,返回类型计数 + 解析错误数。
45    /// 拉取(并行/缓存)由调用方负责;这里只做纯解析与索引,便于编译到 WASM。
46    pub fn from_contents(contents: Vec<String>) -> (Library, BuildStats) {
47        let mut lib = Library::default();
48        let mut stats = BuildStats::default();
49
50        // 顺序解析(核心库不引入线程);保留笔记原始内容用于写回。
51        let parsed: Vec<Option<(String, RawItem)>> = contents
52            .into_iter()
53            .map(|content| parser::parse_item(&content).ok().map(|raw| (content, raw)))
54            .collect();
55
56        // 分类,构建索引
57        for item in parsed {
58            let (content, raw) = match item {
59                Some(x) => x,
60                None => {
61                    stats.errors += 1;
62                    continue;
63                }
64            };
65            if raw.is_encrypted() {
66                stats.encrypted += 1;
67                continue;
68            }
69            match raw.item_type() {
70                ItemType::Note => match parser::to_note(&raw) {
71                    Ok(n) => {
72                        lib.raw_notes.insert(n.id.clone(), content);
73                        lib.notes.insert(n.id.clone(), n);
74                        stats.notes += 1;
75                    }
76                    Err(_) => stats.errors += 1,
77                },
78                ItemType::Folder => match parser::to_folder(&raw) {
79                    Ok(f) => {
80                        lib.folders.insert(f.id.clone(), f);
81                        stats.folders += 1;
82                    }
83                    Err(_) => stats.errors += 1,
84                },
85                ItemType::Resource => match parser::to_resource(&raw) {
86                    Ok(r) => {
87                        lib.resources.insert(r.id.clone(), r);
88                        stats.resources += 1;
89                    }
90                    Err(_) => stats.errors += 1,
91                },
92                ItemType::Tag => match parser::to_tag(&raw) {
93                    Ok(t) => {
94                        lib.tags.insert(t.id.clone(), t);
95                        stats.tags += 1;
96                    }
97                    Err(_) => stats.errors += 1,
98                },
99                ItemType::NoteTag => match parser::to_note_tag(&raw) {
100                    Ok(nt) => {
101                        lib.note_tags.push(nt);
102                        stats.note_tags += 1;
103                    }
104                    Err(_) => stats.errors += 1,
105                },
106                ItemType::Other => stats.others += 1,
107            }
108        }
109
110        lib.build_indexes();
111        (lib, stats)
112    }
113
114    fn build_indexes(&mut self) {
115        let mut notes_by_folder: HashMap<String, Vec<String>> = HashMap::new();
116        for n in self.notes.values() {
117            notes_by_folder
118                .entry(n.parent_id.clone())
119                .or_default()
120                .push(n.id.clone());
121        }
122        let mut child_folders: HashMap<String, Vec<String>> = HashMap::new();
123        for f in self.folders.values() {
124            child_folders
125                .entry(f.parent_id.clone())
126                .or_default()
127                .push(f.id.clone());
128        }
129        self.notes_by_folder = notes_by_folder;
130        self.child_folders = child_folders;
131    }
132
133    /// 某笔记本(含根 "")直属笔记数。
134    pub fn note_count(&self, folder_id: &str) -> usize {
135        self.notes_by_folder.get(folder_id).map(|v| v.len()).unwrap_or(0)
136    }
137
138    /// 子笔记本,按标题排序。
139    pub fn child_folder_ids_sorted(&self, parent_id: &str) -> Vec<String> {
140        let mut ids = self.child_folders.get(parent_id).cloned().unwrap_or_default();
141        ids.sort_by(|a, b| {
142            let ta = self.folders.get(a).map(|f| f.title.as_str()).unwrap_or("");
143            let tb = self.folders.get(b).map(|f| f.title.as_str()).unwrap_or("");
144            ta.cmp(tb)
145        });
146        ids
147    }
148
149    /// 某笔记本下的笔记,按更新时间倒序。
150    pub fn notes_in_folder_sorted(&self, folder_id: &str) -> Vec<&Note> {
151        let mut notes: Vec<&Note> = self
152            .notes_by_folder
153            .get(folder_id)
154            .map(|ids| ids.iter().filter_map(|id| self.notes.get(id)).collect())
155            .unwrap_or_default();
156        notes.sort_by(|a, b| b.updated_time.cmp(&a.updated_time));
157        notes
158    }
159
160    pub fn note(&self, id: &str) -> Option<&Note> {
161        self.notes.get(id)
162    }
163
164    pub fn resource(&self, id: &str) -> Option<&Resource> {
165        self.resources.get(id)
166    }
167
168    /// 简单全文搜索:标题/正文不区分大小写包含。按更新时间倒序,限制 200 条。
169    pub fn search(&self, query: &str) -> Vec<&Note> {
170        let q = query.trim().to_lowercase();
171        if q.is_empty() {
172            return vec![];
173        }
174        let mut hits: Vec<&Note> = self
175            .notes
176            .values()
177            .filter(|n| {
178                n.title.to_lowercase().contains(&q) || n.body.to_lowercase().contains(&q)
179            })
180            .collect();
181        hits.sort_by(|a, b| b.updated_time.cmp(&a.updated_time));
182        hits.truncate(200);
183        hits
184    }
185
186    /// 笔记的原始 .md 内容(保存时用于保留元数据)。
187    pub fn note_raw(&self, id: &str) -> Option<&str> {
188        self.raw_notes.get(id).map(|s| s.as_str())
189    }
190
191    /// 新增或更新一篇笔记(写回成功后同步内存)。返回笔记 id。
192    pub fn upsert_note(&mut self, content: &str) -> anyhow::Result<String> {
193        let raw = parser::parse_item(content)?;
194        let note = parser::to_note(&raw)?;
195        let id = note.id.clone();
196        self.raw_notes.insert(id.clone(), content.to_string());
197        self.notes.insert(id.clone(), note);
198        self.build_indexes();
199        Ok(id)
200    }
201
202    /// 新增或更新一个笔记本(写回成功后同步内存)。返回笔记本 id。
203    pub fn upsert_folder(&mut self, content: &str) -> anyhow::Result<String> {
204        let raw = parser::parse_item(content)?;
205        let folder = parser::to_folder(&raw)?;
206        let id = folder.id.clone();
207        self.folders.insert(id.clone(), folder);
208        self.build_indexes();
209        Ok(id)
210    }
211
212    /// `candidate` 是否就是 `root` 本身、或位于 `root` 子树之下。
213    /// 用于移动笔记本时防止把笔记本移进它自己或其后代(成环)。
214    pub fn is_self_or_descendant(&self, root: &str, candidate: &str) -> bool {
215        let mut cur = candidate.to_string();
216        // 上限步数防御坏数据里的既有环,避免死循环
217        for _ in 0..=self.folders.len() {
218            if cur == root {
219                return true;
220            }
221            match self.folders.get(&cur) {
222                Some(f) if !f.parent_id.is_empty() => cur = f.parent_id.clone(),
223                _ => return false,
224            }
225        }
226        false
227    }
228
229    /// 新增或更新一个资源(上传成功后同步内存,使 /api/resources 能拿到 mime)。返回资源 id。
230    pub fn upsert_resource(&mut self, content: &str) -> anyhow::Result<String> {
231        let raw = parser::parse_item(content)?;
232        let r = parser::to_resource(&raw)?;
233        let id = r.id.clone();
234        self.resources.insert(id.clone(), r);
235        Ok(id)
236    }
237
238    /// 删除一篇笔记(写回成功后同步内存)。
239    pub fn remove_note(&mut self, id: &str) {
240        self.notes.remove(id);
241        self.raw_notes.remove(id);
242        self.build_indexes();
243    }
244
245    /// 删除一个资源(写回成功后同步内存)。
246    pub fn remove_resource(&mut self, id: &str) {
247        self.resources.remove(id);
248    }
249
250    /// 每个资源被多少篇笔记引用(扫描所有笔记正文里的 `:/<id>`)。
251    /// 未出现在结果中的资源即为无人引用的“孤儿”。
252    pub fn resource_usage(&self) -> HashMap<String, usize> {
253        let mut usage: HashMap<String, usize> = HashMap::new();
254        for n in self.notes.values() {
255            for id in scan_resource_refs(&n.body) {
256                *usage.entry(id).or_insert(0) += 1;
257            }
258        }
259        usage
260    }
261}
262
263/// 统计 markdown 任务清单(GFM checkbox)的完成/总数:`(已完成, 总数)`。
264/// 仅认行首(去缩进后)形如 `- [ ] ` / `* [x] ` / `+ [X] ` 的列表项。
265pub fn count_tasks(body: &str) -> (usize, usize) {
266    let mut done = 0;
267    let mut total = 0;
268    for line in body.lines() {
269        let b = line.trim_start().as_bytes();
270        // `<-|*|+>` SP `[` <mark> `]`  且其后是空白或行尾
271        if b.len() >= 5
272            && matches!(b[0], b'-' | b'*' | b'+')
273            && b[1] == b' '
274            && b[2] == b'['
275            && b[4] == b']'
276        {
277            let after_ok = b.len() == 5 || b[5] == b' ' || b[5] == b'\t';
278            match (after_ok, b[3]) {
279                (true, b' ') => total += 1,
280                (true, b'x') | (true, b'X') => {
281                    total += 1;
282                    done += 1;
283                }
284                _ => {}
285            }
286        }
287    }
288    (done, total)
289}
290
291/// 扫描文本里的 Joplin 资源引用 `:/<32hex>`,返回去重后的 id 集合(每篇笔记内同一资源只计一次)。
292fn scan_resource_refs(body: &str) -> HashSet<String> {
293    let b = body.as_bytes();
294    let mut out = HashSet::new();
295    let mut i = 0;
296    while i + 34 <= b.len() {
297        // `:/` 后接恰好 32 个十六进制,且第 33 位不再是十六进制(id 长度精确为 32)
298        if b[i] == b':' && b[i + 1] == b'/' {
299            let hex = &b[i + 2..i + 34];
300            let bounded = i + 34 >= b.len() || !b[i + 34].is_ascii_hexdigit();
301            if bounded && hex.iter().all(|c| c.is_ascii_hexdigit()) {
302                out.insert(String::from_utf8_lossy(hex).to_lowercase());
303                i += 34;
304                continue;
305            }
306        }
307        i += 1;
308    }
309    out
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn scans_resource_refs() {
318        let body = "见图 ![a](:/0123456789abcdef0123456789abcdef) 和附件 [f](:/0123456789ABCDEF0123456789ABCDEF)\n\
319            重复同一个 :/0123456789abcdef0123456789abcdef 再来个 :/deadbeefdeadbeefdeadbeefdeadbeef";
320        let refs = scan_resource_refs(body);
321        // 大小写归一后,第一个引用去重为 1 个,另有 deadbeef 一个 → 共 2 个不同 id
322        assert_eq!(refs.len(), 2);
323        assert!(refs.contains("0123456789abcdef0123456789abcdef"));
324        assert!(refs.contains("deadbeefdeadbeefdeadbeefdeadbeef"));
325    }
326
327    #[test]
328    fn counts_task_list() {
329        let body = "标题\n- [ ] 待办一\n- [x] 已完成\n* [X] 也完成\n+ [ ] 第四\n普通行\n-[ ] 无空格不算\n- [] 非法不算";
330        assert_eq!(count_tasks(body), (2, 4));
331        assert_eq!(count_tasks("没有任何任务"), (0, 0));
332        assert_eq!(count_tasks("  - [x] 缩进也算"), (1, 1));
333    }
334
335    #[test]
336    fn ignores_non_32hex() {
337        // 太短 / 太长 / 非 hex 都不算
338        let refs = scan_resource_refs(":/short :/0123456789abcdef0123456789abcdefEXTRA :/zzzz");
339        assert!(refs.is_empty());
340    }
341
342    // ---- 下面用 serialize 造 .md 喂 from_contents,做 Library 级集成单测 ----
343    use crate::serialize::{new_folder_md, new_note_md, new_resource_md};
344
345    fn hid(n: u8) -> String {
346        // 造一个确定的 32hex id:把一个字节重复 16 次的十六进制
347        format!("{:02x}", n).repeat(16)
348    }
349
350    #[test]
351    fn builds_tree_counts_and_ordering() {
352        let (root_a, root_b, child) = (hid(0xa0), hid(0xb0), hid(0xc0));
353        let contents = vec![
354            new_folder_md(&root_a, "", "Alpha", 1000),
355            new_folder_md(&root_b, "", "Beta", 2000),
356            new_folder_md(&child, &root_a, "Child", 1500),
357            new_note_md(&hid(1), &root_a, "n1", "body one", false, 100),
358            new_note_md(&hid(2), &root_a, "n2", "body two", false, 200),
359            new_note_md(&hid(3), &child, "n3", "nested", false, 300),
360        ];
361        let (lib, stats) = Library::from_contents(contents);
362        assert_eq!(stats.folders, 3);
363        assert_eq!(stats.notes, 3);
364        assert_eq!(stats.errors, 0);
365
366        // 直属笔记数:root_a 有 2,child 有 1,root_b 有 0
367        assert_eq!(lib.note_count(&root_a), 2);
368        assert_eq!(lib.note_count(&child), 1);
369        assert_eq!(lib.note_count(&root_b), 0);
370
371        // 根下的子笔记本按标题排序:Alpha < Beta
372        let roots = lib.child_folder_ids_sorted("");
373        assert_eq!(roots, vec![root_a.clone(), root_b.clone()]);
374
375        // 笔记按更新时间倒序:n2(200) 在 n1(100) 前
376        let notes = lib.notes_in_folder_sorted(&root_a);
377        assert_eq!(
378            notes.iter().map(|n| n.title.as_str()).collect::<Vec<_>>(),
379            vec!["n2", "n1"]
380        );
381    }
382
383    #[test]
384    fn search_matches_title_and_body_case_insensitively() {
385        let contents = vec![
386            new_note_md(&hid(1), "", "Rust Notes", "hello world", false, 100),
387            new_note_md(&hid(2), "", "Cooking", "about RUST macros", false, 200),
388            new_note_md(&hid(3), "", "Unrelated", "nothing here", false, 300),
389        ];
390        let (lib, _) = Library::from_contents(contents);
391
392        // 命中标题(n1) + 正文(n2),不区分大小写;按更新时间倒序 → n2 在前
393        let hits = lib.search("rust");
394        assert_eq!(hits.len(), 2);
395        assert_eq!(hits[0].title, "Cooking");
396        assert_eq!(hits[1].title, "Rust Notes");
397
398        assert!(lib.search("   ").is_empty()); // 空查询
399        assert!(lib.search("zzz-no-match").is_empty());
400    }
401
402    #[test]
403    fn anti_cycle_self_or_descendant() {
404        // 链:A -> B -> C(C 在 A 子树下),D 是独立根
405        let (a, b, c, d) = (hid(0xa0), hid(0xb0), hid(0xc0), hid(0xd0));
406        let contents = vec![
407            new_folder_md(&a, "", "A", 1),
408            new_folder_md(&b, &a, "B", 2),
409            new_folder_md(&c, &b, "C", 3),
410            new_folder_md(&d, "", "D", 4),
411        ];
412        let (lib, _) = Library::from_contents(contents);
413
414        assert!(lib.is_self_or_descendant(&a, &a)); // 自身
415        assert!(lib.is_self_or_descendant(&a, &c)); // 后代(禁止把 A 移到 C 下)
416        assert!(lib.is_self_or_descendant(&b, &c)); // 直接子
417        assert!(!lib.is_self_or_descendant(&c, &a)); // 祖先不是后代 → 允许
418        assert!(!lib.is_self_or_descendant(&a, &d)); // 无关分支
419    }
420
421    #[test]
422    fn resource_usage_counts_refs_and_orphans() {
423        let (r_used, r_orphan) = (hid(0xe0), hid(0xf0));
424        let contents = vec![
425            new_note_md(&hid(1), "", "n1", &format!("![x](:/{r_used})"), false, 1),
426            new_note_md(&hid(2), "", "n2", &format!("again :/{r_used}"), false, 2),
427            new_note_md(&hid(3), "", "n3", "no images here", false, 3),
428            new_resource_md(&r_used, "used.png", "image/png", "png", 10, 1),
429            new_resource_md(&r_orphan, "orphan.png", "image/png", "png", 10, 1),
430        ];
431        let (lib, stats) = Library::from_contents(contents);
432        assert_eq!(stats.resources, 2);
433
434        let usage = lib.resource_usage();
435        assert_eq!(usage.get(&r_used).copied(), Some(2)); // 被 2 篇引用
436        assert_eq!(usage.get(&r_orphan).copied(), None); // 孤儿:不出现
437        assert!(lib.resource(&r_used).is_some());
438        assert_eq!(lib.resource(&r_used).unwrap().mime, "image/png");
439    }
440}