From ddb8c97034befe707aeadb24dfd210077ac244f3 Mon Sep 17 00:00:00 2001 From: Mario-SO Date: Sat, 10 Jan 2026 12:03:53 +0100 Subject: [PATCH] open google meet links --- README.md | 1 + src/app/commands/types.ts | 1 + src/data/migrations.ts | 12 +++++++++ src/data/repository.ts | 10 ++++++- src/features/agenda/AgendaSideView.tsx | 26 +++++++++++++++--- src/features/agenda/agendaCommands.ts | 10 +++++++ src/features/calendar/DayCell.tsx | 30 +++++++++++++-------- src/features/events/SearchEventsModal.tsx | 21 ++++++++++----- src/features/events/search/searchUtils.ts | 2 ++ src/features/google/googleApi.ts | 20 ++------------ src/features/google/googleSync.ts | 20 ++++++++++++++ src/shared/openBrowser.ts | 19 ++++++++++++++ src/shared/types.ts | 1 + tests/repository.test.ts | 32 +++++++++++++++++++++++ 14 files changed, 166 insertions(+), 39 deletions(-) create mode 100644 src/shared/openBrowser.ts diff --git a/README.md b/README.md index 21694cb..174cf0f 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,7 @@ export const calendarCommands = [ - [ ] iCal export/import - [ ] Notifications for upcoming events - [ ] Recurring events +- [x] Open google meet links - [x] cli support (cronos add ...) - [x] Persistent storage - [x] Search through event list diff --git a/src/app/commands/types.ts b/src/app/commands/types.ts index 2d3a789..752ff79 100644 --- a/src/app/commands/types.ts +++ b/src/app/commands/types.ts @@ -5,6 +5,7 @@ export interface AgendaCommandHandlers { moveSelection: (delta: number) => void; editSelection: () => void; deleteSelection: () => void; + openLink: () => void; } export interface AddModalCommandHandlers { diff --git a/src/data/migrations.ts b/src/data/migrations.ts index ae3ea8f..3d19a7c 100644 --- a/src/data/migrations.ts +++ b/src/data/migrations.ts @@ -98,6 +98,18 @@ const MIGRATIONS: Migration[] = [ } }, }, + { + version: 4, + up: (db) => { + const eventColumns = db.query("PRAGMA table_info(events)").all() as { + name: string; + }[]; + const eventColumnSet = new Set(eventColumns.map((col) => col.name)); + if (!eventColumnSet.has("conference_url")) { + db.run("ALTER TABLE events ADD COLUMN conference_url TEXT"); + } + }, + }, ]; /** diff --git a/src/data/repository.ts b/src/data/repository.ts index 7938dc0..3b61864 100644 --- a/src/data/repository.ts +++ b/src/data/repository.ts @@ -12,6 +12,7 @@ interface EventRow { google_event_id: string | null; google_calendar_id: string | null; google_etag: string | null; + conference_url: string | null; created_at: string; updated_at: string; } @@ -30,6 +31,7 @@ function rowToEvent(row: EventRow): CalendarEvent { googleEventId: row.google_event_id ?? undefined, googleCalendarId: row.google_calendar_id ?? undefined, googleEtag: row.google_etag ?? undefined, + conferenceUrl: row.conference_url ?? undefined, updatedAt: row.updated_at ?? undefined, }; } @@ -52,9 +54,10 @@ export const insertEvent = (event: CalendarEvent) => google_event_id, google_calendar_id, google_etag, + conference_url, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); stmt.run( event.id, @@ -66,6 +69,7 @@ export const insertEvent = (event: CalendarEvent) => event.googleEventId ?? null, event.googleCalendarId ?? null, event.googleEtag ?? null, + event.conferenceUrl ?? null, now, ); }); @@ -116,6 +120,10 @@ export const updateEventById = ( setClauses.push("google_etag = ?"); values.push(updates.googleEtag ?? null); } + if ("conferenceUrl" in updates) { + setClauses.push("conference_url = ?"); + values.push(updates.conferenceUrl ?? null); + } if (setClauses.length === 0) { return; // Nothing to update diff --git a/src/features/agenda/AgendaSideView.tsx b/src/features/agenda/AgendaSideView.tsx index dfbc9a5..ae46367 100644 --- a/src/features/agenda/AgendaSideView.tsx +++ b/src/features/agenda/AgendaSideView.tsx @@ -3,6 +3,7 @@ import { deleteEvent, useEventsForDate } from "@features/events/eventsState"; import { useTheme } from "@features/theme/themeState"; import type { ScrollBoxRenderable } from "@opentui/core"; import { formatDateKey, formatTimeRange } from "@shared/dateUtils"; +import { openBrowser } from "@shared/openBrowser"; import type { CalendarEvent } from "@shared/types"; import { Effect } from "effect"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -98,9 +99,21 @@ export function AgendaSideView({ } }, [clampedIndex, events, isActive, selectedIndex]); + const openLink = useCallback(() => { + if (!isActive) return; + const event = events[clampedIndex]; + const conferenceUrl = event?.conferenceUrl; + if (!conferenceUrl) return; + Effect.runSync( + Effect.catchAll(openBrowser(conferenceUrl), () => + Effect.sync(() => undefined), + ), + ); + }, [clampedIndex, events, isActive]); + const agendaHandlers = useMemo( - () => ({ moveSelection, editSelection, deleteSelection }), - [moveSelection, editSelection, deleteSelection], + () => ({ moveSelection, editSelection, deleteSelection, openLink }), + [moveSelection, editSelection, deleteSelection, openLink], ); useEffect(() => { @@ -121,6 +134,7 @@ export function AgendaSideView({ label: "Navigate", order: ["↑", "↓"], }, + { commandIds: ["agenda.openLink"], label: "Open link" }, { commandIds: ["agenda.edit"], label: "Edit" }, { commandIds: ["agenda.delete"], label: "Delete" }, { commandIds: ["calendar.toggleAgenda"], label: "Toggle" }, @@ -163,6 +177,12 @@ export function AgendaSideView({ > {events.map((event, index) => { const isSelected = index === clampedIndex; + const linkIndicator = event.conferenceUrl ? " ↗" : ""; + const maxTitleLength = Math.max( + 0, + titleWidth - linkIndicator.length, + ); + const displayTitle = `${event.title.slice(0, maxTitleLength)}${linkIndicator}`; return ( - {event.title.slice(0, titleWidth)} + {displayTitle} {isSelected && ( diff --git a/src/features/agenda/agendaCommands.ts b/src/features/agenda/agendaCommands.ts index 636e77f..28c2ef0 100644 --- a/src/features/agenda/agendaCommands.ts +++ b/src/features/agenda/agendaCommands.ts @@ -42,4 +42,14 @@ export const agendaCommands = [ ctx.agenda?.deleteSelection(); }), }, + { + id: "agenda.openLink", + title: "Open link", + keys: [{ key: "return", preventDefault: true }], + layers: ["agenda"], + run: (ctx) => + Effect.sync(() => { + ctx.agenda?.openLink(); + }), + }, ] as const satisfies readonly CommandDefinition[]; diff --git a/src/features/calendar/DayCell.tsx b/src/features/calendar/DayCell.tsx index 93c4709..bfcec53 100644 --- a/src/features/calendar/DayCell.tsx +++ b/src/features/calendar/DayCell.tsx @@ -68,17 +68,25 @@ export function DayCell({ {String(day).padStart(2, " ")} - {displayEvents.map((event) => ( - - - - {event.title.slice(0, DAY_CELL_TITLE_LENGTH)} - - - ))} + {displayEvents.map((event) => { + const linkIndicator = event.conferenceUrl ? " ↗" : ""; + const maxTitleLength = Math.max( + 0, + DAY_CELL_TITLE_LENGTH - linkIndicator.length, + ); + const displayTitle = `${event.title.slice(0, maxTitleLength)}${linkIndicator}`; + return ( + + + + {displayTitle} + + + ); + })} {moreCount > 0 && ( - {entry.type === "group" - ? `${entry.title} (${entry.count})`.slice( - 0, - SEARCH_MODAL_TITLE_LENGTH, - ) - : event.title.slice(0, SEARCH_MODAL_TITLE_LENGTH)} + {displayTitle} {isSelected && ( diff --git a/src/features/events/search/searchUtils.ts b/src/features/events/search/searchUtils.ts index 2b1e2ef..cd1f248 100644 --- a/src/features/events/search/searchUtils.ts +++ b/src/features/events/search/searchUtils.ts @@ -8,6 +8,7 @@ export type SearchEntry = title: string; color: CalendarEvent["color"]; count: number; + hasLink: boolean; latest: CalendarEvent; }; @@ -80,6 +81,7 @@ export function groupEventsForSearch(events: CalendarEvent[]): SearchEntry[] { title: latest.title, color: latest.color, count: groupEvents.length, + hasLink: groupEvents.some((event) => Boolean(event.conferenceUrl)), latest, }); } diff --git a/src/features/google/googleApi.ts b/src/features/google/googleApi.ts index 9f6feaf..56185d5 100644 --- a/src/features/google/googleApi.ts +++ b/src/features/google/googleApi.ts @@ -1,7 +1,7 @@ -import { spawn } from "node:child_process"; import { createHash, randomBytes } from "node:crypto"; import { createServer } from "node:http"; import { URL } from "node:url"; +import { openBrowser } from "@shared/openBrowser"; import type { CalendarEvent, GoogleSettings } from "@shared/types"; import { Effect } from "effect"; @@ -40,22 +40,6 @@ function createCodeChallenge(verifier: string): string { return base64UrlEncode(digest); } -function openBrowser(url: string): void { - const platform = process.platform; - if (platform === "darwin") { - spawn("open", [url], { stdio: "ignore", detached: true }).unref(); - return; - } - if (platform === "win32") { - spawn("cmd", ["/c", "start", "", url], { - stdio: "ignore", - detached: true, - }).unref(); - return; - } - spawn("xdg-open", [url], { stdio: "ignore", detached: true }).unref(); -} - export const startGoogleOAuth = () => Effect.gen(function* () { const clientId = getClientId(); @@ -126,7 +110,7 @@ export const startGoogleOAuth = () => authUrl.searchParams.set("code_challenge", codeChallenge); authUrl.searchParams.set("code_challenge_method", "S256"); - openBrowser(authUrl.toString()); + yield* openBrowser(authUrl.toString()); const code = yield* Effect.tryPromise(() => codePromise); const tokenResponse = yield* Effect.tryPromise(() => diff --git a/src/features/google/googleSync.ts b/src/features/google/googleSync.ts index 9ebbee8..551c8c6 100644 --- a/src/features/google/googleSync.ts +++ b/src/features/google/googleSync.ts @@ -50,6 +50,14 @@ interface GoogleEvent { updated?: string; etag?: string; recurringEventId?: string; + hangoutLink?: string; + conferenceData?: { + entryPoints?: Array<{ + entryPointType?: string; + uri?: string; + label?: string; + }>; + }; } interface GoogleEventListResponse { @@ -170,6 +178,15 @@ function parseGoogleDate(event: GoogleEvent): string | null { return null; } +function getConferenceUrl(event: GoogleEvent): string | undefined { + const entryPoints = event.conferenceData?.entryPoints ?? []; + const videoEntry = entryPoints.find( + (entry) => entry.entryPointType === "video" && entry.uri, + ); + const anyEntry = entryPoints.find((entry) => entry.uri); + return videoEntry?.uri ?? anyEntry?.uri ?? event.hangoutLink ?? undefined; +} + function canWriteCalendar(accessRole?: string): boolean { return accessRole === "owner" || accessRole === "writer"; } @@ -226,6 +243,7 @@ function buildLocalEventFromGoogle( googleEventId: event.id, googleCalendarId: calendarId, googleEtag: event.etag ?? undefined, + conferenceUrl: getConferenceUrl(event), updatedAt: toIso(event.updated) ?? new Date().toISOString(), }; } @@ -323,6 +341,7 @@ const listGoogleEvents = ( singleEvents: "true", maxResults: `${MAX_RESULTS}`, showDeleted: "true", + conferenceDataVersion: "1", }; if (syncToken) { params.syncToken = syncToken; @@ -519,6 +538,7 @@ const syncCalendar = ( title: mapped.title, color: mapped.color, googleEtag: mapped.googleEtag, + conferenceUrl: mapped.conferenceUrl, updatedAt: mapped.updatedAt, }); touchedLocalIds.add(local.id); diff --git a/src/shared/openBrowser.ts b/src/shared/openBrowser.ts new file mode 100644 index 0000000..6331623 --- /dev/null +++ b/src/shared/openBrowser.ts @@ -0,0 +1,19 @@ +import { spawn } from "node:child_process"; +import { Effect } from "effect"; + +export const openBrowser = (url: string) => + Effect.try(() => { + const platform = process.platform; + if (platform === "darwin") { + spawn("open", [url], { stdio: "ignore", detached: true }).unref(); + return; + } + if (platform === "win32") { + spawn("cmd", ["/c", "start", "", url], { + stdio: "ignore", + detached: true, + }).unref(); + return; + } + spawn("xdg-open", [url], { stdio: "ignore", detached: true }).unref(); + }); diff --git a/src/shared/types.ts b/src/shared/types.ts index 66ef947..6e12326 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -17,6 +17,7 @@ export interface CalendarEvent { googleEventId?: string; googleCalendarId?: string; googleEtag?: string; + conferenceUrl?: string; updatedAt?: string; // ISO timestamp } diff --git a/tests/repository.test.ts b/tests/repository.test.ts index b4f6f14..98e129c 100644 --- a/tests/repository.test.ts +++ b/tests/repository.test.ts @@ -135,6 +135,22 @@ describe("Repository CRUD Operations", () => { expect(at(events, 0).endTime).toBeUndefined(); }); + test("inserts an event with a conference link", () => { + const event = createTestEvent({ + id: "event-4-1234567890", + title: "Meet Sync", + conferenceUrl: "https://meet.google.com/abc-defg-hij", + }); + + Effect.runSync(insertEvent(event)); + + const events = Effect.runSync(findAllEvents()); + expect(events).toHaveLength(1); + expect(at(events, 0).conferenceUrl).toBe( + "https://meet.google.com/abc-defg-hij", + ); + }); + test("inserts multiple events", () => { const event1 = createTestEvent({ id: "event-1-111", title: "First" }); const event2 = createTestEvent({ id: "event-2-222", title: "Second" }); @@ -347,6 +363,22 @@ describe("Repository CRUD Operations", () => { expect(at(events, 0).endTime).toBe(660); }); + test("updates conference url", () => { + const event = createTestEvent({ id: "e1" }); + Effect.runSync(insertEvent(event)); + + Effect.runSync( + updateEventById("e1", { + conferenceUrl: "https://meet.google.com/updated-link", + }), + ); + + const events = Effect.runSync(findAllEvents()); + expect(at(events, 0).conferenceUrl).toBe( + "https://meet.google.com/updated-link", + ); + }); + test("clears start time by setting to undefined", () => { const event = createTestEvent({ id: "e1", startTime: 540 }); Effect.runSync(insertEvent(event));