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    /// tag_id -> 打了该标签且仍存在的笔记 id(去重;据 note_tags 关联表构建)
23    notes_by_tag: HashMap<String, Vec<String>>,
24
25    /// 笔记的原始 .md 内容(用于保存时保留元数据,避免重新拉取)
26    raw_notes: HashMap<String, String>,
27}
28
29#[derive(Debug, Default, Clone)]
30pub struct BuildStats {
31    pub notes: usize,
32    pub folders: usize,
33    pub resources: usize,
34    pub tags: usize,
35    pub note_tags: usize,
36    pub others: usize,
37    pub encrypted: usize,
38    pub errors: usize,
39    /// 命中增量缓存、免去拉取的条目数。
40    pub cached: usize,
41    /// 本轮实际从数据源拉取的条目数。
42    pub fetched: usize,
43}
44
45impl Library {
46    /// 把一批原始 .md 内容解析、分类、建索引为 Library,返回类型计数 + 解析错误数。
47    /// 拉取(并行/缓存)由调用方负责;这里只做纯解析与索引,便于编译到 WASM。
48    pub fn from_contents(contents: Vec<String>) -> (Library, BuildStats) {
49        let mut lib = Library::default();
50        let mut stats = BuildStats::default();
51
52        // 顺序解析(核心库不引入线程);保留笔记原始内容用于写回。
53        let parsed: Vec<Option<(String, RawItem)>> = contents
54            .into_iter()
55            .map(|content| parser::parse_item(&content).ok().map(|raw| (content, raw)))
56            .collect();
57
58        // 分类,构建索引
59        for item in parsed {
60            let (content, raw) = match item {
61                Some(x) => x,
62                None => {
63                    stats.errors += 1;
64                    continue;
65                }
66            };
67            if raw.is_encrypted() {
68                stats.encrypted += 1;
69                continue;
70            }
71            match raw.item_type() {
72                ItemType::Note => match parser::to_note(&raw) {
73                    Ok(n) => {
74                        lib.raw_notes.insert(n.id.clone(), content);
75                        lib.notes.insert(n.id.clone(), n);
76                        stats.notes += 1;
77                    }
78                    Err(_) => stats.errors += 1,
79                },
80                ItemType::Folder => match parser::to_folder(&raw) {
81                    Ok(f) => {
82                        lib.folders.insert(f.id.clone(), f);
83                        stats.folders += 1;
84                    }
85                    Err(_) => stats.errors += 1,
86                },
87                ItemType::Resource => match parser::to_resource(&raw) {
88                    Ok(r) => {
89                        lib.resources.insert(r.id.clone(), r);
90                        stats.resources += 1;
91                    }
92                    Err(_) => stats.errors += 1,
93                },
94                ItemType::Tag => match parser::to_tag(&raw) {
95                    Ok(t) => {
96                        lib.tags.insert(t.id.clone(), t);
97                        stats.tags += 1;
98                    }
99                    Err(_) => stats.errors += 1,
100                },
101                ItemType::NoteTag => match parser::to_note_tag(&raw) {
102                    Ok(nt) => {
103                        lib.note_tags.push(nt);
104                        stats.note_tags += 1;
105                    }
106                    Err(_) => stats.errors += 1,
107                },
108                ItemType::Other => stats.others += 1,
109            }
110        }
111
112        lib.build_indexes();
113        (lib, stats)
114    }
115
116    fn build_indexes(&mut self) {
117        let mut notes_by_folder: HashMap<String, Vec<String>> = HashMap::new();
118        for n in self.notes.values() {
119            notes_by_folder
120                .entry(n.parent_id.clone())
121                .or_default()
122                .push(n.id.clone());
123        }
124        let mut child_folders: HashMap<String, Vec<String>> = HashMap::new();
125        for f in self.folders.values() {
126            child_folders
127                .entry(f.parent_id.clone())
128                .or_default()
129                .push(f.id.clone());
130        }
131        // 标签 → 笔记:只收仍存在的笔记(note_tag 可能悬挂到已删笔记),按 (tag,note) 去重。
132        let mut notes_by_tag: HashMap<String, Vec<String>> = HashMap::new();
133        let mut seen: HashMap<String, HashSet<String>> = HashMap::new();
134        for nt in &self.note_tags {
135            if !self.notes.contains_key(&nt.note_id) {
136                continue;
137            }
138            if seen.entry(nt.tag_id.clone()).or_default().insert(nt.note_id.clone()) {
139                notes_by_tag.entry(nt.tag_id.clone()).or_default().push(nt.note_id.clone());
140            }
141        }
142        self.notes_by_folder = notes_by_folder;
143        self.child_folders = child_folders;
144        self.notes_by_tag = notes_by_tag;
145    }
146
147    /// 某笔记本(含根 "")直属笔记数。
148    pub fn note_count(&self, folder_id: &str) -> usize {
149        self.notes_by_folder.get(folder_id).map(|v| v.len()).unwrap_or(0)
150    }
151
152    /// 子笔记本,按标题排序。
153    pub fn child_folder_ids_sorted(&self, parent_id: &str) -> Vec<String> {
154        let mut ids = self.child_folders.get(parent_id).cloned().unwrap_or_default();
155        ids.sort_by(|a, b| {
156            let ta = self.folders.get(a).map(|f| f.title.as_str()).unwrap_or("");
157            let tb = self.folders.get(b).map(|f| f.title.as_str()).unwrap_or("");
158            ta.cmp(tb)
159        });
160        ids
161    }
162
163    /// 某笔记本下的笔记,按更新时间倒序。
164    pub fn notes_in_folder_sorted(&self, folder_id: &str) -> Vec<&Note> {
165        let mut notes: Vec<&Note> = self
166            .notes_by_folder
167            .get(folder_id)
168            .map(|ids| ids.iter().filter_map(|id| self.notes.get(id)).collect())
169            .unwrap_or_default();
170        notes.sort_by(|a, b| b.updated_time.cmp(&a.updated_time));
171        notes
172    }
173
174    /// 某标签下仍存在的笔记数。
175    pub fn tag_note_count(&self, tag_id: &str) -> usize {
176        self.notes_by_tag.get(tag_id).map(|v| v.len()).unwrap_or(0)
177    }
178
179    /// 全部标签,按标题(不区分大小写)排序。
180    pub fn tags_sorted(&self) -> Vec<&Tag> {
181        let mut tags: Vec<&Tag> = self.tags.values().collect();
182        tags.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
183        tags
184    }
185
186    /// 打了某标签的笔记,按更新时间倒序(与笔记本视图一致)。
187    pub fn notes_with_tag(&self, tag_id: &str) -> Vec<&Note> {
188        let mut notes: Vec<&Note> = self
189            .notes_by_tag
190            .get(tag_id)
191            .map(|ids| ids.iter().filter_map(|id| self.notes.get(id)).collect())
192            .unwrap_or_default();
193        notes.sort_by(|a, b| b.updated_time.cmp(&a.updated_time));
194        notes
195    }
196
197    /// 按标题查已有标签 id(trim + 不区分大小写,对齐 Joplin `Tag.loadByTitle` 语义)。
198    /// 多个同名时取 id 最小者以保证确定性(Joplin 用 created_time,本模型不存该字段)。
199    pub fn tag_id_by_title(&self, title: &str) -> Option<String> {
200        let key = title.trim().to_lowercase();
201        if key.is_empty() {
202            return None;
203        }
204        self.tags
205            .values()
206            .filter(|t| t.title.trim().to_lowercase() == key)
207            .map(|t| &t.id)
208            .min()
209            .cloned()
210    }
211
212    /// 笔记是否已打某标签(据关联表;用于新增去重、对齐 Joplin `addNote` 的 hasNote 短路)。
213    pub fn note_has_tag(&self, note_id: &str, tag_id: &str) -> bool {
214        self.note_tags.iter().any(|nt| nt.note_id == note_id && nt.tag_id == tag_id)
215    }
216
217    /// (note,tag) 对应的全部 note_tag 条目 id(Joplin `removeNote` 删全部匹配)。
218    pub fn note_tag_ids_for(&self, note_id: &str, tag_id: &str) -> Vec<String> {
219        self.note_tags
220            .iter()
221            .filter(|nt| nt.note_id == note_id && nt.tag_id == tag_id)
222            .map(|nt| nt.id.clone())
223            .collect()
224    }
225
226    /// 某笔记的标签,按标题(不区分大小写)排序。
227    pub fn tags_of_note(&self, note_id: &str) -> Vec<&Tag> {
228        let mut ids: HashSet<&str> = HashSet::new();
229        let mut out: Vec<&Tag> = Vec::new();
230        for nt in &self.note_tags {
231            if nt.note_id == note_id {
232                if let Some(tag) = self.tags.get(&nt.tag_id) {
233                    if ids.insert(tag.id.as_str()) {
234                        out.push(tag);
235                    }
236                }
237            }
238        }
239        out.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
240        out
241    }
242
243    pub fn note(&self, id: &str) -> Option<&Note> {
244        self.notes.get(id)
245    }
246
247    pub fn resource(&self, id: &str) -> Option<&Resource> {
248        self.resources.get(id)
249    }
250
251    /// 简单全文搜索:标题/正文不区分大小写包含。按更新时间倒序,限制 200 条。
252    pub fn search(&self, query: &str) -> Vec<&Note> {
253        let q = query.trim().to_lowercase();
254        if q.is_empty() {
255            return vec![];
256        }
257        let mut hits: Vec<&Note> = self
258            .notes
259            .values()
260            .filter(|n| {
261                n.title.to_lowercase().contains(&q) || n.body.to_lowercase().contains(&q)
262            })
263            .collect();
264        hits.sort_by(|a, b| b.updated_time.cmp(&a.updated_time));
265        hits.truncate(200);
266        hits
267    }
268
269    /// 笔记的原始 .md 内容(保存时用于保留元数据)。
270    pub fn note_raw(&self, id: &str) -> Option<&str> {
271        self.raw_notes.get(id).map(|s| s.as_str())
272    }
273
274    /// 新增或更新一篇笔记(写回成功后同步内存)。返回笔记 id。
275    pub fn upsert_note(&mut self, content: &str) -> anyhow::Result<String> {
276        let raw = parser::parse_item(content)?;
277        let note = parser::to_note(&raw)?;
278        let id = note.id.clone();
279        self.raw_notes.insert(id.clone(), content.to_string());
280        self.notes.insert(id.clone(), note);
281        self.build_indexes();
282        Ok(id)
283    }
284
285    /// 新增或更新一个笔记本(写回成功后同步内存)。返回笔记本 id。
286    pub fn upsert_folder(&mut self, content: &str) -> anyhow::Result<String> {
287        let raw = parser::parse_item(content)?;
288        let folder = parser::to_folder(&raw)?;
289        let id = folder.id.clone();
290        self.folders.insert(id.clone(), folder);
291        self.build_indexes();
292        Ok(id)
293    }
294
295    /// `candidate` 是否就是 `root` 本身、或位于 `root` 子树之下。
296    /// 用于移动笔记本时防止把笔记本移进它自己或其后代(成环)。
297    pub fn is_self_or_descendant(&self, root: &str, candidate: &str) -> bool {
298        let mut cur = candidate.to_string();
299        // 上限步数防御坏数据里的既有环,避免死循环
300        for _ in 0..=self.folders.len() {
301            if cur == root {
302                return true;
303            }
304            match self.folders.get(&cur) {
305                Some(f) if !f.parent_id.is_empty() => cur = f.parent_id.clone(),
306                _ => return false,
307            }
308        }
309        false
310    }
311
312    /// 某笔记本自身 + 其所有后代笔记本 id(BFS)。`root` 不存在则返回空。
313    /// 用于访问控制的黑白名单按子树展开(server::auth::AuthState::scope)。
314    pub fn subtree_folder_ids(&self, root: &str) -> Vec<String> {
315        let mut out = Vec::new();
316        if root.is_empty() || !self.folders.contains_key(root) {
317            return out; // 空串=未分类根不是真笔记本,无子树
318        }
319        let mut stack = vec![root.to_string()];
320        // 步数上限防坏数据成环
321        let cap = self.folders.len() + 1;
322        while let Some(id) = stack.pop() {
323            if out.len() > cap {
324                break;
325            }
326            if let Some(children) = self.child_folders.get(&id) {
327                stack.extend(children.iter().cloned());
328            }
329            out.push(id);
330        }
331        out
332    }
333
334    /// 新增或更新一个资源(上传成功后同步内存,使 /api/resources 能拿到 mime)。返回资源 id。
335    pub fn upsert_resource(&mut self, content: &str) -> anyhow::Result<String> {
336        let raw = parser::parse_item(content)?;
337        let r = parser::to_resource(&raw)?;
338        let id = r.id.clone();
339        self.resources.insert(id.clone(), r);
340        Ok(id)
341    }
342
343    /// 删除一篇笔记(写回成功后同步内存)。
344    pub fn remove_note(&mut self, id: &str) {
345        self.notes.remove(id);
346        self.raw_notes.remove(id);
347        self.build_indexes();
348    }
349
350    /// 新增或更新一个标签(写回成功后同步内存)。返回标签 id。
351    pub fn upsert_tag(&mut self, content: &str) -> anyhow::Result<String> {
352        let raw = parser::parse_item(content)?;
353        let tag = parser::to_tag(&raw)?;
354        let id = tag.id.clone();
355        self.tags.insert(id.clone(), tag);
356        // 标签本身不入 notes_by_tag(成员由 note_tag 决定),无需重建索引。
357        Ok(id)
358    }
359
360    /// 新增一个 note_tag 关联(写回成功后同步内存)。按 id 去重后重建标签成员索引。
361    pub fn upsert_note_tag(&mut self, content: &str) -> anyhow::Result<NoteTag> {
362        let raw = parser::parse_item(content)?;
363        let nt = parser::to_note_tag(&raw)?;
364        self.note_tags.retain(|x| x.id != nt.id);
365        self.note_tags.push(nt.clone());
366        self.build_indexes();
367        Ok(nt)
368    }
369
370    /// 删除一个 note_tag 关联(按条目 id;写回成功后同步内存)。
371    pub fn remove_note_tag(&mut self, id: &str) {
372        self.note_tags.retain(|nt| nt.id != id);
373        self.build_indexes();
374    }
375
376    /// 删除一个资源(写回成功后同步内存)。
377    pub fn remove_resource(&mut self, id: &str) {
378        self.resources.remove(id);
379    }
380
381    /// 每个资源被多少篇笔记引用(扫描所有笔记正文里的 `:/<id>`)。
382    /// 未出现在结果中的资源即为无人引用的“孤儿”。
383    pub fn resource_usage(&self) -> HashMap<String, usize> {
384        let mut usage: HashMap<String, usize> = HashMap::new();
385        for n in self.notes.values() {
386            for id in scan_resource_refs(&n.body) {
387                *usage.entry(id).or_insert(0) += 1;
388            }
389        }
390        usage
391    }
392}
393
394/// 统计 markdown 任务清单(GFM checkbox)的完成/总数:`(已完成, 总数)`。
395/// 仅认行首(去缩进后)形如 `- [ ] ` / `* [x] ` / `+ [X] ` 的列表项。
396pub fn count_tasks(body: &str) -> (usize, usize) {
397    let mut done = 0;
398    let mut total = 0;
399    for line in body.lines() {
400        let b = line.trim_start().as_bytes();
401        // `<-|*|+>` SP `[` <mark> `]`  且其后是空白或行尾
402        if b.len() >= 5
403            && matches!(b[0], b'-' | b'*' | b'+')
404            && b[1] == b' '
405            && b[2] == b'['
406            && b[4] == b']'
407        {
408            let after_ok = b.len() == 5 || b[5] == b' ' || b[5] == b'\t';
409            match (after_ok, b[3]) {
410                (true, b' ') => total += 1,
411                (true, b'x') | (true, b'X') => {
412                    total += 1;
413                    done += 1;
414                }
415                _ => {}
416            }
417        }
418    }
419    (done, total)
420}
421
422/// 扫描文本里的 Joplin 资源引用 `:/<32hex>`,返回去重后的 id 集合(每篇笔记内同一资源只计一次)。
423fn scan_resource_refs(body: &str) -> HashSet<String> {
424    let b = body.as_bytes();
425    let mut out = HashSet::new();
426    let mut i = 0;
427    while i + 34 <= b.len() {
428        // `:/` 后接恰好 32 个十六进制,且第 33 位不再是十六进制(id 长度精确为 32)
429        if b[i] == b':' && b[i + 1] == b'/' {
430            let hex = &b[i + 2..i + 34];
431            let bounded = i + 34 >= b.len() || !b[i + 34].is_ascii_hexdigit();
432            if bounded && hex.iter().all(|c| c.is_ascii_hexdigit()) {
433                out.insert(String::from_utf8_lossy(hex).to_lowercase());
434                i += 34;
435                continue;
436            }
437        }
438        i += 1;
439    }
440    out
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    #[test]
448    fn scans_resource_refs() {
449        let body = "见图 ![a](:/0123456789abcdef0123456789abcdef) 和附件 [f](:/0123456789ABCDEF0123456789ABCDEF)\n\
450            重复同一个 :/0123456789abcdef0123456789abcdef 再来个 :/deadbeefdeadbeefdeadbeefdeadbeef";
451        let refs = scan_resource_refs(body);
452        // 大小写归一后,第一个引用去重为 1 个,另有 deadbeef 一个 → 共 2 个不同 id
453        assert_eq!(refs.len(), 2);
454        assert!(refs.contains("0123456789abcdef0123456789abcdef"));
455        assert!(refs.contains("deadbeefdeadbeefdeadbeefdeadbeef"));
456    }
457
458    #[test]
459    fn counts_task_list() {
460        let body = "标题\n- [ ] 待办一\n- [x] 已完成\n* [X] 也完成\n+ [ ] 第四\n普通行\n-[ ] 无空格不算\n- [] 非法不算";
461        assert_eq!(count_tasks(body), (2, 4));
462        assert_eq!(count_tasks("没有任何任务"), (0, 0));
463        assert_eq!(count_tasks("  - [x] 缩进也算"), (1, 1));
464    }
465
466    #[test]
467    fn ignores_non_32hex() {
468        // 太短 / 太长 / 非 hex 都不算
469        let refs = scan_resource_refs(":/short :/0123456789abcdef0123456789abcdefEXTRA :/zzzz");
470        assert!(refs.is_empty());
471    }
472
473    // ---- 下面用 serialize 造 .md 喂 from_contents,做 Library 级集成单测 ----
474    use crate::serialize::{new_folder_md, new_note_md, new_note_tag_md, new_resource_md, new_tag_md};
475
476    fn hid(n: u8) -> String {
477        // 造一个确定的 32hex id:把一个字节重复 16 次的十六进制
478        format!("{:02x}", n).repeat(16)
479    }
480
481    #[test]
482    fn builds_tree_counts_and_ordering() {
483        let (root_a, root_b, child) = (hid(0xa0), hid(0xb0), hid(0xc0));
484        let contents = vec![
485            new_folder_md(&root_a, "", "Alpha", 1000),
486            new_folder_md(&root_b, "", "Beta", 2000),
487            new_folder_md(&child, &root_a, "Child", 1500),
488            new_note_md(&hid(1), &root_a, "n1", "body one", false, 100),
489            new_note_md(&hid(2), &root_a, "n2", "body two", false, 200),
490            new_note_md(&hid(3), &child, "n3", "nested", false, 300),
491        ];
492        let (lib, stats) = Library::from_contents(contents);
493        assert_eq!(stats.folders, 3);
494        assert_eq!(stats.notes, 3);
495        assert_eq!(stats.errors, 0);
496
497        // 直属笔记数:root_a 有 2,child 有 1,root_b 有 0
498        assert_eq!(lib.note_count(&root_a), 2);
499        assert_eq!(lib.note_count(&child), 1);
500        assert_eq!(lib.note_count(&root_b), 0);
501
502        // 根下的子笔记本按标题排序:Alpha < Beta
503        let roots = lib.child_folder_ids_sorted("");
504        assert_eq!(roots, vec![root_a.clone(), root_b.clone()]);
505
506        // 笔记按更新时间倒序:n2(200) 在 n1(100) 前
507        let notes = lib.notes_in_folder_sorted(&root_a);
508        assert_eq!(
509            notes.iter().map(|n| n.title.as_str()).collect::<Vec<_>>(),
510            vec!["n2", "n1"]
511        );
512    }
513
514    #[test]
515    fn search_matches_title_and_body_case_insensitively() {
516        let contents = vec![
517            new_note_md(&hid(1), "", "Rust Notes", "hello world", false, 100),
518            new_note_md(&hid(2), "", "Cooking", "about RUST macros", false, 200),
519            new_note_md(&hid(3), "", "Unrelated", "nothing here", false, 300),
520        ];
521        let (lib, _) = Library::from_contents(contents);
522
523        // 命中标题(n1) + 正文(n2),不区分大小写;按更新时间倒序 → n2 在前
524        let hits = lib.search("rust");
525        assert_eq!(hits.len(), 2);
526        assert_eq!(hits[0].title, "Cooking");
527        assert_eq!(hits[1].title, "Rust Notes");
528
529        assert!(lib.search("   ").is_empty()); // 空查询
530        assert!(lib.search("zzz-no-match").is_empty());
531    }
532
533    #[test]
534    fn anti_cycle_self_or_descendant() {
535        // 链:A -> B -> C(C 在 A 子树下),D 是独立根
536        let (a, b, c, d) = (hid(0xa0), hid(0xb0), hid(0xc0), hid(0xd0));
537        let contents = vec![
538            new_folder_md(&a, "", "A", 1),
539            new_folder_md(&b, &a, "B", 2),
540            new_folder_md(&c, &b, "C", 3),
541            new_folder_md(&d, "", "D", 4),
542        ];
543        let (lib, _) = Library::from_contents(contents);
544
545        assert!(lib.is_self_or_descendant(&a, &a)); // 自身
546        assert!(lib.is_self_or_descendant(&a, &c)); // 后代(禁止把 A 移到 C 下)
547        assert!(lib.is_self_or_descendant(&b, &c)); // 直接子
548        assert!(!lib.is_self_or_descendant(&c, &a)); // 祖先不是后代 → 允许
549        assert!(!lib.is_self_or_descendant(&a, &d)); // 无关分支
550    }
551
552    // 造一个 tag(type_=5,带标题)/ note_tag(type_=6,纯元数据)条目内容。
553    fn tag_md(id: &str, title: &str) -> String {
554        format!("{title}\n\nid: {id}\nparent_id: \ntype_: 5")
555    }
556    fn note_tag_md(id: &str, note_id: &str, tag_id: &str) -> String {
557        format!("id: {id}\nnote_id: {note_id}\ntag_id: {tag_id}\ntype_: 6")
558    }
559
560    #[test]
561    fn indexes_tags_with_counts_and_membership() {
562        let (t_work, t_idea) = (hid(0x51), hid(0x52));
563        let (n1, n2, n3) = (hid(1), hid(2), hid(3));
564        let contents = vec![
565            new_note_md(&n1, "", "n1", "body", false, 100),
566            new_note_md(&n2, "", "n2", "body", false, 300),
567            new_note_md(&n3, "", "n3", "body", false, 200),
568            tag_md(&t_work, "Work"),
569            tag_md(&t_idea, "idea"),
570            // n1 与 n2 打了 Work;n3 打了 idea;重复关联应被去重
571            note_tag_md(&hid(0x61), &n1, &t_work),
572            note_tag_md(&hid(0x62), &n2, &t_work),
573            note_tag_md(&hid(0x63), &n2, &t_work), // 重复:不重复计数
574            note_tag_md(&hid(0x64), &n3, &t_idea),
575            // 悬挂关联:引用不存在的笔记,应被忽略
576            note_tag_md(&hid(0x65), &hid(0xff), &t_work),
577        ];
578        let (lib, stats) = Library::from_contents(contents);
579        assert_eq!(stats.tags, 2);
580        assert_eq!(stats.note_tags, 5);
581
582        // 标签按标题不区分大小写排序:idea < Work
583        let tags = lib.tags_sorted();
584        assert_eq!(tags.iter().map(|t| t.title.as_str()).collect::<Vec<_>>(), vec!["idea", "Work"]);
585
586        // 计数:Work 去重后 2(n1,n2),idea 1(n3)
587        assert_eq!(lib.tag_note_count(&t_work), 2);
588        assert_eq!(lib.tag_note_count(&t_idea), 1);
589        assert_eq!(lib.tag_note_count(&hid(0xee)), 0); // 未知标签
590
591        // 成员:Work 下按更新时间倒序 → n2(300) 在 n1(100) 前
592        let work_notes = lib.notes_with_tag(&t_work);
593        assert_eq!(work_notes.iter().map(|n| n.title.as_str()).collect::<Vec<_>>(), vec!["n2", "n1"]);
594        assert!(lib.notes_with_tag(&hid(0xee)).is_empty());
595    }
596
597    #[test]
598    fn tag_mutations_add_reuse_and_remove() {
599        let (n1, n2) = (hid(1), hid(2));
600        let (mut lib, _) = Library::from_contents(vec![
601            new_note_md(&n1, "", "n1", "b", false, 100),
602            new_note_md(&n2, "", "n2", "b", false, 200),
603        ]);
604
605        // 新建标签 + 关联 n1
606        let tag_id = hid(0x51);
607        lib.upsert_tag(&new_tag_md(&tag_id, "Work", 1)).unwrap();
608        assert_eq!(lib.tag_id_by_title("work").as_deref(), Some(tag_id.as_str())); // 不区分大小写
609        assert_eq!(lib.tag_id_by_title("  WORK  ").as_deref(), Some(tag_id.as_str())); // trim
610        assert_eq!(lib.tag_id_by_title("nope"), None);
611
612        assert!(!lib.note_has_tag(&n1, &tag_id));
613        let nt = lib.upsert_note_tag(&new_note_tag_md(&hid(0x61), &n1, &tag_id, 1)).unwrap();
614        assert_eq!(nt.note_id, n1);
615        assert!(lib.note_has_tag(&n1, &tag_id));
616        assert_eq!(lib.tag_note_count(&tag_id), 1);
617        assert_eq!(lib.notes_with_tag(&tag_id).iter().map(|n| n.title.as_str()).collect::<Vec<_>>(), vec!["n1"]);
618        assert_eq!(lib.tags_of_note(&n1).iter().map(|t| t.title.as_str()).collect::<Vec<_>>(), vec!["Work"]);
619        assert!(lib.tags_of_note(&n2).is_empty());
620
621        // 再关联 n2;成员两条
622        lib.upsert_note_tag(&new_note_tag_md(&hid(0x62), &n2, &tag_id, 1)).unwrap();
623        assert_eq!(lib.tag_note_count(&tag_id), 2);
624
625        // 移除 n1 的关联:按 (note,tag) 查 id 再删
626        let ids = lib.note_tag_ids_for(&n1, &tag_id);
627        assert_eq!(ids, vec![hid(0x61)]);
628        lib.remove_note_tag(&ids[0]);
629        assert!(!lib.note_has_tag(&n1, &tag_id));
630        assert_eq!(lib.tag_note_count(&tag_id), 1);
631        assert_eq!(lib.notes_with_tag(&tag_id).iter().map(|n| n.title.as_str()).collect::<Vec<_>>(), vec!["n2"]);
632
633        // upsert 同 id 关联幂等(不重复计数)
634        lib.upsert_note_tag(&new_note_tag_md(&hid(0x62), &n2, &tag_id, 1)).unwrap();
635        assert_eq!(lib.tag_note_count(&tag_id), 1);
636    }
637
638    #[test]
639    fn tag_membership_drops_deleted_notes() {
640        let t = hid(0x51);
641        let (n1, n2) = (hid(1), hid(2));
642        let contents = vec![
643            new_note_md(&n1, "", "n1", "body", false, 100),
644            new_note_md(&n2, "", "n2", "body", false, 200),
645            tag_md(&t, "Work"),
646            note_tag_md(&hid(0x61), &n1, &t),
647            note_tag_md(&hid(0x62), &n2, &t),
648        ];
649        let (mut lib, _) = Library::from_contents(contents);
650        assert_eq!(lib.tag_note_count(&t), 2);
651
652        // 删除 n1 后,标签成员随索引重建而剔除(note_tag 悬挂无害)
653        lib.remove_note(&n1);
654        assert_eq!(lib.tag_note_count(&t), 1);
655        assert_eq!(lib.notes_with_tag(&t).iter().map(|n| n.title.as_str()).collect::<Vec<_>>(), vec!["n2"]);
656    }
657
658    #[test]
659    fn resource_usage_counts_refs_and_orphans() {
660        let (r_used, r_orphan) = (hid(0xe0), hid(0xf0));
661        let contents = vec![
662            new_note_md(&hid(1), "", "n1", &format!("![x](:/{r_used})"), false, 1),
663            new_note_md(&hid(2), "", "n2", &format!("again :/{r_used}"), false, 2),
664            new_note_md(&hid(3), "", "n3", "no images here", false, 3),
665            new_resource_md(&r_used, "used.png", "image/png", "png", 10, 1),
666            new_resource_md(&r_orphan, "orphan.png", "image/png", "png", 10, 1),
667        ];
668        let (lib, stats) = Library::from_contents(contents);
669        assert_eq!(stats.resources, 2);
670
671        let usage = lib.resource_usage();
672        assert_eq!(usage.get(&r_used).copied(), Some(2)); // 被 2 篇引用
673        assert_eq!(usage.get(&r_orphan).copied(), None); // 孤儿:不出现
674        assert!(lib.resource(&r_used).is_some());
675        assert_eq!(lib.resource(&r_used).unwrap().mime, "image/png");
676    }
677}