use std::sync::Mutex; use chrono::NaiveDateTime; use rusqlite::{params, Connection, Result as SqlResult}; use crate::domain::models::*; pub struct Db { conn: Mutex, } impl Db { pub fn new(path: &str) -> SqlResult { let conn = Connection::open(path)?; conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?; conn.execute_batch( "CREATE TABLE IF NOT EXISTS task_lists ( id TEXT PRIMARY KEY, title TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, list_id TEXT NOT NULL, title TEXT NOT NULL, notes TEXT, status TEXT NOT NULL DEFAULT 'needsAction', due TEXT, position INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')), FOREIGN KEY (list_id) REFERENCES task_lists(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS sync_queue ( id INTEGER PRIMARY KEY AUTOINCREMENT, action TEXT NOT NULL, task_id TEXT NOT NULL, list_id TEXT NOT NULL, payload 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(); conn.execute_batch( "ALTER TABLE tasks ADD COLUMN created_at TEXT NOT NULL DEFAULT '';", ) .ok(); conn.execute_batch( "UPDATE tasks SET created_at = updated_at WHERE created_at = '';", ) .ok(); Ok(Self { conn: Mutex::new(conn) }) } pub fn get_lists(&self) -> Vec { let conn = self.conn.lock().unwrap(); let mut stmt = conn .prepare("SELECT id, title FROM task_lists ORDER BY title") .unwrap(); stmt.query_map([], |row| { Ok(TaskList { id: row.get(0)?, title: row.get(1)?, }) }) .unwrap() .filter_map(|r| r.ok()) .collect() } pub fn insert_list(&self, list: &TaskList) -> SqlResult<()> { let conn = self.conn.lock().unwrap(); conn.execute( "INSERT OR REPLACE INTO task_lists (id, title) VALUES (?1, ?2)", params![list.id, list.title], )?; Ok(()) } pub fn delete_list(&self, list_id: &str) -> SqlResult<()> { let conn = self.conn.lock().unwrap(); conn.execute("DELETE FROM tasks WHERE list_id = ?1", params![list_id])?; conn.execute("DELETE FROM task_lists WHERE id = ?1", params![list_id])?; Ok(()) } pub fn get_tasks(&self, list_id: &str) -> Vec { let conn = self.conn.lock().unwrap(); let mut stmt = conn .prepare( "SELECT id, list_id, title, notes, status, due, position, created_at, updated_at FROM tasks WHERE list_id = ?1 ORDER BY position ASC", ) .unwrap(); stmt.query_map(params![list_id], |row| { let due_str: Option = row.get(5)?; let due = due_str.and_then(|s| NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M").ok()); let created_str: String = row.get(7)?; let updated_str: String = row.get(8)?; Ok(Task { id: row.get(0)?, list_id: row.get(1)?, title: row.get(2)?, notes: row.get(3)?, status: match row.get::<_, String>(4)?.as_str() { "completed" => TaskStatus::Completed, _ => TaskStatus::NeedsAction, }, due, position: row.get(6)?, created_at: NaiveDateTime::parse_from_str(&created_str, "%Y-%m-%d %H:%M:%S").ok(), updated_at: NaiveDateTime::parse_from_str(&updated_str, "%Y-%m-%d %H:%M:%S").ok(), }) }) .unwrap() .filter_map(|r| r.ok()) .collect() } pub fn insert_task(&self, task: &Task) -> SqlResult<()> { let due_str = task.due.map(|d| d.format("%Y-%m-%d %H:%M").to_string()); let status_str = match task.status { TaskStatus::Completed => "completed", TaskStatus::NeedsAction => "needsAction", }; let conn = self.conn.lock().unwrap(); let position = if task.position == 0 { conn.query_row( "SELECT COALESCE(MAX(position), -1) + 1 FROM tasks WHERE list_id = ?1", params![task.list_id], |row| row.get(0), ) .unwrap_or(0) } else { task.position }; // Preserve existing created_at if the task already exists. // For new tasks from API, use updated_at as created_at proxy // (Google Tasks API does not provide a creation timestamp). let created_at = task.created_at.unwrap_or_else(|| { conn.query_row( "SELECT created_at FROM tasks WHERE id = ?1", params![task.id], |row| row.get::<_, String>(0), ) .ok() .and_then(|s| NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S").ok()) .or_else(|| task.updated_at) .unwrap_or_else(|| chrono::Utc::now().naive_utc()) }); let updated_at = task .updated_at .unwrap_or_else(|| chrono::Utc::now().naive_utc()); conn.execute( "INSERT OR REPLACE INTO tasks (id, list_id, title, notes, status, due, position, updated_at, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", params![ task.id, task.list_id, task.title, task.notes, status_str, due_str, position, updated_at.format("%Y-%m-%d %H:%M:%S").to_string(), created_at.format("%Y-%m-%d %H:%M:%S").to_string(), ], )?; Ok(()) } pub fn update_task(&self, task: &Task) -> SqlResult<()> { let due_str = task.due.map(|d| d.format("%Y-%m-%d %H:%M").to_string()); let status_str = match task.status { TaskStatus::Completed => "completed", TaskStatus::NeedsAction => "needsAction", }; let conn = self.conn.lock().unwrap(); conn.execute( "UPDATE tasks SET title=?1, notes=?2, status=?3, due=?4, position=?5, updated_at=?6 WHERE id=?7", params![ task.title, task.notes, status_str, due_str, task.position, chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(), task.id, ], )?; Ok(()) } pub fn delete_task(&self, task_id: &str) -> SqlResult<()> { let conn = self.conn.lock().unwrap(); conn.execute("DELETE FROM tasks WHERE id = ?1", params![task_id])?; Ok(()) } pub fn reorder_task(&self, task_id: &str, new_position: i64) -> SqlResult<()> { let conn = self.conn.lock().unwrap(); let (old_position, list_id): (i64, String) = conn.query_row( "SELECT position, list_id FROM tasks WHERE id = ?1", params![task_id], |row| Ok((row.get(0)?, row.get(1)?)), )?; if old_position == new_position { return Ok(()); } if new_position > old_position { conn.execute( "UPDATE tasks SET position = position - 1 WHERE list_id = ?1 AND position > ?2 AND position <= ?3", params![list_id, old_position, new_position], )?; } else { conn.execute( "UPDATE tasks SET position = position + 1 WHERE list_id = ?1 AND position >= ?2 AND position < ?3", params![list_id, new_position, old_position], )?; } conn.execute( "UPDATE tasks SET position = ?1 WHERE id = ?2", params![new_position, task_id], )?; 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])?; drop(conn); for (i, task) in tasks.iter().enumerate() { let mut t = task.clone(); if t.position == 0 { t.position = i as i64; } t.list_id = list_id.to_string(); self.insert_task(&t)?; } Ok(()) } 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", SyncAction::Delete => "Delete", SyncAction::Reorder => "Reorder", SyncAction::CreateList => "CreateList", SyncAction::DeleteList => "DeleteList", }; let conn = self.conn.lock().unwrap(); conn.execute( "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 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 = { let mut stmt = conn .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)?; let action = match action_str.as_str() { "\"Create\"" | "Create" => SyncAction::Create, "\"Update\"" | "Update" => SyncAction::Update, "\"Delete\"" | "Delete" => SyncAction::Delete, "\"Reorder\"" | "Reorder" => SyncAction::Reorder, "\"CreateList\"" | "CreateList" => SyncAction::CreateList, "\"DeleteList\"" | "DeleteList" => SyncAction::DeleteList, _ => SyncAction::Update, }; Ok(SyncQueueItem { id: row.get(0)?, action, task_id: row.get(2)?, list_id: row.get(3)?, payload: row.get(4)?, created_at: row.get(5)?, retries: row.get(6)?, }) }) .unwrap() .filter_map(|r| r.ok()) .collect() }; if !items.is_empty() { conn.execute("DELETE FROM sync_queue", []).unwrap(); } items } }