mod app; mod domain; mod infrastructure; mod ui; use std::io; use std::path::PathBuf; use std::sync::Arc; use crossterm::event::{self, Event}; use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}; use crossterm::ExecutableCommand; use ratatui::backend::CrosstermBackend; use ratatui::Terminal; use tokio::sync::Mutex; use crate::app::{App, SyncCommand, SyncStats}; use crate::domain::models::*; use crate::infrastructure::api::ApiClient; use crate::infrastructure::db::Db; use crate::ui::{draw, AppView, NetworkStatus}; fn find_secret_file() -> Option { if let Ok(path) = std::env::var("GOOGLE_CLIENT_SECRET_FILE") { let p = PathBuf::from(&path); if p.exists() { return Some(p); } } let config_path = dirs::config_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("task_app") .join("client_secret.json"); if config_path.exists() { return Some(config_path); } let local_path = PathBuf::from("client_secret.json"); if local_path.exists() { return Some(local_path); } None } fn main() -> io::Result<()> { let db_path = dirs::data_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("task_app") .join("tasks.db"); std::fs::create_dir_all(db_path.parent().unwrap()).ok(); let db = Arc::new(Db::new(db_path.to_str().unwrap()).expect("Failed to open database")); let secret_path = find_secret_file().unwrap_or_else(|| { eprintln!( "ERROR: Google client secret file not found.\n\ Place client_secret.json in one of:\n\ - Set GOOGLE_CLIENT_SECRET_FILE env var\n\ - ~/.config/task_app/client_secret.json\n\ - ./client_secret.json (current directory)" ); std::process::exit(1); }); enable_raw_mode()?; let mut stdout = io::stdout(); stdout.execute(EnterAlternateScreen)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; let api_client = Arc::new( tokio::runtime::Runtime::new() .unwrap() .block_on(async { ApiClient::new(&secret_path) .await .expect("Failed to create ApiClient") }), ); let network_status = Arc::new(Mutex::new(NetworkStatus::Online)); let sync_stats = Arc::new(Mutex::new(SyncStats::default())); let (sync_tx, mut sync_rx) = tokio::sync::mpsc::channel::(32); let mut app = App::new(db.clone(), api_client.clone(), sync_tx.clone()); let network_clone = network_status.clone(); let stats_clone = sync_stats.clone(); let db_clone = db.clone(); let api_clone = api_client.clone(); std::thread::spawn(move || { let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(async move { run_sync_engine(db_clone, api_clone, network_clone, stats_clone, &mut sync_rx).await; }); }); // Trigger initial sync if already authenticated if !app.needs_auth { let _ = sync_tx.try_send(SyncCommand::InitialSync); } while !app.should_quit { // Poll auth status (non-blocking) app.poll_auth(); // Check if initial sync has loaded data into DB app.check_initial_load(); terminal.draw(|frame| { let status = { let guard = network_status.blocking_lock(); guard.clone() }; app.network_status = status; { let guard = sync_stats.blocking_lock(); app.sync_stats = guard.clone(); } // Reload lists/tasks if sync engine changed data in background app.refresh_if_needed(); let view = AppView { lists: &app.lists, tasks: &app.tasks, selected_list: app.selected_list, selected_task: app.selected_task, focus: app.focus.clone(), show_popup: app.show_popup.as_ref(), popup_input: &app.popup_input, popup_cursor: app.popup_cursor, popup_secondary: &app.popup_secondary, popup_secondary_cursor: app.popup_secondary_cursor, draft_date: app.draft_date, network_status: &app.network_status, task_list_scroll: app.task_list_scroll, detail_scroll: app.detail_scroll, auth_error: app.auth_error.as_deref(), sync_stats: &app.sync_stats, }; draw(frame, view); })?; if event::poll(std::time::Duration::from_millis(100))? { if let Event::Key(key) = event::read()? { app.handle_key(key); } } } disable_raw_mode()?; io::stdout().execute(LeaveAlternateScreen)?; Ok(()) } async fn run_sync_engine( db: Arc, api: Arc, network_status: Arc>, sync_stats: Arc>, rx: &mut tokio::sync::mpsc::Receiver, ) { loop { match rx.recv().await { Some(SyncCommand::TriggerSync) => { push_sync(&db, &api, &network_status, &sync_stats).await; } Some(SyncCommand::FullSync) => { push_sync(&db, &api, &network_status, &sync_stats).await; pull_sync(&db, &api, &network_status, &sync_stats, true).await; } Some(SyncCommand::InitialSync) => { run_initial_sync(&db, &api, &network_status, &sync_stats).await; } Some(SyncCommand::Shutdown) | None => break, } } } async fn run_initial_sync( db: &Arc, api: &Arc, network_status: &Arc>, sync_stats: &Arc>, ) { *network_status.lock().await = NetworkStatus::Syncing; let mut total_lists = 0usize; let mut total_tasks = 0usize; match api.fetch_lists().await { Ok(lists) => { total_lists = lists.len(); for list in &lists { db.insert_list(list).ok(); } for list in &lists { if let Ok(tasks) = api.fetch_tasks(&list.id).await { total_tasks += tasks.len(); db.replace_all_tasks(&list.id, &tasks).ok(); } } *network_status.lock().await = NetworkStatus::Online; } Err(_) => { *network_status.lock().await = NetworkStatus::Offline; } } let now = chrono::Local::now().naive_local(); let mut stats = sync_stats.lock().await; stats.last_sync_time = Some(now); stats.last_pull_time = Some(now); stats.lists_changed = total_lists; stats.tasks_changed = total_tasks; stats.version += 1; } async fn push_sync( db: &Arc, api: &Arc, network_status: &Arc>, sync_stats: &Arc>, ) { if !api.has_token() { *network_status.lock().await = NetworkStatus::Offline; return; } let items = db.drain_sync(); let count = items.len(); if count == 0 { *network_status.lock().await = NetworkStatus::Online; return; } *network_status.lock().await = NetworkStatus::Syncing; let mut all_ok = true; for item in &items { let result = match item.action { SyncAction::Create => { let task = serde_json::from_str::(&item.payload).unwrap_or_else(|_| Task { id: item.task_id.clone(), list_id: item.list_id.clone(), title: String::new(), notes: None, status: TaskStatus::NeedsAction, due: None, position: 0, created_at: None, updated_at: None, }); api.create_task(&item.list_id, &task).await } SyncAction::Update => { let task = serde_json::from_str::(&item.payload).unwrap_or_else(|_| Task { id: item.task_id.clone(), list_id: item.list_id.clone(), title: String::new(), notes: None, status: TaskStatus::NeedsAction, due: None, position: 0, created_at: None, updated_at: None, }); api.update_task(&item.list_id, &task).await } SyncAction::Delete => { api.delete_task(&item.list_id, &item.task_id).await } SyncAction::Reorder => { api.move_task(&item.list_id, &item.task_id, None, None).await } }; if let Err(err) = result { eprintln!("[task_app] Sync failed (retry {}/{}): action={:?} task={} error={}", item.retries, MAX_SYNC_RETRIES, item.action, item.task_id, err); if item.retries < MAX_SYNC_RETRIES { let _ = db.push_sync_with_retry( item.action.clone(), &item.task_id, &item.list_id, &item.payload, item.retries + 1, ); } else { eprintln!("[task_app] Dropping sync item after {} failed attempts: action={:?} task={}", MAX_SYNC_RETRIES, item.action, item.task_id); } all_ok = false; } } *network_status.lock().await = if all_ok { NetworkStatus::Online } else { let remaining = db.has_pending_sync(); if remaining { NetworkStatus::Offline } else { NetworkStatus::Online } }; let mut stats = sync_stats.lock().await; stats.last_sync_time = Some(chrono::Local::now().naive_local()); stats.lists_changed = 0; stats.tasks_changed = count; stats.version += 1; } async fn pull_sync( db: &Arc, api: &Arc, network_status: &Arc>, sync_stats: &Arc>, force_full: bool, ) { if !api.has_token() { *network_status.lock().await = NetworkStatus::Offline; return; } if db.has_pending_sync() { return; } *network_status.lock().await = NetworkStatus::Syncing; let mut total_lists = 0usize; let mut total_tasks = 0usize; let last_pull = { let stats = sync_stats.lock().await; stats.last_pull_time }; let use_incremental = !force_full && last_pull.is_some(); match api.fetch_lists().await { Ok(lists) => { total_lists = lists.len(); for list in &lists { db.insert_list(list).ok(); } for list in &lists { let result = if use_incremental { api.fetch_tasks_since(&list.id, last_pull.as_ref().unwrap()).await } else { api.fetch_tasks(&list.id).await }; if let Ok(tasks) = result { total_tasks += tasks.len(); if use_incremental { for task in &tasks { db.insert_task(task).ok(); } } else { db.replace_all_tasks(&list.id, &tasks).ok(); } } } *network_status.lock().await = NetworkStatus::Online; } Err(_) => { *network_status.lock().await = NetworkStatus::Offline; } } let now = chrono::Local::now().naive_local(); let mut stats = sync_stats.lock().await; stats.last_sync_time = Some(now); stats.last_pull_time = Some(now); stats.lists_changed = total_lists; stats.tasks_changed = total_tasks; stats.version += 1; }