Merge pull request #42 from SteelDynamite/tm-branch-1

tm-branch-1
This commit is contained in:
SteelDynamite 2026-04-14 15:50:02 +01:00 committed by GitHub
commit 068cc70443
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1145 additions and 88 deletions

7
Cargo.lock generated
View file

@ -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",
]

View file

@ -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",
]

View file

@ -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]

View file

@ -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(&params)
.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");

View file

@ -248,7 +248,7 @@ body {
border-color: rgba(0, 0, 0, 0.5);
}
/* ── Dropdown/kebab menu shadow ──────────────────────────────────── */
/* ── Dropdown/kebab menu shadow + open/close animation ───────────── */
.menu-shadow {
box-shadow: 0 4px 10px 0 rgba(0, 0, 0, 0.12);
@ -257,6 +257,18 @@ body {
box-shadow: 0 4px 10px 0 rgba(0, 0, 0, 0.3);
}
.dropdown-menu {
transition: opacity 120ms ease, transform 120ms ease, display 120ms ease allow-discrete;
transform-origin: top right;
opacity: 1;
transform: scale(1);
@starting-style {
opacity: 0;
transform: scale(0.92);
}
}
/* ── Borderless mode: square corners on all popups/overlays ─────── */
.decorations-none .rounded-sm,

View file

@ -139,8 +139,8 @@
<!-- Date picker overlay -->
{#if showDatePicker}
<DateTimePicker
value={dueDate}
has_time={dueDateHasTime}
value={date}
has_time={dateHasTime}
onchange={handleDateChange}
onclose={() => (showDatePicker = false)}
/>

View file

@ -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>

View file

@ -118,12 +118,12 @@
renamingListId = null;
}
async function handleToggleGroupByDueDate() {
async function handleToggleGroupByDate() {
showListMenu = false;
if (!app.activeListId) return;
var list = app.lists.find(l => l.id === app.activeListId);
if (!list) return;
await app.setGroupByDueDate(app.activeListId, !list.group_by_due_date);
await app.setGroupByDate(app.activeListId, !list.group_by_date);
}
function handleKeydown(e: KeyboardEvent) {
@ -188,16 +188,16 @@
const targetGroup = app.groupedPendingTasks?.find((g) => g.label === group);
const task = app.pendingTasks.find((t) => t.id === taskId);
if (task && targetGroup !== undefined) {
let newDueDate: string | null = null;
let newDate: string | null = null;
if (targetGroup.date !== null) {
const target = new Date(targetGroup.date);
if (task.has_time && task.due_date) {
const existing = new Date(task.due_date);
if (task.has_time && task.date) {
const existing = new Date(task.date);
target.setHours(existing.getHours(), existing.getMinutes(), existing.getSeconds(), 0);
}
newDueDate = target.toISOString();
newDate = target.toISOString();
}
await app.updateTask({ ...task, due_date: newDueDate, has_time: newDueDate ? task.has_time : false });
await app.updateTask({ ...task, date: newDate, has_time: newDate ? task.has_time : false });
}
}
@ -382,37 +382,39 @@
</button>
{/each}
<!-- New list inline -->
<div class="px-2 mt-1">
{#if showNewList}
<div class="flex gap-2 px-1">
<input
type="text"
bind:value={newListName}
placeholder="List name"
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 = ""; } }}
/>
<!-- 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">
<input
type="text"
bind:value={newListName}
placeholder="List name"
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
onclick={handleNewList}
disabled={!newListName.trim()}
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-40"
onclick={() => (showNewList = true)}
class="w-full rounded-lg px-3 py-2.5 text-left text-sm text-primary hover:bg-primary/5"
>
Add
+ New list
</button>
</div>
{:else}
<button
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>
{/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,30 +542,46 @@
</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"
>
<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
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>
<button
onclick={handleToggleGroupByDueDate}
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 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>
Group by due date
{#if app.activeList?.group_by_due_date}
Group by date
{#if app.activeList?.group_by_date}
<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.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
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,21 +721,23 @@
{/key}
</main>
<!-- FAB button -->
<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))"
>
<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"
<!-- 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))"
>
<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>
<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">
<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>
<!-- Sub-panel: Task detail -->

View file

