Add Google Tasks read-only workspace and sync
Introduce a new Google Tasks workspace mode that performs a one-way,
read-only pull from the Google Tasks API into the local
FileSystemStorage format. This adds a Google Tasks client (token
refresh, paginated list fetching, stable UUID v5 mapping), desktop
PKCE+loopback OAuth flow (Android stub), credential storage, workspace
creation and sync commands, UI flows for sign-in/creation, and guards to
disable write operations for Google Tasks workspaces. Changes also
include Cargo dependency updates and exports to wire the new module into
the app.
Hide editing UI for Google Tasks workspaces
Make Google Tasks workspaces read-only by hiding editing controls and
the FAB when app.isGoogleTasks is true. This prevents rename,
delete-completed, and new-task actions in read-only Google Tasks
workspaces and adjusts menu options (Rename removed, Group by due date
and Show subtasks remain with proper toggles). The change clarifies the
UX for Google Tasks by disabling interaction where appropriate and
conditionally showing the FAB only for editable workspaces.
Add Google Tasks workspace (read-only)
Introduce Google Tasks as a third workspace mode to allow one-way,
read-only sync from the Google Tasks API into local FileSystemStorage.
This includes a new WorkspaceMode::GoogleTasks, workspace google_account
metadata, a GoogleTasks client and sync logic (with stable UUID v5 task
IDs), OAuth PKCE+loopback flow for desktop (Android uses a
credential-manager stub), UI changes to mark Google Tasks workspaces as
read-only and disable write operations, and commands to create and sync
Google Tasks workspaces. Dependency updates add sha2/sha1_smol/reqwest
for OAuth and API calls.
when I go to create a google tasks workspace, I see the content associated with creating a webdav workspace as part of the interface
- SetupScreen.svelte: fixed template structure where WebDAV branches lacked mode === "webdav" guards, causing them to render when mode === "googletasks"; folded Google Tasks steps into the main if/else chain and removed the separate Google Tasks {#if} block that was appending below the WebDAV content
This commit is contained in:
parent
9ab9e49265
commit
6a4b79801b
7
Cargo.lock
generated
7
Cargo.lock
generated
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
9
apps/tauri/src-tauri/Cargo.lock
generated
9
apps/tauri/src-tauri/Cargo.lock
generated
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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<GoogleAuthResult, String> {
|
||||
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<u8> = (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\
|
||||
<html><body style='font-family:sans-serif;text-align:center;padding-top:4rem'>\
|
||||
<h2>Connected!</h2><p>You can close this tab and return to Onyx.</p>\
|
||||
</body></html>";
|
||||
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<String>,
|
||||
}
|
||||
|
||||
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::<UserInfo>().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<GoogleAuthResult, String> {
|
||||
// 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<AppState>>,
|
||||
) -> 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::<Credentials<tauri::Wry>>();
|
||||
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<AppState>>,
|
||||
) -> Result<SyncResult, String> {
|
||||
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::<Credentials<tauri::Wry>>();
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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 @@
|
|||
|
||||
<button
|
||||
onclick={() => (mode = "webdav")}
|
||||
class="w-full rounded-xl border border-border-light p-4 text-left hover:bg-black/5 dark:border-border-dark dark:hover:bg-white/10"
|
||||
class="mb-3 w-full rounded-xl border border-border-light p-4 text-left hover:bg-black/5 dark:border-border-dark dark:hover:bg-white/10"
|
||||
>
|
||||
<p class="text-sm font-semibold">WebDAV Server</p>
|
||||
<p class="mt-0.5 text-xs text-text-secondary-light dark:text-text-secondary-dark">
|
||||
|
|
@ -293,6 +353,16 @@
|
|||
</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => (mode = "googletasks")}
|
||||
class="w-full rounded-xl border border-border-light p-4 text-left hover:bg-black/5 dark:border-border-dark dark:hover:bg-white/10"
|
||||
>
|
||||
<p class="text-sm font-semibold">Google Tasks</p>
|
||||
<p class="mt-0.5 text-xs text-text-secondary-light dark:text-text-secondary-dark">
|
||||
Read your Google Tasks. Sign in with Google to sync. Read-only.
|
||||
</p>
|
||||
</button>
|
||||
|
||||
{:else if mode === "local"}
|
||||
<!-- Step 2a: Local workspace -->
|
||||
<p class="mb-6 text-sm text-text-secondary-light dark:text-text-secondary-dark">
|
||||
|
|
@ -357,7 +427,7 @@
|
|||
</button>
|
||||
{/if}
|
||||
|
||||
{:else if webdavStep === "connect"}
|
||||
{:else if mode === "webdav" && webdavStep === "connect"}
|
||||
<!-- Step 2b: WebDAV connect -->
|
||||
<p class="mb-6 text-sm text-text-secondary-light dark:text-text-secondary-dark">
|
||||
Connect to a WebDAV server.
|
||||
|
|
@ -406,7 +476,7 @@
|
|||
</button>
|
||||
{/if}
|
||||
|
||||
{:else if webdavStep === "browse"}
|
||||
{:else if mode === "webdav" && webdavStep === "browse"}
|
||||
<!-- Step 3: Folder explorer -->
|
||||
<p class="mb-4 text-sm text-text-secondary-light dark:text-text-secondary-dark">
|
||||
Pick a folder or create a new workspace.
|
||||
|
|
@ -476,7 +546,7 @@
|
|||
Back
|
||||
</button>
|
||||
|
||||
{:else if webdavStep === "preview"}
|
||||
{:else if mode === "webdav" && webdavStep === "preview"}
|
||||
<!-- Step 4a: Workspace preview -->
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<button onclick={() => (webdavStep = "browse")} class="rounded-lg p-1 opacity-50 hover:opacity-80">
|
||||
|
|
@ -511,7 +581,7 @@
|
|||
Open Workspace
|
||||
</button>
|
||||
|
||||
{:else if webdavStep === "create"}
|
||||
{:else if mode === "webdav" && webdavStep === "create"}
|
||||
<!-- Step 4b: Create workspace -->
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<button onclick={() => (webdavStep = "browse")} class="rounded-lg p-1 opacity-50 hover:opacity-80">
|
||||
|
|
@ -547,6 +617,71 @@
|
|||
>
|
||||
{creating ? "Creating..." : "Create Workspace"}
|
||||
</button>
|
||||
{:else if mode === "googletasks" && googleStep === "connect"}
|
||||
<!-- Google Tasks: step 1 — sign in -->
|
||||
<p class="mb-6 text-sm text-text-secondary-light dark:text-text-secondary-dark">
|
||||
Sign in with Google to sync your tasks. The workspace will be read-only.
|
||||
</p>
|
||||
|
||||
{#if googleError}
|
||||
<p class="mb-3 text-xs text-danger">{googleError}</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={connectGoogle}
|
||||
disabled={googleConnecting}
|
||||
class="w-full rounded-lg bg-primary py-2.5 text-sm font-medium text-white hover:bg-primary-hover disabled:opacity-40"
|
||||
>
|
||||
{googleConnecting ? "Waiting for sign-in..." : "Sign in with Google"}
|
||||
</button>
|
||||
|
||||
{#if !isMobile}
|
||||
<button
|
||||
onclick={goBack}
|
||||
class="mt-3 w-full rounded-lg py-2 text-sm opacity-50 hover:opacity-80"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{:else if mode === "googletasks" && googleStep === "confirm"}
|
||||
<!-- Google Tasks: step 2 — name workspace and confirm -->
|
||||
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<button onclick={googleBack} class="rounded-lg p-1 opacity-50 hover:opacity-80">
|
||||
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M17 10a.75.75 0 01-.75.75H5.612l4.158 3.96a.75.75 0 11-1.04 1.08l-5.5-5.25a.75.75 0 010-1.08l5.5-5.25a.75.75 0 111.04 1.08L5.612 9.25H16.25A.75.75 0 0117 10z" />
|
||||
</svg>
|
||||
</button>
|
||||
<h2 class="text-lg font-semibold">Connected</h2>
|
||||
</div>
|
||||
|
||||
<p class="mb-4 text-sm text-text-secondary-light dark:text-text-secondary-dark">
|
||||
Signed in as <span class="font-medium">{googleAuth?.account || "Google Account"}</span>.
|
||||
All your Google Tasks lists will be imported as a read-only workspace.
|
||||
</p>
|
||||
|
||||
<label class="mb-1 block text-sm font-medium">
|
||||
Workspace name
|
||||
<input
|
||||
type="text"
|
||||
bind:value={googleWorkspaceName}
|
||||
placeholder="Google Tasks"
|
||||
class="mt-1 mb-4 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm font-normal outline-none focus:border-primary dark:border-border-dark"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{#if googleError}
|
||||
<p class="mb-3 text-xs text-danger">{googleError}</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={handleCreateGoogle}
|
||||
disabled={creating}
|
||||
class="w-full rounded-lg bg-primary py-2.5 text-sm font-medium text-white hover:bg-primary-hover disabled:opacity-40"
|
||||
>
|
||||
{creating ? "Syncing..." : "Create Workspace"}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -382,7 +382,8 @@
|
|||
</button>
|
||||
{/each}
|
||||
|
||||
<!-- New list inline -->
|
||||
<!-- New list inline (hidden for read-only Google Tasks workspaces) -->
|
||||
{#if !app.isGoogleTasks}
|
||||
<div class="px-2 mt-1">
|
||||
{#if showNewList}
|
||||
<div class="flex gap-2 px-1">
|
||||
|
|
@ -410,9 +411,10 @@
|
|||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Drawer footer: sync status -->
|
||||
<!-- Drawer footer: sync status / workspace type label -->
|
||||
<div class="shrink-0 px-4 py-2.5" style="padding-bottom: max(1.25rem, var(--safe-bottom))">
|
||||
{#if app.isWebdav}
|
||||
<div class="flex items-center gap-2">
|
||||
|
|
@ -435,6 +437,23 @@
|
|||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{:else if app.isGoogleTasks}
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-block h-2 w-2 rounded-full {app.syncing ? 'animate-pulse bg-primary' : app.syncStatus === 'synced' || app.syncStatus === 'idle' ? 'bg-green-500' : app.syncStatus === 'error' ? 'bg-red-500' : 'bg-gray-400'}"
|
||||
></span>
|
||||
<span class="flex-1 text-xs opacity-60">Google Tasks Workspace (Read Only)</span>
|
||||
<button
|
||||
onclick={() => app.triggerSync()}
|
||||
disabled={app.syncing}
|
||||
class="rounded-lg p-1.5 hover:bg-black/5 disabled:opacity-30 dark:hover:bg-white/10"
|
||||
title="Sync now"
|
||||
>
|
||||
<svg class="h-4 w-4" style={app.syncing ? 'animation: spin 1s linear infinite reverse' : ''} viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-xs opacity-40">Local workspace</span>
|
||||
{/if}
|
||||
|
|
@ -523,6 +542,7 @@
|
|||
</button>
|
||||
{#if showListMenu}
|
||||
<div class="dropdown-menu absolute right-0 top-full z-40 mt-1 min-w-[200px] rounded-lg border border-border-light bg-surface-light py-1 menu-shadow dark:border-border-dark dark:bg-surface-dark">
|
||||
{#if !app.isGoogleTasks}
|
||||
<button
|
||||
onclick={startRenameList}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-black/5 dark:hover:bg-white/10"
|
||||
|
|
@ -532,6 +552,7 @@
|
|||
</svg>
|
||||
Rename
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={handleToggleGroupByDueDate}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-black/5 dark:hover:bg-white/10"
|
||||
|
|
@ -546,7 +567,21 @@
|
|||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{#if app.completedTasks.length > 0}
|
||||
<button
|
||||
onclick={() => (showSubtasks = !showSubtasks)}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm4 4a1 1 0 011-1h8a1 1 0 110 2H8a1 1 0 01-1-1zm4 4a1 1 0 011-1h4a1 1 0 110 2h-4a1 1 0 01-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Show subtasks
|
||||
{#if showSubtasks}
|
||||
<svg class="ml-auto h-4 w-4 text-primary" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{#if !app.isGoogleTasks && app.completedTasks.length > 0}
|
||||
<button
|
||||
onclick={promptDeleteCompleted}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-danger hover:bg-black/5 dark:hover:bg-white/10"
|
||||
|
|
@ -686,7 +721,8 @@
|
|||
{/key}
|
||||
</main>
|
||||
|
||||
<!-- FAB button -->
|
||||
<!-- FAB button (hidden for read-only Google Tasks workspaces) -->
|
||||
{#if !app.isGoogleTasks}
|
||||
<div
|
||||
class="pointer-events-none absolute left-0 right-0 z-20 flex justify-center transition-all duration-250 ease-out {newTaskState.open ? 'opacity-0 scale-75' : ''} {showDrawer || taskStack.length > 0 ? 'translate-y-24 opacity-0' : 'translate-y-0 opacity-100'}"
|
||||
style="bottom: max(1.5rem, var(--safe-bottom))"
|
||||
|
|
@ -701,6 +737,7 @@
|
|||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Sub-panel: Task detail -->
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────
|
||||
|
|
@ -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,7 +417,11 @@ async function triggerSync() {
|
|||
if (!config?.current_workspace || syncing) return;
|
||||
syncing = true;
|
||||
try {
|
||||
const result = await invoke<SyncResult>("sync_workspace", {
|
||||
const result = isGoogleTasks
|
||||
? await invoke<SyncResult>("sync_google_tasks_workspace", {
|
||||
workspaceId: config.current_workspace,
|
||||
})
|
||||
: await invoke<SyncResult>("sync_workspace", {
|
||||
workspaceId: config.current_workspace,
|
||||
mode: "full",
|
||||
});
|
||||
|
|
@ -476,7 +486,7 @@ async function setSyncInterval(secs: number | null) {
|
|||
intervalSecs: secs,
|
||||
});
|
||||
config = await invoke<AppConfig>("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<AppConfig>("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<AppConfig>("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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
476
crates/onyx-core/src/google_tasks.rs
Normal file
476
crates/onyx-core/src/google_tasks.rs
Normal file
|
|
@ -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<GtTaskList>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GtTaskList {
|
||||
id: String,
|
||||
title: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GtTasksResponse {
|
||||
#[serde(default)]
|
||||
items: Vec<GtTask>,
|
||||
#[serde(rename = "nextPageToken")]
|
||||
next_page_token: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
/// Parent task Google ID (absent for top-level tasks).
|
||||
parent: Option<String>,
|
||||
/// 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<Self> {
|
||||
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<T: serde::de::DeserializeOwned>(&self, url: &str) -> Result<T> {
|
||||
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<Vec<GtTaskList>> {
|
||||
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<Vec<GtTask>> {
|
||||
let mut all_tasks = Vec::new();
|
||||
let mut page_token: Option<String> = 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<String> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
/// 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<GoogleSyncResult> {
|
||||
let client = GoogleTasksClient::new(access_token.to_string())?;
|
||||
|
||||
std::fs::create_dir_all(workspace_path)?;
|
||||
let mut downloaded: u32 = 0;
|
||||
let mut errors: Vec<String> = 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<Uuid> = 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::<ListMetadata>(&content) {
|
||||
if !remote_list_uuids.contains(&meta.id) {
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut new_list_order: Vec<Uuid> = 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<Uuid> = 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<Uuid> = 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 due dates are date-only (time is always T00:00:00Z).
|
||||
let due_date = gt_task.due.as_deref()
|
||||
.and_then(|s| s.parse::<DateTime<Utc>>().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,
|
||||
due_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<PathBuf> {
|
||||
// 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::<ListMetadata>(&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<Uuid> {
|
||||
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.due_date {
|
||||
yaml.push_str(&format!("due: {}\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::<String>()
|
||||
.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(())
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue