2026-06-20 19:38:12 +01:00
|
|
|
mod app;
|
2026-06-20 19:35:19 +01:00
|
|
|
mod domain;
|
|
|
|
|
mod infrastructure;
|
2026-06-20 19:38:12 +01:00
|
|
|
mod ui;
|
|
|
|
|
|
|
|
|
|
use std::io;
|
2026-06-21 10:23:25 +01:00
|
|
|
use std::path::PathBuf;
|
2026-06-20 19:41:47 +01:00
|
|
|
use std::sync::Arc;
|
2026-06-20 19:38:12 +01:00
|
|
|
|
|
|
|
|
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;
|
2026-06-20 19:41:47 +01:00
|
|
|
use tokio::sync::Mutex;
|
2026-06-20 19:38:12 +01:00
|
|
|
|
2026-06-21 14:21:14 +01:00
|
|
|
use crate::app::{App, SyncCommand, SyncStats};
|
2026-06-20 19:41:47 +01:00
|
|
|
use crate::domain::models::*;
|
|
|
|
|
use crate::infrastructure::api::ApiClient;
|
2026-06-20 19:38:12 +01:00
|
|
|
use crate::infrastructure::db::Db;
|
2026-06-20 19:41:47 +01:00
|
|
|
use crate::ui::{draw, AppView, NetworkStatus};
|
2026-06-20 19:38:12 +01:00
|
|
|
|
2026-06-21 10:23:25 +01:00
|
|
|
fn find_secret_file() -> Option<PathBuf> {
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 19:38:12 +01:00
|
|
|
fn main() -> io::Result<()> {
|
|
|
|
|
let db_path = dirs::data_dir()
|
2026-06-21 10:23:25 +01:00
|
|
|
.unwrap_or_else(|| PathBuf::from("."))
|
2026-06-20 19:38:12 +01:00
|
|
|
.join("task_app")
|
|
|
|
|
.join("tasks.db");
|
|
|
|
|
|
|
|
|
|
std::fs::create_dir_all(db_path.parent().unwrap()).ok();
|
|
|
|
|
|
2026-06-20 19:41:47 +01:00
|
|
|
let db = Arc::new(Db::new(db_path.to_str().unwrap()).expect("Failed to open database"));
|
2026-06-20 19:38:12 +01:00
|
|
|
|
2026-06-21 10:23:25 +01:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-20 19:38:12 +01:00
|
|
|
enable_raw_mode()?;
|
|
|
|
|
let mut stdout = io::stdout();
|
|
|
|
|
stdout.execute(EnterAlternateScreen)?;
|
|
|
|
|
let backend = CrosstermBackend::new(stdout);
|
|
|
|
|
let mut terminal = Terminal::new(backend)?;
|
|
|
|
|
|
2026-06-21 10:04:41 +01:00
|
|
|
let api_client = Arc::new(
|
|
|
|
|
tokio::runtime::Runtime::new()
|
|
|
|
|
.unwrap()
|
|
|
|
|
.block_on(async {
|
2026-06-21 10:23:25 +01:00
|
|
|
ApiClient::new(&secret_path)
|
|
|
|
|
.await
|
|
|
|
|
.expect("Failed to create ApiClient")
|
2026-06-21 10:04:41 +01:00
|
|
|
}),
|
|
|
|
|
);
|
2026-06-20 19:41:47 +01:00
|
|
|
|
|
|
|
|
let network_status = Arc::new(Mutex::new(NetworkStatus::Online));
|
2026-06-21 14:21:14 +01:00
|
|
|
let sync_stats = Arc::new(Mutex::new(SyncStats::default()));
|
2026-06-20 19:41:47 +01:00
|
|
|
let (sync_tx, mut sync_rx) = tokio::sync::mpsc::channel::<SyncCommand>(32);
|
|
|
|
|
|
|
|
|
|
let mut app = App::new(db.clone(), api_client.clone(), sync_tx.clone());
|
|
|
|
|
|
|
|
|
|
let network_clone = network_status.clone();
|
2026-06-21 14:21:14 +01:00
|
|
|
let stats_clone = sync_stats.clone();
|
2026-06-20 19:41:47 +01:00
|
|
|
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 {
|
2026-06-21 14:21:14 +01:00
|
|
|
run_sync_engine(db_clone, api_clone, network_clone, stats_clone, &mut sync_rx).await;
|
2026-06-20 19:41:47 +01:00
|
|
|
});
|
|
|
|
|
});
|
2026-06-20 19:38:12 +01:00
|
|
|
|
2026-06-20 19:51:10 +01:00
|
|
|
// Trigger initial sync if already authenticated
|
|
|
|
|
if !app.needs_auth {
|
|
|
|
|
let _ = sync_tx.try_send(SyncCommand::InitialSync);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 19:38:12 +01:00
|
|
|
while !app.should_quit {
|
2026-06-20 19:51:10 +01:00
|
|
|
// Poll auth status (non-blocking)
|
|
|
|
|
app.poll_auth();
|
|
|
|
|
|
|
|
|
|
// Check if initial sync has loaded data into DB
|
|
|
|
|
app.check_initial_load();
|
|
|
|
|
|
2026-06-20 19:38:12 +01:00
|
|
|
terminal.draw(|frame| {
|
2026-06-20 19:41:47 +01:00
|
|
|
let status = {
|
|
|
|
|
let guard = network_status.blocking_lock();
|
|
|
|
|
guard.clone()
|
|
|
|
|
};
|
|
|
|
|
app.network_status = status;
|
|
|
|
|
|
2026-06-21 14:21:14 +01:00
|
|
|
{
|
|
|
|
|
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();
|
|
|
|
|
|
2026-06-20 19:38:12 +01:00
|
|
|
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,
|
|
|
|
|
draft_date: app.draft_date,
|
|
|
|
|
network_status: &app.network_status,
|
|
|
|
|
task_list_scroll: app.task_list_scroll,
|
|
|
|
|
detail_scroll: app.detail_scroll,
|
2026-06-20 19:56:41 +01:00
|
|
|
auth_error: app.auth_error.as_deref(),
|
2026-06-21 14:21:14 +01:00
|
|
|
sync_stats: &app.sync_stats,
|
2026-06-20 19:38:12 +01:00
|
|
|
};
|
|
|
|
|
draw(frame, view);
|
|
|
|
|
})?;
|
|
|
|
|
|
2026-06-21 14:21:14 +01:00
|
|
|
if event::poll(std::time::Duration::from_millis(100))? {
|
|
|
|
|
if let Event::Key(key) = event::read()? {
|
|
|
|
|
app.handle_key(key);
|
|
|
|
|
}
|
2026-06-20 19:38:12 +01:00
|
|
|
}
|
|
|
|
|
}
|
2026-06-20 19:35:19 +01:00
|
|
|
|
2026-06-20 19:38:12 +01:00
|
|
|
disable_raw_mode()?;
|
|
|
|
|
io::stdout().execute(LeaveAlternateScreen)?;
|
|
|
|
|
Ok(())
|
2026-06-20 19:35:19 +01:00
|
|
|
}
|
2026-06-20 19:41:47 +01:00
|
|
|
|
|
|
|
|
async fn run_sync_engine(
|
|
|
|
|
db: Arc<Db>,
|
|
|
|
|
api: Arc<ApiClient>,
|
|
|
|
|
network_status: Arc<Mutex<NetworkStatus>>,
|
2026-06-21 14:21:14 +01:00
|
|
|
sync_stats: Arc<Mutex<SyncStats>>,
|
2026-06-20 19:41:47 +01:00
|
|
|
rx: &mut tokio::sync::mpsc::Receiver<SyncCommand>,
|
|
|
|
|
) {
|
|
|
|
|
loop {
|
2026-06-21 14:38:23 +01:00
|
|
|
match rx.recv().await {
|
|
|
|
|
Some(SyncCommand::TriggerSync) => {
|
2026-06-21 14:21:14 +01:00
|
|
|
push_sync(&db, &api, &network_status, &sync_stats).await;
|
|
|
|
|
}
|
2026-06-21 14:38:23 +01:00
|
|
|
Some(SyncCommand::FullSync) => {
|
|
|
|
|
push_sync(&db, &api, &network_status, &sync_stats).await;
|
|
|
|
|
pull_sync(&db, &api, &network_status, &sync_stats, true).await;
|
2026-06-20 19:41:47 +01:00
|
|
|
}
|
2026-06-21 14:38:23 +01:00
|
|
|
Some(SyncCommand::InitialSync) => {
|
|
|
|
|
run_initial_sync(&db, &api, &network_status, &sync_stats).await;
|
2026-06-20 19:41:47 +01:00
|
|
|
}
|
2026-06-21 14:38:23 +01:00
|
|
|
Some(SyncCommand::Shutdown) | None => break,
|
2026-06-20 19:41:47 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 19:51:10 +01:00
|
|
|
async fn run_initial_sync(
|
|
|
|
|
db: &Arc<Db>,
|
|
|
|
|
api: &Arc<ApiClient>,
|
|
|
|
|
network_status: &Arc<Mutex<NetworkStatus>>,
|
2026-06-21 14:21:14 +01:00
|
|
|
sync_stats: &Arc<Mutex<SyncStats>>,
|
2026-06-20 19:51:10 +01:00
|
|
|
) {
|
|
|
|
|
*network_status.lock().await = NetworkStatus::Syncing;
|
|
|
|
|
|
2026-06-21 14:21:14 +01:00
|
|
|
let mut total_lists = 0usize;
|
|
|
|
|
let mut total_tasks = 0usize;
|
|
|
|
|
|
2026-06-20 19:51:10 +01:00
|
|
|
match api.fetch_lists().await {
|
|
|
|
|
Ok(lists) => {
|
2026-06-21 14:21:14 +01:00
|
|
|
total_lists = lists.len();
|
|
|
|
|
for list in &lists {
|
|
|
|
|
db.insert_list(list).ok();
|
|
|
|
|
}
|
2026-06-20 19:51:10 +01:00
|
|
|
for list in &lists {
|
|
|
|
|
if let Ok(tasks) = api.fetch_tasks(&list.id).await {
|
2026-06-21 14:21:14 +01:00
|
|
|
total_tasks += tasks.len();
|
2026-06-20 19:51:10 +01:00
|
|
|
db.replace_all_tasks(&list.id, &tasks).ok();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
*network_status.lock().await = NetworkStatus::Online;
|
|
|
|
|
}
|
|
|
|
|
Err(_) => {
|
|
|
|
|
*network_status.lock().await = NetworkStatus::Offline;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-21 14:21:14 +01:00
|
|
|
|
|
|
|
|
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;
|
2026-06-20 19:51:10 +01:00
|
|
|
}
|
|
|
|
|
|
2026-06-21 14:21:14 +01:00
|
|
|
async fn push_sync(
|
2026-06-20 19:41:47 +01:00
|
|
|
db: &Arc<Db>,
|
|
|
|
|
api: &Arc<ApiClient>,
|
|
|
|
|
network_status: &Arc<Mutex<NetworkStatus>>,
|
2026-06-21 14:21:14 +01:00
|
|
|
sync_stats: &Arc<Mutex<SyncStats>>,
|
2026-06-20 19:41:47 +01:00
|
|
|
) {
|
2026-06-21 10:04:41 +01:00
|
|
|
if !api.has_token() {
|
2026-06-20 19:41:47 +01:00
|
|
|
*network_status.lock().await = NetworkStatus::Offline;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let items = db.drain_sync();
|
2026-06-21 14:21:14 +01:00
|
|
|
let count = items.len();
|
2026-06-20 19:41:47 +01:00
|
|
|
|
2026-06-21 14:21:14 +01:00
|
|
|
if count == 0 {
|
2026-06-20 19:41:47 +01:00
|
|
|
*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::<Task>(&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,
|
|
|
|
|
});
|
|
|
|
|
api.create_task(&item.list_id, &task).await
|
|
|
|
|
}
|
|
|
|
|
SyncAction::Update => {
|
|
|
|
|
let task = serde_json::from_str::<Task>(&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,
|
|
|
|
|
});
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-21 14:21:14 +01:00
|
|
|
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);
|
|
|
|
|
}
|
2026-06-20 19:41:47 +01:00
|
|
|
all_ok = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
*network_status.lock().await = if all_ok {
|
|
|
|
|
NetworkStatus::Online
|
|
|
|
|
} else {
|
2026-06-21 14:21:14 +01:00
|
|
|
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<Db>,
|
|
|
|
|
api: &Arc<ApiClient>,
|
|
|
|
|
network_status: &Arc<Mutex<NetworkStatus>>,
|
|
|
|
|
sync_stats: &Arc<Mutex<SyncStats>>,
|
|
|
|
|
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
|
2026-06-20 19:41:47 +01:00
|
|
|
};
|
2026-06-21 14:21:14 +01:00
|
|
|
|
|
|
|
|
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;
|
2026-06-20 19:41:47 +01:00
|
|
|
}
|