diff --git a/Cargo.lock b/Cargo.lock index 83b01455..7cd24eef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2077,6 +2077,7 @@ dependencies = [ "locality-connector", "locality-core", "locality-gmail", + "locality-google-calendar", "locality-google-docs", "locality-granola", "locality-linear", @@ -2133,6 +2134,7 @@ dependencies = [ "locality-connector", "locality-core", "locality-gmail", + "locality-google-calendar", "locality-google-docs", "locality-notion", "locality-platform", @@ -2180,6 +2182,20 @@ dependencies = [ "yaml_serde", ] +[[package]] +name = "locality-google-calendar" +version = "0.3.2" +dependencies = [ + "chrono", + "locality-connector", + "locality-core", + "reqwest", + "rustls", + "serde", + "serde_json", + "yaml_serde", +] + [[package]] name = "locality-google-docs" version = "0.3.4" @@ -2266,6 +2282,7 @@ dependencies = [ "locality-connector", "locality-core", "locality-gmail", + "locality-google-calendar", "locality-google-docs", "locality-granola", "locality-linear", diff --git a/Cargo.toml b/Cargo.toml index 5f1e6c24..8c956134 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/locality-store", "crates/locality-notion", "crates/locality-google-docs", + "crates/locality-google-calendar", "crates/locality-gmail", "crates/locality-granola", "crates/locality-linear", @@ -30,6 +31,7 @@ locality-platform = { path = "crates/locality-platform" } locality-store = { path = "crates/locality-store" } locality-notion = { path = "crates/locality-notion" } locality-google-docs = { path = "crates/locality-google-docs" } +locality-google-calendar = { path = "crates/locality-google-calendar" } locality-gmail = { path = "crates/locality-gmail" } locality-granola = { path = "crates/locality-granola" } locality-linear = { path = "crates/locality-linear" } diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 4d583e6d..d77d0e57 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -14,6 +14,7 @@ loc-cli = { path = "../../../crates/loc-cli" } locality-connector.workspace = true locality-core.workspace = true locality-gmail.workspace = true +locality-google-calendar.workspace = true locality-google-docs.workspace = true locality-notion.workspace = true locality-platform.workspace = true diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 1dfb8646..8b8debc3 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -22,9 +22,10 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use loc_cli::connect::DEFAULT_NOTION_PROFILE_ID; use loc_cli::connect::{ BrokerOAuthConnectOptions, ConnectOptions, GmailBrokerOAuthConnectOptions, - GoogleDocsBrokerOAuthConnectOptions, HttpGranolaConnectionProbe, HttpLinearConnectionProbe, - run_connect_gmail_broker_oauth, run_connect_google_docs_broker_oauth, run_connect_granola, - run_connect_linear, run_connect_notion_broker_oauth, run_disconnect, + GoogleCalendarBrokerOAuthConnectOptions, GoogleDocsBrokerOAuthConnectOptions, + HttpGranolaConnectionProbe, HttpLinearConnectionProbe, run_connect_gmail_broker_oauth, + run_connect_google_calendar_broker_oauth, run_connect_google_docs_broker_oauth, + run_connect_granola, run_connect_linear, run_connect_notion_broker_oauth, run_disconnect, }; use loc_cli::daemon::{DaemonRunState, run_daemon_control}; use loc_cli::diff::{DiffReport, run_diff}; @@ -65,6 +66,10 @@ use locality_gmail::{ DEFAULT_GMAIL_OAUTH_BROKER_URL, DEFAULT_GMAIL_OAUTH_REDIRECT_URI, GMAIL_CONNECTOR_ID, HttpGmailOAuthBrokerClient, }; +use locality_google_calendar::{ + DEFAULT_GOOGLE_CALENDAR_OAUTH_BROKER_URL, DEFAULT_GOOGLE_CALENDAR_OAUTH_REDIRECT_URI, + GOOGLE_CALENDAR_CONNECTOR_ID, HttpGoogleCalendarOAuthBrokerClient, +}; use locality_google_docs::{ DEFAULT_GOOGLE_DOCS_OAUTH_BROKER_URL, DEFAULT_GOOGLE_DOCS_OAUTH_REDIRECT_URI, GOOGLE_DOCS_CONNECTOR_ID, HttpGoogleDocsOAuthBrokerClient, @@ -531,6 +536,7 @@ struct MountLiveModeChange { static CONNECT_NOTION_IN_PROGRESS: AtomicBool = AtomicBool::new(false); static CONNECT_GOOGLE_DOCS_IN_PROGRESS: AtomicBool = AtomicBool::new(false); +static CONNECT_GOOGLE_CALENDAR_IN_PROGRESS: AtomicBool = AtomicBool::new(false); static CONNECT_GMAIL_IN_PROGRESS: AtomicBool = AtomicBool::new(false); static DAEMON_LIFECYCLE_LOCK: OnceLock> = OnceLock::new(); static NOTION_LOGIN_LINK: OnceLock>> = OnceLock::new(); @@ -963,6 +969,11 @@ async fn connect_google_docs(app: AppHandle) -> ActionReport { run_google_docs_connection_flow(app, true).await } +#[tauri::command] +async fn connect_google_calendar(app: AppHandle) -> ActionReport { + run_google_calendar_connection_flow(app, true).await +} + #[tauri::command] async fn connect_gmail(app: AppHandle) -> ActionReport { run_gmail_connection_flow(app, true).await @@ -1101,6 +1112,55 @@ async fn run_google_docs_connection_flow(app: AppHandle, open_browser: bool) -> report } +async fn run_google_calendar_connection_flow(app: AppHandle, open_browser: bool) -> ActionReport { + if CONNECT_GOOGLE_CALENDAR_IN_PROGRESS.swap(true, Ordering::AcqRel) { + return ActionReport { + ok: false, + message: "A Google Calendar connection flow is already waiting for browser approval." + .to_string(), + }; + } + + let state_root = default_state_root(); + let activity_state_root = state_root.clone(); + let result = tauri::async_runtime::spawn_blocking(move || { + connect_google_calendar_with_broker(state_root, open_browser) + }) + .await + .map_err(|error| format!("Google Calendar OAuth worker failed: {error}")); + CONNECT_GOOGLE_CALENDAR_IN_PROGRESS.store(false, Ordering::Release); + + let report = match result { + Ok(Ok(message)) => { + if let Err(error) = record_desktop_activity( + &activity_state_root, + "Connected Google Calendar", + &message, + "connect", + ) { + desktop_log( + "warn", + "activity.record_failed", + format!("could not record Google Calendar access activity: {error}"), + ); + } + ActionReport { ok: true, message } + } + Ok(Err(message)) | Err(message) => { + desktop_log( + "warn", + "google_calendar_access.failed", + format!("connect google calendar failed: {message}"), + ); + ActionReport { ok: false, message } + } + }; + if report.ok { + refresh_desktop_surfaces(&app); + } + report +} + async fn run_gmail_connection_flow(app: AppHandle, open_browser: bool) -> ActionReport { if CONNECT_GMAIL_IN_PROGRESS.swap(true, Ordering::AcqRel) { return ActionReport { @@ -4230,8 +4290,9 @@ fn connection_connector_rank(connector: &str) -> usize { match connector { "notion" => 0, "google-docs" => 1, - "gmail" => 2, - "granola" => 3, + GOOGLE_CALENDAR_CONNECTOR_ID => 2, + "gmail" => 3, + "granola" => 4, _ => 10, } } @@ -5515,6 +5576,11 @@ fn mount_access_scope_label(store: Option<&SqliteStateStore>, mount: &MountConfi .as_ref() .map(|remote_id| format!("Drive folder {}", remote_id.0)) .unwrap_or_else(|| "Google Docs workspace folder".to_string()), + GOOGLE_CALENDAR_CONNECTOR_ID => mount + .remote_root_id + .as_ref() + .map(|remote_id| format!("Calendar {}", remote_id.0)) + .unwrap_or_else(|| "Primary calendar".to_string()), _ => mount .remote_root_id .as_ref() @@ -7480,7 +7546,7 @@ fn create_desktop_mount_blocking(request: CreateDesktopMountRequest) -> Result None, + GOOGLE_CALENDAR_CONNECTOR_ID | "gmail" | "granola" | "linear" => None, _ => unreachable!("unsupported desktop connector should be rejected before mount setup"), }; let preserved = if can_remount_existing_workspace { @@ -7552,7 +7618,7 @@ fn append_macos_file_provider_activation_warning(message: &mut String, warning: fn desktop_mount_creation_supports_connector(connector: &str) -> bool { matches!( connector, - "notion" | "google-docs" | "gmail" | "granola" | "linear" + "notion" | "google-docs" | GOOGLE_CALENDAR_CONNECTOR_ID | "gmail" | "granola" | "linear" ) } @@ -10061,6 +10127,64 @@ fn connect_google_docs_with_broker( )) } +fn connect_google_calendar_with_broker( + state_root: PathBuf, + open_browser: bool, +) -> Result { + let mut store = SqliteStateStore::open(state_root.clone()) + .map_err(|error| format!("Could not open Locality state: {error}"))?; + let credentials = open_credential_store(&state_root); + let broker_url = env_first(&[ + "LOCALITY_GOOGLE_CALENDAR_OAUTH_BROKER_URL", + "LOCALITY_AUTH_BROKER_URL", + ]) + .unwrap_or_else(|| DEFAULT_GOOGLE_CALENDAR_OAUTH_BROKER_URL.to_string()); + let redirect_uri = env_first(&["LOCALITY_GOOGLE_CALENDAR_OAUTH_REDIRECT_URI"]) + .unwrap_or_else(|| DEFAULT_GOOGLE_CALENDAR_OAUTH_REDIRECT_URI.to_string()); + let broker = HttpGoogleCalendarOAuthBrokerClient::new(broker_url.clone()); + let start = broker + .start(&OAuthBrokerStart { + connector: GOOGLE_CALENDAR_CONNECTOR_ID.to_string(), + redirect_uri, + }) + .map_err(|error| format!("Could not start Google Calendar OAuth broker flow: {error}"))?; + let authorization = run_local_oauth_authorization( + "Google Calendar", + &start.authorization_url, + &start.redirect_uri, + &start.state, + !open_browser, + true, + ) + .map_err(|error| error.message)?; + let options = GoogleCalendarBrokerOAuthConnectOptions { + connection_id: Some(ConnectionId::new("google-calendar-default")), + broker_url, + client_id: start.client_id, + session: start.session, + state: start.state, + code: authorization.code, + redirect_uri: start.redirect_uri, + }; + + let report = run_connect_google_calendar_broker_oauth( + &mut store, + credentials.as_ref(), + options, + &broker, + ) + .map_err(|error| error.message())?; + let connected_message = match report.workspace_name.or(report.account_label) { + Some(label) if !label.is_empty() => { + format!("Connected Google Calendar account {label}.") + } + _ => "Connected Google Calendar.".to_string(), + }; + Ok(format!( + "{connected_message} Create a Google Calendar source folder to mount primary calendar events." + )) +} + fn connect_gmail_with_broker(state_root: PathBuf, open_browser: bool) -> Result { let mut store = SqliteStateStore::open(state_root.clone()) .map_err(|error| format!("Could not open Locality state: {error}"))?; @@ -11976,6 +12100,87 @@ mod tests { assert!(summary.hydration_progress.is_none()); } + #[test] + fn source_connection_order_places_google_calendar_before_gmail() { + let mut connections = vec![ + connection_for_connector("gmail", "Gmail"), + connection_for_connector("google-calendar", "Primary calendar"), + connection_for_connector("granola", "Granola"), + connection_for_connector("notion", "CodeFlash"), + connection_for_connector("google-docs", "Drive"), + ]; + + let summaries = super::connection_summaries(&connections); + + assert_eq!( + summaries + .iter() + .map(|connection| connection.connector.as_str()) + .collect::>(), + vec![ + "notion", + "google-docs", + "google-calendar", + "gmail", + "granola" + ] + ); + + connections.reverse(); + let summaries = super::connection_summaries(&connections); + assert_eq!( + summaries + .iter() + .map(|connection| connection.connector.as_str()) + .collect::>(), + vec![ + "notion", + "google-docs", + "google-calendar", + "gmail", + "granola" + ] + ); + } + + #[test] + fn desktop_snapshot_lists_google_calendar_mount_without_remote_root() { + let temp = TestTempDir::new("desktop-google-calendar-mount"); + let mut store = SqliteStateStore::open(temp.path().to_path_buf()).expect("open store"); + let calendar_connection = connection_for_connector("google-calendar", "Primary calendar"); + let calendar_mount = MountConfig::new( + MountId::new("google-calendar-main"), + "google-calendar", + temp.path().join("google-calendar"), + ) + .with_connection_id(calendar_connection.connection_id.clone()) + .projection(ProjectionMode::LinuxFuse); + + store + .save_connection(calendar_connection) + .expect("save calendar connection"); + store + .save_mount(calendar_mount) + .expect("save calendar mount"); + + let snapshot = super::load_desktop_snapshot_from_store(&store, temp.path()) + .expect("load snapshot from test store"); + let calendar = snapshot + .mounts + .iter() + .find(|mount| mount.mount_id == "google-calendar-main") + .expect("calendar mount"); + + assert_eq!( + snapshot.active_mount_id.as_deref(), + Some("google-calendar-main") + ); + assert_eq!(snapshot.connection.connector, "google-calendar"); + assert_eq!(calendar.connector_name, "Google Calendar"); + assert_eq!(calendar.access_scope, "Primary calendar"); + assert_eq!(calendar.remote_root_id, None); + } + #[test] fn desktop_snapshot_lists_all_mounts_and_marks_active_mount() { let temp = TestTempDir::new("desktop-all-mounts"); @@ -14567,6 +14772,9 @@ mod tests { #[test] fn desktop_mount_creation_supports_linear_as_editable_source() { + assert!(super::desktop_mount_creation_supports_connector( + "google-calendar" + )); assert!(super::desktop_mount_creation_supports_connector("linear")); assert!(super::desktop_mount_is_editable_by_default("linear")); assert!(!super::desktop_mount_creation_supports_connector("slack")); @@ -16672,6 +16880,26 @@ mod tests { } } + fn connection_for_connector(connector: &str, workspace_name: &str) -> ConnectionRecord { + ConnectionRecord { + connection_id: ConnectionId::new(format!("{connector}-default")), + profile_id: None, + connector: connector.to_string(), + display_name: format!("{connector}-default"), + account_label: Some(workspace_name.to_string()), + workspace_id: None, + workspace_name: Some(workspace_name.to_string()), + auth_kind: "oauth".to_string(), + secret_ref: format!("connection:{connector}-default"), + scopes: Vec::new(), + capabilities_json: "{}".to_string(), + status: "active".to_string(), + created_at: "1".to_string(), + updated_at: "1".to_string(), + expires_at: None, + } + } + fn search_result(kind: &str, state: &str) -> SearchResult { SearchResult { mount_id: "notion-main".to_string(), @@ -17492,6 +17720,7 @@ fn main() { connect_notion_without_browser, change_notion_access, connect_google_docs, + connect_google_calendar, connect_gmail, notion_login_link, install_state_review, diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 2a1f0bc4..d3eb16a4 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -111,6 +111,7 @@ import { type SourceSetupState, } from "./source-setup"; import gmailIconUrl from "./assets/connectors/gmail.svg"; +import googleCalendarIconUrl from "./assets/connectors/google-calendar.svg"; import googleDocsIconUrl from "./assets/connectors/google-docs.svg"; import granolaIconUrl from "./assets/connectors/granola.svg"; import linearIconUrl from "./assets/connectors/linear.svg"; @@ -147,6 +148,7 @@ type ConnectorOption = { const CONNECTOR_ICON_URLS: Record = { notion: notionIconUrl, "google-docs": googleDocsIconUrl, + "google-calendar": googleCalendarIconUrl, gmail: gmailIconUrl, granola: granolaIconUrl, linear: linearIconUrl, @@ -716,6 +718,8 @@ function suggestedAgentPrompt(mountPath: string, connector: OnboardingConnectorI return `Use Locality to read my Granola meetings. Open the files under ${mountPath}, search summaries and transcripts with normal file tools, and cite the meeting files you used. Granola is read-only in Locality, so do not try to push edits back.`; case "google-docs": return `Use Locality to edit my Google Docs workspace. Open the files under ${mountPath}, make the requested edits directly in Markdown, and leave changes pending for Locality review before pushing.`; + case "google-calendar": + return `Use Locality to inspect my Google Calendar source. Open the files under ${mountPath}, review calendar events with normal file tools, and prepare new event drafts for Locality review before creating them.`; case "gmail": return `Use Locality to inspect my Gmail source. Open the files under ${mountPath}, search mail with normal file tools, and prepare draft updates only when the mounted draft files support it. Leave outbound changes for Locality review.`; case "linear": @@ -775,6 +779,8 @@ function onboardingConnectorDescription( return `${workspaceLabel} is ready. Locality will now create the Notion folder under CloudStorage and prepare the local workspace.`; case "google-docs": return "Google Docs is ready. Locality mounted the selected Drive folder as local files under CloudStorage."; + case "google-calendar": + return "Google Calendar is ready. Locality mounted primary calendar events as local files under CloudStorage."; case "gmail": return "Gmail is ready. Locality mounted mailboxes as local files under CloudStorage."; case "granola": @@ -790,6 +796,8 @@ function onboardingConnectorDescription( return "A browser window is open. Choose the workspace and pages Locality can access, then approve."; case "google-docs": return "A browser window is open. Approve Google Docs access, then Locality will create the local folder."; + case "google-calendar": + return "A browser window is open. Approve Google Calendar access, then Locality will create the local calendar folder."; case "gmail": return "A browser window is open. Approve Gmail access, then Locality will create the local mailbox folder."; case "granola": @@ -804,6 +812,8 @@ function onboardingConnectorDescription( return "Connect the source you want agents to help with. Your machine talks directly to Notion, and app credentials are protected by macOS Keychain."; case "google-docs": return "Connect Google Docs during setup so agents can work with docs through the same local file workflow."; + case "google-calendar": + return "Connect Google Calendar during setup so agents can review events and prepare new event drafts through local files."; case "gmail": return "Connect Gmail during setup so agents can search mailboxes and prepare reviewed draft work from local files."; case "granola": @@ -819,6 +829,8 @@ function onboardingConnectorPills(connector: OnboardingConnectorId) { return ["Scoped access", "Credentials in Keychain", "Direct app connection"]; case "google-docs": return ["Google OAuth", "Drive folder", "Markdown edits"]; + case "google-calendar": + return ["Google OAuth", "Primary calendar", "Event drafts"]; case "gmail": return ["Google OAuth", "Mailbox files", "Draft review"]; case "granola": @@ -834,6 +846,8 @@ function onboardingReadyCopy(connector: OnboardingConnectorId) { return "Your local workspace is ready. Agents can open this folder, edit Markdown, and leave changes for Review Center. Open the app to review changes, manage sync, and turn on Live Mode when you want file saves to update Notion and new Notion changes to appear locally."; case "google-docs": return "Your Google Docs workspace is ready as local files. Agents can edit docs in Markdown and leave changes for Review Center before anything is pushed back."; + case "google-calendar": + return "Your Google Calendar source is ready as local files. Agents can review events and prepare new event drafts before anything is created remotely."; case "gmail": return "Your Gmail source is ready as local files. Agents can search mailbox content and prepare reviewed draft work without leaving the filesystem."; case "granola": @@ -2021,7 +2035,7 @@ function Onboarding({ ); } - async function connectGoogleOnboarding(connector: "google-docs" | "gmail") { + async function connectGoogleOnboarding(connector: "google-docs" | "google-calendar" | "gmail") { if (selectedConnectorBusy) { return; } @@ -2036,7 +2050,7 @@ function Onboarding({ setOauthInFlight(true); setStep(3); try { - const command = connector === "google-docs" ? "connect_google_docs" : "connect_gmail"; + const command = googleOAuthConnectCommand(connector); const connectReport = await callCommand( command, undefined, @@ -2085,6 +2099,7 @@ function Onboarding({ await startConnect(); return; case "google-docs": + case "google-calendar": case "gmail": await connectGoogleOnboarding(selectedOnboardingConnector); return; @@ -3504,7 +3519,7 @@ function MountsView({ return { ok: false, message: `${sourceDisplayName(connector)} requires an API key.` }; } - const command = connector === "google-docs" ? "connect_google_docs" : "connect_gmail"; + const command = googleOAuthConnectCommand(connector); setActionError(""); setActionMessage(""); const report = await callCommand( @@ -3868,6 +3883,14 @@ function AddSourceDialog({ keywords: ["google", "docs", "gdocs", "drive", "documents"], mounted: sourceMounted(snapshot, "google-docs"), }, + { + id: "google-calendar", + name: "Google Calendar", + description: "Primary calendar events as files, new events from reviewed drafts.", + status: sourceConnectorStatus(snapshot, "google-calendar"), + keywords: ["google", "calendar", "gcal", "events", "meet"], + mounted: sourceMounted(snapshot, "google-calendar"), + }, { id: "gmail", name: "Gmail", @@ -4029,6 +4052,11 @@ function AddSourceDialog({ + ) : connector.id === "google-calendar" ? ( + <> + + + ) : connector.id === "granola" ? ( <> @@ -4144,6 +4172,8 @@ function sourceDisplayName(connector: SourceConnectorId) { return "Notion"; case "google-docs": return "Google Docs"; + case "google-calendar": + return "Google Calendar"; case "gmail": return "Gmail"; case "granola": @@ -4159,6 +4189,8 @@ function sourceMountId(connector: SourceConnectorId) { return "notion-main"; case "google-docs": return "google-docs-main"; + case "google-calendar": + return "google-calendar-main"; case "gmail": return "gmail-main"; case "granola": @@ -4220,6 +4252,8 @@ function sourceDefaultPath(snapshot: DesktopSnapshot, connector: SourceConnector return "~/Library/CloudStorage/Locality/notion"; case "google-docs": return "~/Library/CloudStorage/Locality/google-docs-main"; + case "google-calendar": + return "~/Library/CloudStorage/Locality/google-calendar-main"; case "gmail": return "~/Library/CloudStorage/Locality/gmail-main"; case "granola": @@ -4255,6 +4289,17 @@ function sourceActionIcon(connector: SourceConnectorId, needsConnection: boolean return ; } +function googleOAuthConnectCommand(connector: "google-docs" | "google-calendar" | "gmail") { + switch (connector) { + case "google-docs": + return "connect_google_docs"; + case "google-calendar": + return "connect_google_calendar"; + case "gmail": + return "connect_gmail"; + } +} + function ConnectorIcon({ connector }: { connector: SourceConnectorId }) { return (