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 }