@ -13,7 +13,7 @@ import type {
listen("fs-changed", () => {
loadLists();
// Debounced sync for WebDAV workspaces on local file changes
if (isWebdav) debouncedSync();
if (isSyncedWorkspace) debouncedSync();
});
// ── Reactive state ───────────────────────────────────────────────────
@ -95,6 +95,7 @@ let groupedPendingTasks = $derived.by((): TaskGroup[] | null => {
tomorrow.sort(sortByDue);
const groups: TaskGroup[] = [];
if (noDate.length) groups.push({ label: "No Date", tasks: noDate, date: null });
if (overdue.length) groups.push({ label: "Overdue", tasks: overdue, date: null });
if (today.length) groups.push({ label: "Today", tasks: today, date: todayStart });
if (tomorrow.length) groups.push({ label: "Tomorrow", tasks: tomorrow, date: tomorrowStart });
@ -108,7 +109,6 @@ let groupedPendingTasks = $derived.by((): TaskGroup[] | null => {
groups.push({ label: date.toLocaleDateString(undefined, opts), tasks, date });
}
if (noDate.length) groups.push({ label: "No Date", tasks: noDate, date: null });
return groups;
});
@ -148,6 +148,12 @@ let isWebdav = $derived(
? config.workspaces[config.current_workspace]?.mode === "webdav"
: false,
);
let isGoogleTasks = $derived(
config?.current_workspace
? config.workspaces[config.current_workspace]?.mode === "googletasks"
: false,
);
let isSyncedWorkspace = $derived(isWebdav || isGoogleTasks);
let syncIntervalSecs = $derived(
config?.current_workspace
? config.workspaces[config.current_workspace]?.sync_interval_secs ?? DEFAULT_SYNC_INTERVAL_SECS
@ -177,7 +183,7 @@ async function loadConfig() {
if (lists.length > 0 && !activeListId) activeListId = lists[0].id;
if (activeListId) await loadTasks();
screen = "tasks";
if (isWebdav) startAutoSync();
if (isSyncedWorkspace) startAutoSync();
} else {
screen = "setup";
}
@ -210,7 +216,7 @@ async function switchWorkspace(id: string) {
await loadLists();
const ws = config?.workspaces[id];
if (ws) invoke("watch_workspace", { path: ws.path }).catch((e) => console.warn("File watcher failed:", e));
if (isWebdav) startAutoSync(); else stopAutoSync();
if (isSyncedWorkspace) startAutoSync(); else stopAutoSync();
error = null;
} catch (e) {
error = String(e);
@ -411,10 +417,14 @@ async function triggerSync() {
if (!config?.current_workspace || syncing) return;
syncing = true;
try {
const result = await invoke<SyncResult>("sync_workspace", {
workspaceId: config.current_workspace,
mode: "full",
});
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",
});
lastSyncResult = result;
lastSyncTime = Date.now();
syncStatus = result.errors.length > 0 ? "error" : "synced";
@ -476,7 +486,7 @@ async function setSyncInterval(secs: number | null) {
intervalSecs: secs,
});
config = await invoke<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,

View file

@ -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;

View file

@ -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 }

View 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(&GT_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(&params)
.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 &gt_lists {
let list_uuid = gt_id_to_uuid(&gt_list.id);
new_list_order.push(list_uuid);
let list_dir = match find_or_create_list_dir(workspace_path, list_uuid, &gt_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(&gt_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(&gt_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(())
}

View file

@ -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;

View file

@ -166,10 +166,10 @@ mod tests {
}
#[test]
fn test_task_with_due_date() {
fn test_task_with_date() {
let dt = Utc::now();
let task = Task::new("T".to_string()).with_due_date(dt);
assert_eq!(task.due_date, Some(dt));
let task = Task::new("T".to_string()).with_date(dt);
assert_eq!(task.date, Some(dt));
}
#[test]
@ -242,7 +242,7 @@ mod tests {
let list = TaskList::new("My List".to_string());
assert_eq!(list.title, "My List");
assert!(list.tasks.is_empty());
assert!(!list.group_by_due_date);
assert!(!list.group_by_date);
assert!(list.created_at <= Utc::now());
assert!(list.updated_at <= Utc::now());
}

View file

@ -102,7 +102,7 @@ impl From<&Task> for TaskFrontmatter {
Self {
id: task.id,
status: task.status,
due: task.due_date,
date: task.date,
has_time: task.has_time,
version: task.version,
parent: task.parent_id,