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

commit3aece658b3bea5d23757cc2a7ee547d204ecb4e0
parent2c5b3c1481
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 19:23
feat: the vault is the source of truth; drop the Google Tasks sync

With a notes vault configured, lists are the notes in its Tasks/ folder
rather than ~/.local/share/cce-list/lists, so cce-notes, Obsidian on the
phone and cce-list all edit the same files and the vault's own sync
carries them. A note there with no tasks that is more than headings (the
Tasks Index) is not shown as a list. A new list opens with a `# Title`
heading, and the first item goes below it.

cce-list-sync (Google Tasks / iCloud Reminders), its units, the sync
state and the pre-lists migration are removed, with reqwest, roxmltree,
keyring and serde. The <!-- list: --> / <!-- uid: --> comments are still
parsed so an old file round-trips; nothing writes new ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 Cargo.toml            |   13 -
 cce-list-sync.service |   10 -
 cce-list-sync.timer   |   10 -
 src/bin/sync.rs       | 1827 -------------------------------------------------
 src/lib.rs            |  244 ++-----
 src/main.rs           |   30 +-
 6 files changed, 80 insertions(+), 2054 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml
index 2c7bcdb..ad1ba36 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,22 +9,9 @@ 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", "json"] }
-roxmltree = "0.20"
 chrono = "0.4"
 cce-vault = { git = "https://github.com/lsgalante/cce-vault.git", rev = "252827c1f6033514fbed46d1c7c1632241344fce" }
