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(¤t_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