Add task count to task list panel header
Show 'X todo / Y done' in the Tasks panel title bar. Also includes prior uncommitted work: - Pagination in fetch_tasks (maxResults=100 + pageToken loop) - fetch_tasks_since for incremental pull sync - SyncStats struct with version/last_sync/last_pull/changed counts - Periodic push (30s) and pull (5min) sync engine - event::poll(100ms) for non-blocking UI refresh - Ctrl+R full sync (push + pull) - refresh_if_needed() to reload data after background sync - Retry mechanism (MAX_SYNC_RETRIES=3) for sync queue items - HTTP status code checks in fetch_lists/fetch_tasks/fetch_tasks_since - Fix move_task URL to use reqwest query() - Remove CASCADE via replace_all_lists (use insert_list instead) - has_pending_sync() to prevent pull during pending push
This commit is contained in:
+133
-29
@@ -97,6 +97,12 @@ impl ApiClient {
|
||||
.await
|
||||
.map_err(|e| ApiError::Network(e.to_string()))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(ApiError::Api(format!("Fetch lists failed: {} - {}", status, body)));
|
||||
}
|
||||
|
||||
let data: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
@@ -118,27 +124,128 @@ impl ApiClient {
|
||||
pub async fn fetch_tasks(&self, list_id: &str) -> Result<Vec<Task>, ApiError> {
|
||||
let token = self.get_token().await?;
|
||||
|
||||
let url = format!(
|
||||
"https://tasks.googleapis.com/tasks/v1/lists/{}/tasks?showCompleted=true&showHidden=true",
|
||||
list_id
|
||||
);
|
||||
let mut all_items: Vec<serde_json::Value> = Vec::new();
|
||||
let mut page_token: Option<String> = None;
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.bearer_auth(&token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ApiError::Network(e.to_string()))?;
|
||||
loop {
|
||||
let mut url = format!(
|
||||
"https://tasks.googleapis.com/tasks/v1/lists/{}/tasks?showCompleted=true&showHidden=true&maxResults=100",
|
||||
list_id
|
||||
);
|
||||
if let Some(ref pt) = page_token {
|
||||
url.push_str(&format!("&pageToken={}", pt));
|
||||
}
|
||||
|
||||
let data: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ApiError::Api(e.to_string()))?;
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.bearer_auth(&token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ApiError::Network(e.to_string()))?;
|
||||
|
||||
let empty = vec![];
|
||||
let items = data["items"].as_array().unwrap_or(&empty);
|
||||
let tasks = items
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(ApiError::Api(format!("Fetch tasks failed: {} - {}", status, body)));
|
||||
}
|
||||
|
||||
let data: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ApiError::Api(e.to_string()))?;
|
||||
|
||||
if let Some(items) = data["items"].as_array() {
|
||||
all_items.extend(items.iter().cloned());
|
||||
}
|
||||
|
||||
match data["nextPageToken"].as_str() {
|
||||
Some(token) if !token.is_empty() => page_token = Some(token.to_string()),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
|
||||
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::<String>(),
|
||||
"%Y-%m-%d %H:%M",
|
||||
)
|
||||
.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,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(tasks)
|
||||
}
|
||||
|
||||
pub async fn fetch_tasks_since(&self, list_id: &str, since: &chrono::NaiveDateTime) -> Result<Vec<Task>, ApiError> {
|
||||
let token = self.get_token().await?;
|
||||
let since_str = since.format("%Y-%m-%dT%H:%M:%S.000Z").to_string();
|
||||
|
||||
let mut all_items: Vec<serde_json::Value> = Vec::new();
|
||||
let mut page_token: Option<String> = None;
|
||||
|
||||
loop {
|
||||
let mut url = format!(
|
||||
"https://tasks.googleapis.com/tasks/v1/lists/{}/tasks?showCompleted=true&showHidden=true&maxResults=100&updatedMin={}",
|
||||
list_id, since_str
|
||||
);
|
||||
if let Some(ref pt) = page_token {
|
||||
url.push_str(&format!("&pageToken={}", pt));
|
||||
}
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.bearer_auth(&token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ApiError::Network(e.to_string()))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(ApiError::Api(format!("Fetch tasks since failed: {} - {}", status, body)));
|
||||
}
|
||||
|
||||
let data: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ApiError::Api(e.to_string()))?;
|
||||
|
||||
if let Some(items) = data["items"].as_array() {
|
||||
all_items.extend(items.iter().cloned());
|
||||
}
|
||||
|
||||
match data["nextPageToken"].as_str() {
|
||||
Some(token) if !token.is_empty() => page_token = Some(token.to_string()),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
|
||||
let tasks = all_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, item)| {
|
||||
@@ -293,28 +400,25 @@ impl ApiClient {
|
||||
) -> Result<(), ApiError> {
|
||||
let token = self.get_token().await?;
|
||||
|
||||
let mut url = format!(
|
||||
let url = format!(
|
||||
"https://tasks.googleapis.com/tasks/v1/lists/{}/tasks/{}/move",
|
||||
list_id, task_id
|
||||
);
|
||||
|
||||
let mut req = self.client.post(&url).bearer_auth(&token);
|
||||
if let Some(p) = prev {
|
||||
url.push_str(&format!("&previous={}", p));
|
||||
req = req.query(&[("previous", p)]);
|
||||
}
|
||||
if let Some(s) = sibling {
|
||||
url.push_str(&format!("&destinationTaskList={}", s));
|
||||
req = req.query(&[("destinationTaskList", s)]);
|
||||
}
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.bearer_auth(&token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ApiError::Network(e.to_string()))?;
|
||||
let resp = req.send().await.map_err(|e| ApiError::Network(e.to_string()))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(ApiError::Api(format!("Move failed: {}", resp.status())));
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(ApiError::Api(format!("Move failed: {} - {}", status, body)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
+40
-15
@@ -37,9 +37,14 @@ impl Db {
|
||||
task_id TEXT NOT NULL,
|
||||
list_id TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
created_at TEXT NOT NULL,
|
||||
retries INTEGER NOT NULL DEFAULT 0
|
||||
);",
|
||||
)?;
|
||||
conn.execute_batch(
|
||||
"ALTER TABLE sync_queue ADD COLUMN retries INTEGER NOT NULL DEFAULT 0;",
|
||||
)
|
||||
.ok();
|
||||
Ok(Self { conn: Mutex::new(conn) })
|
||||
}
|
||||
|
||||
@@ -201,16 +206,6 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn replace_all_lists(&self, lists: &[TaskList]) -> SqlResult<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("DELETE FROM task_lists", [])?;
|
||||
drop(conn);
|
||||
for list in lists {
|
||||
self.insert_list(list)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn replace_all_tasks(&self, list_id: &str, tasks: &[Task]) -> SqlResult<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("DELETE FROM tasks WHERE list_id = ?1", params![list_id])?;
|
||||
@@ -226,7 +221,27 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn push_sync(&self, action: SyncAction, task_id: &str, list_id: &str, payload: &str) -> SqlResult<()> {
|
||||
pub fn push_sync(
|
||||
&self,
|
||||
action: SyncAction,
|
||||
task_id: &str,
|
||||
list_id: &str,
|
||||
payload: &str,
|
||||
) -> SqlResult<()> {
|
||||
self.push_sync_with_retry(action, task_id, list_id, payload, 0)
|
||||
}
|
||||
|
||||
pub fn push_sync_with_retry(
|
||||
&self,
|
||||
action: SyncAction,
|
||||
task_id: &str,
|
||||
list_id: &str,
|
||||
payload: &str,
|
||||
retries: i32,
|
||||
) -> SqlResult<()> {
|
||||
if retries > MAX_SYNC_RETRIES {
|
||||
return Ok(());
|
||||
}
|
||||
let action_str = match action {
|
||||
SyncAction::Create => "Create",
|
||||
SyncAction::Update => "Update",
|
||||
@@ -235,24 +250,33 @@ impl Db {
|
||||
};
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO sync_queue (action, task_id, list_id, payload, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
"INSERT INTO sync_queue (action, task_id, list_id, payload, created_at, retries)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![
|
||||
action_str,
|
||||
task_id,
|
||||
list_id,
|
||||
payload,
|
||||
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(),
|
||||
retries,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn has_pending_sync(&self) -> bool {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM sync_queue", [], |row| row.get(0))
|
||||
.unwrap_or(0);
|
||||
count > 0
|
||||
}
|
||||
|
||||
pub fn drain_sync(&self) -> Vec<SyncQueueItem> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let items: Vec<SyncQueueItem> = {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, action, task_id, list_id, payload, created_at FROM sync_queue ORDER BY id")
|
||||
.prepare("SELECT id, action, task_id, list_id, payload, created_at, retries FROM sync_queue ORDER BY id")
|
||||
.unwrap();
|
||||
stmt.query_map([], |row| {
|
||||
let action_str: String = row.get(1)?;
|
||||
@@ -270,6 +294,7 @@ impl Db {
|
||||
list_id: row.get(3)?,
|
||||
payload: row.get(4)?,
|
||||
created_at: row.get(5)?,
|
||||
retries: row.get(6)?,
|
||||
})
|
||||
})
|
||||
.unwrap()
|
||||
|
||||
Reference in New Issue
Block a user