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

src/lib.rs (15.5K)

  1 //! Shared between the `cce-list` app and the `cce-list-sync` helper: the
  2 //! item model, the markdown checklists on disk, and the sync-state sidecar.
  3 //!
  4 //! Lists are plain markdown checklists, one file per list under
  5 //! `~/.local/share/cce-list/lists/<title>.md`, readable and editable with
  6 //! anything. The file's stem is the list's title. A list mirrored from a
  7 //! server carries its identity as a first-line HTML comment —
  8 //! `<!-- list:MDM5… -->` — and each mirrored item as a trailing one —
  9 //! `- [ ] call mom <!-- uid:ABC-123 -->`; markdown renderers hide both and
 10 //! hand-editors can ignore them (deleting one reads as "delete and
 11 //! recreate"). Which list the app shows is a one-line `current` file next
 12 //! to `lists/`. Everything else the sync needs (etags, item URLs, the
 13 //! last-synced snapshot) lives in `sync-state.json`, never in the markdown.
 14 //!
 15 //! Before lists existed there was a single `list.md`; `load_lists` migrates
 16 //! it on first sight (see [`migrate_legacy`]).
 17 
 18 use std::collections::BTreeMap;
 19 use std::path::{Path, PathBuf};
 20 
 21 #[derive(Debug, Clone, PartialEq, Eq)]
 22 pub struct Item {
 23     pub text: String,
 24     pub done: bool,
 25     /// Server identity for synced items; None for purely local ones.
 26     pub uid: Option<String>,
 27 }
 28 
 29 /// One checklist: its file stem, its server identity (if mirrored), items.
 30 #[derive(Debug, Clone, PartialEq, Eq)]
 31 pub struct ListFile {
 32     pub title: String,
 33     pub id: Option<String>,
 34     pub items: Vec<Item>,
 35 }
 36 
 37 pub fn data_dir() -> PathBuf {
 38     std::env::var_os("XDG_DATA_HOME")
 39         .map(PathBuf::from)
 40         .filter(|p| p.is_absolute())
 41         .unwrap_or_else(|| {
 42             PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".local/share")
 43         })
 44         .join("cce-list")
 45 }
 46 
 47 pub fn lists_dir() -> PathBuf {
 48     data_dir().join("lists")
 49 }
 50 
 51 /// The pre-lists single checklist; only read by the migration.
 52 pub fn legacy_path() -> PathBuf {
 53     data_dir().join("list.md")
 54 }
 55 
 56 pub fn current_path() -> PathBuf {
 57     data_dir().join("current")
 58 }
 59 
 60 pub fn sync_state_path() -> PathBuf {
 61     data_dir().join("sync-state.json")
 62 }
 63 
 64 /// A title as a file stem. `/` is the one character a stem cannot hold; a
 65 /// server title carrying one comes back to the server renamed, which is the
 66 /// lesser evil next to a list that cannot be written at all.
 67 pub fn safe_title(title: &str) -> String {
 68     let t: String = title.trim().replace('/', "-");
 69     if t.is_empty() || t == "." || t == ".." { "Untitled".to_string() } else { t }
 70 }
 71 
 72 pub fn list_path(title: &str) -> PathBuf {
 73     lists_dir().join(format!("{}.md", safe_title(title)))
 74 }
 75 
 76 // ── Markdown ──────────────────────────────────────────────────────────────
 77 
 78 /// Checklist lines become items; any other non-empty line is adopted as a
 79 /// not-done item rather than parsed around — the next save rewrites the file,
 80 /// so a line this reader skipped would be a line silently deleted.
 81 pub fn parse_items(text: &str) -> Vec<Item> {
 82     text.lines()
 83         .filter_map(|line| {
 84             let trimmed = line.trim();
 85             if trimmed.is_empty() {
 86                 return None;
 87             }
 88             let (done, rest) = if let Some(r) = trimmed.strip_prefix("- [ ] ") {
 89                 (false, r)
 90             } else if let Some(r) =
 91                 trimmed.strip_prefix("- [x] ").or_else(|| trimmed.strip_prefix("- [X] "))
 92             {
 93                 (true, r)
 94             } else {
 95                 (false, trimmed)
 96             };
 97             let (text, uid) = split_uid_comment(rest);
 98             Some(Item { text: text.to_string(), done, uid })
 99         })
100         .collect()
101 }
102 
103 /// Peel a trailing `<!-- uid:… -->` off an item's text, if present.
104 fn split_uid_comment(rest: &str) -> (&str, Option<String>) {
105     let rest = rest.trim_end();
106     if let Some(open) = rest.rfind("<!-- uid:") {
107         if let Some(inner) = rest[open..].strip_prefix("<!-- uid:").and_then(|s| s.strip_suffix("-->")) {
108             let uid = inner.trim();
109             if !uid.is_empty() {
110                 return (rest[..open].trim_end(), Some(uid.to_string()));
111             }
112         }
113     }
114     (rest, None)
115 }
116 
117 pub fn serialize_items(items: &[Item]) -> String {
118     items
119         .iter()
120         .map(|i| {
121             let mark = if i.done { 'x' } else { ' ' };
122             match &i.uid {
123                 Some(uid) => format!("- [{mark}] {} <!-- uid:{uid} -->\n", i.text),
124                 None => format!("- [{mark}] {}\n", i.text),
125             }
126         })
127         .collect()
128 }
129 
130 /// A whole list file: the optional `<!-- list:ID -->` header, then items.
131 pub fn parse_list(text: &str) -> (Option<String>, Vec<Item>) {
132     let mut lines = text.lines();
133     let mut first = lines.next();
134     while matches!(first, Some(l) if l.trim().is_empty()) {
135         first = lines.next();
136     }
137     if let Some(id) = first
138         .map(str::trim)
139         .and_then(|l| l.strip_prefix("<!-- list:"))
140         .and_then(|l| l.strip_suffix("-->"))
141         .map(str::trim)
142         .filter(|id| !id.is_empty())
143     {
144         let rest: Vec<&str> = lines.collect();
145         return (Some(id.to_string()), parse_items(&rest.join("\n")));
146     }
147     (None, parse_items(text))
148 }
149 
150 pub fn serialize_list(id: Option<&str>, items: &[Item]) -> String {
151     let mut out = String::new();
152     if let Some(id) = id {
153         out.push_str(&format!("<!-- list:{id} -->\n"));
154     }
155     out.push_str(&serialize_items(items));
156     out
157 }
158 
159 // ── Files ─────────────────────────────────────────────────────────────────
160 
161 /// Every list on disk, titles sorted case-insensitively. Runs the legacy
162 /// migration first, so a pre-lists install comes up with its old checklist
163 /// intact rather than empty.
164 pub fn load_lists() -> std::io::Result<Vec<ListFile>> {
165     migrate_legacy()?;
166     let dir = lists_dir();
167     let mut out = Vec::new();
168     let entries = match std::fs::read_dir(&dir) {
169         Ok(e) => e,
170         Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
171         Err(e) => return Err(e),
172     };
173     for entry in entries {
174         let path = entry?.path();
175         if path.extension().and_then(|e| e.to_str()) != Some("md") {
176             continue;
177         }
178         let Some(title) = path.file_stem().and_then(|s| s.to_str()).map(String::from) else {
179             continue;
180         };
181         let text = std::fs::read_to_string(&path)?;
182         let (id, items) = parse_list(&text);
183         out.push(ListFile { title, id, items });
184     }
185     out.sort_by_key(|l| l.title.to_lowercase());
186     Ok(out)
187 }
188 
189 pub fn load_list(title: &str) -> std::io::Result<ListFile> {
190     let text = std::fs::read_to_string(list_path(title))?;
191     let (id, items) = parse_list(&text);
192     Ok(ListFile { title: safe_title(title), id, items })
193 }
194 
195 /// Write-temp-then-rename in the same directory, so a crash mid-write never
196 /// leaves a truncated list behind.
197 pub fn save_list(list: &ListFile) -> std::io::Result<()> {
198     atomic_write(&list_path(&list.title), &serialize_list(list.id.as_deref(), &list.items))
199 }
200 
201 pub fn delete_list(title: &str) -> std::io::Result<()> {
202     match std::fs::remove_file(list_path(title)) {
203         Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
204         r => r,
205     }
206 }
207 
208 pub fn rename_list(old: &str, new: &str) -> std::io::Result<()> {
209     let (from, to) = (list_path(old), list_path(new));
210     if from == to {
211         return Ok(());
212     }
213     std::fs::rename(from, to)
214 }
215 
216 pub fn load_current() -> Option<String> {
217     std::fs::read_to_string(current_path())
218         .ok()
219         .map(|s| s.trim().to_string())
220         .filter(|s| !s.is_empty())
221 }
222 
223 pub fn save_current(title: &str) -> std::io::Result<()> {
224     atomic_write(&current_path(), &format!("{}\n", safe_title(title)))
225 }
226 
227 pub fn atomic_write(path: &Path, content: &str) -> std::io::Result<()> {
228     if let Some(dir) = path.parent() {
229         std::fs::create_dir_all(dir)?;
230     }
231     let tmp = path.with_extension("tmp");
232     std::fs::write(&tmp, content)?;
233     std::fs::rename(&tmp, path)
234 }
235 
236 /// `list.md` → `lists/…`, once. The single checklist used to mirror EVERY
237 /// server list flat, so its rows are split by the list the sync state says
238 /// each belongs to: the biggest group becomes `Tasks.md`, any other group a
239 /// file named by its list id — both carrying that id in the header, so the
240 /// next sync recognises them as those lists and renames the files to the
241 /// server's titles instead of creating new lists on the phone. Rows the
242 /// state does not know (typed locally, never synced) go with the biggest
243 /// group. The old file is kept as `list.md.migrated`.
244 pub fn migrate_legacy() -> std::io::Result<()> {
245     let legacy = legacy_path();
246     if lists_dir().exists() || !legacy.exists() {
247         return Ok(());
248     }
249     let text = std::fs::read_to_string(&legacy)?;
250     let items = parse_items(&text);
251     let state = load_sync_state().unwrap_or_default();
252     let majority = state.majority_list_id();
253     let mut groups: BTreeMap<Option<String>, Vec<Item>> = BTreeMap::new();
254     for item in items {
255         let owner = item
256             .uid
257             .as_deref()
258             .and_then(|u| state.items.get(u))
259             .map(|s| s.list_id())
260             .filter(|id| !id.is_empty())
261             .or_else(|| majority.clone());
262         groups.entry(owner).or_default().push(item);
263     }
264     if groups.is_empty() {
265         groups.insert(majority.clone(), Vec::new());
266     }
267     for (id, items) in groups {
268         let title = match (&id, &majority) {
269             (Some(i), Some(m)) if i != m => safe_title(i),
270             _ => "Tasks".to_string(),
271         };
272         save_list(&ListFile { title, id, items })?;
273     }
274     save_current("Tasks")?;
275     std::fs::rename(&legacy, legacy.with_extension("md.migrated"))
276 }
277 
278 // ── Sync state (cce-list-sync's merge base; the app never touches it) ─────
279 
280 /// What the server held for one item at the end of the last sync. Comparing
281 /// the live list and the live server against this is what tells "the user
282 /// checked it off here" apart from "it changed on the phone" — and a uid in
283 /// the state but missing from the list is a local deletion to push, where a
284 /// uid on the server but not in the state is a new item to pull.
285 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
286 pub struct SyncedItem {
287     /// Absolute resource URL (PUT/DELETE target).
288     pub url: String,
289     pub etag: String,
290     /// Which account's credentials the URL answers to.
291     pub account: String,
292     pub text: String,
293     pub done: bool,
294     /// The server list the item belongs to. Older state files lack it; see
295     /// [`SyncedItem::list_id`], which falls back to reading the URL.
296     #[serde(default)]
297     pub list: String,
298 }
299 
300 impl SyncedItem {
301     /// Google task URLs are `…/lists/{id}/tasks/{task}`; CalDAV item URLs
302     /// are `<calendar>/<uid>.ics`, where the calendar URL is the list id.
303     pub fn list_id(&self) -> String {
304         if !self.list.is_empty() {
305             return self.list.clone();
306         }
307         list_id_from_url(&self.url)
308     }
309 }
310 
311 pub fn list_id_from_url(url: &str) -> String {
312     if let Some(rest) = url.split("/lists/").nth(1) {
313         if let Some(id) = rest.split("/tasks").next() {
314             return id.to_string();
315         }
316     }
317     match url.rfind('/') {
318         Some(i) => url[..=i].to_string(),
319         None => String::new(),
320     }
321 }
322 
323 /// A server list as last synced: its title then, so a rename on either
324 /// side is told apart from the other.
325 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
326 pub struct SyncedList {
327     pub title: String,
328     #[serde(default)]
329     pub etag: String,
330     pub account: String,
331 }
332 
333 #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
334 pub struct SyncState {
335     #[serde(default)]
336     pub items: BTreeMap<String, SyncedItem>,
337     /// Keyed by server list id.
338     #[serde(default)]
339     pub lists: BTreeMap<String, SyncedList>,
340 }
341 
342 impl SyncState {
343     /// The list most tracked items belong to — what the legacy single
344     /// checklist "was", for the migration.
345     pub fn majority_list_id(&self) -> Option<String> {
346         let mut counts: BTreeMap<String, usize> = BTreeMap::new();
347         for item in self.items.values() {
348             let id = item.list_id();
349             if !id.is_empty() {
350                 *counts.entry(id).or_default() += 1;
351             }
352         }
353         counts.into_iter().max_by_key(|(_, n)| *n).map(|(id, _)| id)
354     }
355 }
356 
357 pub fn load_sync_state() -> std::io::Result<SyncState> {
358     let text = match std::fs::read_to_string(sync_state_path()) {
359         Ok(t) => t,
360         Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(SyncState::default()),
361         Err(e) => return Err(e),
362     };
363     serde_json::from_str(&text).map_err(std::io::Error::other)
364 }
365 
366 pub fn save_sync_state(state: &SyncState) -> std::io::Result<()> {
367     atomic_write(
368         &sync_state_path(),
369         &serde_json::to_string_pretty(state).unwrap_or_default(),
370     )
371 }
372 
373 #[cfg(test)]
374 mod tests {
375     use super::*;
376 
377     #[test]
378     fn checklist_round_trips() {
379         let items = vec![
380             Item { text: "water the plants".into(), done: false, uid: None },
381             Item { text: "renew passport".into(), done: true, uid: Some("AB-12".into()) },
382         ];
383         assert_eq!(parse_items(&serialize_items(&items)), items);
384     }
385 
386     /// A hand-edited file must survive a load/save cycle: plain lines are
387     /// adopted as items, not dropped, and `[X]` reads the same as `[x]`.
388     #[test]
389     fn foreign_lines_are_adopted_not_dropped() {
390         let parsed = parse_items("buy stamps\n- [X] call mom\n\n  - [ ] indented\n");
391         assert_eq!(
392             parsed,
393             vec![
394                 Item { text: "buy stamps".into(), done: false, uid: None },
395                 Item { text: "call mom".into(), done: true, uid: None },
396                 Item { text: "indented".into(), done: false, uid: None },
397             ]
398         );
399     }
400 
401     #[test]
402     fn uid_comment_is_identity_not_text() {
403         let parsed = parse_items("- [ ] call mom <!-- uid:X-1 -->\n- [ ] literal <!-- not a uid -->\n");
404         assert_eq!(parsed[0], Item { text: "call mom".into(), done: false, uid: Some("X-1".into()) });
405         // A comment that is not `uid:` stays part of the text.
406         assert_eq!(parsed[1].uid, None);
407         assert_eq!(parsed[1].text, "literal <!-- not a uid -->");
408     }
409 
410     #[test]
411     fn list_header_round_trips_and_is_optional() {
412         let items = vec![Item { text: "a".into(), done: false, uid: None }];
413         let text = serialize_list(Some("MDM5"), &items);
414         assert_eq!(parse_list(&text), (Some("MDM5".into()), items.clone()));
415         // No header: a hand-made file is a local-only list, first line and all.
416         assert_eq!(parse_list("- [ ] a\n"), (None, items));
417         // A header that is not `list:` is just an adopted line.
418         let (id, adopted) = parse_list("<!-- note -->\n- [ ] a\n");
419         assert_eq!(id, None);
420         assert_eq!(adopted.len(), 2);
421     }
422 
423     #[test]
424     fn list_ids_come_from_urls_when_the_state_predates_them() {
425         assert_eq!(
426             list_id_from_url("https://tasks.googleapis.com/tasks/v1/lists/MDM5/tasks/abc"),
427             "MDM5"
428         );
429         assert_eq!(
430             list_id_from_url("https://p1-caldav.icloud.com/1/calendars/reminders/X.ics"),
431             "https://p1-caldav.icloud.com/1/calendars/reminders/"
432         );
433     }
434 
435     #[test]
436     fn titles_become_safe_stems() {
437         assert_eq!(safe_title("Home/Garden"), "Home-Garden");
438         assert_eq!(safe_title("  "), "Untitled");
439     }
440 }
441