From a35eab35af20e1c4a0a202f36d1b95fcd78a5afb Mon Sep 17 00:00:00 2001 From: Ruben Rosario Date: Sun, 21 Jun 2026 18:14:24 +0100 Subject: [PATCH] Fix sync: CreateList action + server ID mapping - Added SyncAction::CreateList variant - create_task returns server Task, added create_list API - Sync engine processes CreateList first, updates IDs in batch - After Create/CreateList success, local IDs updated to server IDs --- src/app.rs | 2 +- src/domain/models.rs | 1 + src/infrastructure/api.rs | 173 +++++++++++++++++++------------------- src/infrastructure/db.rs | 43 ++++++++++ src/main.rs | 53 +++++++++++- 5 files changed, 181 insertions(+), 91 deletions(-) diff --git a/src/app.rs b/src/app.rs index ff49b6f..8919f7c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -473,7 +473,7 @@ impl App { }; self.db.insert_list(&list).ok(); self.db.push_sync( - SyncAction::Create, + SyncAction::CreateList, &list.id, &list.id, &serde_json::to_string(&list).unwrap_or_default(), diff --git a/src/domain/models.rs b/src/domain/models.rs index b437872..6150717 100644 --- a/src/domain/models.rs +++ b/src/domain/models.rs @@ -32,6 +32,7 @@ pub enum SyncAction { Update, Delete, Reorder, + CreateList, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/infrastructure/api.rs b/src/infrastructure/api.rs index 9be184d..ed61098 100644 --- a/src/infrastructure/api.rs +++ b/src/infrastructure/api.rs @@ -196,47 +196,7 @@ impl ApiClient { let tasks = all_items .iter() .enumerate() - .map(|(i, item)| { - let due_str = item["due"].as_str().and_then(|s| { - chrono::NaiveDateTime::parse_from_str( - &s.replace("T", " ") - .replace("Z", "") - .chars() - .take(16) - .collect::(), - "%Y-%m-%d %H:%M", - ) - .ok() - }); - - let updated = item["updated"].as_str().and_then(|s| { - chrono::NaiveDateTime::parse_from_str( - &s.replace("T", " ") - .replace("Z", "") - .chars() - .take(19) - .collect::(), - "%Y-%m-%d %H:%M:%S", - ) - .ok() - }); - - Task { - id: item["id"].as_str().unwrap_or("").to_string(), - list_id: list_id.to_string(), - title: item["title"].as_str().unwrap_or("").to_string(), - notes: item["notes"].as_str().map(|s| s.to_string()), - status: if item["status"].as_str() == Some("completed") { - TaskStatus::Completed - } else { - TaskStatus::NeedsAction - }, - due: due_str, - position: i as i64, - created_at: None, - updated_at: updated, - } - }) + .map(|(i, item)| parse_task_value(item, list_id, i)) .collect(); Ok(tasks) @@ -290,53 +250,13 @@ impl ApiClient { let tasks = all_items .iter() .enumerate() - .map(|(i, item)| { - let due_str = item["due"].as_str().and_then(|s| { - chrono::NaiveDateTime::parse_from_str( - &s.replace("T", " ") - .replace("Z", "") - .chars() - .take(16) - .collect::(), - "%Y-%m-%d %H:%M", - ) - .ok() - }); - - let updated = item["updated"].as_str().and_then(|s| { - chrono::NaiveDateTime::parse_from_str( - &s.replace("T", " ") - .replace("Z", "") - .chars() - .take(19) - .collect::(), - "%Y-%m-%d %H:%M:%S", - ) - .ok() - }); - - Task { - id: item["id"].as_str().unwrap_or("").to_string(), - list_id: list_id.to_string(), - title: item["title"].as_str().unwrap_or("").to_string(), - notes: item["notes"].as_str().map(|s| s.to_string()), - status: if item["status"].as_str() == Some("completed") { - TaskStatus::Completed - } else { - TaskStatus::NeedsAction - }, - due: due_str, - position: i as i64, - created_at: None, - updated_at: updated, - } - }) + .map(|(i, item)| parse_task_value(item, list_id, i)) .collect(); Ok(tasks) } - pub async fn create_task(&self, list_id: &str, task: &Task) -> Result<(), ApiError> { + pub async fn create_task(&self, list_id: &str, task: &Task) -> Result { let token = self.get_token().await?; let mut body = serde_json::json!({ @@ -369,13 +289,48 @@ impl ApiClient { .map_err(|e| ApiError::Network(e.to_string()))?; if !resp.status().is_success() { - return Err(ApiError::Api(format!( - "Create failed: {}", - resp.status() - ))); + let status = resp.status(); + let body_text = resp.text().await.unwrap_or_default(); + return Err(ApiError::Api(format!("Create failed: {} - {}", status, body_text))); } - Ok(()) + let data: serde_json::Value = resp + .json() + .await + .map_err(|e| ApiError::Api(format!("Create parse error: {}", e)))?; + + Ok(parse_task_value(&data, list_id, 0)) + } + + pub async fn create_list(&self, title: &str) -> Result { + let token = self.get_token().await?; + + let body = serde_json::json!({ "title": title }); + + let resp = self + .client + .post("https://tasks.googleapis.com/tasks/v1/users/@me/lists") + .bearer_auth(&token) + .json(&body) + .send() + .await + .map_err(|e| ApiError::Network(e.to_string()))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body_text = resp.text().await.unwrap_or_default(); + return Err(ApiError::Api(format!("Create list failed: {} - {}", status, body_text))); + } + + let data: serde_json::Value = resp + .json() + .await + .map_err(|e| ApiError::Api(format!("Create list parse error: {}", e)))?; + + Ok(TaskList { + id: data["id"].as_str().unwrap_or("").to_string(), + title: data["title"].as_str().unwrap_or("").to_string(), + }) } pub async fn update_task(&self, list_id: &str, task: &Task) -> Result<(), ApiError> { @@ -530,6 +485,48 @@ impl ApiClient { } } +fn parse_task_value(item: &serde_json::Value, list_id: &str, position: usize) -> Task { + let due_str = item["due"].as_str().and_then(|s| { + chrono::NaiveDateTime::parse_from_str( + &s.replace("T", " ") + .replace("Z", "") + .chars() + .take(16) + .collect::(), + "%Y-%m-%d %H:%M", + ) + .ok() + }); + + let updated = item["updated"].as_str().and_then(|s| { + chrono::NaiveDateTime::parse_from_str( + &s.replace("T", " ") + .replace("Z", "") + .chars() + .take(19) + .collect::(), + "%Y-%m-%d %H:%M:%S", + ) + .ok() + }); + + Task { + id: item["id"].as_str().unwrap_or("").to_string(), + list_id: list_id.to_string(), + title: item["title"].as_str().unwrap_or("").to_string(), + notes: item["notes"].as_str().map(|s| s.to_string()), + status: if item["status"].as_str() == Some("completed") { + TaskStatus::Completed + } else { + TaskStatus::NeedsAction + }, + due: due_str, + position: position as i64, + created_at: None, + updated_at: updated, + } +} + fn parse_calendar_time(obj: &serde_json::Value) -> Option { if let Some(dt) = obj["dateTime"].as_str() { chrono::DateTime::parse_from_rfc3339(dt).ok().map(|d| d.naive_local()) diff --git a/src/infrastructure/db.rs b/src/infrastructure/db.rs index ba83541..b79eb56 100644 --- a/src/infrastructure/db.rs +++ b/src/infrastructure/db.rs @@ -281,6 +281,7 @@ impl Db { SyncAction::Update => "Update", SyncAction::Delete => "Delete", SyncAction::Reorder => "Reorder", + SyncAction::CreateList => "CreateList", }; let conn = self.conn.lock().unwrap(); conn.execute( @@ -306,6 +307,47 @@ impl Db { count > 0 } + pub fn update_task_id(&self, old_id: &str, new_id: &str) -> SqlResult<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE tasks SET id = ?1 WHERE id = ?2", + params![new_id, old_id], + )?; + Ok(()) + } + + pub fn update_sync_task_id(&self, old_id: &str, new_id: &str) -> SqlResult<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE sync_queue SET task_id = ?1 WHERE task_id = ?2", + params![new_id, old_id], + )?; + Ok(()) + } + + pub fn update_list_id(&self, old_id: &str, new_id: &str) -> SqlResult<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE task_lists SET id = ?1 WHERE id = ?2", + params![new_id, old_id], + )?; + conn.execute( + "UPDATE tasks SET list_id = ?1 WHERE list_id = ?2", + params![new_id, old_id], + )?; + Ok(()) + } + + #[allow(dead_code)] + pub fn update_sync_list_id(&self, old_id: &str, new_id: &str) -> SqlResult<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE sync_queue SET list_id = ?1 WHERE list_id = ?2", + params![new_id, old_id], + )?; + Ok(()) + } + pub fn drain_sync(&self) -> Vec { let conn = self.conn.lock().unwrap(); let items: Vec = { @@ -319,6 +361,7 @@ impl Db { "\"Update\"" | "Update" => SyncAction::Update, "\"Delete\"" | "Delete" => SyncAction::Delete, "\"Reorder\"" | "Reorder" => SyncAction::Reorder, + "\"CreateList\"" | "CreateList" => SyncAction::CreateList, _ => SyncAction::Update, }; Ok(SyncQueueItem { diff --git a/src/main.rs b/src/main.rs index 7ed82b5..ee9baa8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -246,7 +246,7 @@ async fn push_sync( return; } - let items = db.drain_sync(); + let mut items = db.drain_sync(); let count = items.len(); if count == 0 { @@ -257,7 +257,50 @@ async fn push_sync( *network_status.lock().await = NetworkStatus::Syncing; let mut all_ok = true; + + // First pass: CreateList items (so list IDs are updated before task operations) + for i in 0..items.len() { + if items[i].action != SyncAction::CreateList { + continue; + } + let list: TaskList = serde_json::from_str(&items[i].payload).unwrap_or_else(|_| TaskList { + id: items[i].task_id.clone(), + title: String::new(), + }); + match api.create_list(&list.title).await { + Ok(server_list) => { + if server_list.id != items[i].task_id { + let _ = db.update_list_id(&items[i].task_id, &server_list.id); + // Update list_id in remaining items of this batch + for j in (i + 1)..items.len() { + if items[j].list_id == items[i].task_id { + items[j].list_id = server_list.id.clone(); + } + } + } + } + Err(err) => { + eprintln!("[task_app] Sync failed (retry {}/{}): action=CreateList list={} error={}", + items[i].retries, MAX_SYNC_RETRIES, items[i].task_id, err); + if items[i].retries < MAX_SYNC_RETRIES { + let _ = db.push_sync_with_retry( + SyncAction::CreateList, + &items[i].task_id, + &items[i].list_id, + &items[i].payload, + items[i].retries + 1, + ); + } + all_ok = false; + } + } + } + + // Second pass: everything else for item in &items { + if item.action == SyncAction::CreateList { + continue; + } let result = match item.action { SyncAction::Create => { let task = serde_json::from_str::(&item.payload).unwrap_or_else(|_| Task { @@ -271,7 +314,12 @@ async fn push_sync( created_at: None, updated_at: None, }); - api.create_task(&item.list_id, &task).await + api.create_task(&item.list_id, &task).await.map(|server_task| { + if server_task.id != item.task_id { + let _ = db.update_task_id(&item.task_id, &server_task.id); + let _ = db.update_sync_task_id(&item.task_id, &server_task.id); + } + }) } SyncAction::Update => { let task = serde_json::from_str::(&item.payload).unwrap_or_else(|_| Task { @@ -293,6 +341,7 @@ async fn push_sync( SyncAction::Reorder => { api.move_task(&item.list_id, &item.task_id, None, None).await } + _ => Ok(()), }; if let Err(err) = result {