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

commit55fa63353127812a05dda4277e1f0b154e170921
parent83aa987b82
authorLucas Galante <[email protected]>
date2026-09-06 11:09
Google Tasks backend for cce-list-sync

The iCloud CalDAV path turned out to reach only Apple's legacy stub list,
so the sync now has a second backend that reaches the phone: Google
Tasks over its REST API, with the OAuth tokens the settings app stores
(tasks scope). One backend runs per invocation - --backend google|icloud,
defaulting to Google when an OAuth account exists - and state entries
record their account, so a run reasons only about the items it owns and
rows another backend claimed pass through untouched.

Pushes are field-level PATCHes (title, status; un-completing also clears
the completion timestamp, which the API otherwise rejects) so due dates
and notes set in Google's apps survive a checkbox toggle. Creates land in
the default list; every list is read. The access token is refreshed in
memory each run and never written back. The merge planner and the
CalDAV backend are unchanged.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 Cargo.toml      |   2 +-
 src/bin/sync.rs | 433 +++++++++++++++++++++++++++++++++++++++++++++++++-------
 2 files changed, 385 insertions(+), 50 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml
index 062c950..db7adba 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -13,7 +13,7 @@ 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"] }
+reqwest = { version = "0.12", features = ["blocking", "json"] }
 roxmltree = "0.20"
 chrono = "0.4"
 keyring = { version = "3", features = ["sync-secret-service"] }
diff --git a/src/bin/sync.rs b/src/bin/sync.rs
index ccedcec..49efaab 100644
--- a/src/bin/sync.rs
+++ b/src/bin/sync.rs
@@ -1,33 +1,46 @@
-//! `cce-list-sync` — two-way sync between iCloud Reminders and cce-list's
+//! `cce-list-sync` — two-way sync between a remote task list and cce-list's
 //! markdown checklist.
 //!
-//! Accounts and credentials are cce-mail's (accounts.json + the "cce-mail"
-//! keyring service); Reminders lists are the VTODO calendars on
-//! caldav.icloud.com, reached the same way cce-calendar-sync reaches the
-//! event calendars. The discovery/DAV code is deliberately duplicated from
-//! that helper rather than extracted: every crate builds standalone
-//! (multi-repo), and two copies of ~100 lines beats a new published crate
-//! until a third consumer exists.
+//! 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.
+//! - **iCloud Reminders**, over CalDAV VTODO with the "cce-mail" keyring
+//!   password. 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 per uid: a difference between the list and the state is a local
 //! edit to push; between the server and the state, a remote edit to pull;
 //! both changed → local wins (the next tick reconciles). Deletions propagate
 //! both ways, guarded: a missing list.md re-imports instead of deleting, and
-//! a run that would delete most tracked reminders (>5 and >50%) refuses
-//! without `--force-deletes` — a mangled file must not empty the phone.
+//! a run that would delete most tracked items (>5 and >50%) refuses without
+//! `--force-deletes` — a mangled file must not empty the phone. State
+//! entries record their account, so a run only reasons about its own
+//! backend's items; rows another backend owns pass through untouched.
 //!
-//! Pushes PATCH the fetched iCalendar rather than rebuilding it, so due
-//! dates, notes, and alarms Apple attached survive a checkbox toggle.
-//! Recurring reminders (RRULE) are skipped entirely — completing one means
-//! "advance to the next occurrence", which this checkbox model cannot say.
-//! Server-side completed reminders that were never tracked are not imported
+//! 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 — completing one means "advance
+//! to the next occurrence", which this checkbox model cannot say.
+//! Server-side completed items that were never tracked are not imported
 //! (years of checked-off junk stays on the phone).
 //!
-//! Usage: `cce-list-sync [--dry-run] [--force-deletes]`. Driven by
-//! cce-list-sync.timer; harmless to run by hand.
+//! 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;
+use std::collections::{BTreeMap, BTreeSet};
 
 use cce_list::{
     atomic_write, data_path, load_sync_state, parse_items, save_sync_state, serialize_items,
@@ -44,28 +57,302 @@ fn main() {
     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 accounts = match icloud_accounts() {
-        Ok(a) => a,
+    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);
         }
     };
-    let Some(acc) = accounts.into_iter().next() else {
-        log::info!("no iCloud account in accounts.json; nothing to sync");
-        return;
-    };
 
