use crate::model::*;
use crate::parser;
use std::collections::{HashMap, HashSet};
#[derive(Default)]
pub struct Library {
pub folders: HashMap<String, Folder>,
pub notes: HashMap<String, Note>,
pub resources: HashMap<String, Resource>,
pub tags: HashMap<String, Tag>,
pub note_tags: Vec<NoteTag>,
notes_by_folder: HashMap<String, Vec<String>>,
child_folders: HashMap<String, Vec<String>>,
notes_by_tag: HashMap<String, Vec<String>>,
raw_notes: HashMap<String, String>,
}
#[derive(Debug, Default, Clone)]
pub struct BuildStats {
pub notes: usize,
pub folders: usize,
pub resources: usize,
pub tags: usize,
pub note_tags: usize,
pub others: usize,
pub encrypted: usize,
pub errors: usize,
pub cached: usize,
pub fetched: usize,
}
impl Library {
pub fn from_contents(contents: Vec<String>) -> (Library, BuildStats) {
let mut lib = Library::default();
let mut stats = BuildStats::default();
let parsed: Vec<Option<(String, RawItem)>> = contents
.into_iter()
.map(|content| parser::parse_item(&content).ok().map(|raw| (content, raw)))
.collect();
for item in parsed {
let (content, raw) = match item {
Some(x) => x,
None => {
stats.errors += 1;
continue;
}
};
if raw.is_encrypted() {
stats.encrypted += 1;
continue;
}
match raw.item_type() {
ItemType::Note => match parser::to_note(&raw) {
Ok(n) => {
lib.raw_notes.insert(n.id.clone(), content);
lib.notes.insert(n.id.clone(), n);
stats.notes += 1;
}
Err(_) => stats.errors += 1,
},
ItemType::Folder => match parser::to_folder(&raw) {
Ok(f) => {
lib.folders.insert(f.id.clone(), f);
stats.folders += 1;
}
Err(_) => stats.errors += 1,
},
ItemType::Resource => match parser::to_resource(&raw) {
Ok(r) => {
lib.resources.insert(r.id.clone(), r);
stats.resources += 1;
}
Err(_) => stats.errors += 1,
},
ItemType::Tag => match parser::to_tag(&raw) {
Ok(t) => {
lib.tags.insert(t.id.clone(), t);
stats.tags += 1;
}
Err(_) => stats.errors += 1,
},
ItemType::NoteTag => match parser::to_note_tag(&raw) {
Ok(nt) => {
lib.note_tags.push(nt);
stats.note_tags += 1;
}
Err(_) => stats.errors += 1,
},
ItemType::Other => stats.others += 1,
}
}
lib.build_indexes();
(lib, stats)
}
fn build_indexes(&mut self) {
let mut notes_by_folder: HashMap<String, Vec<String>> = HashMap::new();
for n in self.notes.values() {
notes_by_folder
.entry(n.parent_id.clone())
.or_default()
.push(n.id.clone());
}
let mut child_folders: HashMap<String, Vec<String>> = HashMap::new();
for f in self.folders.values() {
child_folders
.entry(f.parent_id.clone())
.or_default()
.push(f.id.clone());
}
let mut notes_by_tag: HashMap<String, Vec<String>> = HashMap::new();
let mut seen: HashMap<String, HashSet<String>> = HashMap::new();
for nt in &self.note_tags {
if !self.notes.contains_key(&nt.note_id) {
continue;
}
if seen.entry(nt.tag_id.clone()).or_default().insert(nt.note_id.clone()) {
notes_by_tag.entry(nt.tag_id.clone()).or_default().push(nt.note_id.clone());
}
}
self.notes_by_folder = notes_by_folder;
self.child_folders = child_folders;
self.notes_by_tag = notes_by_tag;
}
pub fn note_count(&self, folder_id: &str) -> usize {
self.notes_by_folder.get(folder_id).map(|v| v.len()).unwrap_or(0)
}
pub fn child_folder_ids_sorted(&self, parent_id: &str) -> Vec<String> {
let mut ids = self.child_folders.get(parent_id).cloned().unwrap_or_default();
ids.sort_by(|a, b| {
let ta = self.folders.get(a).map(|f| f.title.as_str()).unwrap_or("");
let tb = self.folders.get(b).map(|f| f.title.as_str()).unwrap_or("");
ta.cmp(tb)
});
ids
}
pub fn notes_in_folder_sorted(&self, folder_id: &str) -> Vec<&Note> {
let mut notes: Vec<&Note> = self
.notes_by_folder
.get(folder_id)
.map(|ids| ids.iter().filter_map(|id| self.notes.get(id)).collect())
.unwrap_or_default();
notes.sort_by(|a, b| b.updated_time.cmp(&a.updated_time));
notes
}
pub fn tag_note_count(&self, tag_id: &str) -> usize {
self.notes_by_tag.get(tag_id).map(|v| v.len()).unwrap_or(0)
}
pub fn tags_sorted(&self) -> Vec<&Tag> {
let mut tags: Vec<&Tag> = self.tags.values().collect();
tags.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
tags
}
pub fn notes_with_tag(&self, tag_id: &str) -> Vec<&Note> {
let mut notes: Vec<&Note> = self
.notes_by_tag
.get(tag_id)
.map(|ids| ids.iter().filter_map(|id| self.notes.get(id)).collect())
.unwrap_or_default();
notes.sort_by(|a, b| b.updated_time.cmp(&a.updated_time));
notes
}
pub fn tag_id_by_title(&self, title: &str) -> Option<String> {
let key = title.trim().to_lowercase();
if key.is_empty() {
return None;
}
self.tags
.values()
.filter(|t| t.title.trim().to_lowercase() == key)
.map(|t| &t.id)
.min()
.cloned()
}
pub fn note_has_tag(&self, note_id: &str, tag_id: &str) -> bool {
self.note_tags.iter().any(|nt| nt.note_id == note_id && nt.tag_id == tag_id)
}
pub fn note_tag_ids_for(&self, note_id: &str, tag_id: &str) -> Vec<String> {
self.note_tags
.iter()
.filter(|nt| nt.note_id == note_id && nt.tag_id == tag_id)
.map(|nt| nt.id.clone())
.collect()
}
pub fn tags_of_note(&self, note_id: &str) -> Vec<&Tag> {
let mut ids: HashSet<&str> = HashSet::new();
let mut out: Vec<&Tag> = Vec::new();
for nt in &self.note_tags {
if nt.note_id == note_id {
if let Some(tag) = self.tags.get(&nt.tag_id) {
if ids.insert(tag.id.as_str()) {
out.push(tag);
}
}
}
}
out.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
out
}
pub fn note(&self, id: &str) -> Option<&Note> {
self.notes.get(id)
}
pub fn resource(&self, id: &str) -> Option<&Resource> {
self.resources.get(id)
}
pub fn search(&self, query: &str) -> Vec<&Note> {
let q = query.trim().to_lowercase();
if q.is_empty() {
return vec![];
}
let mut hits: Vec<&Note> = self
.notes
.values()
.filter(|n| {
n.title.to_lowercase().contains(&q) || n.body.to_lowercase().contains(&q)
})
.collect();
hits.sort_by(|a, b| b.updated_time.cmp(&a.updated_time));
hits.truncate(200);
hits
}
pub fn note_raw(&self, id: &str) -> Option<&str> {
self.raw_notes.get(id).map(|s| s.as_str())
}
pub fn upsert_note(&mut self, content: &str) -> anyhow::Result<String> {
let raw = parser::parse_item(content)?;
let note = parser::to_note(&raw)?;
let id = note.id.clone();
self.raw_notes.insert(id.clone(), content.to_string());
self.notes.insert(id.clone(), note);
self.build_indexes();
Ok(id)
}
pub fn upsert_folder(&mut self, content: &str) -> anyhow::Result<String> {
let raw = parser::parse_item(content)?;
let folder = parser::to_folder(&raw)?;
let id = folder.id.clone();
self.folders.insert(id.clone(), folder);
self.build_indexes();
Ok(id)
}
pub fn is_self_or_descendant(&self, root: &str, candidate: &str) -> bool {
let mut cur = candidate.to_string();
for _ in 0..=self.folders.len() {
if cur == root {
return true;
}
match self.folders.get(&cur) {
Some(f) if !f.parent_id.is_empty() => cur = f.parent_id.clone(),
_ => return false,
}
}
false
}
pub fn subtree_folder_ids(&self, root: &str) -> Vec<String> {
let mut out = Vec::new();
if root.is_empty() || !self.folders.contains_key(root) {
return out; }
let mut stack = vec![root.to_string()];
let cap = self.folders.len() + 1;
while let Some(id) = stack.pop() {
if out.len() > cap {
break;
}
if let Some(children) = self.child_folders.get(&id) {
stack.extend(children.iter().cloned());
}
out.push(id);
}
out
}
pub fn upsert_resource(&mut self, content: &str) -> anyhow::Result<String> {
let raw = parser::parse_item(content)?;
let r = parser::to_resource(&raw)?;
let id = r.id.clone();
self.resources.insert(id.clone(), r);
Ok(id)
}
pub fn remove_note(&mut self, id: &str) {
self.notes.remove(id);
self.raw_notes.remove(id);
self.build_indexes();
}
pub fn upsert_tag(&mut self, content: &str) -> anyhow::Result<String> {
let raw = parser::parse_item(content)?;
let tag = parser::to_tag(&raw)?;
let id = tag.id.clone();
self.tags.insert(id.clone(), tag);
Ok(id)
}
pub fn upsert_note_tag(&mut self, content: &str) -> anyhow::Result<NoteTag> {
let raw = parser::parse_item(content)?;
let nt = parser::to_note_tag(&raw)?;
self.note_tags.retain(|x| x.id != nt.id);
self.note_tags.push(nt.clone());
self.build_indexes();
Ok(nt)
}
pub fn remove_note_tag(&mut self, id: &str) {
self.note_tags.retain(|nt| nt.id != id);
self.build_indexes();
}
pub fn remove_resource(&mut self, id: &str) {
self.resources.remove(id);
}
pub fn resource_usage(&self) -> HashMap<String, usize> {
let mut usage: HashMap<String, usize> = HashMap::new();
for n in self.notes.values() {
for id in scan_resource_refs(&n.body) {
*usage.entry(id).or_insert(0) += 1;
}
}
usage
}
}
pub fn count_tasks(body: &str) -> (usize, usize) {
let mut done = 0;
let mut total = 0;
for line in body.lines() {
let b = line.trim_start().as_bytes();
if b.len() >= 5
&& matches!(b[0], b'-' | b'*' | b'+')
&& b[1] == b' '
&& b[2] == b'['
&& b[4] == b']'
{
let after_ok = b.len() == 5 || b[5] == b' ' || b[5] == b'\t';
match (after_ok, b[3]) {
(true, b' ') => total += 1,
(true, b'x') | (true, b'X') => {
total += 1;
done += 1;
}
_ => {}
}
}
}
(done, total)
}
fn scan_resource_refs(body: &str) -> HashSet<String> {
let b = body.as_bytes();
let mut out = HashSet::new();
let mut i = 0;
while i + 34 <= b.len() {
if b[i] == b':' && b[i + 1] == b'/' {
let hex = &b[i + 2..i + 34];
let bounded = i + 34 >= b.len() || !b[i + 34].is_ascii_hexdigit();
if bounded && hex.iter().all(|c| c.is_ascii_hexdigit()) {
out.insert(String::from_utf8_lossy(hex).to_lowercase());
i += 34;
continue;
}
}
i += 1;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scans_resource_refs() {
let body = "见图  和附件 [f](:/0123456789ABCDEF0123456789ABCDEF)\n\
重复同一个 :/0123456789abcdef0123456789abcdef 再来个 :/deadbeefdeadbeefdeadbeefdeadbeef";
let refs = scan_resource_refs(body);
assert_eq!(refs.len(), 2);
assert!(refs.contains("0123456789abcdef0123456789abcdef"));
assert!(refs.contains("deadbeefdeadbeefdeadbeefdeadbeef"));
}
#[test]
fn counts_task_list() {
let body = "标题\n- [ ] 待办一\n- [x] 已完成\n* [X] 也完成\n+ [ ] 第四\n普通行\n-[ ] 无空格不算\n- [] 非法不算";
assert_eq!(count_tasks(body), (2, 4));
assert_eq!(count_tasks("没有任何任务"), (0, 0));
assert_eq!(count_tasks(" - [x] 缩进也算"), (1, 1));
}
#[test]
fn ignores_non_32hex() {
let refs = scan_resource_refs(":/short :/0123456789abcdef0123456789abcdefEXTRA :/zzzz");
assert!(refs.is_empty());
}
use crate::serialize::{new_folder_md, new_note_md, new_note_tag_md, new_resource_md, new_tag_md};
fn hid(n: u8) -> String {
format!("{:02x}", n).repeat(16)
}
#[test]
fn builds_tree_counts_and_ordering() {
let (root_a, root_b, child) = (hid(0xa0), hid(0xb0), hid(0xc0));
let contents = vec![
new_folder_md(&root_a, "", "Alpha", 1000),
new_folder_md(&root_b, "", "Beta", 2000),
new_folder_md(&child, &root_a, "Child", 1500),
new_note_md(&hid(1), &root_a, "n1", "body one", false, 100),
new_note_md(&hid(2), &root_a, "n2", "body two", false, 200),
new_note_md(&hid(3), &child, "n3", "nested", false, 300),
];
let (lib, stats) = Library::from_contents(contents);
assert_eq!(stats.folders, 3);
assert_eq!(stats.notes, 3);
assert_eq!(stats.errors, 0);
assert_eq!(lib.note_count(&root_a), 2);
assert_eq!(lib.note_count(&child), 1);
assert_eq!(lib.note_count(&root_b), 0);
let roots = lib.child_folder_ids_sorted("");
assert_eq!(roots, vec![root_a.clone(), root_b.clone()]);
let notes = lib.notes_in_folder_sorted(&root_a);
assert_eq!(
notes.iter().map(|n| n.title.as_str()).collect::<Vec<_>>(),
vec!["n2", "n1"]
);
}
#[test]
fn search_matches_title_and_body_case_insensitively() {
let contents = vec![
new_note_md(&hid(1), "", "Rust Notes", "hello world", false, 100),
new_note_md(&hid(2), "", "Cooking", "about RUST macros", false, 200),
new_note_md(&hid(3), "", "Unrelated", "nothing here", false, 300),
];
let (lib, _) = Library::from_contents(contents);
let hits = lib.search("rust");
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].title, "Cooking");
assert_eq!(hits[1].title, "Rust Notes");
assert!(lib.search(" ").is_empty()); assert!(lib.search("zzz-no-match").is_empty());
}
#[test]
fn anti_cycle_self_or_descendant() {
let (a, b, c, d) = (hid(0xa0), hid(0xb0), hid(0xc0), hid(0xd0));
let contents = vec![
new_folder_md(&a, "", "A", 1),
new_folder_md(&b, &a, "B", 2),
new_folder_md(&c, &b, "C", 3),
new_folder_md(&d, "", "D", 4),
];
let (lib, _) = Library::from_contents(contents);
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)); }
fn tag_md(id: &str, title: &str) -> String {
format!("{title}\n\nid: {id}\nparent_id: \ntype_: 5")
}
fn note_tag_md(id: &str, note_id: &str, tag_id: &str) -> String {
format!("id: {id}\nnote_id: {note_id}\ntag_id: {tag_id}\ntype_: 6")
}
#[test]
fn indexes_tags_with_counts_and_membership() {
let (t_work, t_idea) = (hid(0x51), hid(0x52));
let (n1, n2, n3) = (hid(1), hid(2), hid(3));
let contents = vec![
new_note_md(&n1, "", "n1", "body", false, 100),
new_note_md(&n2, "", "n2", "body", false, 300),
new_note_md(&n3, "", "n3", "body", false, 200),
tag_md(&t_work, "Work"),
tag_md(&t_idea, "idea"),
note_tag_md(&hid(0x61), &n1, &t_work),
note_tag_md(&hid(0x62), &n2, &t_work),
note_tag_md(&hid(0x63), &n2, &t_work), note_tag_md(&hid(0x64), &n3, &t_idea),
note_tag_md(&hid(0x65), &hid(0xff), &t_work),
];
let (lib, stats) = Library::from_contents(contents);
assert_eq!(stats.tags, 2);
assert_eq!(stats.note_tags, 5);
let tags = lib.tags_sorted();
assert_eq!(tags.iter().map(|t| t.title.as_str()).collect::<Vec<_>>(), vec!["idea", "Work"]);
assert_eq!(lib.tag_note_count(&t_work), 2);
assert_eq!(lib.tag_note_count(&t_idea), 1);
assert_eq!(lib.tag_note_count(&hid(0xee)), 0);
let work_notes = lib.notes_with_tag(&t_work);
assert_eq!(work_notes.iter().map(|n| n.title.as_str()).collect::<Vec<_>>(), vec!["n2", "n1"]);
assert!(lib.notes_with_tag(&hid(0xee)).is_empty());
}
#[test]
fn tag_mutations_add_reuse_and_remove() {
let (n1, n2) = (hid(1), hid(2));
let (mut lib, _) = Library::from_contents(vec![
new_note_md(&n1, "", "n1", "b", false, 100),
new_note_md(&n2, "", "n2", "b", false, 200),
]);
let tag_id = hid(0x51);
lib.upsert_tag(&new_tag_md(&tag_id, "Work", 1)).unwrap();
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);
assert!(!lib.note_has_tag(&n1, &tag_id));
let nt = lib.upsert_note_tag(&new_note_tag_md(&hid(0x61), &n1, &tag_id, 1)).unwrap();
assert_eq!(nt.note_id, n1);
assert!(lib.note_has_tag(&n1, &tag_id));
assert_eq!(lib.tag_note_count(&tag_id), 1);
assert_eq!(lib.notes_with_tag(&tag_id).iter().map(|n| n.title.as_str()).collect::<Vec<_>>(), vec!["n1"]);
assert_eq!(lib.tags_of_note(&n1).iter().map(|t| t.title.as_str()).collect::<Vec<_>>(), vec!["Work"]);
assert!(lib.tags_of_note(&n2).is_empty());
lib.upsert_note_tag(&new_note_tag_md(&hid(0x62), &n2, &tag_id, 1)).unwrap();
assert_eq!(lib.tag_note_count(&tag_id), 2);
let ids = lib.note_tag_ids_for(&n1, &tag_id);
assert_eq!(ids, vec![hid(0x61)]);
lib.remove_note_tag(&ids[0]);
assert!(!lib.note_has_tag(&n1, &tag_id));
assert_eq!(lib.tag_note_count(&tag_id), 1);
assert_eq!(lib.notes_with_tag(&tag_id).iter().map(|n| n.title.as_str()).collect::<Vec<_>>(), vec!["n2"]);
lib.upsert_note_tag(&new_note_tag_md(&hid(0x62), &n2, &tag_id, 1)).unwrap();
assert_eq!(lib.tag_note_count(&tag_id), 1);
}
#[test]
fn tag_membership_drops_deleted_notes() {
let t = hid(0x51);
let (n1, n2) = (hid(1), hid(2));
let contents = vec![
new_note_md(&n1, "", "n1", "body", false, 100),
new_note_md(&n2, "", "n2", "body", false, 200),
tag_md(&t, "Work"),
note_tag_md(&hid(0x61), &n1, &t),
note_tag_md(&hid(0x62), &n2, &t),
];
let (mut lib, _) = Library::from_contents(contents);
assert_eq!(lib.tag_note_count(&t), 2);
lib.remove_note(&n1);
assert_eq!(lib.tag_note_count(&t), 1);
assert_eq!(lib.notes_with_tag(&t).iter().map(|n| n.title.as_str()).collect::<Vec<_>>(), vec!["n2"]);
}
#[test]
fn resource_usage_counts_refs_and_orphans() {
let (r_used, r_orphan) = (hid(0xe0), hid(0xf0));
let contents = vec"), false, 1),
new_note_md(&hid(2), "", "n2", &format!("again :/{r_used}"), false, 2),
new_note_md(&hid(3), "", "n3", "no images here", false, 3),
new_resource_md(&r_used, "used.png", "image/png", "png", 10, 1),
new_resource_md(&r_orphan, "orphan.png", "image/png", "png", 10, 1),
];
let (lib, stats) = Library::from_contents(contents);
assert_eq!(stats.resources, 2);
let usage = lib.resource_usage();
assert_eq!(usage.get(&r_used).copied(), Some(2)); assert_eq!(usage.get(&r_orphan).copied(), None); assert!(lib.resource(&r_used).is_some());
assert_eq!(lib.resource(&r_used).unwrap().mime, "image/png");
}
}