diff --git a/src-tauri/src/commands/calendar.rs b/src-tauri/src/commands/calendar.rs index 2aa5bda..9c32f32 100644 --- a/src-tauri/src/commands/calendar.rs +++ b/src-tauri/src/commands/calendar.rs @@ -83,11 +83,108 @@ pub async fn update_calendar( name, color ); + + // Load current calendar + account so we know whether the name changed + // and, if so, which protocol to push the rename through. Drop the + // reader before any await so the backend stays non-blocking. + let (existing, account) = { + let conn = state.db.reader(); + let cal = db::calendar::get_calendar(&conn, &calendar_id)?; + let acct = db::accounts::get_account_full(&conn, &cal.account_id)?; + (cal, acct) + }; + + let name_changed = existing.name != name; + let remote_id = existing.remote_id.clone().filter(|r| !r.is_empty()); + if name_changed { + if let Some(ref rid) = remote_id { + push_calendar_rename(&account, rid, &name).await?; + } else { + log::info!( + "update_calendar: skipping remote rename (no remote_id, local-only calendar)" + ); + } + } + let conn = state.db.writer().await; db::calendar::update_calendar(&conn, &calendar_id, &name, &color)?; Ok(()) } +/// Push a calendar rename to the account's remote server. Mirrors the +/// per-protocol dispatch in [`sync_calendars`]. Errors here must propagate +/// so the command leaves the local DB unchanged on remote failure. +async fn push_calendar_rename( + account: &db::accounts::AccountFull, + remote_id: &str, + new_name: &str, +) -> Result<()> { + if account.mail_protocol == "jmap" { + let jmap_config = crate::commands::sync_cmd::build_jmap_config(account).await?; + let conn_jmap = crate::mail::jmap::JmapConnection::connect(&jmap_config).await?; + conn_jmap + .rename_calendar(&jmap_config, remote_id, new_name) + .await?; + return Ok(()); + } + + if account.provider == "o365" { + let token = crate::mail::graph::get_graph_token(&account.id).await?; + let client = crate::mail::graph::GraphClient::new(&token); + client.rename_calendar(remote_id, new_name).await?; + return Ok(()); + } + + if account.provider == "gmail" { + // Prefer the Google Calendar REST endpoint; fall back to CalDAV + // PROPPATCH if REST fails (OAuth not configured, or remote_id is + // actually a CalDAV href). + if let Ok(token) = get_google_token(&account.id).await { + let url = format!( + "https://www.googleapis.com/calendar/v3/calendars/{}", + urlencoding::encode(remote_id) + ); + let http = reqwest::Client::new(); + let resp = http + .patch(&url) + .bearer_auth(&token) + .json(&serde_json::json!({ "summary": new_name })) + .send() + .await + .map_err(|e| { + crate::error::Error::Other(format!("Google Calendar PATCH failed: {}", e)) + })?; + if resp.status().is_success() { + return Ok(()); + } + let body = resp.text().await.unwrap_or_default(); + log::warn!( + "update_calendar: Google REST rename failed ({}), falling back to CalDAV", + body.chars().take(200).collect::() + ); + } + // Fall through to CalDAV below. + } + + if !account.caldav_url.is_empty() { + use crate::mail::caldav::{CalDavClient, CalDavConfig}; + let caldav_config = CalDavConfig { + caldav_url: account.caldav_url.clone(), + username: account.username.clone(), + password: account.password.clone(), + email: account.email.clone(), + }; + let client = CalDavClient::connect(&caldav_config).await?; + client.rename_calendar(remote_id, new_name).await?; + return Ok(()); + } + + Err(crate::error::Error::Other(format!( + "No remote rename path configured for account {} (provider={}, protocol={})", + account.id, account.provider, account.mail_protocol + ))) +} + #[tauri::command] pub async fn delete_calendar(state: State<'_, AppState>, calendar_id: String) -> Result<()> { log::info!("delete_calendar: id={}", calendar_id); diff --git a/src-tauri/src/db/schema.rs b/src-tauri/src/db/schema.rs index c389721..d7d4d61 100644 --- a/src-tauri/src/db/schema.rs +++ b/src-tauri/src/db/schema.rs @@ -38,6 +38,7 @@ pub fn initialize(conn: &Connection) -> Result<()> { unread_count INTEGER DEFAULT 0, total_count INTEGER DEFAULT 0, uid_next INTEGER DEFAULT 0, + parent_id TEXT, UNIQUE(account_id, path) ); CREATE INDEX IF NOT EXISTS idx_folders_account ON folders(account_id); @@ -303,6 +304,19 @@ fn run_migrations(conn: &Connection) -> Result<()> { )?; } + // Add parent_id column to folders. Existing DBs that were populated by + // older JMAP sync builds already had it; fresh installs didn't because + // the CREATE TABLE in initialize() was never updated to match. Without + // this column the first JMAP folder upsert fails with "no column named + // parent_id". + let has_folder_parent_id: bool = conn + .prepare("SELECT parent_id FROM folders LIMIT 0") + .is_ok(); + if !has_folder_parent_id { + log::info!("Migration: adding parent_id column to folders table"); + conn.execute_batch("ALTER TABLE folders ADD COLUMN parent_id TEXT;")?; + } + // Populate FTS index for existing messages (one-time migration) if !has_migration(conn, "fts5_initial_populate") { log::info!("Migration: populating FTS5 index for existing messages"); diff --git a/src-tauri/src/mail/caldav.rs b/src-tauri/src/mail/caldav.rs index e8da180..a90720c 100644 --- a/src-tauri/src/mail/caldav.rs +++ b/src-tauri/src/mail/caldav.rs @@ -444,6 +444,66 @@ impl CalDavClient { Ok(()) } + /// PROPPATCH the `{DAV:}displayname` property to rename a calendar. + /// `calendar_href` is the calendar collection URL (as returned by + /// `list_calendars`). + pub async fn rename_calendar(&self, calendar_href: &str, new_name: &str) -> Result<()> { + let url = self.resolve_url(calendar_href)?; + log::info!("caldav: PROPPATCH rename calendar {} -> {}", url, new_name); + + let body = format!( + r#" + + + + {} + + +"#, + xml_escape(new_name) + ); + + let resp = self + .apply_auth( + self.http + .request(reqwest::Method::from_bytes(b"PROPPATCH").unwrap(), &url), + ) + .header("Content-Type", "application/xml; charset=utf-8") + .body(body) + .send() + .await + .map_err(|e| Error::Other(format!("CalDAV PROPPATCH failed: {}", e)))?; + + let status = resp.status(); + let text = resp + .text() + .await + .unwrap_or_else(|_| "(no body)".to_string()); + + // 207 Multi-Status is the canonical PROPPATCH success response; some + // servers reply with a plain 200. Any 2xx counts as OK provided the + // Multi-Status body doesn't flag the property as failed. + if !status.is_success() && status.as_u16() != 207 { + return Err(Error::Other(format!( + "CalDAV PROPPATCH returned {}: {}", + status, + text.chars().take(500).collect::() + ))); + } + + // Multi-Status body may report per-property failures even with a 207. + // Look for a non-2xx status inside the response XML. + if text.contains("HTTP/1.1 4") || text.contains("HTTP/1.1 5") { + return Err(Error::Other(format!( + "CalDAV PROPPATCH rejected displayname update: {}", + text.chars().take(500).collect::() + ))); + } + + log::info!("caldav: PROPPATCH success"); + Ok(()) + } + // ----------------------------------------------------------------------- // Internal helpers // ----------------------------------------------------------------------- @@ -773,6 +833,23 @@ pub fn generate_ical_event( lines.join("\r\n") } +/// Escape `&`, `<`, `>`, `"`, `'` for safe interpolation into XML text or +/// attribute values. Used by PROPPATCH to carry a user-supplied name. +fn xml_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(c), + } + } + out +} + /// Convert ISO 8601 datetime to iCalendar datetime format. /// "2025-04-15T10:00:00Z" -> "20250415T100000Z" fn to_ical_datetime(iso: &str) -> String { diff --git a/src-tauri/src/mail/graph.rs b/src-tauri/src/mail/graph.rs index 624433a..b3e3ff1 100644 --- a/src-tauri/src/mail/graph.rs +++ b/src-tauri/src/mail/graph.rs @@ -555,6 +555,14 @@ impl GraphClient { .collect()) } + /// Rename a calendar via PATCH /me/calendars/{id}. + pub async fn rename_calendar(&self, calendar_id: &str, new_name: &str) -> Result<()> { + log::info!("Graph rename calendar: id={} -> {}", calendar_id, new_name); + let path = format!("/me/calendars/{}", urlencoding::encode(calendar_id)); + self.patch_json(&path, &serde_json::json!({ "name": new_name })) + .await + } + /// Fetch events in a time range via calendarView. /// Uses `Prefer: outlook.timezone="UTC"` so all times come back in UTC. pub async fn list_events(&self, start: &str, end: &str) -> Result> { diff --git a/src-tauri/src/mail/jmap.rs b/src-tauri/src/mail/jmap.rs index 40d5863..0e39d90 100644 --- a/src-tauri/src/mail/jmap.rs +++ b/src-tauri/src/mail/jmap.rs @@ -998,6 +998,53 @@ impl JmapConnection { Ok(calendars) } + /// Rename a JMAP calendar. Uses Calendar/set with an update entry. + pub async fn rename_calendar( + &self, + config: &JmapConfig, + calendar_id: &str, + new_name: &str, + ) -> Result<()> { + log::info!("JMAP rename calendar: id={} -> {}", calendar_id, new_name); + + // Build the update map by hand: `serde_json::json!({ calendar_id: ... })` + // would emit the literal key "calendar_id", not the id's value. + let mut update = serde_json::Map::new(); + update.insert( + calendar_id.to_string(), + serde_json::json!({ "name": new_name }), + ); + + let request = serde_json::json!({ + "using": [ + "urn:ietf:params:jmap:core", + "urn:ietf:params:jmap:calendars" + ], + "methodCalls": [ + ["Calendar/set", { + "accountId": self.account_id, + "update": update + }, "c1"] + ] + }); + + let resp = self.api_request(&request, config).await?; + + if let Some(err) = resp["methodResponses"][0][1]["notUpdated"][calendar_id].as_object() { + let desc = err + .get("description") + .and_then(|d| d.as_str()) + .unwrap_or("Unknown error"); + return Err(Error::Other(format!( + "JMAP Calendar/set rejected rename: {}", + desc + ))); + } + + log::info!("JMAP renamed calendar {}", calendar_id); + Ok(()) + } + /// Fetch calendar events, optionally filtered by calendar_id. /// Uses CalendarEvent/query + CalendarEvent/get with JSCalendar format. pub async fn fetch_calendar_events( diff --git a/src/components/calendar/CalendarSidebar.vue b/src/components/calendar/CalendarSidebar.vue index 8ee1702..aef1e4d 100644 --- a/src/components/calendar/CalendarSidebar.vue +++ b/src/components/calendar/CalendarSidebar.vue @@ -1,5 +1,5 @@ @@ -581,4 +700,96 @@ async function unsubscribeThisCalendar() { .cal-context-menu .ctx-item:hover { background: var(--color-bg-hover); } + +.cal-rename-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; +} + +.rename-modal { + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: 8px; + width: 360px; + max-width: calc(100vw - 32px); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); +} + +.rename-body { + padding: 18px 20px 4px; +} + +.rename-body h3 { + margin: 0 0 6px; + font-size: 15px; + color: var(--color-text); +} + +.rename-sub { + margin: 0 0 12px; + font-size: 12px; + color: var(--color-text-muted); +} + +.rename-input { + width: 100%; + padding: 8px 10px; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-bg-secondary); + color: var(--color-text); + font-size: 14px; +} + +.rename-input:focus { + outline: none; + border-color: var(--color-accent); +} + +.rename-error { + margin: 8px 0 0; + font-size: 12px; + color: #dc2626; +} + +.rename-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 20px 16px; +} + +.rename-btn-cancel, +.rename-btn-save { + padding: 6px 14px; + border-radius: 4px; + font-size: 13px; + cursor: pointer; +} + +.rename-btn-cancel { + background: var(--color-bg-tertiary); + color: var(--color-text); + border: 1px solid var(--color-border); +} + +.rename-btn-save { + background: var(--color-accent); + color: white; + border: 1px solid var(--color-accent); +} + +.rename-btn-save:disabled, +.rename-btn-cancel:disabled { + opacity: 0.5; + cursor: not-allowed; +}