things-to-remember checklist
git clone https://git.lucas.co/cce-list.git
Two-way iCloud Reminders sync (cce-list-sync)
A second [[bin]], cce-list-sync, syncs the checklist with the VTODO
calendars on caldav.icloud.com, using cce-mail's accounts.json and keyring
credentials like cce-calendar-sync does (whose DAV/discovery code is
deliberately duplicated here - crates build standalone, and two copies of
~100 lines beats a new published crate until a third consumer exists).
The merge is three-way against a sync-state.json sidecar (the last-synced
server snapshot per uid): list-vs-state differences push, server-vs-state
differences pull, both-changed lets local win and the next tick reconcile.
Deletions propagate both ways, guarded - a missing list.md re-imports
rather than deleting, and a run that would delete most tracked reminders
refuses without --force-deletes. Pushes patch the fetched iCalendar in
place so due dates, notes, and alarms survive a checkbox toggle; recurring
reminders are skipped; server-side completed items never tracked are not
imported. Item identity rides in list.md as a trailing <!-- uid:... -->
comment; parse/serialize move to a lib target shared by both binaries, and
the final write applies the plan as deltas onto a fresh re-read so rows
typed mid-sync survive.
Verified against the live account: create, toggle, and delete each
round-tripped and converged to a zero-op pass. Caveat found doing so: this
account's Reminders are "upgraded" (CloudKit), so CalDAV exposes only
Apple's legacy stub list - the sync works, but does not reach the
Reminders app on Apple devices. The timer (15 min) ships but is not
enabled by default.
Co-Authored-By: Claude Fable 5 <[email protected]>
Cargo.toml | 18 +
cce-list-sync.service | 10 +
cce-list-sync.timer | 10 +
src/bin/sync.rs | 955 ++++++++++++++++++++++++++++++++++++++++++++++++++
src/lib.rs | 192 ++++++++++
src/main.rs | 113 +-----
6 files changed, 1197 insertions(+), 101 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
index e0e03ee..062c950 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,3 +9,21 @@ calloop = "0.13.0"
wayland-client = { version = "0.31", features = ["system"] }
log = "0.4"
env_logger = "0.11"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+# cce-list-sync only: CalDAV (VTODO) against iCloud Reminders, credentials
+# from the same keyring service cce-mail uses.
+reqwest = { version = "0.12", features = ["blocking"] }
+roxmltree = "0.20"
+chrono = "0.4"
+keyring = { version = "3", features = ["sync-secret-service"] }
+
+# The desktop checklist app; src/bin/sync.rs adds the cce-list-sync helper,
+# which ccebuild discovers via cargo metadata like any other [[bin]].
+[[bin]]
+name = "cce-list"
+path = "src/main.rs"
+
+[[bin]]
+name = "cce-list-sync"
+path = "src/bin/sync.rs"
diff --git a/cce-list-sync.service b/cce-list-sync.service
new file mode 100644
index 0000000..b1a5bf7
--- /dev/null
+++ b/cce-list-sync.service
@@ -0,0 +1,10 @@
+# One two-way sync pass between iCloud Reminders and cce-list's list.md
+# (see src/bin/sync.rs). Driven by cce-list-sync.timer; harmless to start by
+# hand. Failures (locked keyring, no network, iCloud down) exit nonzero and
+# the timer simply tries again next tick.
+[Unit]
+Description=Sync iCloud Reminders with cce-list
+
+[Service]
+Type=oneshot
+ExecStart=%h/.local/bin/cce-list-sync
diff --git a/cce-list-sync.timer b/cce-list-sync.timer
new file mode 100644
index 0000000..c2d1d88
--- /dev/null
+++ b/cce-list-sync.timer
@@ -0,0 +1,10 @@
+[Unit]
+Description=Sync iCloud Reminders with cce-list every 15 minutes
+
+[Timer]
+OnBootSec=2min
+OnUnitActiveSec=15min
+RandomizedDelaySec=90
+
+[Install]
+WantedBy=timers.target
diff --git a/src/bin/sync.rs b/src/bin/sync.rs
new file mode 100644
index 0000000..ccedcec
--- /dev/null
+++ b/src/bin/sync.rs
@@ -0,0 +1,955 @@
+//! `cce-list-sync` — two-way sync between iCloud Reminders and cce-list's
+//! markdown checklist.
+//!
+//! Accounts and credentials are cce-mail's (accounts.json + the "cce-mail"
+//! keyring service); Reminders lists are the VTODO calendars on
+//! caldav.icloud.com, reached the same way cce-calendar-sync reaches the
+//! event calendars. The discovery/DAV code is deliberately duplicated from
+//! that helper rather than extracted: every crate builds standalone
+//! (multi-repo), and two copies of ~100 lines beats a new published crate
+//! until a third consumer exists.
+//!
+//! The merge is three-way against `sync-state.json`, the last-synced server
+//! snapshot per uid: a difference between the list and the state is a local
+//! edit to push; between the server and the state, a remote edit to pull;
+//! both changed → local wins (the next tick reconciles). Deletions propagate
+//! both ways, guarded: a missing list.md re-imports instead of deleting, and
+//! a run that would delete most tracked reminders (>5 and >50%) refuses
+//! without `--force-deletes` — a mangled file must not empty the phone.
+//!
+//! Pushes PATCH the fetched iCalendar rather than rebuilding it, so due
+//! dates, notes, and alarms Apple attached survive a checkbox toggle.
+//! Recurring reminders (RRULE) are skipped entirely — completing one means
+//! "advance to the next occurrence", which this checkbox model cannot say.
+//! Server-side completed reminders that were never tracked are not imported
+//! (years of checked-off junk stays on the phone).
+//!
+//! Usage: `cce-list-sync [--dry-run] [--force-deletes]`. Driven by
+//! cce-list-sync.timer; harmless to run by hand.
+
+use std::collections::BTreeMap;
+
+use cce_list::{
+ atomic_write, data_path, load_sync_state, parse_items, save_sync_state, serialize_items,
+ Item, SyncState, SyncedItem,
+};
+use chrono::Utc;
+
+const CALDAV_ROOT: &str = "https://caldav.icloud.com/";
+const CALDAV_NS: &str = "urn:ietf:params:xml:ns:caldav";
+const KEYRING_SERVICE: &str = "cce-mail";
+
+fn main() {
+ env_logger::init();
+ let args: Vec<String> = std::env::args().collect();
+ let dry_run = args.iter().any(|a| a == "--dry-run");
+ let force_deletes = args.iter().any(|a| a == "--force-deletes");
+
+ let accounts = match icloud_accounts() {
+ Ok(a) => a,
+ Err(e) => {
+ log::error!("cannot read accounts: {e}");
+ std::process::exit(1);
+ }
+ };
+ let Some(acc) = accounts.into_iter().next() else {
+ log::info!("no iCloud account in accounts.json; nothing to sync");
+ return;
+ };
+
+ match run_sync(&acc, dry_run, force_deletes) {
+ Ok(()) => {}
+ Err(e) => {
+ log::error!("{}: sync failed: {e}", acc.email);
+ std::process::exit(1);
+ }
+ }
+}
+
+struct Account {
+ email: String,
+ password: String,
+}
+
+#[derive(serde::Deserialize)]
+struct AccountOnDisk {
+ email: String,
+ #[serde(default)]
+ imap: String,
+ #[serde(default)]
+ password: String,
+}
+
+fn icloud_accounts() -> Result<Vec<Account>, String> {
+ let path = cce_ui::config::cce_config_dir().join("accounts.json");
+ let text =
+ std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
+ let on_disk: Vec<AccountOnDisk> =
+ serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;
+ let mut out = Vec::new();
+ for acc in on_disk {
+ let host = acc.imap.split(':').next().unwrap_or("");
+ let icloud = host.ends_with(".mail.me.com")
+ || ["@icloud.com", "@me.com", "@mac.com"].iter().any(|d| acc.email.ends_with(d));
+ if !icloud {
+ continue;
+ }
+ let password = if !acc.password.is_empty() {
+ acc.password.clone()
+ } else {
+ match keyring::Entry::new(KEYRING_SERVICE, &acc.email).and_then(|e| e.get_password()) {
+ Ok(p) => p,
+ Err(e) => {
+ log::warn!("{}: no password available ({e}); skipping", acc.email);
+ continue;
+ }
+ }
+ };
+ out.push(Account { email: acc.email, password });
+ }
+ Ok(out)
+}
+
+// ── The pass ──────────────────────────────────────────────────────────────
+
+fn run_sync(acc: &Account, dry_run: bool, force_deletes: bool) -> Result<(), String> {
+ let client = reqwest::blocking::Client::builder()
+ .timeout(std::time::Duration::from_secs(60))
+ .build()
+ .map_err(|e| e.to_string())?;
+
+ // Read the list first: if it is unreadable there is nothing safe to do.
+ let list_exists = data_path().exists();
+ let local = if list_exists {
+ parse_items(&std::fs::read_to_string(data_path()).map_err(|e| e.to_string())?)
+ } else {
+ Vec::new()
+ };
+ let mut state = load_sync_state().map_err(|e| format!("sync-state.json: {e}"))?;
+ if !list_exists && !state.items.is_empty() {
+ // The list is gone (fresh clone, deleted file). Re-import rather
+ // than reading absence as "delete everything on the server".
+ log::warn!("list.md missing; discarding sync state and re-importing");
+ state = SyncState::default();
+ }
+
+ let remote = fetch_remote(&client, acc)?;
+ let plan = plan(&local, &state, &remote.todos);
+
+ if !force_deletes && plan.push_deletes.len() > 5 && plan.push_deletes.len() * 2 > state.items.len()
+ {
+ return Err(format!(
+ "refusing to delete {} of {} tracked reminders on the server — if the list \
+ was really emptied on purpose, run cce-list-sync --force-deletes",
+ plan.push_deletes.len(),
+ state.items.len()
+ ));
+ }
+
+ log::info!(
+ "{}: pull {} new / {} changed / {} deleted; push {} changed / {} new / {} deleted",
+ acc.email,
+ plan.pull_new.len(),
+ plan.pull_updates.len(),
+ plan.pull_deletes.len(),
+ plan.push_updates.len(),
+ plan.push_creates.len(),
+ plan.push_deletes.len(),
+ );
+ if dry_run {
+ print_plan(&plan, &remote.todos);
+ return Ok(());
+ }
+
+ // Server side first: every push refreshes `state` only on success, so a
+ // failed request is simply retried next tick.
+ for uid in &plan.push_updates {
+ let todo = &remote.todos[uid];
+ let item = local.iter().find(|i| i.uid.as_deref() == Some(uid)).expect("planned");
+ let body = patch_vtodo(&todo.lines, &item.text, item.done);
+ match put_ics(&client, acc, &todo.url, &body, Some(&todo.etag)) {
+ Ok(etag) => {
+ state.items.insert(uid.clone(), SyncedItem {
+ url: todo.url.to_string(),
+ etag,
+ account: acc.email.clone(),
+ text: item.text.clone(),
+ done: item.done,
+ });
+ }
+ Err(e) => log::warn!("push update {uid} failed (will retry next tick): {e}"),
+ }
+ }
+ let mut created: Vec<(String, String)> = Vec::new(); // (text, uid) to annotate
+ for text in &plan.push_creates {
+ let uid = new_uid();
+ let url = remote
+ .create_target
+ .join(&format!("{uid}.ics"))
+ .map_err(|e| e.to_string())?;
+ let body = new_vtodo(&uid, text, false);
+ match put_ics(&client, acc, &url, &body, None) {
+ Ok(etag) => {
+ state.items.insert(uid.clone(), SyncedItem {
+ url: url.to_string(),
+ etag,
+ account: acc.email.clone(),
+ text: text.clone(),
+ done: false,
+ });
+ created.push((text.clone(), uid));
+ }
+ Err(e) => log::warn!("push create {text:?} failed (will retry next tick): {e}"),
+ }
+ }
+ for uid in &plan.push_deletes {
+ let entry = &state.items[uid];
+ let url = reqwest::Url::parse(&entry.url).map_err(|e| e.to_string())?;
+ match delete_ics(&client, acc, &url, &entry.etag) {
+ Ok(()) => {
+ state.items.remove(uid);
+ }
+ Err(e) => log::warn!("push delete {uid} failed (will retry next tick): {e}"),
+ }
+ }
+
+ // Pulls refresh the state from the server snapshot.
+ for uid in plan.pull_new.iter().chain(&plan.pull_updates) {
+ let todo = &remote.todos[uid];
+ state.items.insert(uid.clone(), SyncedItem {
+ url: todo.url.to_string(),
+ etag: todo.etag.clone(),
+ account: acc.email.clone(),
+ text: todo.summary.clone(),
+ done: todo.done,
+ });
+ }
+ for uid in &plan.pull_deletes {
+ state.items.remove(uid);
+ }
+ for uid in &plan.refresh_etags {
+ if let (Some(entry), Some(todo)) = (state.items.get_mut(uid), remote.todos.get(uid)) {
+ entry.etag = todo.etag.clone();
+ }
+ }
+
+ // Local side last, as deltas on a FRESH read: the user may have edited
+ // the list while the network calls ran, and rows this plan does not
+ // touch must survive verbatim.
+ let mut fresh = if data_path().exists() {
+ parse_items(&std::fs::read_to_string(data_path()).map_err(|e| e.to_string())?)
+ } else {
+ Vec::new()
+ };
+ apply_local(&mut fresh, &plan, &remote.todos, &created);
+ atomic_write(&data_path(), &serialize_items(&fresh)).map_err(|e| e.to_string())?;
+ save_sync_state(&state).map_err(|e| e.to_string())?;
+ Ok(())
+}
+
+fn print_plan(plan: &Plan, remote: &BTreeMap<String, RemoteTodo>) {
+ for uid in &plan.pull_new {
+ println!("pull new: {} ({uid})", remote[uid].summary);
+ }
+ for uid in &plan.pull_updates {
+ println!("pull change: {} ({uid})", remote[uid].summary);
+ }
+ for uid in &plan.pull_deletes {
+ println!("pull delete: {uid}");
+ }
+ for uid in &plan.push_updates {
+ println!("push change: {uid}");
+ }
+ for text in &plan.push_creates {
+ println!("push new: {text}");
+ }
+ for uid in &plan.push_deletes {
+ println!("push delete: {uid}");
+ }
+}
+
+// ── Merge planning (pure; the tests live on this) ─────────────────────────
+
+#[derive(Default, Debug)]
+struct Plan {
+ pull_new: Vec<String>,
+ pull_updates: Vec<String>,
+ pull_deletes: Vec<String>,
+ push_updates: Vec<String>,
+ /// Texts of local uid-less rows to create server-side.
+ push_creates: Vec<String>,
+ push_deletes: Vec<String>,
+ /// Server etag moved but content is identical — track it, change nothing.
+ refresh_etags: Vec<String>,
+}
+
+fn plan(local: &[Item], state: &SyncState, remote: &BTreeMap<String, RemoteTodo>) -> Plan {
+ let mut plan = Plan::default();
+ let local_by_uid: BTreeMap<&str, &Item> = local
+ .iter()
+ .filter_map(|i| i.uid.as_deref().map(|u| (u, i)))
+ .collect();
+
+ for (uid, todo) in remote {
+ let in_state = state.items.get(uid);
+ let in_local = local_by_uid.get(uid.as_str());
+ match (in_state, in_local) {
+ (Some(base), Some(item)) => {
+ let local_changed = item.text != base.text || item.done != base.done;
+ let remote_changed = todo.summary != base.text || todo.done != base.done;
+ if local_changed {
+ // Local wins on both-changed; the push makes the server
+ // match, and the next tick sees all three agree.
+ plan.push_updates.push(uid.clone());
+ } else if remote_changed {
+ plan.pull_updates.push(uid.clone());
+ } else if todo.etag != base.etag {
+ plan.refresh_etags.push(uid.clone());
+ }
+ }
+ (Some(_), None) => plan.push_deletes.push(uid.clone()),
+ (None, Some(item)) => {
+ // Untracked but present on both ends (state lost, or a
+ // hand-copied line): adopt it, local text/done winning.
+ if item.text != todo.summary || item.done != todo.done {
+ plan.push_updates.push(uid.clone());
+ } else {
+ plan.refresh_etags.push(uid.clone());
+ }
+ }
+ (None, None) => {
+ if !todo.done {
+ plan.pull_new.push(uid.clone());
+ }
+ }
+ }
+ }
+ for (uid, _) in &state.items {
+ if !remote.contains_key(uid) {
+ if local_by_uid.contains_key(uid.as_str()) {
+ plan.pull_deletes.push(uid.clone());
+ } else {
+ // Gone on both ends independently; just forget it.
+ plan.pull_deletes.push(uid.clone());
+ }
+ }
+ }
+ for item in local {
+ match &item.uid {
+ None => plan.push_creates.push(item.text.clone()),
+ // A uid the server never heard of and the state does not track:
+ // recreate it under that uid rather than orphaning the row.
+ Some(uid) if !remote.contains_key(uid) && !state.items.contains_key(uid) => {
+ plan.push_creates.push(item.text.clone());
+ }
+ Some(_) => {}
+ }
+ }
+ plan
+}
+
+/// Apply the plan's local half as deltas onto a fresh read of the list.
+fn apply_local(
+ items: &mut Vec<Item>,
+ plan: &Plan,
+ remote: &BTreeMap<String, RemoteTodo>,
+ created: &[(String, String)],
+) {
+ items.retain(|i| {
+ i.uid.as_deref().is_none_or(|u| !plan.pull_deletes.iter().any(|d| d == u))
+ });
+ for uid in &plan.pull_updates {
+ let todo = &remote[uid];
+ if let Some(item) = items.iter_mut().find(|i| i.uid.as_deref() == Some(uid)) {
+ item.text = todo.summary.clone();
+ item.done = todo.done;
+ }
+ }
+ for (text, uid) in created {
+ // A row we recreated under its own stale uid already carries it.
+ if let Some(item) =
+ items.iter_mut().find(|i| i.uid.is_none() && i.text == *text)
+ {
+ item.uid = Some(uid.clone());
+ } else if let Some(item) = items
+ .iter_mut()
+ .find(|i| i.text == *text && i.uid.as_deref() == Some(uid))
+ {
+ item.uid = Some(uid.clone());
+ }
+ }
+ for uid in &plan.pull_new {
+ let todo = &remote[uid];
+ items.push(Item { text: todo.summary.clone(), done: todo.done, uid: Some(uid.clone()) });
+ }
+}
+
+// ── CalDAV ────────────────────────────────────────────────────────────────
+
+#[derive(Debug)]
+struct RemoteTodo {
+ url: reqwest::Url,
+ etag: String,
+ summary: String,
+ done: bool,
+ /// Unfolded logical lines of the full VCALENDAR, for patch-and-PUT.
+ lines: Vec<String>,
+}
+
+struct RemoteSnapshot {
+ todos: BTreeMap<String, RemoteTodo>,
+ /// Where push_creates land: the list named "Reminders" if there is one,
+ /// else the first VTODO calendar.
+ create_target: reqwest::Url,
+}
+
+fn fetch_remote(
+ client: &reqwest::blocking::Client,
+ acc: &Account,
+) -> Result<RemoteSnapshot, String> {
+ let root = reqwest::Url::parse(CALDAV_ROOT).expect("static url");
+ let principal = discover_href(
+ client, acc, &root, "0",
+ r#"<?xml version="1.0" encoding="utf-8"?>
+<propfind xmlns="DAV:"><prop><current-user-principal/></prop></propfind>"#,
+ "current-user-principal",
+ )?;
+ let home = discover_href(
+ client, acc, &principal, "0",
+ r#"<?xml version="1.0" encoding="utf-8"?>
+<propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav"><prop><C:calendar-home-set/></prop></propfind>"#,
+ "calendar-home-set",
+ )?;
+ let lists = todo_calendars(client, acc, &home)?;
+ if lists.is_empty() {
+ return Err("no VTODO calendars (Reminders lists) found".into());
+ }
+ let create_target = lists
+ .iter()
+ .find(|(_, name)| name == "Reminders")
+ .unwrap_or(&lists[0])
+ .0
+ .clone();
+ log::info!(
+ "{}: {} Reminders list(s), new items go to {}",
+ acc.email,
+ lists.len(),
+ lists.iter().find(|(u, _)| *u == create_target).map(|(_, n)| n.as_str()).unwrap_or("?")
+ );
+
+ let mut todos = BTreeMap::new();
+ let mut skipped_recurring = 0usize;
+ for (url, name) in &lists {
+ fetch_todos(client, acc, url, &mut todos, &mut skipped_recurring)
+ .map_err(|e| format!("list {name}: {e}"))?;
+ }
+ if skipped_recurring > 0 {
+ log::info!("{skipped_recurring} recurring reminder(s) left alone (RRULE)");
+ }
+ Ok(RemoteSnapshot { todos, create_target })
+}
+
+fn dav_request(
+ client: &reqwest::blocking::Client,
+ acc: &Account,
+ method: &str,
+ url: &reqwest::Url,
+ depth: &str,
+ body: &str,
+) -> Result<String, String> {
+ let resp = client
+ .request(reqwest::Method::from_bytes(method.as_bytes()).expect("static method"), url.clone())
+ .basic_auth(&acc.email, Some(&acc.password))
+ .header("Depth", depth)
+ .header("Content-Type", "application/xml; charset=utf-8")
+ .body(body.to_string())
+ .send()
+ .map_err(|e| format!("{method} {url}: {e}"))?;
+ let status = resp.status();
+ let text = resp.text().map_err(|e| e.to_string())?;
+ if !status.is_success() {
+ return Err(format!("{method} {url}: HTTP {status}"));
+ }
+ Ok(text)
+}
+
+fn discover_href(
+ client: &reqwest::blocking::Client,
+ acc: &Account,
+ url: &reqwest::Url,
+ depth: &str,
+ body: &str,
+ prop: &str,
+) -> Result<reqwest::Url, String> {
+ let xml = dav_request(client, acc, "PROPFIND", url, depth, body)?;
+ let doc = roxmltree::Document::parse(&xml).map_err(|e| format!("bad multistatus: {e}"))?;
+ let href = doc
+ .descendants()
+ .find(|n| n.tag_name().name() == prop)
+ .and_then(|n| n.descendants().find(|c| c.tag_name().name() == "href"))
+ .and_then(|n| n.text())
+ .ok_or_else(|| format!("no {prop} in PROPFIND response"))?;
+ url.join(href.trim()).map_err(|e| format!("bad {prop} href {href:?}: {e}"))
+}
+
+fn todo_calendars(
+ client: &reqwest::blocking::Client,
+ acc: &Account,
+ home: &reqwest::Url,
+) -> Result<Vec<(reqwest::Url, String)>, String> {
+ let body = r#"<?xml version="1.0" encoding="utf-8"?>
+<propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
+ <prop><resourcetype/><displayname/><C:supported-calendar-component-set/></prop>
+</propfind>"#;
+ let xml = dav_request(client, acc, "PROPFIND", home, "1", body)?;
+ let doc = roxmltree::Document::parse(&xml).map_err(|e| format!("bad multistatus: {e}"))?;
+ let mut out = Vec::new();
+ for resp in doc.descendants().filter(|n| n.tag_name().name() == "response") {
+ let Some(href) = resp
+ .children()
+ .find(|c| c.tag_name().name() == "href")
+ .and_then(|n| n.text())
+ else {
+ continue;
+ };
+ let is_calendar = resp.descendants().any(|n| {
+ n.tag_name().name() == "calendar" && n.tag_name().namespace() == Some(CALDAV_NS)
+ });
+ if !is_calendar {
+ continue;
+ }
+ // Unlike the events side, VTODO support must be stated: a calendar
+ // that lists no component set is an events calendar here.
+ let supports_vtodo = resp
+ .descendants()
+ .filter(|n| n.tag_name().name() == "comp")
+ .filter_map(|n| n.attribute("name"))
+ .any(|c| c == "VTODO");
+ if !supports_vtodo {
+ continue;
+ }
+ let name = resp
+ .descendants()
+ .find(|n| n.tag_name().name() == "displayname")
+ .and_then(|n| n.text())
+ .unwrap_or(href)
+ .to_string();
+ let url = home.join(href.trim()).map_err(|e| format!("bad href {href:?}: {e}"))?;
+ if url.path().trim_end_matches('/') == home.path().trim_end_matches('/') {
+ continue;
+ }
+ out.push((url, name));
+ }
+ Ok(out)
+}
+
+fn fetch_todos(
+ client: &reqwest::blocking::Client,
+ acc: &Account,
+ cal: &reqwest::Url,
+ todos: &mut BTreeMap<String, RemoteTodo>,
+ skipped_recurring: &mut usize,
+) -> Result<(), String> {
+ let body = r#"<?xml version="1.0" encoding="utf-8"?>
+<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
+ <D:prop><D:getetag/><C:calendar-data/></D:prop>
+ <C:filter><C:comp-filter name="VCALENDAR"><C:comp-filter name="VTODO"/></C:comp-filter></C:filter>
+</C:calendar-query>"#;
+ let xml = dav_request(client, acc, "REPORT", cal, "1", body)?;
+ let doc = roxmltree::Document::parse(&xml).map_err(|e| format!("bad multistatus: {e}"))?;
+ for resp in doc.descendants().filter(|n| n.tag_name().name() == "response") {
+ let href = resp
+ .children()
+ .find(|c| c.tag_name().name() == "href")
+ .and_then(|n| n.text())
+ .unwrap_or_default();
+ let etag = resp
+ .descendants()
+ .find(|n| n.tag_name().name() == "getetag")
+ .and_then(|n| n.text())
+ .unwrap_or_default()
+ .to_string();
+ let Some(ics) = resp
+ .descendants()
+ .find(|n| n.tag_name().name() == "calendar-data")
+ .and_then(|n| n.text())
+ else {
+ continue;
+ };
+ let url = cal.join(href.trim()).map_err(|e| format!("bad href {href:?}: {e}"))?;
+ match parse_vtodo(ics) {
+ Some(parsed) if parsed.recurring => *skipped_recurring += 1,
+ Some(parsed) => {
+ todos.insert(parsed.uid.clone(), RemoteTodo {
+ url,
+ etag,
+ summary: parsed.summary,
+ done: parsed.done,
+ lines: parsed.lines,
+ });
+ }
+ None => log::warn!("unparsable VTODO at {url}, skipping"),
+ }
+ }
+ Ok(())
+}
+
+fn put_ics(
+ client: &reqwest::blocking::Client,
+ acc: &Account,
+ url: &reqwest::Url,
+ body: &str,
+ etag: Option<&str>,
+) -> Result<String, String> {
+ let mut req = client
+ .put(url.clone())
+ .basic_auth(&acc.email, Some(&acc.password))
+ .header("Content-Type", "text/calendar; charset=utf-8")
+ .body(body.to_string());
+ req = match etag {
+ // An empty stored etag (a PUT whose response carried none) falls
+ // back to an unconditional overwrite of our own resource.
+ Some(e) if !e.is_empty() => req.header("If-Match", e),
+ Some(_) => req,
+ None => req.header("If-None-Match", "*"),
+ };
+ let resp = req.send().map_err(|e| format!("PUT {url}: {e}"))?;
+ let status = resp.status();
+ if !status.is_success() {
+ return Err(format!("PUT {url}: HTTP {status}"));
+ }
+ let etag = resp
+ .headers()
+ .get("etag")
+ .and_then(|v| v.to_str().ok())
+ .unwrap_or_default()
+ .to_string();
+ if !etag.is_empty() {
+ return Ok(etag);
+ }
+ // No ETag on the PUT response: ask for it, so the next If-Match works.
+ Ok(fetch_etag(client, acc, url).unwrap_or_default())
+}
+
+fn fetch_etag(
+ client: &reqwest::blocking::Client,
+ acc: &Account,
+ url: &reqwest::Url,
+) -> Option<String> {
+ let body = r#"<?xml version="1.0" encoding="utf-8"?>
+<propfind xmlns="DAV:"><prop><getetag/></prop></propfind>"#;
+ let xml = dav_request(client, acc, "PROPFIND", url, "0", body).ok()?;
+ let doc = roxmltree::Document::parse(&xml).ok()?;
+ doc.descendants()
+ .find(|n| n.tag_name().name() == "getetag")
+ .and_then(|n| n.text())
+ .map(|s| s.to_string())
+}
+
+fn delete_ics(
+ client: &reqwest::blocking::Client,
+ acc: &Account,
+ url: &reqwest::Url,
+ etag: &str,
+) -> Result<(), String> {
+ let mut req = client.delete(url.clone()).basic_auth(&acc.email, Some(&acc.password));
+ if !etag.is_empty() {
+ req = req.header("If-Match", etag);
+ }
+ let resp = req.send().map_err(|e| format!("DELETE {url}: {e}"))?;
+ let status = resp.status();
+ // Already gone counts as done.
+ if status.is_success() || status == reqwest::StatusCode::NOT_FOUND {
+ Ok(())
+ } else {
+ Err(format!("DELETE {url}: HTTP {status}"))
+ }
+}
+
+// ── iCalendar: parse, patch, mint ─────────────────────────────────────────
+
+struct ParsedTodo {
+ uid: String,
+ summary: String,
+ done: bool,
+ recurring: bool,
+ lines: Vec<String>,
+}
+
+fn unfold(ics: &str) -> Vec<String> {
+ let mut lines: Vec<String> = Vec::new();
+ for raw in ics.split('\n') {
+ let raw = raw.strip_suffix('\r').unwrap_or(raw);
+ if let Some(rest) = raw.strip_prefix(' ').or_else(|| raw.strip_prefix('\t')) {
+ if let Some(last) = lines.last_mut() {
+ last.push_str(rest);
+ continue;
+ }
+ }
+ lines.push(raw.to_string());
+ }
+ lines.retain(|l| !l.is_empty());
+ lines
+}
+
+fn split_content_line(line: &str) -> Option<(&str, &str)> {
+ let mut in_quotes = false;
+ for (i, c) in line.char_indices() {
+ match c {
+ '"' => in_quotes = !in_quotes,
+ ':' if !in_quotes => return Some((&line[..i], &line[i + 1..])),
+ _ => {}
+ }
+ }
+ None
+}
+
+fn unescape_text(v: &str) -> String {
+ let mut out = String::with_capacity(v.len());
+ let mut chars = v.chars();
+ while let Some(c) = chars.next() {
+ if c != '\\' {
+ out.push(c);
+ continue;
+ }
+ match chars.next() {
+ Some('n') | Some('N') => out.push(' '),
+ Some(other) => out.push(other),
+ None => {}
+ }
+ }
+ out
+}
+
+fn escape_text(v: &str) -> String {
+ let mut out = String::with_capacity(v.len());
+ for c in v.chars() {
+ match c {
+ '\\' => out.push_str("\\\\"),
+ ',' => out.push_str("\\,"),
+ ';' => out.push_str("\\;"),
+ '\n' => out.push_str("\\n"),
+ _ => out.push(c),
+ }
+ }
+ out
+}
+
+fn parse_vtodo(ics: &str) -> Option<ParsedTodo> {
+ let lines = unfold(ics);
+ let mut in_todo = false;
+ let mut uid = String::new();
+ let mut summary = String::new();
+ let mut done = false;
+ let mut recurring = false;
+ for line in &lines {
+ let Some((head, value)) = split_content_line(line) else { continue };
+ let name = head.split(';').next().unwrap_or("").to_ascii_uppercase();
+ match name.as_str() {
+ "BEGIN" if value.eq_ignore_ascii_case("VTODO") => in_todo = true,
+ "END" if value.eq_ignore_ascii_case("VTODO") => in_todo = false,
+ _ if !in_todo => {}
+ _ => match name.as_str() {
+ "UID" => uid = value.trim().to_string(),
+ "SUMMARY" => summary = unescape_text(value.trim()),
+ "STATUS" => done |= value.trim().eq_ignore_ascii_case("COMPLETED"),
+ "COMPLETED" => done = true,
+ "PERCENT-COMPLETE" => done |= value.trim() == "100",
+ "RRULE" | "RDATE" => recurring = true,
+ _ => {}
+ },
+ }
+ }
+ (!uid.is_empty()).then_some(ParsedTodo { uid, summary, done, recurring, lines })
+}
+
+/// Rewrite only SUMMARY and the completion trio inside the VTODO block,
+/// leaving every other property (DUE, DESCRIPTION, VALARM, X-APPLE-*)
+/// exactly as the server sent it.
+fn patch_vtodo(lines: &[String], summary: &str, done: bool) -> String {
+ let now = Utc::now().format("%Y%m%dT%H%M%SZ");
+ let mut out: Vec<String> = Vec::with_capacity(lines.len() + 4);
+ let mut in_todo = false;
+ for line in lines {
+ let name = split_content_line(line)
+ .map(|(h, _)| h.split(';').next().unwrap_or("").to_ascii_uppercase())
+ .unwrap_or_default();
+ let value = split_content_line(line).map(|(_, v)| v).unwrap_or_default();
+ if name == "BEGIN" && value.eq_ignore_ascii_case("VTODO") {
+ in_todo = true;
+ out.push(line.clone());
+ continue;
+ }
+ if name == "END" && value.eq_ignore_ascii_case("VTODO") {
+ out.push(format!("SUMMARY:{}", escape_text(summary)));
+ if done {
+ out.push("STATUS:COMPLETED".to_string());
+ out.push("PERCENT-COMPLETE:100".to_string());
+ out.push(format!("COMPLETED:{now}"));
+ } else {
+ out.push("STATUS:NEEDS-ACTION".to_string());
+ out.push("PERCENT-COMPLETE:0".to_string());
+ }
+ in_todo = false;
+ out.push(line.clone());
+ continue;
+ }
+ if in_todo
+ && matches!(name.as_str(), "SUMMARY" | "STATUS" | "PERCENT-COMPLETE" | "COMPLETED")
+ {
+ continue;
+ }
+ out.push(line.clone());
+ }
+ let mut s = out.join("\r\n");
+ s.push_str("\r\n");
+ s
+}
+
+fn new_vtodo(uid: &str, summary: &str, done: bool) -> String {
+ let now = Utc::now().format("%Y%m%dT%H%M%SZ");
+ let status = if done {
+ format!("STATUS:COMPLETED\r\nPERCENT-COMPLETE:100\r\nCOMPLETED:{now}\r\n")
+ } else {
+ "STATUS:NEEDS-ACTION\r\n".to_string()
+ };
+ format!(
+ "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//cce//cce-list-sync//EN\r\n\
+ BEGIN:VTODO\r\nUID:{uid}\r\nDTSTAMP:{now}\r\nCREATED:{now}\r\n\
+ SUMMARY:{}\r\n{status}END:VTODO\r\nEND:VCALENDAR\r\n",
+ escape_text(summary)
+ )
+}
+
+/// Random-enough UID from the kernel, no uuid dependency.
+fn new_uid() -> String {
+ let mut bytes = [0u8; 16];
+ if std::fs::File::open("/dev/urandom")
+ .and_then(|mut f| std::io::Read::read_exact(&mut f, &mut bytes))
+ .is_err()
+ {
+ // Fall back to a timestamp; uniqueness against one user's own list.
+ return format!("CCE-{}", Utc::now().format("%Y%m%dT%H%M%S%fZ"));
+ }
+ let hex: String = bytes.iter().map(|b| format!("{b:02X}")).collect();
+ format!("CCE-{}-{}-{}", &hex[..8], &hex[8..16], &hex[16..])
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn item(text: &str, done: bool, uid: Option<&str>) -> Item {
+ Item { text: text.into(), done, uid: uid.map(String::from) }
+ }
+
+ fn todo(summary: &str, done: bool, etag: &str) -> RemoteTodo {
+ RemoteTodo {
+ url: reqwest::Url::parse("https://example.com/cal/x.ics").unwrap(),
+ etag: etag.into(),
+ summary: summary.into(),
+ done,
+ lines: Vec::new(),
+ }
+ }
+
+ fn synced(text: &str, done: bool, etag: &str) -> SyncedItem {
+ SyncedItem {
+ url: "https://example.com/cal/x.ics".into(),
+ etag: etag.into(),
+ account: "[email protected]".into(),
+ text: text.into(),
+ done,
+ }
+ }
+
+ #[test]
+ fn merge_decision_table() {
+ let local = vec![
+ item("unchanged", false, Some("u1")),
+ item("toggled here", true, Some("u2")), // local change → push
+ item("renamed on phone", false, Some("u3")), // remote change → pull
+ item("fresh local", false, None), // no uid → create
+ ];
+ // u4 in state but not local → deleted here → push delete.
+ // u5 on server, unknown → pull new. u6 server-completed, unknown → ignore.
+ let mut state = SyncState::default();
+ state.items.insert("u1".into(), synced("unchanged", false, "e1"));
+ state.items.insert("u2".into(), synced("toggled here", false, "e2"));
+ state.items.insert("u3".into(), synced("old name", false, "e3"));
+ state.items.insert("u4".into(), synced("deleted here", false, "e4"));
+ let mut remote = BTreeMap::new();
+ remote.insert("u1".into(), todo("unchanged", false, "e1"));
+ remote.insert("u2".into(), todo("toggled here", false, "e2"));
+ remote.insert("u3".into(), todo("renamed on phone", false, "e3b"));
+ remote.insert("u4".into(), todo("deleted here", false, "e4"));
+ remote.insert("u5".into(), todo("from the phone", false, "e5"));
+ remote.insert("u6".into(), todo("ancient done thing", true, "e6"));
+
+ // The local u3 text matches the state ("old name" changed remotely),
+ // so fix the fixture: local u3 must equal the state's text.
+ let mut local = local;
+ local[2].text = "old name".into();
+
+ let p = plan(&local, &state, &remote);
+ assert_eq!(p.push_updates, vec!["u2"]);
+ assert_eq!(p.pull_updates, vec!["u3"]);
+ assert_eq!(p.push_deletes, vec!["u4"]);
+ assert_eq!(p.pull_new, vec!["u5"]);
+ assert_eq!(p.push_creates, vec!["fresh local"]);
+ assert!(p.pull_deletes.is_empty());
+ }
+
+ #[test]
+ fn both_changed_local_wins() {
+ let local = vec![item("mine", false, Some("u1"))];
+ let mut state = SyncState::default();
+ state.items.insert("u1".into(), synced("base", false, "e1"));
+ let mut remote = BTreeMap::new();
+ remote.insert("u1".into(), todo("theirs", false, "e2"));
+ let p = plan(&local, &state, &remote);
+ assert_eq!(p.push_updates, vec!["u1"]);
+ assert!(p.pull_updates.is_empty());
+ }
+
+ #[test]
+ fn server_deletion_pulls_row_out() {
+ let local = vec![item("gone on phone", false, Some("u1"))];
+ let mut state = SyncState::default();
+ state.items.insert("u1".into(), synced("gone on phone", false, "e1"));
+ let remote = BTreeMap::new();
+ let p = plan(&local, &state, &remote);
+ assert_eq!(p.pull_deletes, vec!["u1"]);
+ assert!(p.push_creates.is_empty());
+
+ let mut items = local;
+ apply_local(&mut items, &p, &remote, &[]);
+ assert!(items.is_empty());
+ }
+
+ #[test]
+ fn patch_preserves_foreign_properties() {
+ let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:u\r\nDUE;VALUE=DATE:20261001\r\nSUMMARY:old\r\nSTATUS:NEEDS-ACTION\r\nX-APPLE-SORT-ORDER:7\r\nEND:VTODO\r\nEND:VCALENDAR\r\n";
+ let patched = patch_vtodo(&unfold(ics), "new, name", true);
+ assert!(patched.contains("DUE;VALUE=DATE:20261001"));
+ assert!(patched.contains("X-APPLE-SORT-ORDER:7"));
+ assert!(patched.contains("SUMMARY:new\\, name"));
+ assert!(patched.contains("STATUS:COMPLETED"));
+ assert!(patched.contains("PERCENT-COMPLETE:100"));
+ assert!(!patched.contains("SUMMARY:old"));
+ assert!(!patched.contains("NEEDS-ACTION"));
+ }
+
+ #[test]
+ fn fresh_edits_survive_apply_local() {
+ // A row typed while the sync was talking to the network is untouched.
+ let plan = Plan { pull_new: vec!["u9".into()], ..Default::default() };
+ let mut remote = BTreeMap::new();
+ remote.insert("u9".into(), todo("from phone", false, "e9"));
+ let mut items = vec![item("typed mid-sync", false, None)];
+ apply_local(&mut items, &plan, &remote, &[]);
+ assert_eq!(items.len(), 2);
+ assert_eq!(items[0].text, "typed mid-sync");
+ assert_eq!(items[1].uid.as_deref(), Some("u9"));
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..1f64dfc
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,192 @@
+//! Shared between the `cce-list` app and the `cce-list-sync` helper: the
+//! item model, the markdown checklist on disk, and the sync-state sidecar.
+//!
+//! The list stays a plain markdown checklist (`~/.local/share/cce-list/
+//! list.md`), readable and editable with anything. Items mirrored from a
+//! server carry their identity as a trailing HTML comment —
+//! `- [ ] call mom <!-- uid:ABC-123 -->` — which markdown renderers hide and
+//! hand-editors can ignore (or delete, which reads as "delete and recreate").
+//! Everything else the sync needs (etags, item URLs, the last-synced
+//! snapshot) lives in `sync-state.json` next to the list, never in the
+//! markdown.
+
+use std::collections::BTreeMap;
+use std::path::PathBuf;
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Item {
+ pub text: String,
+ pub done: bool,
+ /// Server identity for synced items; None for purely local ones.
+ pub uid: Option<String>,
+}
+
+pub fn data_path() -> PathBuf {
+ data_dir().join("list.md")
+}
+
+pub fn sync_state_path() -> PathBuf {
+ data_dir().join("sync-state.json")
+}
+
+fn data_dir() -> PathBuf {
+ std::env::var_os("XDG_DATA_HOME")
+ .map(PathBuf::from)
+ .filter(|p| p.is_absolute())
+ .unwrap_or_else(|| {
+ PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".local/share")
+ })
+ .join("cce-list")
+}
+
+/// Checklist lines become items; any other non-empty line is adopted as a
+/// not-done item rather than parsed around — the next save rewrites the file,
+/// so a line this reader skipped would be a line silently deleted.
+pub fn parse_items(text: &str) -> Vec<Item> {
+ text.lines()
+ .filter_map(|line| {
+ let trimmed = line.trim();
+ if trimmed.is_empty() {
+ return None;
+ }
+ let (done, rest) = if let Some(r) = trimmed.strip_prefix("- [ ] ") {
+ (false, r)
+ } else if let Some(r) =
+ trimmed.strip_prefix("- [x] ").or_else(|| trimmed.strip_prefix("- [X] "))
+ {
+ (true, r)
+ } else {
+ (false, trimmed)
+ };
+ let (text, uid) = split_uid_comment(rest);
+ Some(Item { text: text.to_string(), done, uid })
+ })
+ .collect()
+}
+
+/// Peel a trailing `<!-- uid:… -->` off an item's text, if present.
+fn split_uid_comment(rest: &str) -> (&str, Option<String>) {
+ let rest = rest.trim_end();
+ if let Some(open) = rest.rfind("<!-- uid:") {
+ if let Some(inner) = rest[open..].strip_prefix("<!-- uid:").and_then(|s| s.strip_suffix("-->")) {
+ let uid = inner.trim();
+ if !uid.is_empty() {
+ return (rest[..open].trim_end(), Some(uid.to_string()));
+ }
+ }
+ }
+ (rest, None)
+}
+
+pub fn serialize_items(items: &[Item]) -> String {
+ items
+ .iter()
+ .map(|i| {
+ let mark = if i.done { 'x' } else { ' ' };
+ match &i.uid {
+ Some(uid) => format!("- [{mark}] {} <!-- uid:{uid} -->\n", i.text),
+ None => format!("- [{mark}] {}\n", i.text),
+ }
+ })
+ .collect()
+}
+
+pub fn load_items() -> Vec<Item> {
+ match std::fs::read_to_string(data_path()) {
+ Ok(text) => parse_items(&text),
+ Err(_) => Vec::new(),
+ }
+}
+
+/// Write-temp-then-rename in the same directory, so a crash mid-write never
+/// leaves a truncated list behind.
+pub fn save_items(items: &[Item]) -> std::io::Result<()> {
+ atomic_write(&data_path(), &serialize_items(items))
+}
+
+pub fn atomic_write(path: &std::path::Path, content: &str) -> std::io::Result<()> {
+ if let Some(dir) = path.parent() {
+ std::fs::create_dir_all(dir)?;
+ }
+ let tmp = path.with_extension("tmp");
+ std::fs::write(&tmp, content)?;
+ std::fs::rename(&tmp, path)
+}
+
+// ── Sync state (cce-list-sync's merge base; the app never touches it) ─────
+
+/// What the server held for one item at the end of the last sync. Comparing
+/// the live list and the live server against this is what tells "the user
+/// checked it off here" apart from "it changed on the phone" — and a uid in
+/// the state but missing from the list is a local deletion to push, where a
+/// uid on the server but not in the state is a new item to pull.
+#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
+pub struct SyncedItem {
+ /// Absolute resource URL (PUT/DELETE target).
+ pub url: String,
+ pub etag: String,
+ /// Which account's credentials the URL answers to.
+ pub account: String,
+ pub text: String,
+ pub done: bool,
+}
+
+#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
+pub struct SyncState {
+ #[serde(default)]
+ pub items: BTreeMap<String, SyncedItem>,
+}
+
+pub fn load_sync_state() -> std::io::Result<SyncState> {
+ let text = match std::fs::read_to_string(sync_state_path()) {
+ Ok(t) => t,
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(SyncState::default()),
+ Err(e) => return Err(e),
+ };
+ serde_json::from_str(&text).map_err(std::io::Error::other)
+}
+
+pub fn save_sync_state(state: &SyncState) -> std::io::Result<()> {
+ atomic_write(
+ &sync_state_path(),
+ &serde_json::to_string_pretty(state).unwrap_or_default(),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn checklist_round_trips() {
+ let items = vec![
+ Item { text: "water the plants".into(), done: false, uid: None },
+ Item { text: "renew passport".into(), done: true, uid: Some("AB-12".into()) },
+ ];
+ assert_eq!(parse_items(&serialize_items(&items)), items);
+ }
+
+ /// A hand-edited file must survive a load/save cycle: plain lines are
+ /// adopted as items, not dropped, and `[X]` reads the same as `[x]`.
+ #[test]
+ fn foreign_lines_are_adopted_not_dropped() {
+ let parsed = parse_items("buy stamps\n- [X] call mom\n\n - [ ] indented\n");
+ assert_eq!(
+ parsed,
+ vec![
+ Item { text: "buy stamps".into(), done: false, uid: None },
+ Item { text: "call mom".into(), done: true, uid: None },
+ Item { text: "indented".into(), done: false, uid: None },
+ ]
+ );
+ }
+
+ #[test]
+ fn uid_comment_is_identity_not_text() {
+ let parsed = parse_items("- [ ] call mom <!-- uid:X-1 -->\n- [ ] literal <!-- not a uid -->\n");
+ assert_eq!(parsed[0], Item { text: "call mom".into(), done: false, uid: Some("X-1".into()) });
+ // A comment that is not `uid:` stays part of the text.
+ assert_eq!(parsed[1].uid, None);
+ assert_eq!(parsed[1].text, "literal <!-- not a uid -->");
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index 4173d7e..fdd7483 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -6,8 +6,11 @@
//! click on a row toggles it done; the ✕ that appears on hover deletes it.
//! Rows scroll when they outgrow the window. The list is a plain markdown
//! checklist on disk (`~/.local/share/cce-list/list.md`), so it can be read
-//! and edited with anything.
+//! and edited with anything. Items mirrored from iCloud Reminders by
+//! `cce-list-sync` carry a trailing `<!-- uid:… -->` comment; toggling or
+//! deleting them here is pushed to the server on the next sync tick.
+use cce_list::{load_items, save_items, Item};
use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
use cce_ui::scene::layout::Rect;
use cce_ui::scene::paint::{Cap, DisplayList, PaintCtx, PlateSpec};
@@ -15,7 +18,6 @@ use cce_ui::widget::{
Adapted, Bounds, ElementState, Event, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey,
ScrollMotion, TextBox, WidgetHost,
};
-use std::path::PathBuf;
use wayland_client::QueueHandle;
/// Initial size only — the window is freely resizable and the compositor
@@ -37,74 +39,12 @@ enum ListMessage {
Exit,
}
-#[derive(Debug, Clone, PartialEq, Eq)]
-struct Item {
- text: String,
- done: bool,
-}
-
-// ── Persistence: a markdown checklist ─────────────────────────────────────
-
-fn data_path() -> PathBuf {
- std::env::var_os("XDG_DATA_HOME")
- .map(PathBuf::from)
- .filter(|p| p.is_absolute())
- .unwrap_or_else(|| {
- PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".local/share")
- })
- .join("cce-list/list.md")
-}
-
-/// Checklist lines become items; any other non-empty line is adopted as a
-/// not-done item rather than parsed around — the next save rewrites the file,
-/// so a line this reader skipped would be a line silently deleted.
-fn parse_items(text: &str) -> Vec<Item> {
- text.lines()
- .filter_map(|line| {
- let trimmed = line.trim();
- if trimmed.is_empty() {
- return None;
- }
- let (done, rest) = if let Some(r) = trimmed.strip_prefix("- [ ] ") {
- (false, r)
- } else if let Some(r) = trimmed.strip_prefix("- [x] ").or_else(|| trimmed.strip_prefix("- [X] ")) {
- (true, r)
- } else {
- (false, trimmed)
- };
- Some(Item { text: rest.to_string(), done })
- })
- .collect()
-}
-
-fn serialize_items(items: &[Item]) -> String {
- items
- .iter()
- .map(|i| format!("- [{}] {}\n", if i.done { 'x' } else { ' ' }, i.text))
- .collect()
-}
+// Item, the markdown parse/serialize, and load/save live in the lib
+// (src/lib.rs), shared with the cce-list-sync helper.
-fn load_items() -> Vec<Item> {
- match std::fs::read_to_string(data_path()) {
- Ok(text) => parse_items(&text),
- Err(_) => Vec::new(),
- }
-}
-
-/// Write-temp-then-rename in the same directory, so a crash mid-write never
-/// leaves a truncated list behind.
-fn save_items(items: &[Item]) {
- let path = data_path();
- let write = || -> std::io::Result<()> {
- if let Some(dir) = path.parent() {
- std::fs::create_dir_all(dir)?;
- }
- let tmp = path.with_extension("md.tmp");
- std::fs::write(&tmp, serialize_items(items))?;
- std::fs::rename(&tmp, &path)
- };
- if let Err(e) = write() {
- log::error!("cce-list: failed to save {}: {e}", path.display());
+fn save(items: &[Item]) {
+ if let Err(e) = save_items(items) {
+ log::error!("cce-list: failed to save list: {e}");
}
}
@@ -252,11 +192,11 @@ impl ListApp {
if text.is_empty() {
return;
}
- self.items.push(Item { text, done: false });
+ self.items.push(Item { text, done: false, uid: None });
self.input_box.text.clear();
self.input_box.edit_buffer.clear();
self.input_box.cursor_idx = 0;
- save_items(&self.items);
+ save(&self.items);
// Keep the fresh item in view once the window is at its height cap.
let m = metrics(self.width as f32);
self.scroll = self.max_scroll(&m);
@@ -519,7 +459,7 @@ impl Application for ListApp {
} else {
self.items[i].done = !self.items[i].done;
}
- save_items(&self.items);
+ save(&self.items);
self.needs_rebuild = true;
*needs_rebuild = true;
return None;
@@ -599,32 +539,3 @@ fn main() {
env_logger::init();
cce_ui::engine::run::<ListApp>();
}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn checklist_round_trips() {
- let items = vec![
- Item { text: "water the plants".into(), done: false },
- Item { text: "renew passport".into(), done: true },
- ];
- assert_eq!(parse_items(&serialize_items(&items)), items);
- }
-
- /// A hand-edited file must survive a load/save cycle: plain lines are
- /// adopted as items, not dropped, and `[X]` reads the same as `[x]`.
- #[test]
- fn foreign_lines_are_adopted_not_dropped() {
- let parsed = parse_items("buy stamps\n- [X] call mom\n\n - [ ] indented\n");
- assert_eq!(
- parsed,
- vec![
- Item { text: "buy stamps".into(), done: false },
- Item { text: "call mom".into(), done: true },
- Item { text: "indented".into(), done: false },
- ]
- );
- }
-}