-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
deleted file mode 100644
index b1a5bf7..0000000
--- a/cce-list-sync.service
+++ /dev/null
@@ -1,10 +0,0 @@
-# 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
deleted file mode 100644
index c2d1d88..0000000
--- a/cce-list-sync.timer
+++ /dev/null
@@ -1,10 +0,0 @@
-[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
deleted file mode 100644
index 9fad50b..0000000
--- a/src/bin/sync.rs
+++ /dev/null
@@ -1,1827 +0,0 @@
-//! `cce-list-sync` — two-way sync between remote task lists and cce-list's
-//! markdown checklists.
-//!
-//! Accounts are cce-mail's (accounts.json, owned by cce-system-interface).
-//! Two backends, one of which is chosen per run (`--backend google|icloud`;
-//! default Google when an OAuth account exists, else iCloud):
-//!
-//! - **Google Tasks**, over its REST API with the OAuth tokens the settings
-//!   app's Google sign-in stores (it requests the `tasks` scope). The
-//!   access token is refreshed in memory each run, never written back. This
-//!   is the backend that reaches the phone. Lists sync both ways: a file
-//!   created in cce-list becomes a Google list, a list made on the phone
-//!   becomes a file, renames and deletions follow in either direction.
-//! - **iCloud Reminders**, over CalDAV VTODO with the "cce-mail" keyring
-//!   password. Each VTODO calendar is a list, read-only as a list (items
-//!   inside it sync both ways; creating, renaming or deleting calendars is
-//!   not attempted). Kept working but unlikely to be useful: an account
-//!   whose Reminders were "upgraded" (CloudKit) exposes only Apple's legacy
-//!   stub list over CalDAV, invisible to the Reminders app.
-//!
-//! The DAV discovery code is deliberately duplicated from cce-calendar-sync
-//! 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: for lists (by id: title), and for items (by uid: text, done).
-//! A difference between the files 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 `lists/` directory re-imports instead of deleting, a
-//! run that would delete every remote list refuses, and one that would
-//! delete most of a list's tracked items (>5 and >50%) refuses without
-//! `--force-deletes` — a mangled tree must not empty the phone. State
-//! entries record their account, so a run only reasons about its own
-//! backend's lists and items; rows another backend owns pass through.
-//!
-//! An item whose uid belongs to a different list than the file it sits in
-//! has been moved by hand; it is recreated in the new list and deleted from
-//! the old one (the Tasks API has no cross-list move). CalDAV pushes PATCH
-//! the fetched iCalendar rather than rebuilding it, so due dates, notes, and
-//! alarms Apple attached survive a checkbox toggle; Google pushes are
-//! field-level PATCHes for the same reason. Recurring reminders (RRULE) are
-//! skipped entirely. Server-side completed items that were never tracked
-//! are not imported, and neither are blank-title tasks.
-//!
-//! Usage: `cce-list-sync [--dry-run] [--force-deletes] [--backend google|icloud]`.
-//! Driven by cce-list-sync.timer; harmless to run by hand.
-
-use std::collections::{BTreeMap, BTreeSet};
-
-use cce_list::{
-    delete_list, legacy_path, list_path, lists_dir, load_current, load_lists, load_sync_state,
-    rename_list, safe_title, save_current, save_list, save_sync_state, Item, ListFile, SyncState,
-    SyncedItem, SyncedList,
-};
-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 wanted = args
-        .iter()
-        .position(|a| a == "--backend")
-        .and_then(|i| args.get(i + 1))
-        .map(|s| s.to_ascii_lowercase());
-
-    let backend = match choose_backend(wanted.as_deref()) {
-        Ok(Some(b)) => b,
-        Ok(None) => {
-            log::info!("no usable account in accounts.json; nothing to sync");
-            return;
-        }
-        Err(e) => {
-            log::error!("cannot read accounts: {e}");
-            std::process::exit(1);
-        }
-    };
-
-    match run_sync(&backend, dry_run, force_deletes) {
-        Ok(()) => {}
-        Err(e) => {
-            log::error!("{}: sync failed: {e}", backend.email());
-            std::process::exit(1);
-        }
-    }
-}
-
-enum Backend {
-    ICloud(Account),
-    Google(GoogleAccount),
-}
-
-impl Backend {
-    fn email(&self) -> &str {
-        match self {
-            Backend::ICloud(a) => &a.email,
-            Backend::Google(a) => &a.email,
-        }
-    }
-}
-
-fn choose_backend(wanted: Option<&str>) -> Result<Option<Backend>, String> {
-    match wanted {
-        Some("icloud") => Ok(icloud_accounts()?.into_iter().next().map(Backend::ICloud)),
-        Some("google") => Ok(google_accounts()?.into_iter().next().map(Backend::Google)),
-        Some(other) => Err(format!("unknown --backend {other:?} (google|icloud)")),
-        None => {
-            if let Some(g) = google_accounts()?.into_iter().next() {
-                return Ok(Some(Backend::Google(g)));
-            }
-            Ok(icloud_accounts()?.into_iter().next().map(Backend::ICloud))
-        }
-    }
-}
-
-/// Per-run credentials the pushes need beyond the account itself.
-enum Session {
-    ICloud,
-    Google(String),
-}
-
-// ── Remote model (both backends produce it) ───────────────────────────────
-
-#[derive(Debug, Clone)]
-struct RemoteList {
-    title: String,
-    etag: String,
-    /// Where a new item in this list is created.
-    create_target: reqwest::Url,
-}
-
-#[derive(Debug)]
-struct RemoteTodo {
-    url: reqwest::Url,
-    etag: String,
-    summary: String,
-    done: bool,
-    /// Unfolded logical lines of the full VCALENDAR, for patch-and-PUT
-    /// (CalDAV only; empty for Google).
-    lines: Vec<String>,
-    /// The list (id) the item lives in.
-    list: String,
-}
-
-struct RemoteSnapshot {
-    lists: BTreeMap<String, RemoteList>,
-    todos: BTreeMap<String, RemoteTodo>,
-}
-
-// ── The pass ──────────────────────────────────────────────────────────────
-
-fn run_sync(backend: &Backend, 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())?;
-    let email = backend.email().to_string();
-
-    // Whether there is any local knowledge at all, judged BEFORE load_lists
-    // runs the legacy migration (which creates lists/ from list.md).
-    let had_local = lists_dir().exists() || legacy_path().exists();
-    let mut local = load_lists().map_err(|e| format!("reading lists: {e}"))?;
-    let mut state = load_sync_state().map_err(|e| format!("sync-state.json: {e}"))?;
-    if !had_local && (!state.items.is_empty() || !state.lists.is_empty()) {
-        // The tree is gone (fresh clone, deleted directory). Re-import
-        // rather than reading absence as "delete everything on the server".
-        log::warn!("lists/ missing; discarding sync state and re-importing");
-        state = SyncState::default();
-    }
-
-    let (remote, session) = match backend {
-        Backend::ICloud(acc) => (fetch_icloud(&client, acc)?, Session::ICloud),
-        Backend::Google(acc) => {
-            let token = google_access_token(&client, acc)?;
-            (google_fetch(&client, &token, &acc.email)?, Session::Google(token))
-        }
-    };
-    let ops = Ops { client: &client, backend, session: &session };
-
-    // ── Lists ───────────────────────────────────────────────────────────
-    let mine_lists: BTreeMap<String, SyncedList> = state
-        .lists
-        .iter()
-        .filter(|(_, v)| v.account == email)
-        .map(|(k, v)| (k.clone(), v.clone()))
-        .collect();
-    let lplan = plan_lists(&local, &mine_lists, &remote.lists);
-    if !force_deletes && !lplan.push_deletes.is_empty() && lplan.push_deletes.len() >= remote.lists.len()
-    {
-        return Err(format!(
-            "refusing to delete every remote list ({}) — if that was really meant, run \
-             cce-list-sync --force-deletes",
-            lplan.push_deletes.len()
-        ));
-    }
-    log::info!(
-        "{email}: lists — pull {} new / {} renamed / {} deleted; push {} new / {} renamed / {} deleted",
-        lplan.pull_new.len(),
-        lplan.pull_renames.len(),
-        lplan.pull_deletes.len(),
-        lplan.push_creates.len(),
-        lplan.push_renames.len(),
-        lplan.push_deletes.len(),
-    );
-    if dry_run {
-        print_list_plan(&lplan, &remote.lists);
-    } else {
-        apply_list_plan(&ops, &lplan, &mut local, &mut state, &remote, &email)?;
-    }
-
-    // The lists this run can sync items for: local files with an id the
-    // server knows (after the list phase, that is every list unless a push
-    // failed and will retry next tick).
-    let mut remote_lists = remote.lists.clone();
-    for (id, l) in &state.lists {
-        // Lists created this run are not in the fetched snapshot yet.
-        if l.account == email && !remote_lists.contains_key(id) {
-            if let Some(target) = ops.create_target_for(id) {
-                remote_lists.insert(
-                    id.clone(),
-                    RemoteList { title: l.title.clone(), etag: l.etag.clone(), create_target: target },
-                );
-            }
-        }
-    }
-
-    // ── Items, per list ─────────────────────────────────────────────────
-    let foreign_uids: BTreeSet<String> = state
-        .items
-        .iter()
-        .filter(|(_, v)| v.account != email)
-        .map(|(k, _)| k.clone())
-        .collect();
-    // Where each uid lives, server-side or as last synced: a row found in a
-    // different file has been moved by hand. Precomputed so the loop below
-    // can take `state` mutably.
-    let owners: BTreeMap<String, String> = state
-        .items
-        .iter()
-        .filter(|(_, v)| v.account == email)
-        .map(|(k, v)| (k.clone(), v.list_id()))
-        .chain(remote.todos.iter().map(|(k, t)| (k.clone(), t.list.clone())))
-        .collect();
-    let owner_of = |uid: &str| -> Option<String> { owners.get(uid).cloned() };
-
-    let mut current_title = load_current();
-    for file in &local {
-        let Some(list_id) = file.id.clone() else {
-            continue; // creation failed this run; retried next tick
-        };
-        if !remote_lists.contains_key(&list_id) {
-            continue;
-        }
-        let rows: Vec<Item> = file
-            .items
-            .iter()
-            .filter(|i| i.uid.as_deref().is_none_or(|u| !foreign_uids.contains(u)))
-            .map(|i| {
-                let moved = i
-                    .uid
-                    .as_deref()
-                    .and_then(owner_of)
-                    .is_some_and(|owner| owner != list_id);
-                if moved {
-                    // Recreate here; the old list's plan pushes the delete.
-                    Item { uid: None, ..i.clone() }
-                } else {
-                    i.clone()
-                }
-            })
-            .collect();
-        // Rows that were moved keep their old uid on disk until apply_local
-        // swaps it, so remember which text came from which uid.
-        let moved_from: Vec<(String, String)> = file
-            .items
-            .iter()
-            .filter_map(|i| {
-                let uid = i.uid.as_deref()?;
-                (owner_of(uid)? != list_id).then(|| (i.text.clone(), uid.to_string()))
-            })
-            .collect();
-
-        let mine = SyncState {
-            items: state
-                .items
-                .iter()
-                .filter(|(_, v)| v.account == email && v.list_id() == list_id)
-                .map(|(k, v)| (k.clone(), v.clone()))
-                .collect(),
-            lists: BTreeMap::new(),
-        };
-        let todos: BTreeMap<String, &RemoteTodo> = remote
-            .todos
-            .iter()
-            .filter(|(_, t)| t.list == list_id)
-            .map(|(k, v)| (k.clone(), v))
-            .collect();
-        let plan = plan_items(&rows, &mine, &todos);
-
-        if !force_deletes && plan.push_deletes.len() > 5 && plan.push_deletes.len() * 2 > mine.items.len()
-        {
-            return Err(format!(
-                "{}: refusing to delete {} of {} tracked items on the server — if the list \
-                 was really emptied on purpose, run cce-list-sync --force-deletes",
-                file.title,
-                plan.push_deletes.len(),
-                mine.items.len()
-            ));
-        }
-        log::info!(
-            "{email}: {} — pull {} new / {} changed / {} deleted; push {} changed / {} new / {} deleted",
-            file.title,
-            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_item_plan(&plan, &todos);
-            continue;
-        }
-
-        let target = &remote_lists[&list_id].create_target;
-        let created = apply_item_plan(&ops, &plan, &rows, &todos, target, &list_id, &mut state, &email)?;
-
-        // 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 = cce_list::load_list(&file.title).unwrap_or_else(|_| ListFile {
-            title: file.title.clone(),
-            id: Some(list_id.clone()),
-            ..Default::default()
-        });
-        fresh.id = Some(list_id.clone());
-        let orphans = apply_local(&mut fresh.items, &plan, &todos, &created, &moved_from);
-        fresh.trailer.splice(0..0, orphans);
-        save_list(&fresh).map_err(|e| e.to_string())?;
-    }
-
-    if !dry_run {
-        // The app's pointer may name a list this run renamed or removed.
-        if let Some(cur) = current_title.take() {
-            if !list_path(&cur).exists() {
-                if let Some(first) = local.first() {
-                    save_current(&first.title).map_err(|e| e.to_string())?;
-                }
-            }
-        }
-        save_sync_state(&state).map_err(|e| e.to_string())?;
-    }
-    Ok(())
-}
-
-/// The backend operations, bundled so the two phases share one signature.
-struct Ops<'a> {
-    client: &'a reqwest::blocking::Client,
-    backend: &'a Backend,
-    session: &'a Session,
-}
-
-impl Ops<'_> {
-    fn token(&self) -> &str {
-        match self.session {
-            Session::Google(t) => t,
-            Session::ICloud => "",
-        }
-    }
-
-    fn create_target_for(&self, list_id: &str) -> Option<reqwest::Url> {
-        match self.backend {
-            Backend::Google(_) => reqwest::Url::parse(&format!("{TASKS_API}/lists/{list_id}/tasks")).ok(),
-            Backend::ICloud(_) => reqwest::Url::parse(list_id).ok(),
-        }
-    }
-
-    fn list_create(&self, title: &str) -> Result<(String, RemoteList), String> {
-        match self.backend {
-            Backend::Google(_) => google_list_create(self.client, self.token(), title),
-            Backend::ICloud(_) => Err("the iCloud backend cannot create lists".into()),
-        }
-    }
-
-    fn list_rename(&self, id: &str, title: &str) -> Result<String, String> {
-        match self.backend {
-            Backend::Google(_) => google_list_rename(self.client, self.token(), id, title),
-            Backend::ICloud(_) => Err("the iCloud backend cannot rename lists".into()),
-        }
-    }
-
-    fn list_delete(&self, id: &str) -> Result<(), String> {
-        match self.backend {
-            Backend::Google(_) => google_list_delete(self.client, self.token(), id),
-            Backend::ICloud(_) => Err("the iCloud backend cannot delete lists".into()),
-        }
-    }
-
-    fn item_update(&self, todo: &RemoteTodo, text: &str, done: bool) -> Result<String, String> {
-        match self.backend {
-            Backend::ICloud(acc) => {
-                let body = patch_vtodo(&todo.lines, text, done);
-                put_ics(self.client, acc, &todo.url, &body, Some(&todo.etag))
-            }
-            Backend::Google(_) => google_update(self.client, self.token(), &todo.url, text, done),
-        }
-    }
-
-    fn item_create(
-        &self,
-        target: &reqwest::Url,
-        text: &str,
-        done: bool,
-    ) -> Result<(String, reqwest::Url, String), String> {
-        match self.backend {
-            Backend::ICloud(acc) => {
-                let uid = new_uid();
-                let url = target.join(&format!("{uid}.ics")).map_err(|e| e.to_string())?;
-                put_ics(self.client, acc, &url, &new_vtodo(&uid, text, done), None)
-                    .map(|etag| (uid, url, etag))
-            }
-            Backend::Google(_) => google_create(self.client, self.token(), target, text, done),
-        }
-    }
-
-    fn item_delete(&self, url: &reqwest::Url, etag: &str) -> Result<(), String> {
-        match self.backend {
-            Backend::ICloud(acc) => delete_ics(self.client, acc, url, etag),
-            Backend::Google(_) => google_delete(self.client, self.token(), url),
-        }
-    }
-}
-
-// ── List planning (pure; tested) ──────────────────────────────────────────
-
-#[derive(Default, Debug, PartialEq)]
-struct ListPlan {
-    /// Remote ids with no local file and no state: new on the server.
-    pull_new: Vec<String>,
-    /// (id, local title, remote title): renamed on the server.
-    pull_renames: Vec<(String, String, String)>,
-    /// (id, local title): the server list is gone.
-    pull_deletes: Vec<(String, String)>,
-    /// (title, stale id if the file carried one the server does not know).
-    push_creates: Vec<(String, Option<String>)>,
-    /// (id, new title): renamed locally.
-    push_renames: Vec<(String, String)>,
-    /// Ids whose local file is gone.
-    push_deletes: Vec<String>,
-    /// Title/etag unchanged in substance; just record the server's etag.
-    refresh: Vec<String>,
-}
-
-fn plan_lists(
-    local: &[ListFile],
-    state: &BTreeMap<String, SyncedList>,
-    remote: &BTreeMap<String, RemoteList>,
-) -> ListPlan {
-    let mut plan = ListPlan::default();
-    let mut claimed: BTreeSet<&str> = BTreeSet::new();
-    for file in local {
-        match &file.id {
-            None => plan.push_creates.push((file.title.clone(), None)),
-            Some(id) => match (remote.get(id), state.get(id)) {
-                (Some(r), base) => {
-                    claimed.insert(id.as_str());
-                    let remote_title = safe_title(&r.title);
-                    match base {
-                        // Never synced as a list before (e.g. the migrated
-                        // legacy file): the server's name wins.
-                        None if remote_title != file.title => {
-                            plan.pull_renames.push((id.clone(), file.title.clone(), remote_title));
-                        }
-                        None => plan.refresh.push(id.clone()),
-                        Some(b) => {
-                            let base_title = safe_title(&b.title);
-                            let local_changed = file.title != base_title;
-                            let remote_changed = remote_title != base_title;
-                            if local_changed && file.title != remote_title {
-                                plan.push_renames.push((id.clone(), file.title.clone()));
-                            } else if remote_changed && file.title != remote_title {
-                                plan.pull_renames.push((id.clone(), file.title.clone(), remote_title));
-                            } else if r.etag != b.etag || local_changed {
-                                plan.refresh.push(id.clone());
-                            }
-                        }
-                    }
-                }
-                (None, Some(_)) => plan.pull_deletes.push((id.clone(), file.title.clone())),
-                // A header the server never heard of and the state does not
-                // track: recreate rather than orphan the file.
-                (None, None) => plan.push_creates.push((file.title.clone(), Some(id.clone()))),
-            },
-        }
-    }
-    for id in remote.keys() {
-        if claimed.contains(id.as_str()) {
-            continue;
-        }
-        if state.contains_key(id) {
-            plan.push_deletes.push(id.clone());
-        } else {
-            plan.pull_new.push(id.clone());
-        }
-    }
-    plan
-}
-
-fn print_list_plan(plan: &ListPlan, remote: &BTreeMap<String, RemoteList>) {
-    for id in &plan.pull_new {
-        println!("list pull new:    {} ({id})", remote[id].title);
-    }
-    for (id, from, to) in &plan.pull_renames {
-        println!("list pull rename: {from} -> {to} ({id})");
-    }
-    for (id, title) in &plan.pull_deletes {
-        println!("list pull delete: {title} ({id})");
-    }
-    for (title, _) in &plan.push_creates {
-        println!("list push new:    {title}");
-    }
-    for (id, title) in &plan.push_renames {
-        println!("list push rename: -> {title} ({id})");
-    }
-    for id in &plan.push_deletes {
-        println!("list push delete: {} ({id})", remote.get(id).map(|l| l.title.as_str()).unwrap_or("?"));
-    }
-}
-
-/// A free file stem for a server title: `Groceries`, then `Groceries (2)`.
-fn unique_local_title(title: &str, taken: &[ListFile]) -> String {
-    let base = safe_title(title);
-    let exists = |t: &str| taken.iter().any(|l| l.title == t) || list_path(t).exists();
-    if !exists(&base) {
-        return base;
-    }
-    (2..)
-        .map(|n| format!("{base} ({n})"))
-        .find(|t| !exists(t))
-        .expect("unbounded")
-}
-
-fn apply_list_plan(
-    ops: &Ops,
-    plan: &ListPlan,
-    local: &mut Vec<ListFile>,
-    state: &mut SyncState,
-    remote: &RemoteSnapshot,
-    email: &str,
-) -> Result<(), String> {
-    // Server side first; each success is recorded before the next call, so
-    // a failure mid-way retries just the remainder next tick.
-    for (title, stale_id) in &plan.push_creates {
-        match ops.list_create(title) {
-            Ok((id, rl)) => {
-                if let Some(file) = local.iter_mut().find(|f| f.title == *title) {
-                    file.id = Some(id.clone());
-                    save_list(file).map_err(|e| e.to_string())?;
-                }
-                if let Some(old) = stale_id {
-                    state.lists.remove(old);
-                }
-                state.lists.insert(
-                    id,
-                    SyncedList { title: rl.title, etag: rl.etag, account: email.to_string() },
-                );
-            }
-            Err(e) => log::warn!("creating list {title:?} failed (will retry next tick): {e}"),
-        }
-    }
-    for (id, title) in &plan.push_renames {
-        match ops.list_rename(id, title) {
-            Ok(etag) => {
-                state.lists.insert(
-                    id.clone(),
-                    SyncedList { title: title.clone(), etag, account: email.to_string() },
-                );
-            }
-            Err(e) => log::warn!("renaming list {id} failed (will retry next tick): {e}"),
-        }
-    }
-    for id in &plan.push_deletes {
-        match ops.list_delete(id) {
-            Ok(()) => {
-                state.lists.remove(id);
-                state.items.retain(|_, v| v.list_id() != *id);
-            }
-            Err(e) => log::warn!("deleting list {id} failed (will retry next tick): {e}"),
-        }
-    }
-
-    // Then the local tree.
-    let current = load_current();
-    for (id, from, to) in &plan.pull_renames {
-        let to = unique_local_title(to, local);
-        rename_list(from, &to).map_err(|e| format!("renaming {from} -> {to}: {e}"))?;
-        if let Some(file) = local.iter_mut().find(|f| f.title == *from) {
-            file.title = to.clone();
-        }
-        if current.as_deref() == Some(from.as_str()) {
-            save_current(&to).map_err(|e| e.to_string())?;
-        }
-        let r = &remote.lists[id];
-        state.lists.insert(
-            id.clone(),
-            SyncedList { title: r.title.clone(), etag: r.etag.clone(), account: email.to_string() },
-        );
-    }
-    for (id, title) in &plan.pull_deletes {
-        delete_list(title).map_err(|e| format!("deleting {title}: {e}"))?;
-        local.retain(|f| f.title != *title);
-        state.lists.remove(id);
-        state.items.retain(|_, v| v.list_id() != *id);
-    }
-    for id in &plan.pull_new {
-        let r = &remote.lists[id];
-        let title = unique_local_title(&r.title, local);
-        let file = ListFile { title, id: Some(id.clone()), ..Default::default() };
-        save_list(&file).map_err(|e| e.to_string())?;
-        local.push(file);
-        state.lists.insert(
-            id.clone(),
-            SyncedList { title: r.title.clone(), etag: r.etag.clone(), account: email.to_string() },
-        );
-    }
-    for id in &plan.refresh {
-        let r = &remote.lists[id];
-        let title = local
-            .iter()
-            .find(|f| f.id.as_deref() == Some(id))
-            .map(|f| f.title.clone())
-            .unwrap_or_else(|| r.title.clone());
-        state.lists.insert(
-            id.clone(),
-            SyncedList { title, etag: r.etag.clone(), account: email.to_string() },
-        );
-    }
-    local.sort_by_key(|l| l.title.to_lowercase());
-    Ok(())
-}
-
-// ── Item planning (pure; tested) ──────────────────────────────────────────
-
-#[derive(Default, Debug)]
-struct Plan {
-    pull_new: Vec<String>,
-    pull_updates: Vec<String>,
-    pull_deletes: Vec<String>,
-    push_updates: Vec<String>,
-    /// (text, done, the row's stale uid if it carried one) to create.
-    push_creates: Vec<(String, bool, Option<String>)>,
-    push_deletes: Vec<String>,
-    /// Server etag moved but content is identical — track it, change nothing.
-    refresh_etags: Vec<String>,
-}
-
-fn plan_items(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.keys() {
-        if !remote.contains_key(uid) {
-            plan.pull_deletes.push(uid.clone());
-        }
-    }
-    for item in local {
-        match &item.uid {
-            None => plan.push_creates.push((item.text.clone(), item.done, None)),
-            // A uid the server never heard of and the state does not track:
-            // recreate it rather than orphan the row.
-            Some(uid) if !remote.contains_key(uid) && !state.items.contains_key(uid) => {
-                plan.push_creates.push((item.text.clone(), item.done, Some(uid.clone())));
-            }
-            Some(_) => {}
-        }
-    }
-    plan
-}
-
-fn print_item_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, done, _) in &plan.push_creates {
-        println!("  push new:    {}{text}", if *done { "[x] " } else { "" });
-    }
-    for uid in &plan.push_deletes {
-        println!("  push delete: {uid}");
-    }
-}
-
-/// Server-side half of an item plan. Returns the creates that succeeded as
-/// (text, stale uid the row carried, new uid) for `apply_local` to annotate.
-#[allow(clippy::too_many_arguments)]
-fn apply_item_plan(
-    ops: &Ops,
-    plan: &Plan,
-    rows: &[Item],
-    todos: &BTreeMap<String, &RemoteTodo>,
-    target: &reqwest::Url,
-    list_id: &str,
-    state: &mut SyncState,
-    email: &str,
-) -> Result<Vec<(String, Option<String>, String)>, String> {
-    let synced = |url: &reqwest::Url, etag: String, text: &str, done: bool| SyncedItem {
-        url: url.to_string(),
-        etag,
-        account: email.to_string(),
-        text: text.to_string(),
-        done,
-        list: list_id.to_string(),
-    };
-    for uid in &plan.push_updates {
-        let todo = todos[uid];
-        let item = rows.iter().find(|i| i.uid.as_deref() == Some(uid)).expect("planned");
-        match ops.item_update(todo, &item.text, item.done) {
-            Ok(etag) => {
-                state.items.insert(uid.clone(), synced(&todo.url, etag, &item.text, item.done));
-            }
-            Err(e) => log::warn!("push update {uid} failed (will retry next tick): {e}"),
-        }
-    }
-    let mut created = Vec::new();
-    for (text, done, stale) in &plan.push_creates {
-        match ops.item_create(target, text, *done) {
-            Ok((uid, url, etag)) => {
-                state.items.insert(uid.clone(), synced(&url, etag, text, *done));
-                created.push((text.clone(), stale.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 ops.item_delete(&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 = todos[uid];
-        state.items.insert(uid.clone(), synced(&todo.url, todo.etag.clone(), &todo.summary, 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), todos.get(uid)) {
-            entry.etag = todo.etag.clone();
-            entry.list = list_id.to_string();
-        }
-    }
-    Ok(created)
-}
-
-/// Apply the plan's local half as deltas onto a fresh read of the list.
-/// `moved_from` pairs a text with the stale uid its row carried when it was
-/// moved in from another list, so the annotation swap finds the row.
-fn apply_local(
-    items: &mut Vec<Item>,
-    plan: &Plan,
-    remote: &BTreeMap<String, &RemoteTodo>,
-    created: &[(String, Option<String>, String)],
-    moved_from: &[(String, String)],
-) -> Vec<String> {
-    // Lines kept above a deleted row move to the next row; any left with
-    // no row after them go back to the caller for the trailer.
-    let orphans = cce_list::retain_items(items, |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, stale, uid) in created {
-        let stale = stale.clone().or_else(|| {
-            moved_from.iter().find(|(t, _)| t == text).map(|(_, u)| u.clone())
-        });
-        let row = match &stale {
-            Some(old) => items.iter_mut().find(|i| i.uid.as_deref() == Some(old)),
-            None => items.iter_mut().find(|i| i.uid.is_none() && i.text == *text),
-        };
-        if let Some(item) = row {
-            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()), ..Default::default() });
-    }
-    orphans
-}
-
-// ── Accounts ──────────────────────────────────────────────────────────────
-
-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)
-}
-
-// ── Google Tasks ──────────────────────────────────────────────────────────
-
-const TASKS_API: &str = "https://tasks.googleapis.com/tasks/v1";
-
-struct GoogleAccount {
-    email: String,
-    refresh_token: String,
-    client_id: String,
-    client_secret: String,
-}
-
-/// The OAuth fields the settings app's Google sign-in writes.
-#[derive(serde::Deserialize)]
-struct OAuthOnDisk {
-    email: String,
-    #[serde(default)]
-    is_oauth: bool,
-    #[serde(default)]
-    refresh_token: Option<String>,
-    #[serde(default)]
-    client_id: Option<String>,
-    #[serde(default)]
-    client_secret: Option<String>,
-}
-
-#[derive(serde::Deserialize, Default)]
-struct GoogleClientConfig {
-    #[serde(default)]
-    client_id: String,
-    #[serde(default)]
-    client_secret: String,
-}
-
-fn google_accounts() -> Result<Vec<GoogleAccount>, String> {
-    let dir = cce_ui::config::cce_config_dir();
-    let path = dir.join("accounts.json");
-    let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
-    let on_disk: Vec<OAuthOnDisk> =
-        serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;
-    // An account without its own pinned client credentials falls back to
-    // the global template the settings app maintains.
-    let template: GoogleClientConfig = std::fs::read_to_string(dir.join("google_client.json"))
-        .ok()
-        .and_then(|t| serde_json::from_str(&t).ok())
-        .unwrap_or_default();
-    let mut out = Vec::new();
-    for acc in on_disk {
-        if !acc.is_oauth {
-            continue;
-        }
-        let Some(refresh_token) = acc.refresh_token.filter(|t| !t.is_empty()) else {
-            log::warn!("{}: OAuth account without a refresh token; sign in again", acc.email);
-            continue;
-        };
-        out.push(GoogleAccount {
-            email: acc.email,
-            refresh_token,
-            client_id: acc.client_id.filter(|s| !s.is_empty()).unwrap_or(template.client_id.clone()),
-            client_secret: acc
-                .client_secret
-                .filter(|s| !s.is_empty())
-                .unwrap_or(template.client_secret.clone()),
-        });
-    }
-    Ok(out)
-}
-
-/// A fresh access token from the refresh grant. Tokens last an hour and a
-/// tick is one request burst, so refreshing every run is simpler than
-/// tracking expiry — and keeps this helper from writing accounts.json.
-fn google_access_token(
-    client: &reqwest::blocking::Client,
-    acc: &GoogleAccount,
-) -> Result<String, String> {
-    let resp = client
-        .post("https://oauth2.googleapis.com/token")
-        .form(&[
-            ("client_id", acc.client_id.as_str()),
-            ("client_secret", acc.client_secret.as_str()),
-            ("refresh_token", acc.refresh_token.as_str()),
-            ("grant_type", "refresh_token"),
-        ])
-        .send()
-        .map_err(|e| format!("token refresh: {e}"))?;
-    let status = resp.status();
-    let body: serde_json::Value = resp.json().map_err(|e| format!("token refresh: {e}"))?;
-    if !status.is_success() {
-        // invalid_grant here means the refresh token was revoked or the
-        // consent predates the tasks scope — a re-login fixes both.
-        return Err(format!("token refresh: HTTP {status} {body}"));
-    }
-    body.get("access_token")
-        .and_then(|v| v.as_str())
-        .map(String::from)
-        .ok_or_else(|| "token refresh: no access_token in response".to_string())
-}
-
-fn google_call(
-    client: &reqwest::blocking::Client,
-    token: &str,
-    method: reqwest::Method,
-    url: &str,
-    query: &[(&str, &str)],
-    body: Option<&serde_json::Value>,
-) -> Result<serde_json::Value, String> {
-    let mut req = client.request(method.clone(), url).bearer_auth(token).query(query);
-    if let Some(b) = body {
-        req = req.json(b);
-    }
-    let resp = req.send().map_err(|e| format!("{method} {url}: {e}"))?;
-    let status = resp.status();
-    if status == reqwest::StatusCode::NO_CONTENT {
-        return Ok(serde_json::Value::Null);
-    }
-    let text = resp.text().map_err(|e| format!("{method} {url}: {e}"))?;
-    if !status.is_success() {
-        return Err(format!("{method} {url}: HTTP {status} {text}"));
-    }
-    if text.trim().is_empty() {
-        return Ok(serde_json::Value::Null);
-    }
-    serde_json::from_str(&text).map_err(|e| format!("{method} {url}: bad JSON: {e}"))
-}
-
-fn google_list_url(id: &str) -> String {
-    format!("{TASKS_API}/users/@me/lists/{id}")
-}
-
-/// Every list and every task in it.
-fn google_fetch(
-    client: &reqwest::blocking::Client,
-    token: &str,
-    email: &str,
-) -> Result<RemoteSnapshot, String> {
-    let get = |url: &str, q: &[(&str, &str)]| {
-        google_call(client, token, reqwest::Method::GET, url, q, None)
-    };
-    let page = get(&format!("{TASKS_API}/users/@me/lists"), &[("maxResults", "100")])?;
-    let mut lists = BTreeMap::new();
-    for l in page["items"].as_array().into_iter().flatten() {
-        let Some(id) = l["id"].as_str() else { continue };
-        lists.insert(
-            id.to_string(),
-            RemoteList {
-                title: l["title"].as_str().unwrap_or("Untitled").to_string(),
-                etag: l["etag"].as_str().unwrap_or("").to_string(),
-                create_target: reqwest::Url::parse(&format!("{TASKS_API}/lists/{id}/tasks"))
-                    .map_err(|e| e.to_string())?,
-            },
-        );
-    }
-    log::info!("{email}: {} task list(s)", lists.len());
-
-    let mut todos = BTreeMap::new();
-    for (list_id, list) in &lists {
-        let base = list.create_target.as_str().to_string();
-        let mut page_token = String::new();
-        loop {
-            let mut q = vec![
-                ("showCompleted", "true"),
-                ("showHidden", "true"),
-                ("maxResults", "100"),
-                ("fields", "nextPageToken,items(id,title,status,etag,deleted)"),
-            ];
-            if !page_token.is_empty() {
-                q.push(("pageToken", page_token.as_str()));
-            }
-            let page = get(&base, &q).map_err(|e| format!("list {}: {e}", list.title))?;
-            for t in page["items"].as_array().into_iter().flatten() {
-                if t["deleted"].as_bool().unwrap_or(false) {
-                    continue;
-                }
-                let Some(id) = t["id"].as_str() else { continue };
-                let summary = t["title"].as_str().unwrap_or("").trim().to_string();
-                if summary.is_empty() {
-                    // Google's apps mint blank placeholder tasks freely; a
-                    // row with no text is nothing to remember.
-                    continue;
-                }
-                let url = reqwest::Url::parse(&format!("{base}/{id}")).map_err(|e| e.to_string())?;
-                todos.insert(id.to_string(), RemoteTodo {
-                    url,
-                    etag: t["etag"].as_str().unwrap_or("").to_string(),
-                    summary,
-                    done: t["status"].as_str() == Some("completed"),
-                    lines: Vec::new(),
-                    list: list_id.clone(),
-                });
-            }
-            match page["nextPageToken"].as_str() {
-                Some(next) if !next.is_empty() => page_token = next.to_string(),
-                _ => break,
-            }
-        }
-    }
-    Ok(RemoteSnapshot { lists, todos })
-}
-
-fn google_list_create(
-    client: &reqwest::blocking::Client,
-    token: &str,
-    title: &str,
-) -> Result<(String, RemoteList), String> {
-    let body = serde_json::json!({ "title": title });
-    let resp = google_call(
-        client, token, reqwest::Method::POST,
-        &format!("{TASKS_API}/users/@me/lists"), &[], Some(&body),
-    )?;
-    let id = resp["id"].as_str().ok_or("created list has no id")?.to_string();
-    let list = RemoteList {
-        title: resp["title"].as_str().unwrap_or(title).to_string(),
-        etag: resp["etag"].as_str().unwrap_or("").to_string(),
-        create_target: reqwest::Url::parse(&format!("{TASKS_API}/lists/{id}/tasks"))
-            .map_err(|e| e.to_string())?,
-    };
-    Ok((id, list))
-}
-
-fn google_list_rename(
-    client: &reqwest::blocking::Client,
-    token: &str,
-    id: &str,
-    title: &str,
-) -> Result<String, String> {
-    let body = serde_json::json!({ "title": title });
-    let resp = google_call(client, token, reqwest::Method::PATCH, &google_list_url(id), &[], Some(&body))?;
-    Ok(resp["etag"].as_str().unwrap_or("").to_string())
-}
-
-fn google_list_delete(client: &reqwest::blocking::Client, token: &str, id: &str) -> Result<(), String> {
-    match google_call(client, token, reqwest::Method::DELETE, &google_list_url(id), &[], None) {
-        Ok(_) => Ok(()),
-        Err(e) if e.contains("HTTP 404") => Ok(()),
-        Err(e) => Err(e),
-    }
-}
-
-/// Field-level PATCH: title and status only, so due dates and notes set in
-/// Google's own apps ride through. Un-completing must also clear the
-/// completion timestamp or the API rejects the status.
-fn google_update(
-    client: &reqwest::blocking::Client,
-    token: &str,
-    url: &reqwest::Url,
-    text: &str,
-    done: bool,
-) -> Result<String, String> {
-    let body = if done {
-        serde_json::json!({ "title": text, "status": "completed" })
-    } else {
-        serde_json::json!({ "title": text, "status": "needsAction", "completed": null })
-    };
-    let resp = google_call(client, token, reqwest::Method::PATCH, url.as_str(), &[], Some(&body))?;
-    Ok(resp["etag"].as_str().unwrap_or("").to_string())
-}
-
-fn google_create(
-    client: &reqwest::blocking::Client,
-    token: &str,
-    target: &reqwest::Url,
-    text: &str,
-    done: bool,
-) -> Result<(String, reqwest::Url, String), String> {
-    let status = if done { "completed" } else { "needsAction" };
-    let body = serde_json::json!({ "title": text, "status": status });
-    let resp = google_call(client, token, reqwest::Method::POST, target.as_str(), &[], Some(&body))?;
-    let id = resp["id"].as_str().ok_or("created task has no id")?.to_string();
-    let url = reqwest::Url::parse(&format!("{}/{id}", target.as_str().trim_end_matches('/')))
-        .map_err(|e| e.to_string())?;
-    Ok((id, url, resp["etag"].as_str().unwrap_or("").to_string()))
-}
-
-fn google_delete(
-    client: &reqwest::blocking::Client,
-    token: &str,
-    url: &reqwest::Url,
-) -> Result<(), String> {
-    match google_call(client, token, reqwest::Method::DELETE, url.as_str(), &[], None) {
-        Ok(_) => Ok(()),
-        // Already gone counts as done.
-        Err(e) if e.contains("HTTP 404") => Ok(()),
-        Err(e) => Err(e),
-    }
-}
-
-// ── CalDAV (iCloud) ───────────────────────────────────────────────────────
-
-/// Every VTODO calendar as a list (its URL is the list id) and its items.
-fn fetch_icloud(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 calendars = todo_calendars(client, acc, &home)?;
-    if calendars.is_empty() {
-        return Err("no VTODO calendars (Reminders lists) found".into());
-    }
-    log::info!("{}: {} Reminders list(s)", acc.email, calendars.len());
-
-    let mut lists = BTreeMap::new();
-    let mut todos = BTreeMap::new();
-    let mut skipped_recurring = 0usize;
-    for (url, name) in &calendars {
-        lists.insert(
-            url.to_string(),
-            RemoteList { title: name.clone(), etag: String::new(), create_target: url.clone() },
-        );
-        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 { lists, todos })
-}
-
-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,
-                    list: cal.to_string(),
-                });
-            }
-            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), ..Default::default() }
-    }
-
-    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(),
-            list: "L".into(),
-        }
-    }
-
-    fn synced(text: &str, done: bool, etag: &str) -> SyncedItem {
-        SyncedItem {
-            url: "https://example.com/cal/x.ics".into(),
-            etag: etag.into(),
-            account: "a@icloud.com".into(),
-            text: text.into(),
-            done,
-            list: "L".into(),
-        }
-    }
-
-    fn rlist(title: &str, etag: &str) -> RemoteList {
-        RemoteList {
-            title: title.into(),
-            etag: etag.into(),
-            create_target: reqwest::Url::parse("https://example.com/l/tasks").unwrap(),
-        }
-    }
-
-    fn slist(title: &str, etag: &str) -> SyncedList {
-        SyncedList { title: title.into(), etag: etag.into(), account: "a".into() }
-    }
-
-    fn lfile(title: &str, id: Option<&str>) -> ListFile {
-        ListFile { title: title.into(), id: id.map(String::from), ..Default::default() }
-    }
-
-    fn refs<'a>(m: &'a BTreeMap<String, RemoteTodo>) -> BTreeMap<String, &'a RemoteTodo> {
-        m.iter().map(|(k, v)| (k.clone(), v)).collect()
-    }
-
-    #[test]
-    fn merge_decision_table() {
-        let local = vec![
-            item("unchanged", false, Some("u1")),
-            item("toggled here", true, Some("u2")), // local change → push
-            item("old name", 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"));
-
-        let p = plan_items(&local, &state, &refs(&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".to_string(), false, None)]);
-        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_items(&local, &state, &refs(&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_items(&local, &state, &refs(&remote));
-        assert_eq!(p.pull_deletes, vec!["u1"]);
-        assert!(p.push_creates.is_empty());
-
-        let mut items = local;
-        apply_local(&mut items, &p, &refs(&remote), &[], &[]);
-        assert!(items.is_empty());
-    }
-
-    #[test]
-    fn recreated_row_is_reannotated_by_its_stale_uid() {
-        // A row carrying a uid nobody knows is recreated; the new uid must
-        // replace the stale one on THAT row, not on a same-text sibling.
-        let plan = Plan {
-            push_creates: vec![("dup".into(), false, Some("stale".into()))],
-            ..Default::default()
-        };
-        let mut items = vec![item("dup", false, None), item("dup", false, Some("stale"))];
-        apply_local(&mut items, &plan, &BTreeMap::new(), &[("dup".into(), Some("stale".into()), "new".into())], &[]);
-        assert_eq!(items[0].uid, None);
-        assert_eq!(items[1].uid.as_deref(), Some("new"));
-    }
-
-    #[test]
-    fn moved_row_swaps_uid_via_moved_from() {
-        let plan = Plan { push_creates: vec![("milk".into(), false, None)], ..Default::default() };
-        let mut items = vec![item("milk", false, Some("from-other-list"))];
-        apply_local(
-            &mut items,
-            &plan,
-            &BTreeMap::new(),
-            &[("milk".into(), None, "new".into())],
-            &[("milk".into(), "from-other-list".into())],
-        );
-        assert_eq!(items[0].uid.as_deref(), Some("new"));
-    }
-
-    #[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, &refs(&remote), &[], &[]);
-        assert_eq!(items.len(), 2);
-        assert_eq!(items[0].text, "typed mid-sync");
-        assert_eq!(items[1].uid.as_deref(), Some("u9"));
-    }
-
-    #[test]
-    fn list_decision_table() {
-        let local = vec![
-            lfile("Tasks", Some("L1")),      // unchanged
-            lfile("Errands", Some("L2")),    // renamed here (base "Chores") → push
-            lfile("Work", Some("L3")),       // renamed on phone → pull
-            lfile("Gone remote", Some("L4")), // server deleted it → pull delete
-            lfile("Brand new", None),        // → push create
-            lfile("Orphan", Some("LX")),     // header nobody knows → recreate
-            lfile("Tasks (legacy)", Some("L7")), // never synced as list; server name wins
-        ];
-        let mut state = BTreeMap::new();
-        state.insert("L1".into(), slist("Tasks", "e1"));
-        state.insert("L2".into(), slist("Chores", "e2"));
-        state.insert("L3".into(), slist("Work", "e3"));
-        state.insert("L4".into(), slist("Gone remote", "e4"));
-        state.insert("L5".into(), slist("Deleted here", "e5")); // no file → push delete
-        let mut remote = BTreeMap::new();
-        remote.insert("L1".into(), rlist("Tasks", "e1"));
-        remote.insert("L2".into(), rlist("Chores", "e2"));
-        remote.insert("L3".into(), rlist("Office", "e3b"));
-        remote.insert("L5".into(), rlist("Deleted here", "e5"));
-        remote.insert("L6".into(), rlist("From the phone", "e6")); // → pull new
-        remote.insert("L7".into(), rlist("LSGalante12's list", "e7"));
-
-        let p = plan_lists(&local, &state, &remote);
-        assert_eq!(p.push_renames, vec![("L2".to_string(), "Errands".to_string())]);
-        assert_eq!(p.pull_renames, vec![
-            ("L3".to_string(), "Work".to_string(), "Office".to_string()),
-            ("L7".to_string(), "Tasks (legacy)".to_string(), "LSGalante12's list".to_string()),
-        ]);
-        assert_eq!(p.pull_deletes, vec![("L4".to_string(), "Gone remote".to_string())]);
-        assert_eq!(p.push_creates, vec![
-            ("Brand new".to_string(), None),
-            ("Orphan".to_string(), Some("LX".to_string())),
-        ]);
-        assert_eq!(p.push_deletes, vec!["L5"]);
-        assert_eq!(p.pull_new, vec!["L6"]);
-        // L1 matches the base exactly; nothing to record.
-        assert!(p.refresh.is_empty());
-    }
-
-    #[test]
-    fn list_rename_both_sides_local_wins() {
-        let local = vec![lfile("Mine", Some("L1"))];
-        let mut state = BTreeMap::new();
-        state.insert("L1".into(), slist("Base", "e1"));
-        let mut remote = BTreeMap::new();
-        remote.insert("L1".into(), rlist("Theirs", "e2"));
-        let p = plan_lists(&local, &state, &remote);
-        assert_eq!(p.push_renames, vec![("L1".to_string(), "Mine".to_string())]);
-        assert!(p.pull_renames.is_empty());
-    }
-}
diff --git a/src/lib.rs b/src/lib.rs
index 849ca9b..d5124fd 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,19 +1,18 @@
-//! Shared between the `cce-list` app and the `cce-list-sync` helper: the
-//! item model, the markdown checklists on disk, and the sync-state sidecar.
+//! The `cce-list` app's model: the item type and the markdown checklists
+//! on disk.
 //!
-//! Lists are plain markdown checklists, one file per list under
-//! `~/.local/share/cce-list/lists/<title>.md`, readable and editable with
-//! anything. The file's stem is the list's title. A list mirrored from a
-//! server carries its identity as a first-line HTML comment —
-//! `<!-- list:MDM5… -->` — and each mirrored item as a trailing one —
-//! `- [ ] call mom <!-- uid:ABC-123 -->`; markdown renderers hide both and
-//! hand-editors can ignore them (deleting one reads as "delete and
-//! recreate"). Which list the app shows is a one-line `current` file next
-//! to `lists/`. Everything else the sync needs (etags, item URLs, the
-//! last-synced snapshot) lives in `sync-state.json`, never in the markdown.
+//! Lists are plain markdown checklists, one file per list, the file's stem
+//! its title. With a notes vault configured (`vault { path }` in
+//! config.kdl, or `$CCE_VAULT`) they are the notes in the vault's `Tasks/`
+//! folder — the vault is the one source of truth, and whatever syncs the
+//! vault syncs the lists. Without one they live in
+//! `~/.local/share/cce-list/lists/`. Which list the app shows is a one-line
+//! `current` file under `~/.local/share/cce-list/` either way.
 //!
-//! Before lists existed there was a single `list.md`; `load_lists` migrates
-//! it on first sight (see [`migrate_legacy`]).
+//! Lists used to be mirrored to Google Tasks / iCloud Reminders by a
+//! `cce-list-sync` helper, which tagged files with `<!-- list:… -->` and
+//! items with `<!-- uid:… -->` comments. The helper is gone; the comments
+//! are still parsed so such a file round-trips, and nothing writes new ones.
 //!
 //! **Reading is lossless.** A file is any Markdown note: task lines become
 //! items, keeping how they were written (indentation, `*`/`+`/`1.` bullets,
@@ -25,14 +24,13 @@
 //! an ordinary note without eating it. (It used to adopt every non-task
 //! line as an item, turning a heading into a checkbox on the next save.)
 
-use std::collections::BTreeMap;
 use std::path::{Path, PathBuf};
 
 #[derive(Debug, Clone, PartialEq, Eq, Default)]
 pub struct Item {
     pub text: String,
     pub done: bool,
-    /// Server identity for synced items; None for purely local ones.
+    /// A retired sync's `<!-- uid:… -->` tag, kept so the line round-trips.
     pub uid: Option<String>,
     /// What came before the `[` as written — `  - `, `* `, `1. ` — when it
     /// is not the plain `- ` a new item gets.
@@ -46,7 +44,7 @@ pub struct Item {
     pub before: Vec<String>,
 }
 
-/// One checklist: its file stem, its server identity (if mirrored), items,
+/// One checklist: its file stem, a retired sync's list tag (if any), items,
 /// and whatever non-task lines follow the last item.
 #[derive(Debug, Clone, PartialEq, Eq, Default)]
 pub struct ListFile {
@@ -66,26 +64,28 @@ pub fn data_dir() -> PathBuf {
         .join("cce-list")
 }
 
+/// The vault's `Tasks/` folder when a vault is configured, else the
+/// app's own `lists/`. Resolved once per process: the vault is not
+/// expected to move under a running app.
 pub fn lists_dir() -> PathBuf {
-    data_dir().join("lists")
+    static DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
+    DIR.get_or_init(|| match cce_vault::config::vault_root(None) {
+        Ok(root) => root.join("Tasks"),
+        Err(_) => data_dir().join("lists"),
+    })
+    .clone()
 }
 
-/// The pre-lists single checklist; only read by the migration.
-pub fn legacy_path() -> PathBuf {
-    data_dir().join("list.md")
+/// Whether lists are vault notes (see [`lists_dir`]).
+pub fn in_vault() -> bool {
+    lists_dir() != data_dir().join("lists")
 }
 
 pub fn current_path() -> PathBuf {
     data_dir().join("current")
 }
 
-pub fn sync_state_path() -> PathBuf {
-    data_dir().join("sync-state.json")
-}
-
-/// A title as a file stem. `/` is the one character a stem cannot hold; a
-/// server title carrying one comes back to the server renamed, which is the
-/// lesser evil next to a list that cannot be written at all.
+/// A title as a file stem. `/` is the one character a stem cannot hold.
 pub fn safe_title(title: &str) -> String {
     let t: String = title.trim().replace('/', "-");
     if t.is_empty() || t == "." || t == ".." { "Untitled".to_string() } else { t }
@@ -312,11 +312,12 @@ pub fn serialize_file(list: &ListFile) -> String {
 
 // ── Files ─────────────────────────────────────────────────────────────────
 
-/// Every list on disk, titles sorted case-insensitively. Runs the legacy
-/// migration first, so a pre-lists install comes up with its old checklist
-/// intact rather than empty.
+/// Every list on disk, titles sorted case-insensitively. In the vault, a
+/// note with no tasks that is more than headings (an index of links, a
+/// page of prose) is a note that lives in `Tasks/`, not a list, and is
+/// left out; a fresh list — just its `# Title` — is kept.
 pub fn load_lists() -> std::io::Result<Vec<ListFile>> {
-    migrate_legacy()?;
+    let vault = in_vault();
     let dir = lists_dir();
     let mut out = Vec::new();
     let entries = match std::fs::read_dir(&dir) {
@@ -334,12 +335,34 @@ pub fn load_lists() -> std::io::Result<Vec<ListFile>> {
         };
         let text = std::fs::read_to_string(&path)?;
         let (id, items, trailer) = parse_file(&text);
+        if vault && items.is_empty() && !only_headings(&trailer) {
+            continue;
+        }
         out.push(ListFile { title, id, items, trailer });
     }
     out.sort_by_key(|l| l.title.to_lowercase());
     Ok(out)
 }
 
+fn only_headings(lines: &[String]) -> bool {
+    lines.iter().map(|l| l.trim()).all(|l| l.is_empty() || l.starts_with('#'))
+}
+
+/// A new, empty list: in the vault it opens with a `# Title` heading like
+/// the vault's other task notes.
+pub fn new_list(title: &str) -> ListFile {
+    let title = safe_title(title);
+    let trailer = if in_vault() { vec![format!("# {title}"), String::new()] } else { Vec::new() };
+    ListFile { title, trailer, ..Default::default() }
+}
+
+/// Append an item. Into a list with no items yet, the lines already there
+/// (its heading) go above it rather than staying below.
+pub fn push_item(list: &mut ListFile, text: String) {
+    let before = if list.items.is_empty() { std::mem::take(&mut list.trailer) } else { Vec::new() };
+    list.items.push(Item { text, before, ..Default::default() });
+}
+
 pub fn load_list(title: &str) -> std::io::Result<ListFile> {
     let text = std::fs::read_to_string(list_path(title))?;
     let (id, items, trailer) = parse_file(&text);
@@ -387,143 +410,6 @@ pub fn atomic_write(path: &Path, content: &str) -> std::io::Result<()> {
     std::fs::rename(&tmp, path)
 }
 
-/// `list.md` → `lists/…`, once. The single checklist used to mirror EVERY
-/// server list flat, so its rows are split by the list the sync state says
-/// each belongs to: the biggest group becomes `Tasks.md`, any other group a
-/// file named by its list id — both carrying that id in the header, so the
-/// next sync recognises them as those lists and renames the files to the
-/// server's titles instead of creating new lists on the phone. Rows the
-/// state does not know (typed locally, never synced) go with the biggest
-/// group. The old file is kept as `list.md.migrated`.
-pub fn migrate_legacy() -> std::io::Result<()> {
-    let legacy = legacy_path();
-    if lists_dir().exists() || !legacy.exists() {
-        return Ok(());
-    }
-    let text = std::fs::read_to_string(&legacy)?;
-    let items = parse_items(&text);
-    let state = load_sync_state().unwrap_or_default();
-    let majority = state.majority_list_id();
-    let mut groups: BTreeMap<Option<String>, Vec<Item>> = BTreeMap::new();
-    for item in items {
-        let owner = item
-            .uid
-            .as_deref()
-            .and_then(|u| state.items.get(u))
-            .map(|s| s.list_id())
-            .filter(|id| !id.is_empty())
-            .or_else(|| majority.clone());
-        groups.entry(owner).or_default().push(item);
-    }
-    if groups.is_empty() {
-        groups.insert(majority.clone(), Vec::new());
-    }
-    for (id, items) in groups {
-        let title = match (&id, &majority) {
-            (Some(i), Some(m)) if i != m => safe_title(i),
-            _ => "Tasks".to_string(),
-        };
-        save_list(&ListFile { title, id, items, trailer: Vec::new() })?;
-    }
-    save_current("Tasks")?;
-    std::fs::rename(&legacy, legacy.with_extension("md.migrated"))
-}
-
-// ── 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,
-    /// The server list the item belongs to. Older state files lack it; see
-    /// [`SyncedItem::list_id`], which falls back to reading the URL.
-    #[serde(default)]
-    pub list: String,
-}
-
-impl SyncedItem {
-    /// Google task URLs are `…/lists/{id}/tasks/{task}`; CalDAV item URLs
-    /// are `<calendar>/<uid>.ics`, where the calendar URL is the list id.
-    pub fn list_id(&self) -> String {
-        if !self.list.is_empty() {
-            return self.list.clone();
-        }
-        list_id_from_url(&self.url)
-    }
-}
-
-pub fn list_id_from_url(url: &str) -> String {
-    if let Some(rest) = url.split("/lists/").nth(1) {
-        if let Some(id) = rest.split("/tasks").next() {
-            return id.to_string();
-        }
-    }
-    match url.rfind('/') {
-        Some(i) => url[..=i].to_string(),
-        None => String::new(),
-    }
-}
-
-/// A server list as last synced: its title then, so a rename on either
-/// side is told apart from the other.
-#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
-pub struct SyncedList {
-    pub title: String,
-    #[serde(default)]
-    pub etag: String,
-    pub account: String,
-}
-
-#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
-pub struct SyncState {
-    #[serde(default)]
-    pub items: BTreeMap<String, SyncedItem>,
-    /// Keyed by server list id.
-    #[serde(default)]
-    pub lists: BTreeMap<String, SyncedList>,
-}
-
-impl SyncState {
-    /// The list most tracked items belong to — what the legacy single
-    /// checklist "was", for the migration.
-    pub fn majority_list_id(&self) -> Option<String> {
-        let mut counts: BTreeMap<String, usize> = BTreeMap::new();
-        for item in self.items.values() {
-            let id = item.list_id();
-            if !id.is_empty() {
-                *counts.entry(id).or_default() += 1;
-            }
-        }
-        counts.into_iter().max_by_key(|(_, n)| *n).map(|(id, _)| id)
-    }
-}
-
-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::*;
@@ -622,15 +508,17 @@ mod tests {
     }
 
     #[test]
-    fn list_ids_come_from_urls_when_the_state_predates_them() {
-        assert_eq!(
-            list_id_from_url("https://tasks.googleapis.com/tasks/v1/lists/MDM5/tasks/abc"),
-            "MDM5"
-        );
-        assert_eq!(
-            list_id_from_url("https://p1-caldav.icloud.com/1/calendars/reminders/X.ics"),
-            "https://p1-caldav.icloud.com/1/calendars/reminders/"
-        );
+    fn a_new_list_keeps_its_heading_on_top() {
+        let mut list = ListFile { title: "t".into(), trailer: vec!["# T".into(), String::new()], ..Default::default() };
+        push_item(&mut list, "a".into());
+        push_item(&mut list, "b".into());
+        assert_eq!(serialize_file(&list), "# T\n\n- [ ] a\n- [ ] b\n");
+    }
+
+    #[test]
+    fn index_notes_are_not_lists() {
+        assert!(only_headings(&["# Gifts".into(), String::new()]));
+        assert!(!only_headings(&["# Tasks".into(), "- [[Home]]".into()]));
     }
 
     #[test]
diff --git a/src/main.rs b/src/main.rs
index aea792d..6826fe8 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -9,14 +9,12 @@
 //! click on a row toggles it done; the ✕ that appears on hover deletes it.
 //! Rows scroll when they outgrow the window.
 //!
-//! Each list is a plain markdown checklist on disk
-//! (`~/.local/share/cce-list/lists/<title>.md`), so it can be read and
-//! edited with anything; the shown list is named in a `current` file next
-//! to them. Lists and items mirrored from Google Tasks by `cce-list-sync`
-//! carry `<!-- list:… -->` / `<!-- uid:… -->` comments; toggling, adding,
-//! deleting — items or whole lists — here is pushed to the server on the
-//! next sync tick, and the app re-reads the directory when the sync (or a
-//! hand edit) changes it.
+//! Each list is a plain markdown checklist on disk — with a notes vault
+//! configured, a note in the vault's `Tasks/` folder; otherwise
+//! `~/.local/share/cce-list/lists/<title>.md` — so it can be read and
+//! edited with anything (cce-notes, Obsidian on the phone); the shown list
+//! is named in a `current` file. The app re-reads the directory when
+//! something else (a vault sync, an editor) changes it.
 
 mod vault_tasks;
 
@@ -51,8 +49,8 @@ const SWITCHER_SHARE: f32 = 0.62;
 const CHECK_R: f32 = cce_ui::widget::Checkbox::ROUND_RADIUS;
 /// Side of the ✕ delete target at a row's right edge.
 const DELETE_S: f32 = 18.0;
-/// How often the lists directory is re-read for outside changes (the sync
-/// timer, a hand edit). The runner wakes an idle app once a second by itself,
+/// How often the lists directory is re-read for outside changes (a vault
+/// sync, an edit elsewhere). The runner wakes an idle app once a second by itself,
 /// so this costs no extra frames; `idle_poll_interval` pins the cadence
 /// rather than inheriting it.
 const WATCH_EVERY: std::time::Duration = std::time::Duration::from_secs(1);
@@ -247,7 +245,7 @@ impl ListApp {
     }
 
     /// (Re)read every list from disk. Keeps the shown list by title where it
-    /// still exists (the sync may have renamed or removed it), guarantees at
+    /// still exists (an edit elsewhere may have renamed or removed it), guarantees at
     /// least one list, and refreshes the switcher.
     fn load_from_disk(&mut self) {
         let mut lists = match load_lists() {
@@ -258,7 +256,7 @@ impl ListApp {
             }
         };
         if lists.is_empty() {
-            let first = ListFile { title: "Tasks".to_string(), ..Default::default() };
+            let first = cce_list::new_list("To Do");
             if let Err(e) = save_list(&first) {
                 log::error!("cce-list: creating the first list: {e}");
             }
@@ -358,7 +356,7 @@ impl ListApp {
             self.select_list(idx);
             return;
         }
-        let list = ListFile { title: title.clone(), ..Default::default() };
+        let list = cce_list::new_list(&title);
         if let Err(e) = save_list(&list) {
             log::error!("cce-list: creating {title}: {e}");
             return;
@@ -379,7 +377,7 @@ impl ListApp {
             return;
         }
         if self.lists.len() <= 1 {
-            // The server keeps a default list too; one is the floor.
+            // The switcher needs something to show; one list is the floor.
             self.set_mode(Mode::Items);
             self.input_box.set_placeholder("Keep at least one list");
             self.needs_rebuild = true;
@@ -532,7 +530,7 @@ impl ListApp {
                     return;
                 }
                 if let Some(list) = self.lists.get_mut(self.cur) {
-                    list.items.push(Item { text, ..Default::default() });
+                    cce_list::push_item(list, text);
                 }
                 self.clear_input();
                 self.save_current_list();
@@ -620,7 +618,7 @@ impl Application for ListApp {
                 self.needs_rebuild = true;
             }
         }
-        // Outside changes (the sync tick, a hand edit) show up without a
+        // Outside changes (a vault sync, a hand edit) show up without a
         // relaunch — but never while typing a name, which a reload would
         // interrupt; that waits a second.
         let now = std::time::Instant::now();