-    match run_sync(&acc, dry_run, force_deletes) {
+    match run_sync(&backend, dry_run, force_deletes) {
         Ok(()) => {}
         Err(e) => {
-            log::error!("{}: sync failed: {e}", acc.email);
+            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),
+}
+
+// ── 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}"))
+}
+
+/// Every task in every list; the default list is where creates go.
+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 default = get(&format!("{TASKS_API}/users/@me/lists/@default"), &[])?;
+    let default_id = default["id"].as_str().ok_or("default task list has no id")?.to_string();
+    let lists = get(&format!("{TASKS_API}/users/@me/lists"), &[("maxResults", "100")])?;
+    let lists: Vec<(String, String)> = lists["items"]
+        .as_array()
+        .into_iter()
+        .flatten()
+        .filter_map(|l| Some((l["id"].as_str()?.to_string(), l["title"].as_str().unwrap_or("?").to_string())))
+        .collect();
+    log::info!(
+        "{email}: {} task list(s), new items go to {}",
+        lists.len(),
+        lists.iter().find(|(id, _)| *id == default_id).map(|(_, t)| t.as_str()).unwrap_or("default")
+    );
+
+    let mut todos = BTreeMap::new();
+    for (list_id, title) in &lists {
+        let base = format!("{TASKS_API}/lists/{list_id}/tasks");
+        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 {title}: {e}"))?;
+            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 url = reqwest::Url::parse(&format!("{base}/{id}")).map_err(|e| e.to_string())?;
+                let summary = t["title"].as_str().unwrap_or("").trim().to_string();
+                todos.insert(id.to_string(), RemoteTodo {
+                    url,
+                    etag: t["etag"].as_str().unwrap_or("").to_string(),
+                    summary: if summary.is_empty() { "(untitled)".to_string() } else { summary },
+                    done: t["status"].as_str() == Some("completed"),
+                    lines: Vec::new(),
+                });
+            }
+            match page["nextPageToken"].as_str() {
+                Some(next) if !next.is_empty() => page_token = next.to_string(),
+                _ => break,
+            }
+        }
+    }
+    let create_target = reqwest::Url::parse(&format!("{TASKS_API}/lists/{default_id}/tasks"))
+        .map_err(|e| e.to_string())?;
+    Ok(RemoteSnapshot { todos, create_target })
+}
+
+/// 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,
+) -> Result<(String, reqwest::Url, String), String> {
+    let body = serde_json::json!({ "title": text, "status": "needsAction" });
+    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),
+    }
+}
+
 struct Account {
     email: String,
     password: String,
@@ -112,15 +399,16 @@ fn icloud_accounts() -> Result<Vec<Account>, String> {
 
 // ── The pass ──────────────────────────────────────────────────────────────
 
-fn run_sync(acc: &Account, dry_run: bool, force_deletes: bool) -> Result<(), String> {
+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();
 
     // Read the list first: if it is unreadable there is nothing safe to do.
     let list_exists = data_path().exists();
-    let local = if list_exists {
+    let local_all = if list_exists {
         parse_items(&std::fs::read_to_string(data_path()).map_err(|e| e.to_string())?)
     } else {
         Vec::new()
@@ -133,22 +421,44 @@ fn run_sync(acc: &Account, dry_run: bool, force_deletes: bool) -> Result<(), Str
         state = SyncState::default();
     }
 
-    let remote = fetch_remote(&client, acc)?;
-    let plan = plan(&local, &state, &remote.todos);
+    // This run reasons only about its own account: the state entries it
+    // owns, and the local rows not claimed by some other account's entry.
+    let foreign: BTreeSet<&str> = state
+        .items
+        .iter()
+        .filter(|(_, v)| v.account != email)
+        .map(|(k, _)| k.as_str())
+        .collect();
+    let local: Vec<Item> = local_all
+        .iter()
+        .filter(|i| i.uid.as_deref().is_none_or(|u| !foreign.contains(u)))
+        .cloned()
+        .collect();
+    let mine = SyncState {
+        items: state.items.iter().filter(|(_, v)| v.account == email).map(|(k, v)| (k.clone(), v.clone())).collect(),
+    };
+
+    let (remote, session) = match backend {
+        Backend::ICloud(acc) => (fetch_remote(&client, acc)?, Session::ICloud),
+        Backend::Google(acc) => {
+            let token = google_access_token(&client, acc)?;
+            (google_fetch(&client, &token, &acc.email)?, Session::Google(token))
+        }
+    };
+    let plan = plan(&local, &mine, &remote.todos);
 
-    if !force_deletes && plan.push_deletes.len() > 5 && plan.push_deletes.len() * 2 > state.items.len()
+    if !force_deletes && plan.push_deletes.len() > 5 && plan.push_deletes.len() * 2 > mine.items.len()
     {
         return Err(format!(
-            "refusing to delete {} of {} tracked reminders on the server — if the list \
+            "refusing to delete {} of {} tracked items on the server — if the list \
              was really emptied on purpose, run cce-list-sync --force-deletes",
             plan.push_deletes.len(),
-            state.items.len()
+            mine.items.len()
         ));
     }
 
     log::info!(
-        "{}: pull {} new / {} changed / {} deleted; push {} changed / {} new / {} deleted",
-        acc.email,
+        "{email}: pull {} new / {} changed / {} deleted; push {} changed / {} new / {} deleted",
         plan.pull_new.len(),
         plan.pull_updates.len(),
         plan.pull_deletes.len(),
@@ -166,13 +476,22 @@ fn run_sync(acc: &Account, dry_run: bool, force_deletes: bool) -> Result<(), Str
     for uid in &plan.push_updates {
         let todo = &remote.todos[uid];
         let item = local.iter().find(|i| i.uid.as_deref() == Some(uid)).expect("planned");
-        let body = patch_vtodo(&todo.lines, &item.text, item.done);
-        match put_ics(&client, acc, &todo.url, &body, Some(&todo.etag)) {
+        let pushed = match (backend, &session) {
+            (Backend::ICloud(acc), _) => {
+                let body = patch_vtodo(&todo.lines, &item.text, item.done);
+                put_ics(&client, acc, &todo.url, &body, Some(&todo.etag))
+            }
+            (Backend::Google(_), Session::Google(token)) => {
+                google_update(&client, token, &todo.url, &item.text, item.done)
+            }
+            (Backend::Google(_), Session::ICloud) => unreachable!("session matches backend"),
+        };
+        match pushed {
             Ok(etag) => {
                 state.items.insert(uid.clone(), SyncedItem {
                     url: todo.url.to_string(),
                     etag,
-                    account: acc.email.clone(),
+                    account: email.clone(),
                     text: item.text.clone(),
                     done: item.done,
                 });
@@ -182,18 +501,29 @@ fn run_sync(acc: &Account, dry_run: bool, force_deletes: bool) -> Result<(), Str
     }
     let mut created: Vec<(String, String)> = Vec::new(); // (text, uid) to annotate
     for text in &plan.push_creates {
-        let uid = new_uid();
-        let url = remote
-            .create_target
-            .join(&format!("{uid}.ics"))
-            .map_err(|e| e.to_string())?;
-        let body = new_vtodo(&uid, text, false);
-        match put_ics(&client, acc, &url, &body, None) {
-            Ok(etag) => {
+        let made = match (backend, &session) {
+            (Backend::ICloud(acc), _) => {
+                let uid = new_uid();
+                remote
+                    .create_target
+                    .join(&format!("{uid}.ics"))
+                    .map_err(|e| e.to_string())
+                    .and_then(|url| {
+                        put_ics(&client, acc, &url, &new_vtodo(&uid, text, false), None)
+                            .map(|etag| (uid, url, etag))
+                    })
+            }
+            (Backend::Google(_), Session::Google(token)) => {
+                google_create(&client, token, &remote.create_target, text)
+            }
+            (Backend::Google(_), Session::ICloud) => unreachable!("session matches backend"),
+        };
+        match made {
+            Ok((uid, url, etag)) => {
                 state.items.insert(uid.clone(), SyncedItem {
                     url: url.to_string(),
                     etag,
-                    account: acc.email.clone(),
+                    account: email.clone(),
                     text: text.clone(),
                     done: false,
                 });
@@ -205,7 +535,12 @@ fn run_sync(acc: &Account, dry_run: bool, force_deletes: bool) -> Result<(), Str
     for uid in &plan.push_deletes {
         let entry = &state.items[uid];
         let url = reqwest::Url::parse(&entry.url).map_err(|e| e.to_string())?;
-        match delete_ics(&client, acc, &url, &entry.etag) {
+        let gone = match (backend, &session) {
+            (Backend::ICloud(acc), _) => delete_ics(&client, acc, &url, &entry.etag),
+            (Backend::Google(_), Session::Google(token)) => google_delete(&client, token, &url),
+            (Backend::Google(_), Session::ICloud) => unreachable!("session matches backend"),
+        };
+        match gone {
             Ok(()) => {
                 state.items.remove(uid);
             }
@@ -219,7 +554,7 @@ fn run_sync(acc: &Account, dry_run: bool, force_deletes: bool) -> Result<(), Str
         state.items.insert(uid.clone(), SyncedItem {
             url: todo.url.to_string(),
             etag: todo.etag.clone(),
-            account: acc.email.clone(),
+            account: email.clone(),
             text: todo.summary.clone(),
             done: todo.done,
         });