notes vault library and CLI (Obsidian-compatible)
git clone https://git.lucas.co/cce-vault.git
src/main.rs (16.1K)
1 //! `cce-vault`: the vault index from a shell — for scripts, agents and
2 //! shadow tests, and the quickest way to check what the apps will see.
3
4 use std::path::PathBuf;
5 use std::process::ExitCode;
6
7 use cce_vault::{config, search, FileKind, Index};
8 use chrono::{Duration, Local, NaiveDate};
9 use serde_json::{json, Value};
10
11 const HELP: &str = "\
12 cce-vault — query and edit the notes vault
13
14 usage: cce-vault [--vault DIR] [--json] [--cache] <command> [args]
15
16 path the vault root
17 stats files, links, tags and tasks; index timing
18 find <query> fuzzy-match note names, aliases and paths
19 search <query> full text; every word must match, \"quote phrases\"
20 resolve <link> [--from N] the file link text reaches (from note N)
21 links <note> outgoing links and where each resolves
22 backlinks <note> links pointing at the note
23 mentions <note> unlinked mentions of the note's name or aliases
24 unresolved links that reach no file
25 tags [tag] tag counts, or the notes carrying a tag
26 tasks [--done|--all] [note] open tasks (vault-wide, or one note)
27 properties <note> frontmatter as JSON
28 daily [date] [--create] the daily note's path; date is YYYY-MM-DD,
29 today, yesterday, tomorrow, or +N / -N days
30 rename <note> <to> [--dry-run]
31 move a file and rewrite every link to it; a
32 bare new name stays in the same folder
33 watch apply and print changes as they happen
34
35 The vault is --vault, else $CCE_VAULT, else `vault { path \"…\" }` in
36 ~/.config/cce/config.kdl. Lines are printed 1-based. --cache reuses (and
37 writes) the parse cache under ~/.cache/cce/vault; off by default, since a
38 parallel parse of a warm vault is faster than loading it.
39 ";
40
41 struct Opts {
42 vault: Option<PathBuf>,
43 json: bool,
44 cache: bool,
45 args: Vec<String>,
46 }
47
48 fn parse_opts() -> Result<Opts, String> {
49 let mut opts = Opts { vault: None, json: false, cache: false, args: Vec::new() };
50 let mut it = std::env::args().skip(1);
51 while let Some(a) = it.next() {
52 match a.as_str() {
53 "--vault" => opts.vault = Some(it.next().ok_or("--vault needs a directory")?.into()),
54 "--json" => opts.json = true,
55 "--cache" => opts.cache = true,
56 "-h" | "--help" | "help" => opts.args = vec!["help".into()],
57 _ => opts.args.push(a),
58 }
59 }
60 Ok(opts)
61 }
62
63 /// Pull `--flag` out of the positional args.
64 fn take_flag(args: &mut Vec<String>, flag: &str) -> bool {
65 let before = args.len();
66 args.retain(|a| a != flag);
67 args.len() != before
68 }
69
70 fn take_value(args: &mut Vec<String>, flag: &str) -> Option<String> {
71 let i = args.iter().position(|a| a == flag)?;
72 args.remove(i);
73 (i < args.len()).then(|| args.remove(i))
74 }
75
76 fn main() -> ExitCode {
77 env_logger_lite();
78 let opts = match parse_opts() {
79 Ok(o) => o,
80 Err(e) => return fail(&e),
81 };
82 if opts.args.is_empty() || opts.args[0] == "help" {
83 print!("{HELP}");
84 return ExitCode::SUCCESS;
85 }
86 let root = match config::vault_root(opts.vault.as_deref()) {
87 Ok(r) => r,
88 Err(e) => return fail(&e.to_string()),
89 };
90 let mut index = match Index::open(&root, opts.cache) {
91 Ok(i) => i,
92 Err(e) => return fail(&format!("{}: {e}", root.display())),
93 };
94 let result = run(&mut index, &opts);
95 if opts.cache {
96 if let Err(e) = index.save_cache() {
97 log_warn(&format!("could not write the index cache: {e}"));
98 }
99 }
100 match result {
101 Ok(()) => ExitCode::SUCCESS,
102 Err(e) => fail(&e),
103 }
104 }
105
106 fn fail(msg: &str) -> ExitCode {
107 eprintln!("cce-vault: {msg}");
108 ExitCode::FAILURE
109 }
110
111 fn log_warn(msg: &str) {
112 eprintln!("cce-vault: warning: {msg}");
113 }
114
115 /// `RUST_LOG=debug` shows the crate's log lines on stderr without pulling
116 /// in a logging stack for a CLI.
117 fn env_logger_lite() {
118 struct Stderr(log::LevelFilter);
119 impl log::Log for Stderr {
120 fn enabled(&self, m: &log::Metadata) -> bool {
121 m.level() <= self.0
122 }
123 fn log(&self, r: &log::Record) {
124 if self.enabled(r.metadata()) {
125 eprintln!("[{}] {}", r.level(), r.args());
126 }
127 }
128 fn flush(&self) {}
129 }
130 let level = match std::env::var("RUST_LOG").unwrap_or_default().as_str() {
131 "trace" => log::LevelFilter::Trace,
132 "debug" => log::LevelFilter::Debug,
133 "info" => log::LevelFilter::Info,
134 _ => log::LevelFilter::Warn,
135 };
136 let _ = log::set_logger(Box::leak(Box::new(Stderr(level)))).map(|_| log::set_max_level(level));
137 }
138
139 /// A note named on the command line, or an error that suggests names.
140 fn note_arg(index: &Index, arg: Option<&String>) -> Result<String, String> {
141 let q = arg.ok_or("which note?")?;
142 if let Some(p) = index.lookup(q) {
143 return Ok(p);
144 }
145 let near: Vec<String> = index.find(q, 3).into_iter().map(|m| m.path).collect();
146 if near.is_empty() {
147 Err(format!("no note matches {q:?}"))
148 } else {
149 Err(format!("no note matches {q:?}; did you mean: {}", near.join(", ")))
150 }
151 }
152
153 fn out_json(v: Value) {
154 println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
155 }
156
157 fn run(index: &mut Index, opts: &Opts) -> Result<(), String> {
158 let mut args = opts.args.clone();
159 let cmd = args.remove(0);
160 let json = opts.json;
161 match cmd.as_str() {
162 "path" => println!("{}", index.root().display()),
163
164 "stats" => {
165 let count = |k| index.files().values().filter(|e| e.kind == k).count();
166 let links: usize = index.documents().map(|(_, n)| n.links.len()).sum();
167 let unresolved: usize = index.unresolved().values().map(Vec::len).sum();
168 let tasks: Vec<_> = index.tasks().collect();
169 let open = tasks.iter().filter(|(_, t)| t.is_open()).count();
170 let rows: Vec<(&str, Value)> = vec![
171 ("root", json!(index.root())),
172 ("notes", json!(count(FileKind::Note))),
173 ("canvases", json!(count(FileKind::Canvas))),
174 ("attachments", json!(count(FileKind::Attachment))),
175 ("links", json!(links)),
176 ("unresolved_links", json!(unresolved)),
177 ("tags", json!(index.tags().len())),
178 ("tasks", json!(tasks.len())),
179 ("open_tasks", json!(open)),
180 ("index.parsed", json!(index.stats.parsed)),
181 ("index.reused", json!(index.stats.reused)),
182 ("index.millis", json!(index.stats.millis)),
183 ];
184 if json {
185 out_json(Value::Object(rows.into_iter().map(|(k, v)| (k.to_string(), v)).collect()));
186 } else {
187 for (k, v) in rows {
188 match v {
189 Value::String(s) => println!("{k:18} {s}"),
190 other => println!("{k:18} {other}"),
191 }
192 }
193 }
194 }
195
196 "find" => {
197 let q = args.join(" ");
198 let hits = index.find(&q, 20);
199 if json {
200 out_json(json!(hits));
201 } else {
202 for h in hits {
203 if h.matched == cce_vault::index::stem(&h.path) || h.matched == h.path {
204 println!("{}", h.path);
205 } else {
206 println!("{} (as {:?})", h.path, h.matched);
207 }
208 }
209 }
210 }
211
212 "search" => {
213 let q = args.join(" ");
214 let hits = index.search(&q, 50);
215 print_hits(&hits, json);
216 }
217
218 "resolve" => {
219 let from = match take_value(&mut args, "--from") {
220 Some(f) => Some(note_arg(index, Some(&f))?),
221 None => None,
222 };
223 let text = args.join(" ");
224 let hit = index.resolve_text(from.as_deref(), &text);
225 if json {
226 out_json(json!({ "link": text, "from": from, "path": hit }));
227 } else {
228 println!("{}", hit.ok_or_else(|| format!("{text:?} reaches no file"))?);
229 }
230 }
231
232 "links" => {
233 let path = note_arg(index, args.first())?;
234 let out = index.outgoing(&path);
235 if json {
236 out_json(json!(out
237 .iter()
238 .map(|(l, t)| json!({ "line": l.line + 1, "link": l, "resolved": t }))
239 .collect::<Vec<_>>()));
240 } else {
241 for (l, t) in out {
242 let sub = l.subpath.as_ref().map(|s| format!("#{s}")).unwrap_or_default();
243 let to = t.map(String::from).unwrap_or_else(|| "(unresolved)".into());
244 println!("{path}:{}: {}{sub} -> {to}", l.line + 1, l.target);
245 }
246 }
247 }
248
249 "backlinks" => {
250 let path = note_arg(index, args.first())?;
251 let bl = index.backlinks(&path);
252 if json {
253 out_json(json!(bl
254 .iter()
255 .map(|b| json!({ "source": b.source, "line": b.link.line + 1, "link": b.link }))
256 .collect::<Vec<_>>()));
257 } else {
258 for b in bl {
259 let sub = b.link.subpath.as_ref().map(|s| format!("#{s}")).unwrap_or_default();
260 let node = b.link.node.as_ref().map(|n| format!(" (node {n})")).unwrap_or_default();
261 println!("{}:{}: {}{sub}{node}", b.source, b.link.line + 1, b.link.target);
262 }
263 }
264 }
265
266 "mentions" => {
267 let path = note_arg(index, args.first())?;
268 print_hits(&index.unlinked_mentions(&path), json);
269 }
270
271 "unresolved" => {
272 let un = index.unresolved();
273 if json {
274 out_json(json!(un
275 .iter()
276 .map(|(t, bl)| (
277 t.clone(),
278 json!(bl.iter().map(|b| json!({ "source": b.source, "line": b.link.line + 1 })).collect::<Vec<_>>())
279 ))
280 .collect::<serde_json::Map<_, _>>()));
281 } else {
282 for (target, bl) in un {
283 let from: Vec<String> = bl.iter().map(|b| format!("{}:{}", b.source, b.link.line + 1)).collect();
284 println!("{target} <- {}", from.join(", "));
285 }
286 }
287 }
288
289 "tags" => match args.first() {
290 Some(tag) => {
291 let notes = index.tagged(tag);
292 if json {
293 out_json(json!(notes));
294 } else {
295 notes.iter().for_each(|n| println!("{n}"));
296 }
297 }
298 None => {
299 let tags = index.tags();
300 if json {
301 out_json(json!(tags.iter().map(|(t, n)| (t.clone(), json!(n))).collect::<serde_json::Map<_, _>>()));
302 } else {
303 for (t, n) in tags {
304 println!("{n:5} #{t}");
305 }
306 }
307 }
308 },
309
310 "tasks" => {
311 let done = take_flag(&mut args, "--done");
312 let all = take_flag(&mut args, "--all");
313 let only = match args.first() {
314 Some(a) => Some(note_arg(index, Some(a))?),
315 None => None,
316 };
317 let tasks: Vec<_> = index
318 .tasks()
319 .filter(|(p, _)| only.as_deref().is_none_or(|o| o == *p))
320 .filter(|(_, t)| all || (t.is_open() != done))
321 .collect();
322 if json {
323 out_json(json!(tasks
324 .iter()
325 .map(|(p, t)| json!({ "path": p, "line": t.line + 1, "status": t.status.to_string(), "text": t.text }))
326 .collect::<Vec<_>>()));
327 } else {
328 for (p, t) in tasks {
329 println!("{p}:{}: [{}] {}", t.line + 1, t.status, t.text);
330 }
331 }
332 }
333
334 "properties" => {
335 let path = note_arg(index, args.first())?;
336 let props = index.note(&path).map(|n| n.properties.clone()).unwrap_or_default();
337 println!("{}", serde_json::to_string_pretty(&props).unwrap_or_default());
338 }
339
340 "daily" => {
341 let create = take_flag(&mut args, "--create");
342 let date = parse_date(args.first().map(String::as_str).unwrap_or("today"))?;
343 let (path, created) = index.daily(date, create).map_err(|e| e.to_string())?;
344 if json {
345 let exists = index.entry(&path).is_some();
346 out_json(json!({ "path": path, "date": date.to_string(), "exists": exists, "created": created }));
347 } else {
348 println!("{path}");
349 }
350 }
351
352 "rename" => {
353 let dry = take_flag(&mut args, "--dry-run");
354 let from = note_arg(index, args.first())?;
355 let to_arg = args.get(1).ok_or("rename to what?")?;
356 let mut to = if to_arg.contains('/') {
357 to_arg.trim_start_matches('/').to_string()
358 } else {
359 match cce_vault::index::parent(&from) {
360 "" => to_arg.clone(),
361 dir => format!("{dir}/{to_arg}"),
362 }
363 };
364 // `rename Old New` for a note means New.md.
365 if FileKind::of(&from) == FileKind::Note && FileKind::of(&to) != FileKind::Note {
366 to.push_str(".md");
367 }
368 let plan = if dry { index.plan_rename(&from, &to) } else { index.rename(&from, &to) }
369 .map_err(|e| e.to_string())?;
370 if json {
371 out_json(json!({ "dry_run": dry, "plan": plan }));
372 } else {
373 println!("{}{} -> {}", if dry { "would move " } else { "moved " }, plan.from, plan.to);
374 for e in &plan.edits {
375 println!(" {}:{}: {} -> {}", e.path, e.line + 1, e.old, e.new);
376 }
377 println!("{} link{} {}", plan.edits.len(), if plan.edits.len() == 1 { "" } else { "s" },
378 if dry { "would change" } else { "rewritten" });
379 }
380 }
381
382 "watch" => {
383 let (tx, rx) = std::sync::mpsc::channel();
384 let _w = cce_vault::VaultWatcher::spawn(index.root(), move |batch| {
385 let _ = tx.send(batch);
386 })
387 .map_err(|e| e.to_string())?;
388 eprintln!("watching {} (ctrl-c to stop)", index.root().display());
389 for batch in rx {
390 let ch = index.apply_changes(&batch);
391 if ch.is_empty() {
392 continue;
393 }
394 if json {
395 println!("{}", json!(ch));
396 } else {
397 ch.updated.iter().for_each(|p| println!("updated {p}"));
398 ch.removed.iter().for_each(|p| println!("removed {p}"));
399 }
400 if opts.cache {
401 let _ = index.save_cache();
402 }
403 }
404 }
405
406 other => return Err(format!("unknown command {other:?} (try --help)")),
407 }
408 Ok(())
409 }
410
411 fn print_hits(hits: &[search::FileHits], json: bool) {
412 if json {
413 out_json(json!(hits));
414 return;
415 }
416 for h in hits {
417 for l in &h.lines {
418 println!("{}:{}: {}", h.path, l.line + 1, l.text);
419 }
420 if h.total > h.lines.len() {
421 println!("{}: … {} more", h.path, h.total - h.lines.len());
422 }
423 }
424 }
425
426 fn parse_date(s: &str) -> Result<NaiveDate, String> {
427 let today = Local::now().date_naive();
428 match s {
429 "today" => Ok(today),
430 "yesterday" => Ok(today - Duration::days(1)),
431 "tomorrow" => Ok(today + Duration::days(1)),
432 _ if s.starts_with('+') || s.starts_with('-') => s
433 .parse::<i64>()
434 .map(|n| today + Duration::days(n))
435 .map_err(|_| format!("not a day offset: {s}")),
436 _ => NaiveDate::parse_from_str(s, "%Y-%m-%d").map_err(|_| format!("not a date (YYYY-MM-DD): {s}")),
437 }
438 }