git.lucas.co / cce-list
things-to-remember checklist
git clone https://git.lucas.co/cce-list.git

src/vault_tasks.rs (6.6K)

  1 //! The "Vault tasks" view: every open checkbox in the notes vault (the one
  2 //! cce-notes uses, from `vault { path }` in config.kdl), grouped by note.
  3 //! Ticking writes the checkbox back into its note through `cce-vault`,
  4 //! which re-reads the file first, so an edit made a second ago elsewhere is
  5 //! not overwritten. Typing adds a task to today's daily note.
  6 //!
  7 //! A task ticked here stays on screen, struck through, until the view is
  8 //! left — so a mis-tick can be undone — although the view otherwise lists
  9 //! only open tasks. Clicking a note's name opens it in cce-notes.
 10 //!
 11 //! The vault is watched like any cce-vault client: a watcher thread queues
 12 //! changed paths, and `poll` applies them on the app's loop.
 13 
 14 use std::collections::HashSet;
 15 use std::io::{BufRead, BufReader, Write};
 16 use std::path::PathBuf;
 17 use std::sync::{Arc, Mutex};
 18 
 19 use cce_vault::index::stem;
 20 use cce_vault::{Index, Task, VaultWatcher};
 21 
 22 #[derive(Debug, Clone, PartialEq)]
 23 pub enum Row {
 24     Note { path: String, name: String },
 25     Task { path: String, line: usize, text: String, done: bool },
 26 }
 27 
 28 pub struct VaultTasks {
 29     index: Index,
 30     _watcher: Option<VaultWatcher>,
 31     pending: Arc<Mutex<Vec<PathBuf>>>,
 32     /// (path, line) of tasks ticked in this view since it was entered.
 33     ticked: HashSet<(String, usize)>,
 34     pub rows: Vec<Row>,
 35 }
 36 
 37 /// The view's rows: each note with open (or just-ticked) tasks, then them.
 38 pub fn build_rows<'a>(tasks: impl Iterator<Item = (&'a str, &'a Task)>, ticked: &HashSet<(String, usize)>) -> Vec<Row> {
 39     let mut rows = Vec::new();
 40     let mut last: Option<&str> = None;
 41     for (path, t) in tasks {
 42         let shown = t.is_open() || ticked.contains(&(path.to_string(), t.line));
 43         if !shown {
 44             continue;
 45         }
 46         if last != Some(path) {
 47             rows.push(Row::Note { path: path.to_string(), name: stem(path).to_string() });
 48             last = Some(path);
 49         }
 50         rows.push(Row::Task { path: path.to_string(), line: t.line, text: t.text.trim().to_string(), done: !t.is_open() });
 51     }
 52     rows
 53 }
 54 
 55 impl VaultTasks {
 56     /// The configured vault's tasks, or `None` when no vault is set up.
 57     /// `wake` runs on the watcher's thread after each batch is queued, so
 58     /// the app's loop comes round to [`poll`](Self::poll) instead of
 59     /// finding the batch on its next timed tick.
 60     pub fn open(wake: impl Fn() + Send + 'static) -> Option<VaultTasks> {
 61         let root = cce_vault::config::vault_root(None).ok()?;
 62         let index = Index::open(&root, true).map_err(|e| log::warn!("vault tasks: {e}")).ok()?;
 63         let pending = Arc::new(Mutex::new(Vec::new()));
 64         let queue = pending.clone();
 65         let watcher = VaultWatcher::spawn(&root, move |paths| {
 66             queue.lock().unwrap_or_else(|e| e.into_inner()).extend(paths);
 67             wake();
 68         })
 69         .map_err(|e| log::warn!("vault watcher: {e}"))
 70         .ok();
 71         let mut v = VaultTasks { index, _watcher: watcher, pending, ticked: HashSet::new(), rows: Vec::new() };
 72         v.rebuild();
 73         Some(v)
 74     }
 75 
 76     fn rebuild(&mut self) {
 77         self.rows = build_rows(self.index.tasks(), &self.ticked);
 78     }
 79 
 80     /// Whether the vault watcher is running (it can fail to start).
 81     pub fn watching(&self) -> bool {
 82         self._watcher.is_some()
 83     }
 84 
 85     /// Apply queued vault changes; true when the rows may have changed.
 86     pub fn poll(&mut self) -> bool {
 87         let paths: Vec<PathBuf> = std::mem::take(&mut *self.pending.lock().unwrap_or_else(|e| e.into_inner()));
 88         if paths.is_empty() {
 89             return false;
 90         }
 91         self.index.apply_changes(&paths);
 92         self.rebuild();
 93         true
 94     }
 95 
 96     /// Entering the view starts a fresh session: earlier ticks drop out.
 97     pub fn enter(&mut self) {
 98         self.ticked.clear();
 99         self.poll();
100         self.rebuild();
101     }
102 
103     /// Tick or untick the task in row `i`.
104     pub fn toggle(&mut self, i: usize) -> Result<(), String> {
105         let Some(Row::Task { path, line, done, .. }) = self.rows.get(i).cloned() else { return Ok(()) };
106         let status = if done { ' ' } else { 'x' };
107         self.index.set_task(&path, line, status).map_err(|e| e.to_string())?;
108         if done {
109             self.ticked.remove(&(path, line));
110         } else {
111             self.ticked.insert((path, line));
112         }
113         self.rebuild();
114         Ok(())
115     }
116 
117     /// Add `- [ ] text` to today's daily note (created from the vault's
118     /// template if missing); returns its path.
119     pub fn add(&mut self, text: &str) -> Result<String, String> {
120         let today = chrono::Local::now().date_naive();
121         let (path, _) = self.index.daily(today, true).map_err(|e| e.to_string())?;
122         self.index.append(&path, &format!("- [ ] {text}")).map_err(|e| e.to_string())?;
123         self.rebuild();
124         Ok(path)
125     }
126 
127     /// Show a note in cce-notes: hand it to the running instance over its
128     /// socket, or start one.
129     pub fn open_note(&self, path: &str) {
130         let abs = self.index.abs(path);
131         let sock = cce_ui::ipc::socket_path("cce-notes");
132         if let Ok(mut s) = std::os::unix::net::UnixStream::connect(&sock) {
133             if s.write_all(format!("open {}\n", abs.display()).as_bytes()).is_ok() {
134                 let mut reply = String::new();
135                 let _ = BufReader::new(s).read_line(&mut reply);
136                 return;
137             }
138         }
139         let _ = std::process::Command::new("cce-notes").arg("open").arg(&abs).spawn();
140     }
141 }
142 
143 #[cfg(test)]
144 mod tests {
145     use super::*;
146 
147     fn task(status: char, text: &str, line: usize) -> Task {
148         Task { status, text: text.into(), line, status_at: 0 }
149     }
150 
151     #[test]
152     fn rows_group_open_tasks_and_keep_this_sessions_ticks() {
153         let a = [task(' ', "one", 1), task('x', "old done", 2)];
154         let b = [task('x', "ticked now", 4), task('x', "long done", 5)];
155         let c = [task('x', "all done", 0)];
156         let all: Vec<(&str, &Task)> = a
157             .iter()
158             .map(|t| ("A.md", t))
159             .chain(b.iter().map(|t| ("dir/B.md", t)))
160             .chain(c.iter().map(|t| ("C.md", t)))
161             .collect();
162         let ticked: HashSet<(String, usize)> = [("dir/B.md".to_string(), 4)].into_iter().collect();
163         let rows = build_rows(all.into_iter(), &ticked);
164         assert_eq!(
165             rows,
166             [
167                 Row::Note { path: "A.md".into(), name: "A".into() },
168                 Row::Task { path: "A.md".into(), line: 1, text: "one".into(), done: false },
169                 Row::Note { path: "dir/B.md".into(), name: "B".into() },
170                 Row::Task { path: "dir/B.md".into(), line: 4, text: "ticked now".into(), done: true },
171             ]
172         );
173     }
174 }