1use 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 notes_by_folder: HashMap<String, Vec<String>>,
20 child_folders: HashMap<String, Vec<String>>,
22 notes_by_tag: HashMap<String, Vec<String>>,
24
25 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 pub cached: usize,
41 pub fetched: usize,
43}
44
45impl Library {
46 pub fn from_contents(contents: Vec<String>) -> (Library, BuildStats) {
49 let mut lib = Library::default();
50 let mut stats = BuildStats::default();
51
52 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn note_raw(&self, id: &str) -> Option<&str> {
271 self.raw_notes.get(id).map(|s| s.as_str())
272 }
273
274 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 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 pub fn is_self_or_descendant(&self, root: &str, candidate: &str) -> bool {
298 let mut cur = candidate.to_string();
299 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 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; }
319 let mut stack = vec![root.to_string()];
320 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 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 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 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 Ok(id)
358 }
359
360 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 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 pub fn remove_resource(&mut self, id: &str) {
378 self.resources.remove(id);
379 }
380
381 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
394pub 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 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
422fn 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 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 = "见图  和附件 [f](:/0123456789ABCDEF0123456789ABCDEF)\n\
450 重复同一个 :/0123456789abcdef0123456789abcdef 再来个 :/deadbeefdeadbeefdeadbeefdeadbeef";
451 let refs = scan_resource_refs(body);
452 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 let refs = scan_resource_refs(":/short :/0123456789abcdef0123456789abcdefEXTRA :/zzzz");
470 assert!(refs.is_empty());
471 }
472
473 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 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 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 let roots = lib.child_folder_ids_sorted("");
504 assert_eq!(roots, vec![root_a.clone(), root_b.clone()]);
505
506 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 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()); assert!(lib.search("zzz-no-match").is_empty());
531 }
532
533 #[test]
534 fn anti_cycle_self_or_descendant() {
535 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)); assert!(lib.is_self_or_descendant(&a, &c)); assert!(lib.is_self_or_descendant(&b, &c)); assert!(!lib.is_self_or_descendant(&c, &a)); assert!(!lib.is_self_or_descendant(&a, &d)); }
551
552 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 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), note_tag_md(&hid(0x64), &n3, &t_idea),
575 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 let tags = lib.tags_sorted();
584 assert_eq!(tags.iter().map(|t| t.title.as_str()).collect::<Vec<_>>(), vec!["idea", "Work"]);
585
586 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); 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 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())); assert_eq!(lib.tag_id_by_title(" WORK ").as_deref(), Some(tag_id.as_str())); 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 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 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 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 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"), 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)); assert_eq!(usage.get(&r_orphan).copied(), None); assert!(lib.resource(&r_used).is_some());
675 assert_eq!(lib.resource(&r_used).unwrap().mime, "image/png");
676 }
677}