commit
068cc70443
7
Cargo.lock
generated
7
Cargo.lock
generated
|
|
@ -1468,6 +1468,12 @@ dependencies = [
|
||||||
"unsafe-libyaml",
|
"unsafe-libyaml",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1_smol"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
|
|
@ -1822,6 +1828,7 @@ dependencies = [
|
||||||
"getrandom 0.3.3",
|
"getrandom 0.3.3",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"serde",
|
"serde",
|
||||||
|
"sha1_smol",
|
||||||
"wasm-bindgen",
|
"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",
|
||||||
"notify-debouncer-mini",
|
"notify-debouncer-mini",
|
||||||
"onyx-core",
|
"onyx-core",
|
||||||
|
"reqwest 0.12.28",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-credentials",
|
"tauri-plugin-credentials",
|
||||||
|
|
@ -3680,6 +3682,12 @@ dependencies = [
|
||||||
"stable_deref_trait",
|
"stable_deref_trait",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1_smol"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
|
|
@ -4790,6 +4798,7 @@ dependencies = [
|
||||||
"getrandom 0.4.2",
|
"getrandom 0.4.2",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
|
"sha1_smol",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ uuid = { version = "1", features = ["serde", "v4"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
notify = { version = "7", optional = true }
|
notify = { version = "7", optional = true }
|
||||||
notify-debouncer-mini = { version = "0.5", 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]
|
[package.metadata.tauri]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,30 @@ use uuid::Uuid;
|
||||||
|
|
||||||
use onyx_core::{
|
use onyx_core::{
|
||||||
config::{AppConfig, WorkspaceConfig, WorkspaceMode},
|
config::{AppConfig, WorkspaceConfig, WorkspaceMode},
|
||||||
|
google_tasks,
|
||||||
models::{Task, TaskList, TaskStatus},
|
models::{Task, TaskList, TaskStatus},
|
||||||
repository::TaskRepository,
|
repository::TaskRepository,
|
||||||
sync::{self, SyncMode, SyncResult as CoreSyncResult},
|
sync::{self, SyncMode, SyncResult as CoreSyncResult},
|
||||||
webdav,
|
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;
|
use tauri_plugin_credentials::Credentials;
|
||||||
|
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
|
|
@ -268,6 +287,12 @@ async fn rename_workspace(
|
||||||
s.repo = None;
|
s.repo = None;
|
||||||
s.save_config()?;
|
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(())
|
Ok(())
|
||||||
|
|
@ -816,6 +841,320 @@ async fn sync_workspace(
|
||||||
Ok(result.into())
|
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 ────────────────────────────────────────────────────
|
// ── File watcher ────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
|
|
@ -939,6 +1278,9 @@ pub fn run() {
|
||||||
test_webdav_connection,
|
test_webdav_connection,
|
||||||
sync_workspace,
|
sync_workspace,
|
||||||
watch_workspace,
|
watch_workspace,
|
||||||
|
start_google_oauth,
|
||||||
|
add_google_tasks_workspace,
|
||||||
|
sync_google_tasks_workspace,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|
|
||||||
|
|
@ -248,7 +248,7 @@ body {
|
||||||
border-color: rgba(0, 0, 0, 0.5);
|
border-color: rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Dropdown/kebab menu shadow ──────────────────────────────────── */
|
/* ── Dropdown/kebab menu shadow + open/close animation ───────────── */
|
||||||
|
|
||||||
.menu-shadow {
|
.menu-shadow {
|
||||||
box-shadow: 0 4px 10px 0 rgba(0, 0, 0, 0.12);
|
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);
|
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 ─────── */
|
/* ── Borderless mode: square corners on all popups/overlays ─────── */
|
||||||
|
|
||||||
.decorations-none .rounded-sm,
|
.decorations-none .rounded-sm,
|
||||||
|
|
|
||||||
|
|
@ -139,8 +139,8 @@
|
||||||
<!-- Date picker overlay -->
|
<!-- Date picker overlay -->
|
||||||
{#if showDatePicker}
|
{#if showDatePicker}
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
value={dueDate}
|
value={date}
|
||||||
has_time={dueDateHasTime}
|
has_time={dateHasTime}
|
||||||
onchange={handleDateChange}
|
onchange={handleDateChange}
|
||||||
onclose={() => (showDatePicker = false)}
|
onclose={() => (showDatePicker = false)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
const isMobile = currentPlatform === "android" || currentPlatform === "ios";
|
const isMobile = currentPlatform === "android" || currentPlatform === "ios";
|
||||||
|
|
||||||
// ── Shared state ──────────────────────────────────────────────────
|
// ── 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 name = $state("Onyx");
|
||||||
let path = $state("");
|
let path = $state("");
|
||||||
|
|
||||||
|
|
@ -43,6 +43,13 @@
|
||||||
let createName = $state("Onyx");
|
let createName = $state("Onyx");
|
||||||
let creating = $state(false);
|
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 ───────────────────────────────────────────────────────
|
// ── Derived ───────────────────────────────────────────────────────
|
||||||
let currentBrowsePath = $derived(browsePath.join("/"));
|
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 ───────────────────────────────────────────────
|
// ── Window dragging ───────────────────────────────────────────────
|
||||||
|
|
||||||
function handleDrag(e: MouseEvent) {
|
function handleDrag(e: MouseEvent) {
|
||||||
|
|
@ -206,6 +262,10 @@
|
||||||
browsePath = [];
|
browsePath = [];
|
||||||
browseEntries = [];
|
browseEntries = [];
|
||||||
browseError = null;
|
browseError = null;
|
||||||
|
googleStep = "connect";
|
||||||
|
googleAuth = null;
|
||||||
|
googleError = null;
|
||||||
|
googleConnecting = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function webdavBack() {
|
function webdavBack() {
|
||||||
|
|
@ -285,7 +345,7 @@
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onclick={() => (mode = "webdav")}
|
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="text-sm font-semibold">WebDAV Server</p>
|
||||||
<p class="mt-0.5 text-xs text-text-secondary-light dark:text-text-secondary-dark">
|
<p class="mt-0.5 text-xs text-text-secondary-light dark:text-text-secondary-dark">
|
||||||
|
|
@ -293,6 +353,16 @@
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</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"}
|
{:else if mode === "local"}
|
||||||
<!-- Step 2a: Local workspace -->
|
<!-- Step 2a: Local workspace -->
|
||||||
<p class="mb-6 text-sm text-text-secondary-light dark:text-text-secondary-dark">
|
<p class="mb-6 text-sm text-text-secondary-light dark:text-text-secondary-dark">
|
||||||
|
|
@ -357,7 +427,7 @@
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{:else if webdavStep === "connect"}
|
{:else if mode === "webdav" && webdavStep === "connect"}
|
||||||
<!-- Step 2b: WebDAV connect -->
|
<!-- Step 2b: WebDAV connect -->
|
||||||
<p class="mb-6 text-sm text-text-secondary-light dark:text-text-secondary-dark">
|
<p class="mb-6 text-sm text-text-secondary-light dark:text-text-secondary-dark">
|
||||||
Connect to a WebDAV server.
|
Connect to a WebDAV server.
|
||||||
|
|
@ -406,7 +476,7 @@
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{:else if webdavStep === "browse"}
|
{:else if mode === "webdav" && webdavStep === "browse"}
|
||||||
<!-- Step 3: Folder explorer -->
|
<!-- Step 3: Folder explorer -->
|
||||||
<p class="mb-4 text-sm text-text-secondary-light dark:text-text-secondary-dark">
|
<p class="mb-4 text-sm text-text-secondary-light dark:text-text-secondary-dark">
|
||||||
Pick a folder or create a new workspace.
|
Pick a folder or create a new workspace.
|
||||||
|
|
@ -476,7 +546,7 @@
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{:else if webdavStep === "preview"}
|
{:else if mode === "webdav" && webdavStep === "preview"}
|
||||||
<!-- Step 4a: Workspace preview -->
|
<!-- Step 4a: Workspace preview -->
|
||||||
<div class="mb-4 flex items-center gap-2">
|
<div class="mb-4 flex items-center gap-2">
|
||||||
<button onclick={() => (webdavStep = "browse")} class="rounded-lg p-1 opacity-50 hover:opacity-80">
|
<button onclick={() => (webdavStep = "browse")} class="rounded-lg p-1 opacity-50 hover:opacity-80">
|
||||||
|
|
@ -511,7 +581,7 @@
|
||||||
Open Workspace
|
Open Workspace
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{:else if webdavStep === "create"}
|
{:else if mode === "webdav" && webdavStep === "create"}
|
||||||
<!-- Step 4b: Create workspace -->
|
<!-- Step 4b: Create workspace -->
|
||||||
<div class="mb-4 flex items-center gap-2">
|
<div class="mb-4 flex items-center gap-2">
|
||||||
<button onclick={() => (webdavStep = "browse")} class="rounded-lg p-1 opacity-50 hover:opacity-80">
|
<button onclick={() => (webdavStep = "browse")} class="rounded-lg p-1 opacity-50 hover:opacity-80">
|
||||||
|
|
@ -547,6 +617,71 @@
|
||||||
>
|
>
|
||||||
{creating ? "Creating..." : "Create Workspace"}
|
{creating ? "Creating..." : "Create Workspace"}
|
||||||
</button>
|
</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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -118,12 +118,12 @@
|
||||||
renamingListId = null;
|
renamingListId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleToggleGroupByDueDate() {
|
async function handleToggleGroupByDate() {
|
||||||
showListMenu = false;
|
showListMenu = false;
|
||||||
if (!app.activeListId) return;
|
if (!app.activeListId) return;
|
||||||
var list = app.lists.find(l => l.id === app.activeListId);
|
var list = app.lists.find(l => l.id === app.activeListId);
|
||||||
if (!list) return;
|
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) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
|
@ -188,16 +188,16 @@
|
||||||
const targetGroup = app.groupedPendingTasks?.find((g) => g.label === group);
|
const targetGroup = app.groupedPendingTasks?.find((g) => g.label === group);
|
||||||
const task = app.pendingTasks.find((t) => t.id === taskId);
|
const task = app.pendingTasks.find((t) => t.id === taskId);
|
||||||
if (task && targetGroup !== undefined) {
|
if (task && targetGroup !== undefined) {
|
||||||
let newDueDate: string | null = null;
|
let newDate: string | null = null;
|
||||||
if (targetGroup.date !== null) {
|
if (targetGroup.date !== null) {
|
||||||
const target = new Date(targetGroup.date);
|
const target = new Date(targetGroup.date);
|
||||||
if (task.has_time && task.due_date) {
|
if (task.has_time && task.date) {
|
||||||
const existing = new Date(task.due_date);
|
const existing = new Date(task.date);
|
||||||
target.setHours(existing.getHours(), existing.getMinutes(), existing.getSeconds(), 0);
|
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 @@
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<!-- New list inline -->
|
<!-- New list inline (hidden for read-only Google Tasks workspaces) -->
|
||||||
<div class="px-2 mt-1">
|
{#if !app.isGoogleTasks}
|
||||||
{#if showNewList}
|
<div class="px-2 mt-1">
|
||||||
<div class="flex gap-2 px-1">
|
{#if showNewList}
|
||||||
<input
|
<div class="flex gap-2 px-1">
|
||||||
type="text"
|
<input
|
||||||
bind:value={newListName}
|
type="text"
|
||||||
placeholder="List name"
|
bind:value={newListName}
|
||||||
class="min-w-0 flex-1 rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm outline-none focus:border-primary dark:border-border-dark"
|
placeholder="List name"
|
||||||
onkeydown={(e) => { if (e.key === "Enter") handleNewList(); if (e.key === "Escape") { showNewList = false; newListName = ""; } }}
|
class="min-w-0 flex-1 rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm outline-none focus:border-primary dark:border-border-dark"
|
||||||
/>
|
onkeydown={(e) => { if (e.key === "Enter") handleNewList(); if (e.key === "Escape") { showNewList = false; newListName = ""; } }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onclick={handleNewList}
|
||||||
|
disabled={!newListName.trim()}
|
||||||
|
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
<button
|
<button
|
||||||
onclick={handleNewList}
|
onclick={() => (showNewList = true)}
|
||||||
disabled={!newListName.trim()}
|
class="w-full rounded-lg px-3 py-2.5 text-left text-sm text-primary hover:bg-primary/5"
|
||||||
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-40"
|
|
||||||
>
|
>
|
||||||
Add
|
+ New list
|
||||||
</button>
|
</button>
|
||||||
</div>
|
{/if}
|
||||||
{:else}
|
</div>
|
||||||
<button
|
{/if}
|
||||||
onclick={() => (showNewList = true)}
|
|
||||||
class="w-full rounded-lg px-3 py-2.5 text-left text-sm text-primary hover:bg-primary/5"
|
|
||||||
>
|
|
||||||
+ New list
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
</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))">
|
<div class="shrink-0 px-4 py-2.5" style="padding-bottom: max(1.25rem, var(--safe-bottom))">
|
||||||
{#if app.isWebdav}
|
{#if app.isWebdav}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|
@ -435,6 +437,23 @@
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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}
|
{:else}
|
||||||
<span class="text-xs opacity-40">Local workspace</span>
|
<span class="text-xs opacity-40">Local workspace</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -523,30 +542,46 @@
|
||||||
</button>
|
</button>
|
||||||
{#if showListMenu}
|
{#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">
|
<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"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||||
|
</svg>
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
<button
|
<button
|
||||||
onclick={startRenameList}
|
onclick={handleToggleGroupByDate}
|
||||||
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 d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
|
||||||
</svg>
|
|
||||||
Rename
|
|
||||||
</button>
|
|
||||||
<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"
|
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">
|
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
<path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd" />
|
<path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
Group by due date
|
Group by date
|
||||||
{#if app.activeList?.group_by_due_date}
|
{#if app.activeList?.group_by_date}
|
||||||
<svg class="ml-auto h-4 w-4 text-primary" viewBox="0 0 20 20" fill="currentColor">
|
<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" />
|
<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>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{#if app.completedTasks.length > 0}
|
<button
|
||||||
|
onclick={() => { showSubtasks = !showSubtasks; showListMenu = false; }}
|
||||||
|
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
|
<button
|
||||||
onclick={promptDeleteCompleted}
|
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"
|
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,21 +721,23 @@
|
||||||
{/key}
|
{/key}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- FAB button -->
|
<!-- FAB button (hidden for read-only Google Tasks workspaces) -->
|
||||||
<div
|
{#if !app.isGoogleTasks}
|
||||||
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'}"
|
<div
|
||||||
style="bottom: max(1.5rem, var(--safe-bottom))"
|
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))"
|
||||||
<button
|
|
||||||
onclick={() => { if (app.activeListId) newTaskState.open = true; }}
|
|
||||||
disabled={!app.activeListId}
|
|
||||||
class="pointer-events-auto flex h-14 w-14 items-center justify-center rounded-full bg-primary text-white shadow-lg transition-transform hover:scale-105 active:scale-95 disabled:opacity-40 disabled:shadow-none"
|
|
||||||
>
|
>
|
||||||
<svg class="h-7 w-7" viewBox="0 0 20 20" fill="currentColor">
|
<button
|
||||||
<path d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" />
|
onclick={() => { if (app.activeListId) newTaskState.open = true; }}
|
||||||
</svg>
|
disabled={!app.activeListId}
|
||||||
</button>
|
class="pointer-events-auto flex h-14 w-14 items-center justify-center rounded-full bg-primary text-white shadow-lg transition-transform hover:scale-105 active:scale-95 disabled:opacity-40 disabled:shadow-none"
|
||||||
</div>
|
>
|
||||||
|
<svg class="h-7 w-7" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sub-panel: Task detail -->
|
<!-- Sub-panel: Task detail -->
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import type {
|
||||||
listen("fs-changed", () => {
|
listen("fs-changed", () => {
|
||||||
loadLists();
|
loadLists();
|
||||||
// Debounced sync for WebDAV workspaces on local file changes
|
// Debounced sync for WebDAV workspaces on local file changes
|
||||||
if (isWebdav) debouncedSync();
|
if (isSyncedWorkspace) debouncedSync();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Reactive state ───────────────────────────────────────────────────
|
// ── Reactive state ───────────────────────────────────────────────────
|
||||||
|
|
@ -95,6 +95,7 @@ let groupedPendingTasks = $derived.by((): TaskGroup[] | null => {
|
||||||
tomorrow.sort(sortByDue);
|
tomorrow.sort(sortByDue);
|
||||||
|
|
||||||
const groups: TaskGroup[] = [];
|
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 (overdue.length) groups.push({ label: "Overdue", tasks: overdue, date: null });
|
||||||
if (today.length) groups.push({ label: "Today", tasks: today, date: todayStart });
|
if (today.length) groups.push({ label: "Today", tasks: today, date: todayStart });
|
||||||
if (tomorrow.length) groups.push({ label: "Tomorrow", tasks: tomorrow, date: tomorrowStart });
|
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 });
|
groups.push({ label: date.toLocaleDateString(undefined, opts), tasks, date });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (noDate.length) groups.push({ label: "No Date", tasks: noDate, date: null });
|
|
||||||
return groups;
|
return groups;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -148,6 +148,12 @@ let isWebdav = $derived(
|
||||||
? config.workspaces[config.current_workspace]?.mode === "webdav"
|
? config.workspaces[config.current_workspace]?.mode === "webdav"
|
||||||
: false,
|
: false,
|
||||||
);
|
);
|
||||||
|
let isGoogleTasks = $derived(
|
||||||
|
config?.current_workspace
|
||||||
|
? config.workspaces[config.current_workspace]?.mode === "googletasks"
|
||||||
|
: false,
|
||||||
|
);
|
||||||
|
let isSyncedWorkspace = $derived(isWebdav || isGoogleTasks);
|
||||||
let syncIntervalSecs = $derived(
|
let syncIntervalSecs = $derived(
|
||||||
config?.current_workspace
|
config?.current_workspace
|
||||||
? config.workspaces[config.current_workspace]?.sync_interval_secs ?? DEFAULT_SYNC_INTERVAL_SECS
|
? 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 (lists.length > 0 && !activeListId) activeListId = lists[0].id;
|
||||||
if (activeListId) await loadTasks();
|
if (activeListId) await loadTasks();
|
||||||
screen = "tasks";
|
screen = "tasks";
|
||||||
if (isWebdav) startAutoSync();
|
if (isSyncedWorkspace) startAutoSync();
|
||||||
} else {
|
} else {
|
||||||
screen = "setup";
|
screen = "setup";
|
||||||
}
|
}
|
||||||
|
|
@ -210,7 +216,7 @@ async function switchWorkspace(id: string) {
|
||||||
await loadLists();
|
await loadLists();
|
||||||
const ws = config?.workspaces[id];
|
const ws = config?.workspaces[id];
|
||||||
if (ws) invoke("watch_workspace", { path: ws.path }).catch((e) => console.warn("File watcher failed:", e));
|
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;
|
error = null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = String(e);
|
error = String(e);
|
||||||
|
|
@ -411,10 +417,14 @@ async function triggerSync() {
|
||||||
if (!config?.current_workspace || syncing) return;
|
if (!config?.current_workspace || syncing) return;
|
||||||
syncing = true;
|
syncing = true;
|
||||||
try {
|
try {
|
||||||
const result = await invoke<SyncResult>("sync_workspace", {
|
const result = isGoogleTasks
|
||||||
workspaceId: config.current_workspace,
|
? await invoke<SyncResult>("sync_google_tasks_workspace", {
|
||||||
mode: "full",
|
workspaceId: config.current_workspace,
|
||||||
});
|
})
|
||||||
|
: await invoke<SyncResult>("sync_workspace", {
|
||||||
|
workspaceId: config.current_workspace,
|
||||||
|
mode: "full",
|
||||||
|
});
|
||||||
lastSyncResult = result;
|
lastSyncResult = result;
|
||||||
lastSyncTime = Date.now();
|
lastSyncTime = Date.now();
|
||||||
syncStatus = result.errors.length > 0 ? "error" : "synced";
|
syncStatus = result.errors.length > 0 ? "error" : "synced";
|
||||||
|
|
@ -476,7 +486,7 @@ async function setSyncInterval(secs: number | null) {
|
||||||
intervalSecs: secs,
|
intervalSecs: secs,
|
||||||
});
|
});
|
||||||
config = await invoke<AppConfig>("get_config");
|
config = await invoke<AppConfig>("get_config");
|
||||||
if (isWebdav) startAutoSync();
|
if (isSyncedWorkspace) startAutoSync();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = String(e);
|
error = String(e);
|
||||||
}
|
}
|
||||||
|
|
@ -490,7 +500,7 @@ async function setSyncIntervalUnfocused(secs: number | null) {
|
||||||
intervalSecs: secs,
|
intervalSecs: secs,
|
||||||
});
|
});
|
||||||
config = await invoke<AppConfig>("get_config");
|
config = await invoke<AppConfig>("get_config");
|
||||||
if (isWebdav) startAutoSync();
|
if (isSyncedWorkspace) startAutoSync();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = String(e);
|
error = String(e);
|
||||||
}
|
}
|
||||||
|
|
@ -534,13 +544,31 @@ async function addWebdavWorkspace(name: string, webdavUrl: string, webdavPath: s
|
||||||
const ws = config.workspaces[config.current_workspace];
|
const ws = config.workspaces[config.current_workspace];
|
||||||
if (ws) invoke("watch_workspace", { path: ws.path }).catch((e) => console.warn("File watcher failed:", e));
|
if (ws) invoke("watch_workspace", { path: ws.path }).catch((e) => console.warn("File watcher failed:", e));
|
||||||
}
|
}
|
||||||
if (isWebdav) startAutoSync();
|
if (isSyncedWorkspace) startAutoSync();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
initialSync = false;
|
initialSync = false;
|
||||||
error = String(e);
|
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() {
|
async function forgetMissingWorkspace() {
|
||||||
if (!missingWorkspace) return;
|
if (!missingWorkspace) return;
|
||||||
await removeWorkspace(missingWorkspace);
|
await removeWorkspace(missingWorkspace);
|
||||||
|
|
@ -617,6 +645,12 @@ export const app = {
|
||||||
get isWebdav() {
|
get isWebdav() {
|
||||||
return isWebdav;
|
return isWebdav;
|
||||||
},
|
},
|
||||||
|
get isGoogleTasks() {
|
||||||
|
return isGoogleTasks;
|
||||||
|
},
|
||||||
|
get isSyncedWorkspace() {
|
||||||
|
return isSyncedWorkspace;
|
||||||
|
},
|
||||||
get syncIntervalSecs() {
|
get syncIntervalSecs() {
|
||||||
return syncIntervalSecs;
|
return syncIntervalSecs;
|
||||||
},
|
},
|
||||||
|
|
@ -665,6 +699,7 @@ export const app = {
|
||||||
setWindowDecorations,
|
setWindowDecorations,
|
||||||
setTheme,
|
setTheme,
|
||||||
addWebdavWorkspace,
|
addWebdavWorkspace,
|
||||||
|
addGoogleTasksWorkspace,
|
||||||
forgetMissingWorkspace,
|
forgetMissingWorkspace,
|
||||||
setScreen,
|
setScreen,
|
||||||
clearError,
|
clearError,
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ export interface TaskList {
|
||||||
group_by_date: boolean;
|
group_by_date: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkspaceMode = "local" | "webdav";
|
export type WorkspaceMode = "local" | "webdav" | "googletasks";
|
||||||
|
|
||||||
export interface WorkspaceConfig {
|
export interface WorkspaceConfig {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -26,6 +26,7 @@ export interface WorkspaceConfig {
|
||||||
mode: WorkspaceMode;
|
mode: WorkspaceMode;
|
||||||
webdav_url: string | null;
|
webdav_url: string | null;
|
||||||
webdav_path: string | null;
|
webdav_path: string | null;
|
||||||
|
google_account: string | null;
|
||||||
last_sync: string | null;
|
last_sync: string | null;
|
||||||
theme: string | null;
|
theme: string | null;
|
||||||
sync_interval_secs: number | null;
|
sync_interval_secs: number | null;
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,10 @@ keyring-storage = ["keyring"]
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true, features = ["v5"] }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
directories = "5.0"
|
directories = "5.0"
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true, features = ["json"] }
|
||||||
sha2 = { workspace = true }
|
sha2 = { workspace = true }
|
||||||
quick-xml = { workspace = true }
|
quick-xml = { workspace = true }
|
||||||
tokio = { 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 dates are date-only (time is always T00:00:00Z).
|
||||||
|
let 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,
|
||||||
|
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.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::<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 error;
|
||||||
pub mod webdav;
|
pub mod webdav;
|
||||||
pub mod sync;
|
pub mod sync;
|
||||||
|
pub mod google_tasks;
|
||||||
|
|
||||||
pub use models::{Task, TaskStatus, TaskList};
|
pub use models::{Task, TaskStatus, TaskList};
|
||||||
pub use repository::TaskRepository;
|
pub use repository::TaskRepository;
|
||||||
|
|
|
||||||
|
|
@ -166,10 +166,10 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_task_with_due_date() {
|
fn test_task_with_date() {
|
||||||
let dt = Utc::now();
|
let dt = Utc::now();
|
||||||
let task = Task::new("T".to_string()).with_due_date(dt);
|
let task = Task::new("T".to_string()).with_date(dt);
|
||||||
assert_eq!(task.due_date, Some(dt));
|
assert_eq!(task.date, Some(dt));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -242,7 +242,7 @@ mod tests {
|
||||||
let list = TaskList::new("My List".to_string());
|
let list = TaskList::new("My List".to_string());
|
||||||
assert_eq!(list.title, "My List");
|
assert_eq!(list.title, "My List");
|
||||||
assert!(list.tasks.is_empty());
|
assert!(list.tasks.is_empty());
|
||||||
assert!(!list.group_by_due_date);
|
assert!(!list.group_by_date);
|
||||||
assert!(list.created_at <= Utc::now());
|
assert!(list.created_at <= Utc::now());
|
||||||
assert!(list.updated_at <= Utc::now());
|
assert!(list.updated_at <= Utc::now());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ impl From<&Task> for TaskFrontmatter {
|
||||||
Self {
|
Self {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
due: task.due_date,
|
date: task.date,
|
||||||
has_time: task.has_time,
|
has_time: task.has_time,
|
||||||
version: task.version,
|
version: task.version,
|
||||||
parent: task.parent_id,
|
parent: task.parent_id,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue