diff --git a/Cargo.lock b/Cargo.lock index f72af96..42a74d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1468,6 +1468,12 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -1822,6 +1828,7 @@ dependencies = [ "getrandom 0.3.3", "js-sys", "serde", + "sha1_smol", "wasm-bindgen", ] diff --git a/apps/tauri/src-tauri/Cargo.lock b/apps/tauri/src-tauri/Cargo.lock index 22a631f..77bc580 100644 --- a/apps/tauri/src-tauri/Cargo.lock +++ b/apps/tauri/src-tauri/Cargo.lock @@ -2423,8 +2423,10 @@ dependencies = [ "notify", "notify-debouncer-mini", "onyx-core", + "reqwest 0.12.28", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-credentials", @@ -3680,6 +3682,12 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -4790,6 +4798,7 @@ dependencies = [ "getrandom 0.4.2", "js-sys", "serde_core", + "sha1_smol", "wasm-bindgen", ] diff --git a/apps/tauri/src-tauri/Cargo.toml b/apps/tauri/src-tauri/Cargo.toml index 4e21b22..cd63bbd 100644 --- a/apps/tauri/src-tauri/Cargo.toml +++ b/apps/tauri/src-tauri/Cargo.toml @@ -31,6 +31,8 @@ uuid = { version = "1", features = ["serde", "v4"] } chrono = { version = "0.4", features = ["serde"] } notify = { version = "7", optional = true } notify-debouncer-mini = { version = "0.5", optional = true } +sha2 = "0.10" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } [package.metadata.tauri] diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs index f865618..ec076a5 100644 --- a/apps/tauri/src-tauri/src/lib.rs +++ b/apps/tauri/src-tauri/src/lib.rs @@ -12,11 +12,30 @@ use uuid::Uuid; use onyx_core::{ config::{AppConfig, WorkspaceConfig, WorkspaceMode}, + google_tasks, models::{Task, TaskList, TaskStatus}, repository::TaskRepository, sync::{self, SyncMode, SyncResult as CoreSyncResult}, webdav, }; + +// ── Google OAuth constants ─────────────────────────────────────────── +// Replace these placeholder values with real credentials from Google Cloud Console. +// Desktop: "Desktop app" OAuth client type. Android: "Android" OAuth client type. +// Neither value is a security secret in the traditional sense — both can be extracted +// from the binary — but keep them out of public source control where possible. + +/// Placeholder: replace with your "Desktop app" client ID from Google Cloud Console. +#[cfg(not(target_os = "android"))] +const GOOGLE_CLIENT_ID: &str = "REPLACE_WITH_DESKTOP_CLIENT_ID.apps.googleusercontent.com"; + +/// Desktop app client secret (required for token exchange even though not truly secret). +#[cfg(not(target_os = "android"))] +const GOOGLE_CLIENT_SECRET: &str = "REPLACE_WITH_DESKTOP_CLIENT_SECRET"; + +/// Placeholder: replace with your "Android" client ID from Google Cloud Console. +#[cfg(target_os = "android")] +const GOOGLE_CLIENT_ID: &str = "REPLACE_WITH_ANDROID_CLIENT_ID.apps.googleusercontent.com"; use tauri_plugin_credentials::Credentials; #[cfg(not(target_os = "android"))] @@ -268,6 +287,12 @@ async fn rename_workspace( s.repo = None; s.save_config()?; } + WorkspaceMode::GoogleTasks => { + // Google Tasks workspaces: local cache path is app-managed; only update display name. + let mut s = lock_state(&state)?; + s.config.rename_workspace(&id, new_name).map_err(|e| e.to_string())?; + s.save_config()?; + } } Ok(()) @@ -816,6 +841,320 @@ async fn sync_workspace( Ok(result.into()) } +// ── Google Tasks OAuth + workspace ────────────────────────────────── + +/// Returned to the frontend after a successful Google OAuth flow. +#[derive(Debug, Serialize, Deserialize)] +struct GoogleAuthResult { + access_token: String, + refresh_token: String, + /// Display name or email for the connected account. + account: String, +} + +/// Desktop-only: run the PKCE OAuth 2.0 Authorization Code flow using a temporary +/// loopback HTTP server. Opens the system browser, waits for the redirect with the +/// auth code, exchanges it for tokens, and returns them. +/// +/// On Android this is a stub — the Kotlin layer handles OAuth via Credential Manager. +#[tauri::command] +#[cfg(not(target_os = "android"))] +async fn start_google_oauth() -> Result { + use sha2::{Digest, Sha256}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + // ── PKCE code verifier + challenge ─────────────────────────────── + // Build 64 random bytes from four UUID v4 values (each contributes 122 bits of + // randomness). Encode as base64url to produce a valid code verifier. + let rand_bytes: Vec = (0..4) + .flat_map(|_| uuid::Uuid::new_v4().as_bytes().to_vec()) + .collect(); + let verifier = base64url_encode(&rand_bytes); + + let challenge_bytes = Sha256::digest(verifier.as_bytes()); + let challenge = base64url_encode(&challenge_bytes); + + // ── Loopback listener ──────────────────────────────────────────── + let listener = TcpListener::bind("127.0.0.1:0") + .await + .map_err(|e| format!("Failed to bind loopback listener: {}", e))?; + let port = listener.local_addr().map_err(|e| e.to_string())?.port(); + let redirect_uri = format!("http://127.0.0.1:{}", port); + + // ── Build auth URL ─────────────────────────────────────────────── + let scope = "https://www.googleapis.com/auth/tasks.readonly \ + https://www.googleapis.com/auth/userinfo.email"; + let auth_url = format!( + "https://accounts.google.com/o/oauth2/v2/auth\ + ?client_id={client_id}\ + &redirect_uri={redirect_uri}\ + &response_type=code\ + &scope={scope}\ + &code_challenge={challenge}\ + &code_challenge_method=S256\ + &access_type=offline\ + &prompt=consent", + client_id = GOOGLE_CLIENT_ID, + redirect_uri = urlencodeq(&redirect_uri), + scope = urlencodeq(scope), + challenge = challenge, + ); + + // Open system browser + open_browser(&auth_url); + + // ── Accept one connection on the loopback server ───────────────── + let (mut stream, _) = listener.accept().await + .map_err(|e| format!("Failed to accept OAuth callback: {}", e))?; + + let mut buf = vec![0u8; 4096]; + let n = stream.read(&mut buf).await + .map_err(|e| format!("Failed to read OAuth callback request: {}", e))?; + let request = String::from_utf8_lossy(&buf[..n]); + + // Parse the request line: "GET /?code=...&state=... HTTP/1.1" + let code = request.lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .and_then(|path| { + path.split('?').nth(1).and_then(|qs| { + qs.split('&') + .find(|p| p.starts_with("code=")) + .and_then(|p| p.strip_prefix("code=")) + .map(|s| s.to_string()) + }) + }) + .ok_or_else(|| "OAuth callback did not contain an authorization code".to_string())?; + + // Return a simple success page to the browser + let html_response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n\ + \ +

Connected!

You can close this tab and return to Onyx.

\ + "; + let _ = stream.write_all(html_response.as_bytes()).await; + + // ── Exchange code for tokens ───────────────────────────────────── + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| e.to_string())?; + + let params = [ + ("client_id", GOOGLE_CLIENT_ID), + ("client_secret", GOOGLE_CLIENT_SECRET), + ("code", &code), + ("redirect_uri", &redirect_uri), + ("grant_type", "authorization_code"), + ("code_verifier", &verifier), + ]; + + let resp = client + .post("https://oauth2.googleapis.com/token") + .form(¶ms) + .send() + .await + .map_err(|e| format!("Token exchange request failed: {}", e))?; + + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(format!("Token exchange failed: {}", body)); + } + + #[derive(serde::Deserialize)] + struct TokenResponse { + access_token: String, + refresh_token: Option, + } + + let token_resp: TokenResponse = resp.json().await + .map_err(|e| format!("Failed to parse token response: {}", e))?; + let refresh_token = token_resp.refresh_token + .ok_or_else(|| "Google did not return a refresh token — try revoking access and reconnecting".to_string())?; + + // ── Fetch account email ────────────────────────────────────────── + let userinfo_resp = client + .get("https://www.googleapis.com/oauth2/v2/userinfo") + .bearer_auth(&token_resp.access_token) + .send() + .await + .map_err(|e| format!("Failed to fetch user info: {}", e))?; + + #[derive(serde::Deserialize)] + struct UserInfo { + #[serde(default)] + email: String, + } + + let account = if userinfo_resp.status().is_success() { + userinfo_resp.json::().await + .map(|u| u.email) + .unwrap_or_default() + } else { + String::new() + }; + + Ok(GoogleAuthResult { + access_token: token_resp.access_token, + refresh_token, + account, + }) +} + +#[tauri::command] +#[cfg(target_os = "android")] +async fn start_google_oauth() -> Result { + // On Android, OAuth is handled by the Kotlin layer via Credential Manager. + // This stub exists only so the command is registered on all platforms. + Err("Android OAuth must be initiated via the native sign-in flow".to_string()) +} + +/// Create a new Google Tasks workspace: provision a local cache directory, +/// store OAuth credentials, run the initial sync, and make it the active workspace. +#[tauri::command] +async fn add_google_tasks_workspace( + name: String, + access_token: String, + refresh_token: String, + account: String, + app_handle: tauri::AppHandle, + state: State<'_, Mutex>, +) -> Result<(), String> { + let managed_dir = { + let s = lock_state(&state)?; + let dir_id = uuid::Uuid::new_v4().to_string(); + s.app_data_dir.join("google-tasks").join(&dir_id) + }; + + std::fs::create_dir_all(&managed_dir).map_err(|e| e.to_string())?; + + // Run initial sync before registering the workspace so the user sees content immediately. + google_tasks::sync_google_tasks(&managed_dir, &access_token) + .await + .map_err(|e| e.to_string())?; + + let mut s = lock_state(&state)?; + let mut ws = WorkspaceConfig::new(name, managed_dir.clone()); + ws.mode = WorkspaceMode::GoogleTasks; + ws.google_account = if account.is_empty() { None } else { Some(account.clone()) }; + ws.last_sync = Some(Utc::now()); + + let id = s.config.add_workspace(ws); + s.config.set_current_workspace(id.clone()).map_err(|e| e.to_string())?; + s.repo = None; + s.save_config()?; + drop(s); + + // Store refresh token: domain = "google-oauth-{workspace_id}", username = account, password = refresh_token + let creds = app_handle.state::>(); + let cred_key = format!("google-oauth-{}", id); + creds.store(&cred_key, &account, &refresh_token)?; + + Ok(()) +} + +/// Sync a Google Tasks workspace: refresh the access token, then pull all remote changes. +#[tauri::command] +async fn sync_google_tasks_workspace( + workspace_id: String, + app_handle: tauri::AppHandle, + state: State<'_, Mutex>, +) -> Result { + let workspace_path = { + let s = lock_state(&state)?; + s.config.workspaces.get(&workspace_id) + .ok_or("Workspace not found")? + .path + .clone() + }; + + // Load the stored refresh token. + let creds = app_handle.state::>(); + let cred_key = format!("google-oauth-{}", workspace_id); + let (_account, refresh_token) = creds.load(&cred_key)?; + + // Refresh to get a fresh access token. + #[cfg(not(target_os = "android"))] + let access_token = google_tasks::refresh_access_token( + GOOGLE_CLIENT_ID, + Some(GOOGLE_CLIENT_SECRET), + &refresh_token, + ) + .await + .map_err(|e| e.to_string())?; + + #[cfg(target_os = "android")] + let access_token = google_tasks::refresh_access_token( + GOOGLE_CLIENT_ID, + None, + &refresh_token, + ) + .await + .map_err(|e| e.to_string())?; + + let result = google_tasks::sync_google_tasks(&workspace_path, &access_token) + .await + .map_err(|e| e.to_string())?; + + { + let mut s = lock_state(&state)?; + mute_watcher(&mut s); + if let Some(ws) = s.config.workspaces.get_mut(&workspace_id) { + ws.last_sync = Some(Utc::now()); + } + s.save_config()?; + } + + Ok(SyncResult { + uploaded: 0, + downloaded: result.downloaded, + deleted_local: 0, + deleted_remote: 0, + conflicts: 0, + errors: result.errors, + }) +} + +// ── OAuth helpers (desktop only) ───────────────────────────────────── + +#[cfg(not(target_os = "android"))] +fn open_browser(url: &str) { + #[cfg(target_os = "linux")] + { let _ = std::process::Command::new("xdg-open").arg(url).spawn(); } + #[cfg(target_os = "macos")] + { let _ = std::process::Command::new("open").arg(url).spawn(); } + #[cfg(target_os = "windows")] + { let _ = std::process::Command::new("cmd").args(["/c", "start", "", url]).spawn(); } +} + +/// Percent-encode a string for use in a URL query parameter value. +#[cfg(not(target_os = "android"))] +fn urlencodeq(s: &str) -> String { + s.bytes().flat_map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' + | b'-' | b'_' | b'.' | b'~' => vec![b as char], + _ => format!("%{:02X}", b).chars().collect(), + }).collect() +} + +/// Encode bytes as base64url (RFC 4648 §5, no padding). +#[cfg(not(target_os = "android"))] +fn base64url_encode(data: &[u8]) -> String { + const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut out = String::with_capacity((data.len() * 4 + 2) / 3); + for chunk in data.chunks(3) { + let b0 = chunk[0] as u32; + let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 }; + let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 }; + let n = (b0 << 16) | (b1 << 8) | b2; + out.push(CHARS[((n >> 18) & 0x3F) as usize] as char); + out.push(CHARS[((n >> 12) & 0x3F) as usize] as char); + if chunk.len() > 1 { out.push(CHARS[((n >> 6) & 0x3F) as usize] as char); } + if chunk.len() > 2 { out.push(CHARS[(n & 0x3F) as usize] as char); } + } + out +} + // ── File watcher ──────────────────────────────────────────────────── #[cfg(not(target_os = "android"))] @@ -939,6 +1278,9 @@ pub fn run() { test_webdav_connection, sync_workspace, watch_workspace, + start_google_oauth, + add_google_tasks_workspace, + sync_google_tasks_workspace, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/apps/tauri/src/app.css b/apps/tauri/src/app.css index 7da049f..c298efe 100644 --- a/apps/tauri/src/app.css +++ b/apps/tauri/src/app.css @@ -248,7 +248,7 @@ body { border-color: rgba(0, 0, 0, 0.5); } -/* ── Dropdown/kebab menu shadow ──────────────────────────────────── */ +/* ── Dropdown/kebab menu shadow + open/close animation ───────────── */ .menu-shadow { box-shadow: 0 4px 10px 0 rgba(0, 0, 0, 0.12); @@ -257,6 +257,18 @@ body { box-shadow: 0 4px 10px 0 rgba(0, 0, 0, 0.3); } +.dropdown-menu { + transition: opacity 120ms ease, transform 120ms ease, display 120ms ease allow-discrete; + transform-origin: top right; + opacity: 1; + transform: scale(1); + + @starting-style { + opacity: 0; + transform: scale(0.92); + } +} + /* ── Borderless mode: square corners on all popups/overlays ─────── */ .decorations-none .rounded-sm, diff --git a/apps/tauri/src/lib/components/NewTaskInput.svelte b/apps/tauri/src/lib/components/NewTaskInput.svelte index e43c7b0..9bac827 100644 --- a/apps/tauri/src/lib/components/NewTaskInput.svelte +++ b/apps/tauri/src/lib/components/NewTaskInput.svelte @@ -139,8 +139,8 @@ {#if showDatePicker} (showDatePicker = false)} /> diff --git a/apps/tauri/src/lib/screens/SetupScreen.svelte b/apps/tauri/src/lib/screens/SetupScreen.svelte index 008e2f8..eb9b1fd 100644 --- a/apps/tauri/src/lib/screens/SetupScreen.svelte +++ b/apps/tauri/src/lib/screens/SetupScreen.svelte @@ -15,7 +15,7 @@ const isMobile = currentPlatform === "android" || currentPlatform === "ios"; // ── Shared state ────────────────────────────────────────────────── - let mode = $state<"local" | "webdav" | null>(isMobile ? "webdav" : null); + let mode = $state<"local" | "webdav" | "googletasks" | null>(isMobile ? "webdav" : null); let name = $state("Onyx"); let path = $state(""); @@ -43,6 +43,13 @@ let createName = $state("Onyx"); let creating = $state(false); + // ── Google Tasks state ──────────────────────────────────────────── + let googleStep = $state<"connect" | "confirm">("connect"); + let googleConnecting = $state(false); + let googleError = $state(null); + let googleAuth = $state<{ accessToken: string; refreshToken: string; account: string } | null>(null); + let googleWorkspaceName = $state("Google Tasks"); + // ── Derived ─────────────────────────────────────────────────────── let currentBrowsePath = $derived(browsePath.join("/")); @@ -186,6 +193,55 @@ } } + // ── Google Tasks handlers ───────────────────────────────────────── + + async function connectGoogle() { + googleConnecting = true; + googleError = null; + try { + const result = await invoke<{ access_token: string; refresh_token: string; account: string }>( + "start_google_oauth" + ); + googleAuth = { + accessToken: result.access_token, + refreshToken: result.refresh_token, + account: result.account, + }; + googleWorkspaceName = result.account || "Google Tasks"; + googleStep = "confirm"; + } catch (e) { + googleError = String(e); + } finally { + googleConnecting = false; + } + } + + async function handleCreateGoogle() { + if (!googleAuth) return; + creating = true; + googleError = null; + try { + await app.addGoogleTasksWorkspace( + googleWorkspaceName.trim() || "Google Tasks", + googleAuth.accessToken, + googleAuth.refreshToken, + googleAuth.account, + ); + } catch (e) { + googleError = String(e); + creating = false; + } + } + + function googleBack() { + if (googleStep === "confirm") { + googleStep = "connect"; + googleAuth = null; + } else { + goBack(); + } + } + // ── Window dragging ─────────────────────────────────────────────── function handleDrag(e: MouseEvent) { @@ -206,6 +262,10 @@ browsePath = []; browseEntries = []; browseError = null; + googleStep = "connect"; + googleAuth = null; + googleError = null; + googleConnecting = false; } function webdavBack() { @@ -285,7 +345,7 @@ + + {:else if mode === "local"}

@@ -357,7 +427,7 @@ {/if} - {:else if webdavStep === "connect"} + {:else if mode === "webdav" && webdavStep === "connect"}

Connect to a WebDAV server. @@ -406,7 +476,7 @@ {/if} - {:else if webdavStep === "browse"} + {:else if mode === "webdav" && webdavStep === "browse"}

Pick a folder or create a new workspace. @@ -476,7 +546,7 @@ Back - {:else if webdavStep === "preview"} + {:else if mode === "webdav" && webdavStep === "preview"}

- {:else if webdavStep === "create"} + {:else if mode === "webdav" && webdavStep === "create"}
+ {:else if mode === "googletasks" && googleStep === "connect"} + +

+ Sign in with Google to sync your tasks. The workspace will be read-only. +

+ + {#if googleError} +

{googleError}

+ {/if} + + + + {#if !isMobile} + + {/if} + + {:else if mode === "googletasks" && googleStep === "confirm"} + + +
+ +

Connected

+
+ +

+ Signed in as {googleAuth?.account || "Google Account"}. + All your Google Tasks lists will be imported as a read-only workspace. +

+ + + + {#if googleError} +

{googleError}

+ {/if} + + {/if}
diff --git a/apps/tauri/src/lib/screens/TasksScreen.svelte b/apps/tauri/src/lib/screens/TasksScreen.svelte index ae70e95..39c8676 100644 --- a/apps/tauri/src/lib/screens/TasksScreen.svelte +++ b/apps/tauri/src/lib/screens/TasksScreen.svelte @@ -118,12 +118,12 @@ renamingListId = null; } - async function handleToggleGroupByDueDate() { + async function handleToggleGroupByDate() { showListMenu = false; if (!app.activeListId) return; var list = app.lists.find(l => l.id === app.activeListId); if (!list) return; - await app.setGroupByDueDate(app.activeListId, !list.group_by_due_date); + await app.setGroupByDate(app.activeListId, !list.group_by_date); } function handleKeydown(e: KeyboardEvent) { @@ -188,16 +188,16 @@ const targetGroup = app.groupedPendingTasks?.find((g) => g.label === group); const task = app.pendingTasks.find((t) => t.id === taskId); if (task && targetGroup !== undefined) { - let newDueDate: string | null = null; + let newDate: string | null = null; if (targetGroup.date !== null) { const target = new Date(targetGroup.date); - if (task.has_time && task.due_date) { - const existing = new Date(task.due_date); + if (task.has_time && task.date) { + const existing = new Date(task.date); target.setHours(existing.getHours(), existing.getMinutes(), existing.getSeconds(), 0); } - newDueDate = target.toISOString(); + newDate = target.toISOString(); } - await app.updateTask({ ...task, due_date: newDueDate, has_time: newDueDate ? task.has_time : false }); + await app.updateTask({ ...task, date: newDate, has_time: newDate ? task.has_time : false }); } } @@ -382,37 +382,39 @@ {/each} - -
- {#if showNewList} -
- { if (e.key === "Enter") handleNewList(); if (e.key === "Escape") { showNewList = false; newListName = ""; } }} - /> + + {#if !app.isGoogleTasks} +
+ {#if showNewList} +
+ { if (e.key === "Enter") handleNewList(); if (e.key === "Escape") { showNewList = false; newListName = ""; } }} + /> + +
+ {:else} -
- {:else} - - {/if} -
+ {/if} +
+ {/if} - +
{#if app.isWebdav}
@@ -435,6 +437,23 @@
+ {:else if app.isGoogleTasks} +
+ + Google Tasks Workspace (Read Only) + +
{:else} Local workspace {/if} @@ -523,30 +542,46 @@ {#if showListMenu} + +
+ {/if} diff --git a/apps/tauri/src/lib/stores/app.svelte.ts b/apps/tauri/src/lib/stores/app.svelte.ts index 38263e4..05fc290 100644 --- a/apps/tauri/src/lib/stores/app.svelte.ts +++ b/apps/tauri/src/lib/stores/app.svelte.ts @@ -13,7 +13,7 @@ import type { listen("fs-changed", () => { loadLists(); // Debounced sync for WebDAV workspaces on local file changes - if (isWebdav) debouncedSync(); + if (isSyncedWorkspace) debouncedSync(); }); // ── Reactive state ─────────────────────────────────────────────────── @@ -95,6 +95,7 @@ let groupedPendingTasks = $derived.by((): TaskGroup[] | null => { tomorrow.sort(sortByDue); const groups: TaskGroup[] = []; + if (noDate.length) groups.push({ label: "No Date", tasks: noDate, date: null }); if (overdue.length) groups.push({ label: "Overdue", tasks: overdue, date: null }); if (today.length) groups.push({ label: "Today", tasks: today, date: todayStart }); if (tomorrow.length) groups.push({ label: "Tomorrow", tasks: tomorrow, date: tomorrowStart }); @@ -108,7 +109,6 @@ let groupedPendingTasks = $derived.by((): TaskGroup[] | null => { groups.push({ label: date.toLocaleDateString(undefined, opts), tasks, date }); } - if (noDate.length) groups.push({ label: "No Date", tasks: noDate, date: null }); return groups; }); @@ -148,6 +148,12 @@ let isWebdav = $derived( ? config.workspaces[config.current_workspace]?.mode === "webdav" : false, ); +let isGoogleTasks = $derived( + config?.current_workspace + ? config.workspaces[config.current_workspace]?.mode === "googletasks" + : false, +); +let isSyncedWorkspace = $derived(isWebdav || isGoogleTasks); let syncIntervalSecs = $derived( config?.current_workspace ? config.workspaces[config.current_workspace]?.sync_interval_secs ?? DEFAULT_SYNC_INTERVAL_SECS @@ -177,7 +183,7 @@ async function loadConfig() { if (lists.length > 0 && !activeListId) activeListId = lists[0].id; if (activeListId) await loadTasks(); screen = "tasks"; - if (isWebdav) startAutoSync(); + if (isSyncedWorkspace) startAutoSync(); } else { screen = "setup"; } @@ -210,7 +216,7 @@ async function switchWorkspace(id: string) { await loadLists(); const ws = config?.workspaces[id]; if (ws) invoke("watch_workspace", { path: ws.path }).catch((e) => console.warn("File watcher failed:", e)); - if (isWebdav) startAutoSync(); else stopAutoSync(); + if (isSyncedWorkspace) startAutoSync(); else stopAutoSync(); error = null; } catch (e) { error = String(e); @@ -411,10 +417,14 @@ async function triggerSync() { if (!config?.current_workspace || syncing) return; syncing = true; try { - const result = await invoke("sync_workspace", { - workspaceId: config.current_workspace, - mode: "full", - }); + const result = isGoogleTasks + ? await invoke("sync_google_tasks_workspace", { + workspaceId: config.current_workspace, + }) + : await invoke("sync_workspace", { + workspaceId: config.current_workspace, + mode: "full", + }); lastSyncResult = result; lastSyncTime = Date.now(); syncStatus = result.errors.length > 0 ? "error" : "synced"; @@ -476,7 +486,7 @@ async function setSyncInterval(secs: number | null) { intervalSecs: secs, }); config = await invoke("get_config"); - if (isWebdav) startAutoSync(); + if (isSyncedWorkspace) startAutoSync(); } catch (e) { error = String(e); } @@ -490,7 +500,7 @@ async function setSyncIntervalUnfocused(secs: number | null) { intervalSecs: secs, }); config = await invoke("get_config"); - if (isWebdav) startAutoSync(); + if (isSyncedWorkspace) startAutoSync(); } catch (e) { error = String(e); } @@ -534,13 +544,31 @@ async function addWebdavWorkspace(name: string, webdavUrl: string, webdavPath: s const ws = config.workspaces[config.current_workspace]; if (ws) invoke("watch_workspace", { path: ws.path }).catch((e) => console.warn("File watcher failed:", e)); } - if (isWebdav) startAutoSync(); + if (isSyncedWorkspace) startAutoSync(); } catch (e) { initialSync = false; error = String(e); } } +async function addGoogleTasksWorkspace( + name: string, + accessToken: string, + refreshToken: string, + account: string, +) { + try { + await invoke("add_google_tasks_workspace", { name, accessToken, refreshToken, account }); + config = await invoke("get_config"); + screen = "tasks"; + error = null; + await loadLists(); + startAutoSync(); + } catch (e) { + error = String(e); + } +} + async function forgetMissingWorkspace() { if (!missingWorkspace) return; await removeWorkspace(missingWorkspace); @@ -617,6 +645,12 @@ export const app = { get isWebdav() { return isWebdav; }, + get isGoogleTasks() { + return isGoogleTasks; + }, + get isSyncedWorkspace() { + return isSyncedWorkspace; + }, get syncIntervalSecs() { return syncIntervalSecs; }, @@ -665,6 +699,7 @@ export const app = { setWindowDecorations, setTheme, addWebdavWorkspace, + addGoogleTasksWorkspace, forgetMissingWorkspace, setScreen, clearError, diff --git a/apps/tauri/src/lib/types.ts b/apps/tauri/src/lib/types.ts index 66749ad..295af49 100644 --- a/apps/tauri/src/lib/types.ts +++ b/apps/tauri/src/lib/types.ts @@ -18,7 +18,7 @@ export interface TaskList { group_by_date: boolean; } -export type WorkspaceMode = "local" | "webdav"; +export type WorkspaceMode = "local" | "webdav" | "googletasks"; export interface WorkspaceConfig { name: string; @@ -26,6 +26,7 @@ export interface WorkspaceConfig { mode: WorkspaceMode; webdav_url: string | null; webdav_path: string | null; + google_account: string | null; last_sync: string | null; theme: string | null; sync_interval_secs: number | null; diff --git a/crates/onyx-core/Cargo.toml b/crates/onyx-core/Cargo.toml index 79dc14f..36f5557 100644 --- a/crates/onyx-core/Cargo.toml +++ b/crates/onyx-core/Cargo.toml @@ -14,10 +14,10 @@ keyring-storage = ["keyring"] serde = { workspace = true } serde_json = "1.0" serde_yaml = "0.9" -uuid = { workspace = true } +uuid = { workspace = true, features = ["v5"] } chrono = { workspace = true } directories = "5.0" -reqwest = { workspace = true } +reqwest = { workspace = true, features = ["json"] } sha2 = { workspace = true } quick-xml = { workspace = true } tokio = { workspace = true } diff --git a/crates/onyx-core/src/google_tasks.rs b/crates/onyx-core/src/google_tasks.rs new file mode 100644 index 0000000..1f4fb34 --- /dev/null +++ b/crates/onyx-core/src/google_tasks.rs @@ -0,0 +1,476 @@ +//! Google Tasks API client and one-way pull sync. +//! +//! Workspaces of mode `GoogleTasks` are read-only: remote always wins. The sync +//! fetches all task lists and tasks from the Google Tasks REST API and writes them +//! to the local `FileSystemStorage` format, overwriting stale local state. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Utc}; +use reqwest::Client; +use serde::Deserialize; +use uuid::Uuid; + +use crate::error::{Error, Result}; +use crate::models::{Task, TaskStatus}; +use crate::storage::{ListMetadata, RootMetadata}; + +const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); +const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +/// Fixed UUID v5 namespace for deterministic Google ID → Onyx UUID conversion. +/// Changing this value would invalidate all existing synced task IDs. +const GT_NAMESPACE: Uuid = Uuid::from_bytes([ + 0x6b, 0xa7, 0xb8, 0x10, 0x9d, 0xad, 0x11, 0xd1, + 0x80, 0xb4, 0x00, 0xc0, 0x4f, 0xd4, 0x30, 0xc8, +]); + +/// Convert a Google Tasks opaque ID to a stable Onyx UUID using UUID v5. +/// The same Google ID always produces the same UUID, enabling stable local files +/// across sync cycles without needing an explicit ID mapping file. +pub fn gt_id_to_uuid(google_id: &str) -> Uuid { + Uuid::new_v5(>_NAMESPACE, google_id.as_bytes()) +} + +// ── API response types ─────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct GtListsResponse { + #[serde(default)] + items: Vec, +} + +#[derive(Debug, Deserialize)] +struct GtTaskList { + id: String, + title: String, +} + +#[derive(Debug, Deserialize)] +struct GtTasksResponse { + #[serde(default)] + items: Vec, + #[serde(rename = "nextPageToken")] + next_page_token: Option, +} + +#[derive(Debug, Deserialize)] +struct GtTask { + id: String, + #[serde(default)] + title: String, + #[serde(default)] + notes: String, + /// "needsAction" or "completed" + #[serde(default)] + status: String, + /// RFC 3339 timestamp; time component is always T00:00:00.000Z (date-only). + due: Option, + /// Parent task Google ID (absent for top-level tasks). + parent: Option, + /// Opaque position string used for ordering within a list. + #[serde(default)] + position: String, +} + +// ── Client ─────────────────────────────────────────────────────────── + +/// Thin wrapper around `reqwest::Client` that adds a Bearer auth header to every +/// request and handles pagination for list endpoints. +pub struct GoogleTasksClient { + client: Client, + access_token: String, +} + +impl GoogleTasksClient { + pub fn new(access_token: String) -> Result { + let client = Client::builder() + .timeout(REQUEST_TIMEOUT) + .connect_timeout(CONNECT_TIMEOUT) + .build() + .map_err(|e| Error::WebDav(format!("Failed to build HTTP client: {}", e)))?; + Ok(Self { client, access_token }) + } + + async fn get(&self, url: &str) -> Result { + let resp = self.client + .get(url) + .bearer_auth(&self.access_token) + .send() + .await?; + + let status = resp.status(); + if status.as_u16() == 401 { + return Err(Error::Credential("Google access token expired or invalid".to_string())); + } + if !status.is_success() { + return Err(Error::WebDav(format!("Google Tasks API error: HTTP {}", status))); + } + + resp.json().await.map_err(|e| Error::WebDav(format!("Failed to parse Google API response: {}", e))) + } + + /// Returns all task lists for the authenticated user. + async fn list_task_lists(&self) -> Result> { + let resp: GtListsResponse = self + .get("https://tasks.googleapis.com/tasks/v1/users/@me/lists") + .await?; + Ok(resp.items) + } + + /// Returns all tasks in a task list, following pagination automatically. + async fn list_tasks(&self, list_id: &str) -> Result> { + let mut all_tasks = Vec::new(); + let mut page_token: Option = None; + + loop { + let url = match &page_token { + Some(token) => format!( + "https://tasks.googleapis.com/tasks/v1/lists/{}/tasks\ + ?showCompleted=true&showHidden=true&maxResults=100&pageToken={}", + list_id, token + ), + None => format!( + "https://tasks.googleapis.com/tasks/v1/lists/{}/tasks\ + ?showCompleted=true&showHidden=true&maxResults=100", + list_id + ), + }; + + let resp: GtTasksResponse = self.get(&url).await?; + all_tasks.extend(resp.items); + match resp.next_page_token { + Some(token) => page_token = Some(token), + None => break, + } + } + + Ok(all_tasks) + } +} + +// ── Token refresh ──────────────────────────────────────────────────── + +/// Exchange a refresh token for a new access token. +/// `client_secret` is `None` for Android (no secret required for Android OAuth clients). +pub async fn refresh_access_token( + client_id: &str, + client_secret: Option<&str>, + refresh_token: &str, +) -> Result { + let client = Client::builder() + .timeout(REQUEST_TIMEOUT) + .connect_timeout(CONNECT_TIMEOUT) + .build() + .map_err(|e| Error::WebDav(format!("Failed to build HTTP client: {}", e)))?; + + let mut params = vec![ + ("client_id", client_id), + ("refresh_token", refresh_token), + ("grant_type", "refresh_token"), + ]; + if let Some(secret) = client_secret { + params.push(("client_secret", secret)); + } + + let resp = client + .post("https://oauth2.googleapis.com/token") + .form(¶ms) + .send() + .await?; + + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(Error::Credential(format!("Token refresh failed: {}", body))); + } + + #[derive(Deserialize)] + struct TokenResponse { + access_token: String, + } + + let token_resp: TokenResponse = resp.json().await + .map_err(|e| Error::WebDav(format!("Failed to parse token response: {}", e)))?; + Ok(token_resp.access_token) +} + +// ── Sync ───────────────────────────────────────────────────────────── + +/// Result of a Google Tasks one-way pull sync. +pub struct GoogleSyncResult { + pub downloaded: u32, + pub errors: Vec, +} + +/// One-way pull sync: fetch all Google Tasks lists and tasks, write to local storage. +/// +/// Remote always wins. Local edits (if any) are silently overwritten. This function +/// never pushes anything to Google. +pub async fn sync_google_tasks( + workspace_path: &Path, + access_token: &str, +) -> Result { + let client = GoogleTasksClient::new(access_token.to_string())?; + + std::fs::create_dir_all(workspace_path)?; + let mut downloaded: u32 = 0; + let mut errors: Vec = Vec::new(); + + let gt_lists = client.list_task_lists().await?; + + // Compute the set of UUIDs that correspond to remote lists (for cleanup). + let remote_list_uuids: HashSet = gt_lists.iter() + .map(|l| gt_id_to_uuid(&l.id)) + .collect(); + + // Remove local list directories that no longer exist remotely. + if let Ok(entries) = std::fs::read_dir(workspace_path) { + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { continue; } + let listdata_path = path.join(".listdata.json"); + if let Ok(content) = std::fs::read_to_string(&listdata_path) { + if let Ok(meta) = serde_json::from_str::(&content) { + if !remote_list_uuids.contains(&meta.id) { + let _ = std::fs::remove_dir_all(&path); + } + } + } + } + } + + let mut new_list_order: Vec = Vec::new(); + + for gt_list in >_lists { + let list_uuid = gt_id_to_uuid(>_list.id); + new_list_order.push(list_uuid); + + let list_dir = match find_or_create_list_dir(workspace_path, list_uuid, >_list.title) { + Ok(d) => d, + Err(e) => { + errors.push(format!("Failed to set up list '{}': {}", gt_list.title, e)); + continue; + } + }; + + let listdata_path = list_dir.join(".listdata.json"); + let mut list_meta: ListMetadata = if listdata_path.exists() { + std::fs::read_to_string(&listdata_path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_else(|| ListMetadata::new(list_uuid)) + } else { + ListMetadata::new(list_uuid) + }; + + let gt_tasks = match client.list_tasks(>_list.id).await { + Ok(tasks) => tasks, + Err(e) => { + errors.push(format!("Failed to fetch tasks for list '{}': {}", gt_list.title, e)); + continue; + } + }; + + // Compute the set of remote task UUIDs so we can remove deleted ones locally. + let remote_task_uuids: HashSet = gt_tasks.iter() + .map(|t| gt_id_to_uuid(&t.id)) + .collect(); + + // Remove local task files for tasks deleted from Google. + if let Ok(entries) = std::fs::read_dir(&list_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; } + if let Ok(content) = std::fs::read_to_string(&path) { + if let Some(task_uuid) = extract_task_uuid(&content) { + if !remote_task_uuids.contains(&task_uuid) { + let _ = std::fs::remove_file(&path); + } + } + } + } + } + + // Sort by position to preserve Google Tasks ordering. + let mut sorted_tasks = gt_tasks; + sorted_tasks.sort_by(|a, b| a.position.cmp(&b.position)); + + let mut task_order: Vec = Vec::new(); + + for gt_task in &sorted_tasks { + if gt_task.title.is_empty() { continue; } + + let task_uuid = gt_id_to_uuid(>_task.id); + task_order.push(task_uuid); + + let status = if gt_task.status == "completed" { + TaskStatus::Completed + } else { + TaskStatus::Backlog + }; + + // Google Tasks dates are date-only (time is always T00:00:00Z). + let date = gt_task.due.as_deref() + .and_then(|s| s.parse::>().ok()); + + let parent_id = gt_task.parent.as_deref().map(gt_id_to_uuid); + + let task = Task { + id: task_uuid, + title: gt_task.title.clone(), + description: gt_task.notes.clone(), + status, + date, + has_time: false, + version: 1, + parent_id, + }; + + // File is named after the sanitized title (matching FileSystemStorage convention). + // If two tasks share a sanitized title, append a short UUID suffix to avoid collision. + let safe_title = sanitize_name(&task.title); + let candidate = list_dir.join(format!("{}.md", safe_title)); + let task_path = if candidate.exists() { + // Check if the existing file already belongs to this task UUID. + let existing_ok = std::fs::read_to_string(&candidate) + .ok() + .and_then(|c| extract_task_uuid(&c)) + .map(|u| u == task_uuid) + .unwrap_or(false); + if existing_ok { + candidate + } else { + list_dir.join(format!("{}_{}.md", safe_title, &task_uuid.to_string()[..8])) + } + } else { + candidate + }; + + let content = render_task_markdown(&task); + if let Err(e) = atomic_write_bytes(&task_path, content.as_bytes()) { + errors.push(format!("Failed to write task '{}': {}", task.title, e)); + } else { + downloaded += 1; + } + } + + list_meta.task_order = task_order; + list_meta.updated_at = Utc::now(); + + if let Ok(meta_content) = serde_json::to_string_pretty(&list_meta) { + let _ = atomic_write_bytes(&listdata_path, meta_content.as_bytes()); + } + } + + // Update workspace root metadata with the new list ordering. + let root_meta_path = workspace_path.join(".onyx-workspace.json"); + let mut root_meta: RootMetadata = if root_meta_path.exists() { + std::fs::read_to_string(&root_meta_path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() + } else { + RootMetadata::default() + }; + root_meta.list_order = new_list_order; + if let Ok(meta_content) = serde_json::to_string_pretty(&root_meta) { + let _ = atomic_write_bytes(&root_meta_path, meta_content.as_bytes()); + } + + Ok(GoogleSyncResult { downloaded, errors }) +} + +// ── Helpers ────────────────────────────────────────────────────────── + +/// Find an existing list directory by UUID, or create a new one named after the list title. +fn find_or_create_list_dir( + workspace_path: &Path, + list_uuid: Uuid, + list_title: &str, +) -> std::io::Result { + // Look for an existing directory already associated with this list UUID. + if let Ok(entries) = std::fs::read_dir(workspace_path) { + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { continue; } + let listdata_path = path.join(".listdata.json"); + if let Ok(content) = std::fs::read_to_string(&listdata_path) { + if let Ok(meta) = serde_json::from_str::(&content) { + if meta.id == list_uuid { + return Ok(path); + } + } + } + } + } + + // No existing directory found; create one named after the list. + let safe_name = sanitize_name(list_title); + let dir = workspace_path.join(&safe_name); + // If the name is taken by a different list, append a short UUID suffix. + let dir = if dir.exists() { + workspace_path.join(format!("{}_{}", safe_name, &list_uuid.to_string()[..8])) + } else { + dir + }; + + std::fs::create_dir_all(&dir)?; + Ok(dir) +} + +/// Extract the task UUID from a `.md` file's frontmatter without fully parsing it. +fn extract_task_uuid(content: &str) -> Option { + let mut lines = content.lines(); + if lines.next()? != "---" { return None; } + for line in lines { + if line == "---" { break; } + if let Some(rest) = line.strip_prefix("id: ") { + return rest.trim().parse().ok(); + } + } + None +} + +/// Render an Onyx `Task` as the markdown format expected by `FileSystemStorage`. +/// Version is fixed at 1; it will be incremented by the storage layer on any +/// subsequent write by the user (which is blocked in the UI for Google Tasks workspaces). +fn render_task_markdown(task: &Task) -> String { + let status_str = match task.status { + TaskStatus::Backlog => "backlog", + TaskStatus::Completed => "completed", + }; + let mut yaml = format!("id: {}\nstatus: {}\nversion: 1\n", task.id, status_str); + if let Some(due) = task.date { + yaml.push_str(&format!("date: {}\n", due.to_rfc3339())); + } + if let Some(parent) = task.parent_id { + yaml.push_str(&format!("parent: {}\n", parent)); + } + format!("---\n{}---\n\n{}", yaml, task.description) +} + +/// Sanitize a string for use as a filesystem path component. +fn sanitize_name(name: &str) -> String { + let s: String = name.chars() + .map(|c| match c { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + '\0'..='\x1f' => '_', + _ => c, + }) + .collect::() + .trim_matches(|c: char| c == '.' || c == ' ') + .to_string(); + if s.is_empty() { "Untitled".to_string() } else { s } +} + +/// Write bytes to a file atomically (write to `.tmp`, then rename). +fn atomic_write_bytes(path: &Path, content: &[u8]) -> std::io::Result<()> { + let temp = path.with_extension("tmp"); + std::fs::write(&temp, content)?; + if let Err(e) = std::fs::rename(&temp, path) { + let _ = std::fs::remove_file(&temp); + return Err(e); + } + Ok(()) +} diff --git a/crates/onyx-core/src/lib.rs b/crates/onyx-core/src/lib.rs index f78de8f..b8c5120 100644 --- a/crates/onyx-core/src/lib.rs +++ b/crates/onyx-core/src/lib.rs @@ -5,6 +5,7 @@ pub mod config; pub mod error; pub mod webdav; pub mod sync; +pub mod google_tasks; pub use models::{Task, TaskStatus, TaskList}; pub use repository::TaskRepository; diff --git a/crates/onyx-core/src/models.rs b/crates/onyx-core/src/models.rs index 0e4f7e2..2b6690b 100644 --- a/crates/onyx-core/src/models.rs +++ b/crates/onyx-core/src/models.rs @@ -166,10 +166,10 @@ mod tests { } #[test] - fn test_task_with_due_date() { + fn test_task_with_date() { let dt = Utc::now(); - let task = Task::new("T".to_string()).with_due_date(dt); - assert_eq!(task.due_date, Some(dt)); + let task = Task::new("T".to_string()).with_date(dt); + assert_eq!(task.date, Some(dt)); } #[test] @@ -242,7 +242,7 @@ mod tests { let list = TaskList::new("My List".to_string()); assert_eq!(list.title, "My List"); assert!(list.tasks.is_empty()); - assert!(!list.group_by_due_date); + assert!(!list.group_by_date); assert!(list.created_at <= Utc::now()); assert!(list.updated_at <= Utc::now()); } diff --git a/crates/onyx-core/src/storage.rs b/crates/onyx-core/src/storage.rs index 5e3141a..9796b22 100644 --- a/crates/onyx-core/src/storage.rs +++ b/crates/onyx-core/src/storage.rs @@ -102,7 +102,7 @@ impl From<&Task> for TaskFrontmatter { Self { id: task.id, status: task.status, - due: task.due_date, + date: task.date, has_time: task.has_time, version: task.version, parent: task.parent_id,