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

commit83aa987b822f7a226330800581b3d9872e61a6f4
parentb632131da5
authorLucas Galante <[email protected]>
date2026-09-05 20:28
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 },
-            ]
-        );
-    }
-}