git.lucas.co / cce-notes
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 }