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
23 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 pub cached: usize,
39 pub fetched: usize,
41}
42
43impl Library {
44 pub fn from_contents(contents: Vec<String>) -> (Library, BuildStats) {
47 let mut lib = Library::default();
48 let mut stats = BuildStats::default();
49
50 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 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 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 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 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 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 pub fn note_raw(&self, id: &str) -> Option<&str> {
188 self.raw_notes.get(id).map(|s| s.as_str())
189 }
190
191 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 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 pub fn is_self_or_descendant(&self, root: &str, candidate: &str) -> bool {
215 let mut cur = candidate.to_string();
216 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 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 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 pub fn remove_resource(&mut self, id: &str) {
247 self.resources.remove(id);
248 }
249
250 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
263pub 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 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
291fn 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 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 = "见图  和附件 [f](:/0123456789ABCDEF0123456789ABCDEF)\n\
319 重复同一个 :/0123456789abcdef0123456789abcdef 再来个 :/deadbeefdeadbeefdeadbeefdeadbeef";
320 let refs = scan_resource_refs(body);
321 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 let refs = scan_resource_refs(":/short :/0123456789abcdef0123456789abcdefEXTRA :/zzzz");
339 assert!(refs.is_empty());
340 }
341
342 use crate::serialize::{new_folder_md, new_note_md, new_resource_md};
344
345 fn hid(n: u8) -> String {
346 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 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 let roots = lib.child_folder_ids_sorted("");
373 assert_eq!(roots, vec![root_a.clone(), root_b.clone()]);
374
375 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 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()); assert!(lib.search("zzz-no-match").is_empty());
400 }
401
402 #[test]
403 fn anti_cycle_self_or_descendant() {
404 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)); 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)); }
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"), 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)); assert_eq!(usage.get(&r_orphan).copied(), None); assert!(lib.resource(&r_used).is_some());
438 assert_eq!(lib.resource(&r_used).unwrap().mime, "image/png");
439 }
440}