notes editor over the vault (Obsidian-compatible)
git clone https://git.lucas.co/cce-notes.git
src/side.rs (6.8K)
1 //! What the side panes list, built from the index: a note's backlinks and
2 //! unlinked mentions, its outline, and full-text or tag search results.
3 //! Pure functions over `Index`, so they test without a window.
4
5 use std::collections::BTreeMap;
6
7 use cce_vault::index::stem;
8 use cce_vault::Index;
9
10 use crate::panel::{Action, Item};
11
12 /// Search results kept per query; the pane is for finding, not listing.
13 const SEARCH_LIMIT: usize = 50;
14
15 /// Linked mentions (grouped by note, each with its lines), then unlinked
16 /// mentions — Obsidian's backlinks pane.
17 pub fn backlinks(ix: &Index, path: &str) -> Vec<Item> {
18 let mut out = Vec::new();
19 let mut by_source: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
20 for b in ix.backlinks(path) {
21 if b.source == path {
22 continue;
23 }
24 by_source.entry(b.source).or_default().push(b.link.line);
25 }
26 let count: usize = by_source.len();
27 out.push(Item::header(format!("Linked mentions ({count})")));
28 if by_source.is_empty() {
29 out.push(Item::note("No backlinks"));
30 }
31 for (source, mut lines) in by_source {
32 lines.sort_unstable();
33 lines.dedup();
34 out.push(Item::title(stem(source), Action::Open { path: source.to_string(), line: lines.first().copied() }));
35 let text = ix.read_text(source).unwrap_or_default();
36 let all: Vec<&str> = text.lines().collect();
37 for line in lines {
38 // A canvas link has no line of its own worth showing.
39 let Some(t) = all.get(line).map(|t| t.trim()).filter(|t| !t.is_empty()) else { continue };
40 out.push(Item::line(1, t.to_string(), Action::Open { path: source.to_string(), line: Some(line) }));
41 }
42 }
43 let mentions = ix.unlinked_mentions(path);
44 out.push(Item::header(format!("Unlinked mentions ({})", mentions.len())));
45 for hits in mentions {
46 let first = hits.lines.first().map(|l| l.line);
47 out.push(Item::title(stem(&hits.path), Action::Open { path: hits.path.clone(), line: first }));
48 for l in hits.lines {
49 out.push(Item::line(1, l.text, Action::Open { path: hits.path.clone(), line: Some(l.line) }));
50 }
51 }
52 out
53 }
54
55 /// The note's headings, indented by level.
56 pub fn outline(ix: &Index, path: &str) -> Vec<Item> {
57 let Some(note) = ix.note(path) else { return vec![Item::note("Not a note")] };
58 if note.headings.is_empty() {
59 return vec![Item::note("No headings")];
60 }
61 let top = note.headings.iter().map(|h| h.level).min().unwrap_or(1);
62 note.headings
63 .iter()
64 .map(|h| Item::line((h.level - top) as usize, h.text.clone(), Action::Line(h.line)))
65 .collect()
66 }
67
68 /// A search query's results: `#tag` or `tag:tag` lists the notes carrying
69 /// it (nested tags included); anything else is full text, every word
70 /// required, grouped by note with the matching lines.
71 pub fn search(ix: &Index, query: &str) -> Vec<Item> {
72 let q = query.trim();
73 if q.is_empty() {
74 return Vec::new();
75 }
76 if let Some(tag) = tag_query(q) {
77 let mut notes = ix.tagged(tag);
78 notes.sort_by_key(|p| p.to_lowercase());
79 let mut out = vec![Item::header(format!("#{tag} — {}", plural(notes.len(), "note")))];
80 out.extend(notes.into_iter().map(|p| Item::title(stem(p), Action::Open { path: p.to_string(), line: None })));
81 return out;
82 }
83 let hits = ix.search(q, SEARCH_LIMIT);
84 let mut out = vec![Item::header(plural(hits.len(), "note"))];
85 for h in hits {
86 let first = h.lines.first().map(|l| l.line);
87 let title = if h.total > h.lines.len() {
88 format!("{} ({} lines)", stem(&h.path), h.total)
89 } else {
90 stem(&h.path).to_string()
91 };
92 out.push(Item::title(title, Action::Open { path: h.path.clone(), line: first }));
93 for l in h.lines {
94 out.push(Item::line(1, l.text, Action::Open { path: h.path.clone(), line: Some(l.line) }));
95 }
96 }
97 out
98 }
99
100 /// The tag a query asks for, if it is a tag query.
101 pub fn tag_query(q: &str) -> Option<&str> {
102 let t = q.strip_prefix("tag:").or_else(|| q.strip_prefix('#'))?.trim().trim_start_matches('#');
103 (!t.is_empty() && !t.contains(char::is_whitespace)).then_some(t)
104 }
105
106 pub fn plural(n: usize, what: &str) -> String {
107 format!("{n} {what}{}", if n == 1 { "" } else { "s" })
108 }
109
110 #[cfg(test)]
111 mod tests {
112 use super::*;
113 use crate::panel::Kind;
114
115 fn vault(files: &[(&str, &str)]) -> (tempfile::TempDir, Index) {
116 let dir = tempfile::tempdir().unwrap();
117 for (p, t) in files {
118 let abs = dir.path().join(p);
119 std::fs::create_dir_all(abs.parent().unwrap()).unwrap();
120 std::fs::write(abs, t).unwrap();
121 }
122 let ix = Index::open(dir.path(), false).unwrap();
123 (dir, ix)
124 }
125
126 fn texts(items: &[Item]) -> Vec<String> {
127 items.iter().map(|i| format!("{}{}", " ".repeat(i.depth), i.text)).collect()
128 }
129
130 #[test]
131 fn backlinks_group_by_note_with_lines_then_mentions() {
132 let (_d, ix) = vault(&[
133 ("Target.md", "# T\n"),
134 ("a/One.md", "intro\nsee [[Target]] here\nand [[Target#T]] again\n"),
135 ("Two.md", "about Target, unlinked\n"),
136 ]);
137 let items = backlinks(&ix, "Target.md");
138 assert_eq!(
139 texts(&items),
140 [
141 "Linked mentions (1)",
142 "One",
143 " see [[Target]] here",
144 " and [[Target#T]] again",
145 "Unlinked mentions (1)",
146 "Two",
147 " about Target, unlinked",
148 ]
149 );
150 assert_eq!(items[2].action, Some(Action::Open { path: "a/One.md".into(), line: Some(1) }));
151 assert_eq!(items[0].kind, Kind::Header);
152 }
153
154 #[test]
155 fn outline_indents_from_the_top_level() {
156 let (_d, ix) = vault(&[("N.md", "## A\ntext\n### B\n## C\n")]);
157 let items = outline(&ix, "N.md");
158 assert_eq!(texts(&items), ["A", " B", "C"]);
159 assert_eq!(items[1].action, Some(Action::Line(2)));
160 let (_d, ix) = vault(&[("E.md", "no headings\n")]);
161 assert_eq!(outline(&ix, "E.md")[0].kind, Kind::Note);
162 }
163
164 #[test]
165 fn search_text_and_tags() {
166 let (_d, ix) = vault(&[
167 ("A.md", "apple pie\nbanana\n#fruit/red\n"),
168 ("B.md", "apple tart #fruit\n"),
169 ("C.md", "nothing\n"),
170 ]);
171 let items = search(&ix, "apple");
172 assert_eq!(items[0].text, "2 notes");
173 assert!(texts(&items).contains(&" apple pie".to_string()));
174 let tags = search(&ix, "#fruit");
175 assert_eq!(texts(&tags), ["#fruit — 2 notes", "A", "B"]);
176 assert_eq!(texts(&search(&ix, "tag:fruit/red")), ["#fruit/red — 1 note", "A"]);
177 assert!(search(&ix, " ").is_empty());
178 assert_eq!(tag_query("#a b"), None);
179 }
180 }