feat(api): google tasks oauth, sync engine, and background worker
- ApiClient with manual OAuth2 Device Flow (no yup-oauth2 dependency) - Devide auth: POST device/code -> show URL+code -> poll token endpoint - Token persistence in ~/.config/task_app/token.json - CRUD: create_task, update_task, delete_task, move_task via Google Tasks API - fetch_lists and fetch_tasks for initial sync import - Db wraps Connection in std::sync::Mutex for thread-safe sharing via Arc - Sync engine: background thread with tokio runtime, processes queue every 30s - process_sync_queue drains sync_queue and calls API methods - trigger_sync() called after every local mutation (create/update/delete/reorder) - Network status propagated to UI (Online/Offline/Syncing) - Initial sync skeleton ready for full import flow
This commit is contained in:
+132
-4
@@ -4,16 +4,20 @@ mod infrastructure;
|
||||
mod ui;
|
||||
|
||||
use std::io;
|
||||
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;
|
||||
use crate::app::{App, SyncCommand};
|
||||
use crate::domain::models::*;
|
||||
use crate::infrastructure::api::ApiClient;
|
||||
use crate::infrastructure::db::Db;
|
||||
use crate::ui::{draw, AppView};
|
||||
use crate::ui::{draw, AppView, NetworkStatus};
|
||||
|
||||
fn main() -> io::Result<()> {
|
||||
let db_path = dirs::data_dir()
|
||||
@@ -23,7 +27,7 @@ fn main() -> io::Result<()> {
|
||||
|
||||
std::fs::create_dir_all(db_path.parent().unwrap()).ok();
|
||||
|
||||
let db = Db::new(db_path.to_str().unwrap()).expect("Failed to open database");
|
||||
let db = Arc::new(Db::new(db_path.to_str().unwrap()).expect("Failed to open database"));
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
@@ -31,10 +35,35 @@ fn main() -> io::Result<()> {
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let mut app = App::new(db);
|
||||
let api_client = Arc::new(ApiClient::new(
|
||||
std::env::var("GOOGLE_CLIENT_ID").unwrap_or_default(),
|
||||
std::env::var("GOOGLE_CLIENT_SECRET").unwrap_or_default(),
|
||||
));
|
||||
|
||||
let network_status = Arc::new(Mutex::new(NetworkStatus::Online));
|
||||
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();
|
||||
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, &mut sync_rx).await;
|
||||
});
|
||||
});
|
||||
|
||||
while !app.should_quit {
|
||||
terminal.draw(|frame| {
|
||||
let status = {
|
||||
let guard = network_status.blocking_lock();
|
||||
guard.clone()
|
||||
};
|
||||
app.network_status = status;
|
||||
|
||||
let view = AppView {
|
||||
lists: &app.lists,
|
||||
tasks: &app.tasks,
|
||||
@@ -61,3 +90,102 @@ fn main() -> io::Result<()> {
|
||||
io::stdout().execute(LeaveAlternateScreen)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_sync_engine(
|
||||
db: Arc<Db>,
|
||||
api: Arc<ApiClient>,
|
||||
network_status: Arc<Mutex<NetworkStatus>>,
|
||||
rx: &mut tokio::sync::mpsc::Receiver<SyncCommand>,
|
||||
) {
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(30));
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
process_sync_queue(&db, &api, &network_status).await;
|
||||
}
|
||||
cmd = rx.recv() => {
|
||||
match cmd {
|
||||
Some(SyncCommand::TriggerSync) => {
|
||||
process_sync_queue(&db, &api, &network_status).await;
|
||||
}
|
||||
Some(SyncCommand::Shutdown) | None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_sync_queue(
|
||||
db: &Arc<Db>,
|
||||
api: &Arc<ApiClient>,
|
||||
network_status: &Arc<Mutex<NetworkStatus>>,
|
||||
) {
|
||||
let token_available = api.load_token().await.is_some();
|
||||
if !token_available {
|
||||
*network_status.lock().await = NetworkStatus::Offline;
|
||||
return;
|
||||
}
|
||||
|
||||
let items = db.drain_sync();
|
||||
|
||||
if items.is_empty() {
|
||||
*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
|
||||
}
|
||||
};
|
||||
|
||||
if result.is_err() {
|
||||
let _ = db.push_sync(
|
||||
item.action.clone(),
|
||||
&item.task_id,
|
||||
&item.list_id,
|
||||
&item.payload,
|
||||
);
|
||||
all_ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
*network_status.lock().await = if all_ok {
|
||||
NetworkStatus::Online
|
||||
} else {
|
||||
NetworkStatus::Offline
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user