From c1f6c50918eccc34461553c0e2d7640bc5b7e6cf Mon Sep 17 00:00:00 2001 From: fisik-yum <54129197+fisik-yum@users.noreply.github.com> Date: Fri, 13 Mar 2026 19:48:32 -0700 Subject: [PATCH 1/4] initial calendar support --- hub-config.example.yaml | 8 + skills/calendar-assistant/SKILL.md | 119 ++++++ src/ai/mcp/server.ts | 123 +++++++ .../connectors/calendar/calendar.test.ts | 79 ++++ src/gateway/connectors/calendar/connector.ts | 200 ++++++++++ src/gateway/gateway.ts | 29 ++ src/gateway/gui/routes.ts | 344 +++++++++++++++++- 7 files changed, 891 insertions(+), 11 deletions(-) create mode 100644 skills/calendar-assistant/SKILL.md create mode 100644 src/gateway/connectors/calendar/calendar.test.ts create mode 100644 src/gateway/connectors/calendar/connector.ts diff --git a/hub-config.example.yaml b/hub-config.example.yaml index b98770f..370920d 100644 --- a/hub-config.example.yaml +++ b/hub-config.example.yaml @@ -10,6 +10,14 @@ sources: # clientId: "your-client-id.apps.googleusercontent.com" # clientSecret: "your-client-secret" + google_calendar: + enabled: true + owner_auth: + type: oauth2 + # Uncomment to use your own OAuth app: + # clientId: "your-client-id.apps.googleusercontent.com" + # clientSecret: "your-client-secret" + github: enabled: true owner_auth: diff --git a/skills/calendar-assistant/SKILL.md b/skills/calendar-assistant/SKILL.md new file mode 100644 index 0000000..73f5dc2 --- /dev/null +++ b/skills/calendar-assistant/SKILL.md @@ -0,0 +1,119 @@ +--- +name: calendar-assistant +description: Manage your Google Calendar by checking availability and scheduling events through PersonalDataHub +user_invocable: true +--- + +# Calendar Assistant + +Help users manage their schedule by checking for conflicts, listing upcoming events, and proposing new calendar items. + +## Instructions + +### 1. Read the PersonalDataHub config + +Read `~/.pdh/config.json` to get the `hubUrl`. If the file doesn't exist, tell the user to run `npx pdh init` and `npx pdh start` first. + +### 2. Verify the hub is running + +Run `curl -s /health` via Bash. If it fails, tell the user to start the server with `npx pdh start`. + +### 3. Parse the user's request + +Analyze the user's message to identify: +- **Intent** — list events, check availability, create a new event, update/delete an existing one. +- **Time context** — specific dates, relative times ("next Tuesday", "tomorrow afternoon"). +- **Event details** — title, description, location, attendees. +- **Missing information** — required fields like start time, duration, or participant emails. + +### 4. Plan the search strategy + +Before scheduling or responding, check existing commitments. Plan queries for: +- **Current schedule** — Pull events for the relevant time range to check for overlaps. +- **Contextual data** — If the meeting relates to an email thread or GitHub issue, pull that data for context (titles, participants). + +### 5. Execute the searches (Pull) + +Pull calendar data from PersonalDataHub: + +```bash +curl -s -X POST /app/v1/pull \ + -H "Content-Type: application/json" \ + -d '{"source": "google_calendar", "query": "", "after": "", "limit": 20, "purpose": "Checking availability for "}' +``` + +**Guidelines:** +- Use the `after` field to limit results to relevant upcoming times. +- If checking a specific day, pull events for that entire 24h window. +- Deduplicate results if running multiple queries. + +### 6. Analyze and synthesize + +Review the retrieved events: +- Identify free slots. +- Note any direct conflicts. +- Extract event IDs if the user wants to update or delete a specific item. + +Present a summary of the current schedule or the proposed time to the user. + +### 7. Propose Actions + +Calendar modifications require user approval via the `propose` endpoint. + +#### Create an Event: +```bash +curl -s -X POST /app/v1/propose \ + -H "Content-Type: application/json" \ + -d '{ + "source": "google_calendar", + "action_type": "create_event", + "action_data": { + "title": "", + "body": "", + "location": "", + "start": "", + "end": "", + "timeZone": "UTC", + "attendees": [{"email": "user@example.com"}] + }, + "purpose": "Scheduling as requested by user" + }' +``` + +#### Update an Event: +```bash +curl -s -X POST <hubUrl>/app/v1/propose \ + -H "Content-Type: application/json" \ + -d '{ + "source": "google_calendar", + "action_type": "update_event", + "action_data": { + "eventId": "<id>", + "title": "<new_summary>" + }, + "purpose": "Updating event title" + }' +``` + +#### Delete an Event: +```bash +curl -s -X POST <hubUrl>/app/v1/propose \ + -H "Content-Type: application/json" \ + -d '{ + "source": "google_calendar", + "action_type": "delete_event", + "action_data": { "eventId": "<id>" }, + "purpose": "Deleting cancelled event" + }' +``` + +### 8. Finalize + +Tell the user the action has been proposed and is waiting for their approval in the PersonalDataHub GUI at `<hubUrl>`. + +## Important notes + +- **All data goes through PersonalDataHub's access control.** You will only see events the owner has authorized. +- **Modifications require owner approval.** The `propose` endpoint stages the change — it does NOT immediately update the calendar. +- **Always provide a clear `purpose`.** Every API call is audited. +- **Show your work.** List the events you found that led to your recommendation or time suggestion. diff --git a/src/ai/mcp/server.ts b/src/ai/mcp/server.ts index 398560e..49f4662 100644 --- a/src/ai/mcp/server.ts +++ b/src/ai/mcp/server.ts @@ -201,6 +201,119 @@ function registerGitHubTools(server: McpServer, hubUrl: string): void { ); } +function registerCalendarTools(server: McpServer, hubUrl: string): void { + server.tool( + 'read_calendar_events', + 'Pull events from Google Calendar. Data is filtered according to the owner\'s access control policy.', + { + query: z.string().optional().describe('Search query for events'), + after: z.string().optional().describe('ISO timestamp to fetch events after (e.g. "2026-03-20T00:00:00Z")'), + limit: z.number().optional().describe('Maximum number of results'), + purpose: z.string().describe('Why this data is needed (logged for audit)'), + }, + { readOnlyHint: true, destructiveHint: false }, + async ({ query, after, limit, purpose }) => { + const body: Record<string, unknown> = { source: 'google_calendar', purpose }; + if (query) body.query = query; + if (after) body.after = after; + if (limit) body.limit = limit; + + const res = await fetch(`${hubUrl}/app/v1/pull`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + const json = await res.json(); + return { content: [{ type: 'text' as const, text: JSON.stringify(json, null, 2) }] }; + }, + ); + + server.tool( + 'create_calendar_event', + 'Propose a new Google Calendar event. The event is staged for the data owner to review — it is NOT created until approved.', + { + title: z.string().describe('Event summary'), + body: z.string().optional().describe('Event description'), + location: z.string().optional().describe('Event location'), + start: z.string().describe('ISO timestamp for start time'), + end: z.string().describe('ISO timestamp for end time'), + timeZone: z.string().optional().describe('Timezone (e.g. "UTC", "America/Los_Angeles")'), + purpose: z.string().describe('Why this action is being proposed (logged for audit)'), + }, + { readOnlyHint: false, destructiveHint: false }, + async ({ title, body, location, start, end, timeZone, purpose }) => { + const res = await fetch(`${hubUrl}/app/v1/propose`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + source: 'google_calendar', + action_type: 'create_event', + action_data: { title, body, location, start, end, timeZone }, + purpose, + }), + }); + + const json = await res.json(); + return { content: [{ type: 'text' as const, text: JSON.stringify(json, null, 2) }] }; + }, + ); + + server.tool( + 'update_calendar_event', + 'Propose an update to an existing Google Calendar event. Staged for owner review.', + { + eventId: z.string().describe('The ID of the event to update'), + title: z.string().optional().describe('New event summary'), + body: z.string().optional().describe('New event description'), + start: z.string().optional().describe('New ISO start time'), + end: z.string().optional().describe('New ISO end time'), + purpose: z.string().describe('Why this action is being proposed (logged for audit)'), + }, + { readOnlyHint: false, destructiveHint: false }, + async ({ eventId, title, body, start, end, purpose }) => { + const res = await fetch(`${hubUrl}/app/v1/propose`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + source: 'google_calendar', + action_type: 'update_event', + action_data: { eventId, title, body, start, end }, + purpose, + }), + }); + + const json = await res.json(); + return { content: [{ type: 'text' as const, text: JSON.stringify(json, null, 2) }] }; + }, + ); + + server.tool( + 'delete_calendar_event', + 'Propose deleting a Google Calendar event. Staged for owner review.', + { + eventId: z.string().describe('The ID of the event to delete'), + purpose: z.string().describe('Why this action is being proposed (logged for audit)'), + }, + { readOnlyHint: false, destructiveHint: true }, + async ({ eventId, purpose }) => { + const res = await fetch(`${hubUrl}/app/v1/propose`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + source: 'google_calendar', + action_type: 'delete_event', + action_data: { eventId }, + purpose, + }), + }); + + const json = await res.json(); + return { content: [{ type: 'text' as const, text: JSON.stringify(json, null, 2) }] }; + }, + ); +} + export async function startMcpServer(): Promise<McpServer> { const config = readConfig(); if (!config) { @@ -227,6 +340,16 @@ export async function startMcpServer(): Promise<McpServer> { registeredTools.push('read_emails', 'draft_email', 'send_email', 'reply_to_email'); } + if (sources.google_calendar?.connected) { + registerCalendarTools(server, hubUrl); + registeredTools.push( + 'read_calendar_events', + 'create_calendar_event', + 'update_calendar_event', + 'delete_calendar_event', + ); + } + if (sources.github?.connected) { registerGitHubTools(server, hubUrl); registeredTools.push('search_github_issues', 'search_github_prs'); diff --git a/src/gateway/connectors/calendar/calendar.test.ts b/src/gateway/connectors/calendar/calendar.test.ts new file mode 100644 index 0000000..707d4d2 --- /dev/null +++ b/src/gateway/connectors/calendar/calendar.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import { mapCalendarEvent } from './connector.js'; +import type { calendar_v3 } from 'googleapis'; + +function makeCalendarEvent(overrides?: Partial<calendar_v3.Schema$Event>): calendar_v3.Schema$Event { + return { + id: 'event_test_123', + summary: 'Team Sync', + description: 'Weekly sync with the team', + location: 'Meeting Room A', + start: { dateTime: '2026-03-20T10:00:00Z' }, + end: { dateTime: '2026-03-20T11:00:00Z' }, + htmlLink: 'https://calendar.google.com/event?id=123', + status: 'confirmed', + creator: { email: 'alice@company.com' }, + organizer: { email: 'alice@company.com' }, + attendees: [ + { displayName: 'Bob Jones', email: 'bob@company.com', responseStatus: 'accepted' }, + { displayName: 'Charlie', email: 'charlie@company.com', responseStatus: 'needsAction' }, + ], + ...overrides, + }; +} + +describe('Google Calendar Connector', () => { + it('maps raw Calendar API event to correct DataRow with all fields', () => { + const event = makeCalendarEvent(); + const row = mapCalendarEvent(event); + + expect(row.source).toBe('google_calendar'); + expect(row.source_item_id).toBe('event_test_123'); + expect(row.type).toBe('calendar_event'); + expect(row.timestamp).toBe('2026-03-20T10:00:00.000Z'); + + expect(row.data.title).toBe('Team Sync'); + expect(row.data.body).toBe('Weekly sync with the team'); + expect(row.data.location).toBe('Meeting Room A'); + expect(row.data.start).toBe('2026-03-20T10:00:00Z'); + expect(row.data.end).toBe('2026-03-20T11:00:00Z'); + expect(row.data.url).toBe('https://calendar.google.com/event?id=123'); + expect(row.data.status).toBe('confirmed'); + expect(row.data.creator).toBe('alice@company.com'); + expect(row.data.isAllDay).toBe(false); + }); + + it('extracts attendees correctly', () => { + const event = makeCalendarEvent(); + const row = mapCalendarEvent(event); + const attendees = row.data.attendees as Array<{ name: string; email: string; responseStatus: string }>; + + expect(attendees).toHaveLength(2); + expect(attendees[0]).toEqual({ name: 'Bob Jones', email: 'bob@company.com', responseStatus: 'accepted' }); + expect(attendees[1]).toEqual({ name: 'Charlie', email: 'charlie@company.com', responseStatus: 'needsAction' }); + }); + + it('handles all-day events correctly', () => { + const event = makeCalendarEvent({ + start: { date: '2026-03-20' }, + end: { date: '2026-03-21' }, + }); + + const row = mapCalendarEvent(event); + expect(row.data.start).toBe('2026-03-20'); + expect(row.data.isAllDay).toBe(true); + expect(row.timestamp).toBe(new Date('2026-03-20').toISOString()); + }); + + it('handles missing summary gracefully', () => { + const event = makeCalendarEvent({ summary: undefined }); + const row = mapCalendarEvent(event); + expect(row.data.title).toBe('(No Title)'); + }); + + it('handles missing description gracefully', () => { + const event = makeCalendarEvent({ description: undefined }); + const row = mapCalendarEvent(event); + expect(row.data.body).toBe(''); + }); +}); diff --git a/src/gateway/connectors/calendar/connector.ts b/src/gateway/connectors/calendar/connector.ts new file mode 100644 index 0000000..7678abc --- /dev/null +++ b/src/gateway/connectors/calendar/connector.ts @@ -0,0 +1,200 @@ +import { google, type calendar_v3 } from 'googleapis'; +import type { SourceConnector, DataRow, SourceBoundary, ActionResult } from '../types.js'; + +export interface CalendarConnectorConfig { + clientId: string; + clientSecret: string; + accessToken?: string; + refreshToken?: string; +} + +export class GoogleCalendarConnector implements SourceConnector { + name = 'google_calendar'; + private calendar: calendar_v3.Calendar; + private auth: InstanceType<typeof google.auth.OAuth2>; + private lastSyncTimestamp?: string; + + constructor(config: CalendarConnectorConfig) { + this.auth = new google.auth.OAuth2(config.clientId, config.clientSecret); + if (config.accessToken || config.refreshToken) { + this.auth.setCredentials({ + access_token: config.accessToken, + refresh_token: config.refreshToken, + }); + } + this.calendar = google.calendar({ version: 'v3', auth: this.auth }); + } + + /** + * Expose the underlying OAuth2 client so callers can listen for + * the 'tokens' event (fired when tokens are auto-refreshed). + */ + getAuth(): InstanceType<typeof google.auth.OAuth2> { + return this.auth; + } + + /** + * Update the access token on the underlying OAuth2 client. + */ + setAccessToken(token: string): void { + this.auth.setCredentials({ ...this.auth.credentials, access_token: token }); + } + + async fetch(boundary: SourceBoundary, params?: Record<string, unknown>): Promise<DataRow[]> { + const listParams: calendar_v3.Params$Resource$Events$List = { + calendarId: 'primary', + maxResults: (params?.limit as number) ?? 50, + singleEvents: true, + orderBy: 'startTime', + }; + + if (boundary.after) { + listParams.timeMin = new Date(boundary.after).toISOString(); + } + + if (params?.query) { + listParams.q = params.query as string; + } + + console.log('[calendar] list params:', JSON.stringify(listParams)); + + const response = await this.calendar.events.list(listParams); + const events = response.data.items ?? []; + + return events.map(mapCalendarEvent); + } + + async executeAction(actionType: string, actionData: Record<string, unknown>): Promise<ActionResult> { + switch (actionType) { + case 'create_event': + return this.createEvent(actionData); + case 'update_event': + return this.updateEvent(actionData); + case 'delete_event': + return this.deleteEvent(actionData); + case 'list_calendars': + return this.listCalendars(); + default: + return { success: false, message: `Unknown action type: ${actionType}` }; + } + } + + async sync(boundary: SourceBoundary): Promise<DataRow[]> { + const params: Record<string, unknown> = {}; + if (this.lastSyncTimestamp) { + // Use lastSyncTimestamp to filter new/updated events + boundary.after = this.lastSyncTimestamp; + } + + const rows = await this.fetch(boundary, params); + this.lastSyncTimestamp = new Date().toISOString(); + return rows; + } + + private async createEvent(data: Record<string, unknown>): Promise<ActionResult> { + const event: calendar_v3.Schema$Event = { + summary: data.title as string, + description: data.body as string, + location: data.location as string, + start: { + dateTime: data.start as string, + timeZone: data.timeZone as string, + }, + end: { + dateTime: data.end as string, + timeZone: data.timeZone as string, + }, + attendees: (data.attendees as Array<{ email: string }>) ?? [], + }; + + const response = await this.calendar.events.insert({ + calendarId: 'primary', + requestBody: event, + }); + + return { + success: true, + message: 'Event created', + resultData: { eventId: response.data.id, htmlLink: response.data.htmlLink }, + }; + } + + private async updateEvent(data: Record<string, unknown>): Promise<ActionResult> { + const eventId = data.eventId as string; + if (!eventId) { + return { success: false, message: 'Missing eventId' }; + } + + const event: calendar_v3.Schema$Event = { + summary: data.title as string, + description: data.body as string, + location: data.location as string, + start: data.start ? { dateTime: data.start as string } : undefined, + end: data.end ? { dateTime: data.end as string } : undefined, + }; + + const response = await this.calendar.events.patch({ + calendarId: 'primary', + eventId, + requestBody: event, + }); + + return { + success: true, + message: 'Event updated', + resultData: { eventId: response.data.id, htmlLink: response.data.htmlLink }, + }; + } + + private async deleteEvent(data: Record<string, unknown>): Promise<ActionResult> { + const eventId = data.eventId as string; + if (!eventId) { + return { success: false, message: 'Missing eventId' }; + } + + await this.calendar.events.delete({ + calendarId: 'primary', + eventId, + }); + + return { success: true, message: 'Event deleted' }; + } + + private async listCalendars(): Promise<ActionResult> { + const response = await this.calendar.calendarList.list(); + return { + success: true, + message: 'Calendars retrieved', + resultData: { calendars: response.data.items }, + }; + } +} + +export function mapCalendarEvent(event: calendar_v3.Schema$Event): DataRow { + const start = event.start?.dateTime || event.start?.date || ''; + const end = event.end?.dateTime || event.end?.date || ''; + + return { + source: 'google_calendar', + source_item_id: event.id ?? '', + type: 'calendar_event', + timestamp: start ? new Date(start).toISOString() : new Date().toISOString(), + data: { + title: event.summary ?? '(No Title)', + body: event.description ?? '', + location: event.location ?? '', + start, + end, + attendees: event.attendees?.map((a) => ({ + name: a.displayName, + email: a.email, + responseStatus: a.responseStatus, + })) ?? [], + url: event.htmlLink ?? '', + status: event.status, + creator: event.creator?.email, + organizer: event.organizer?.email, + isAllDay: !!event.start?.date, + }, + }; +} diff --git a/src/gateway/gateway.ts b/src/gateway/gateway.ts index c586265..5559637 100644 --- a/src/gateway/gateway.ts +++ b/src/gateway/gateway.ts @@ -12,6 +12,7 @@ import type { HubConfigParsed } from '../config/schema.js'; import type { ConnectorRegistry } from './connectors/types.js'; import { TokenManager } from './auth/token-manager.js'; import { GmailConnector } from './connectors/gmail/connector.js'; +import { GoogleCalendarConnector } from './connectors/calendar/connector.js'; import { GitHubConnector } from './connectors/github/connector.js'; import { createServer, type ServerDeps } from './server.js'; @@ -62,6 +63,34 @@ export async function createGateway(opts: GatewayOptions): Promise<GatewayResult } } + // Google Calendar connector + if (config.sources.google_calendar?.enabled) { + const clientId = config.sources.google_calendar.owner_auth.clientId ?? ''; + const clientSecret = config.sources.google_calendar.owner_auth.clientSecret ?? ''; + + const storedToken = await tokenManager.getToken('google_calendar'); + if (storedToken) { + const connector = new GoogleCalendarConnector({ + clientId, + clientSecret, + accessToken: storedToken.access_token, + refreshToken: storedToken.refresh_token, + }); + connectorRegistry.set('google_calendar', connector); + + connector.getAuth().on('tokens', async (newTokens) => { + if (newTokens.access_token) { + const expiresAt = newTokens.expiry_date + ? new Date(newTokens.expiry_date).toISOString() + : undefined; + await tokenManager.updateAccessToken('google_calendar', newTokens.access_token, expiresAt); + } + }); + } else { + connectorRegistry.set('google_calendar', new GoogleCalendarConnector({ clientId, clientSecret })); + } + } + // GitHub connector — restore from stored token or create empty if (config.sources.github?.enabled) { const githubConfig = config.sources.github; diff --git a/src/gateway/gui/routes.ts b/src/gateway/gui/routes.ts index 0eacdf0..7bad484 100644 --- a/src/gateway/gui/routes.ts +++ b/src/gateway/gui/routes.ts @@ -7,6 +7,7 @@ import type { TokenManager } from '../auth/token-manager.js'; import { google } from 'googleapis'; import { AuditLog } from '../audit/log.js'; import { GmailConnector } from '../connectors/gmail/connector.js'; +import { GoogleCalendarConnector } from '../connectors/calendar/connector.js'; import { GitHubConnector } from '../connectors/github/connector.js'; import { Octokit } from 'octokit'; import { FILTER_TYPES, applyFilters, type QuickFilter } from '../filters.js'; @@ -84,6 +85,21 @@ export function createGuiRoutes(deps: GuiDeps): Hono { } } + // Backfill Calendar account info if empty + const calSource = sources.find((s) => s.name === 'google_calendar' && s.connected); + if (calSource && (!calSource.accountInfo || !calSource.accountInfo.email)) { + const connector = deps.connectorRegistry.get('google_calendar'); + if (connector && connector instanceof GoogleCalendarConnector) { + try { + const calApi = google.calendar({ version: 'v3', auth: connector.getAuth() }); + const profile = await calApi.calendarList.get({ calendarId: 'primary' }); + const info = { email: profile.data.id ?? undefined }; + await deps.tokenManager.updateAccountInfo('google_calendar', info); + calSource.accountInfo = info; + } catch (_) { /* non-fatal */ } + } + } + return c.json({ ok: true, sources }); }); @@ -291,6 +307,82 @@ export function createGuiRoutes(deps: GuiDeps): Hono { return c.json({ ok: true }); }); + // Fetch real calendar events from connected account + app.get('/api/calendar/events', async (c) => { + const connector = deps.connectorRegistry.get('google_calendar'); + if (!connector || !(connector instanceof GoogleCalendarConnector)) { + return c.json({ ok: false, error: 'Calendar not connected' }, 401); + } + + try { + const calConfig = deps.config.sources.google_calendar; + const boundary = calConfig?.boundary ?? {}; + const limit = parseInt(c.req.query('limit') ?? '20', 10); + const rows = await connector.fetch(boundary, { limit }); + + const events = rows.map((row) => { + const d = row.data as Record<string, unknown>; + return { + id: row.source_item_id, + title: d.title || '', + start: d.start || '', + end: d.end || '', + location: d.location || '', + body: d.body || '', + url: d.url || '', + isAllDay: d.isAllDay || false, + }; + }); + + return c.json({ ok: true, events }); + } catch (err) { + const message = err instanceof Error ? err.message : 'unknown_error'; + return c.json({ ok: false, error: message }, 500); + } + }); + + // Preview calendar events with filters applied + app.get('/api/calendar/preview', async (c) => { + const connector = deps.connectorRegistry.get('google_calendar'); + if (!connector || !(connector instanceof GoogleCalendarConnector)) { + return c.json({ ok: false, error: 'Calendar not connected' }, 401); + } + + try { + const calConfig = deps.config.sources.google_calendar; + const boundary = calConfig?.boundary ?? {}; + const limit = parseInt(c.req.query('limit') ?? '20', 10); + const rows = await connector.fetch(boundary, { limit }); + + const filters = await deps.store.getEnabledFiltersBySource('google_calendar') as QuickFilter[]; + const filtered = applyFilters(rows, filters); + + const mapRow = (row: import('../connectors/types.js').DataRow) => { + const d = row.data as Record<string, unknown>; + return { + id: row.source_item_id, + title: d.title || '', + start: d.start || '', + end: d.end || '', + location: d.location || '', + body: d.body || '', + url: d.url || '', + isAllDay: d.isAllDay || false, + }; + }; + + return c.json({ + ok: true, + events: filtered.map(mapRow), + totalFetched: rows.length, + afterFilters: filtered.length, + }); + } catch (err) { + const message = err instanceof Error ? err.message : 'unknown_error'; + return c.json({ ok: false, error: message }, 500); + } + }); + // Fetch real emails from connected Gmail account app.get('/api/gmail/emails', async (c) => { const connector = deps.connectorRegistry.get('gmail'); @@ -686,17 +778,17 @@ function getIndexHtml(): string { <span class="status-dot status-dot-disconnected" id="gmail-dot"></span> <span class="nav-badge" id="gmail-badge" style="display:none">0</span> </a> - <a class="nav-item disabled"> + <a class="nav-item" data-tab="github" onclick="switchTab('github')"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"/></svg> <span class="nav-label">GitHub</span> - <span class="nav-badge-muted">soon</span> + <span class="status-dot status-dot-disconnected" id="github-dot"></span> </a> - <a class="nav-item disabled"> + <a class="nav-item" data-tab="google_calendar" onclick="switchTab('google_calendar')"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> <span class="nav-label">Calendar</span> - <span class="nav-badge-muted">soon</span> - </a> - <a class="nav-item disabled"> + <span class="status-dot status-dot-disconnected" id="calendar-dot"></span> + <span class="nav-badge" id="calendar-badge" style="display:none">0</span> + </a> <a class="nav-item disabled"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg> <span class="nav-label">Slack</span> <span class="nav-badge-muted">soon</span> @@ -745,8 +837,10 @@ function getIndexHtml(): string { realEmails: null, emailsLoading: false, emailsError: null, - filterTypes: {}, - }; + realEvents: null, + eventsLoading: false, + eventsError: null, + filterTypes: {}, }; let _saveTimer = null; // Sidebar nav switching @@ -809,6 +903,7 @@ function getIndexHtml(): string { case 'overview': content.innerHTML = renderOverviewTab(); break; case 'gmail': content.innerHTML = renderGmailTab(); break; case 'github': content.innerHTML = renderGitHubTab(); break; + case 'google_calendar': content.innerHTML = renderCalendarTab(); break; case 'settings': content.innerHTML = renderSettingsTab(); break; } // Update sidebar badges and status dots @@ -818,12 +913,24 @@ function getIndexHtml(): string { if (gmailPendingCount) { gmailBadge.textContent = gmailPendingCount; gmailBadge.style.display = ''; } else { gmailBadge.style.display = 'none'; } } + var calPendingCount = state.staging.filter(function(a) { return a.source === 'google_calendar' && a.status === 'pending'; }).length; + var calBadge = document.getElementById('calendar-badge'); + if (calBadge) { + if (calPendingCount) { calBadge.textContent = calPendingCount; calBadge.style.display = ''; } + else { calBadge.style.display = 'none'; } + } // Gmail status dot var gmailSource = state.sources.find(function(s) { return s.name === 'gmail'; }); var gmailDot = document.getElementById('gmail-dot'); if (gmailDot) { gmailDot.className = 'status-dot ' + (gmailSource && gmailSource.connected ? 'status-dot-connected' : 'status-dot-disconnected'); } + // Calendar status dot + var calSource = state.sources.find(function(s) { return s.name === 'google_calendar'; }); + var calDot = document.getElementById('calendar-dot'); + if (calDot) { + calDot.className = 'status-dot ' + (calSource && calSource.connected ? 'status-dot-connected' : 'status-dot-disconnected'); + } // GitHub status dot var ghSource = state.sources.find(function(s) { return s.name === 'github'; }); var ghDot = document.getElementById('github-dot'); @@ -842,12 +949,17 @@ function getIndexHtml(): string { function renderOverviewTab() { var gmail = state.sources.find(function(s) { return s.name === 'gmail'; }); var github = state.sources.find(function(s) { return s.name === 'github'; }); + var cal = state.sources.find(function(s) { return s.name === 'google_calendar'; }); var gmailConnected = gmail && gmail.connected; var ghConnected = github && github.connected; + var calConnected = cal && cal.connected; var gmailAccount = gmail && gmail.accountInfo; var ghAccount = github && github.accountInfo; + var calAccount = cal && cal.accountInfo; var gmailFilters = (state.filters || []).filter(function(f) { return f.source === 'gmail'; }); var activeFilterCount = gmailFilters.filter(function(f) { return f.enabled; }).length; + var calFilters = (state.filters || []).filter(function(f) { return f.source === 'google_calendar'; }); + var activeCalFilterCount = calFilters.filter(function(f) { return f.enabled; }).length; var enabledRepos = (state.github.repoList || []).filter(function(r) { return r.enabled; }).length; var totalRepos = (state.github.repoList || []).length; var pendingCount = state.staging.filter(function(a) { return a.status === 'pending'; }).length; @@ -896,15 +1008,34 @@ function getIndexHtml(): string { <div style="margin-top:12px;display:flex;align-items:center;gap:4px;font-size:14px;color:var(--primary);font-weight:500">Configure <span style="font-size:14px">→</span></div> </div> - <div class="card" style="opacity:0.6"> + <div class="card" style="cursor:pointer" onclick="switchTab('google_calendar')"> + <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px"> + <div style="display:flex;align-items:center;gap:8px"> + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> + <span style="font-weight:600;font-size:14px">Calendar</span> + </div> + <span class="status-dot \${calConnected ? 'status-dot-connected' : 'status-dot-disconnected'}"></span> + </div> + \${calConnected && calAccount && calAccount.email ? '<p style="font-size:14px;color:var(--muted);margin-bottom:8px">' + calAccount.email + '</p>' : '<p style="font-size:14px;color:var(--muted);margin-bottom:8px">Not connected</p>'} + <div style="display:flex;align-items:center;justify-content:space-between"> + <span style="font-size:14px;color:var(--muted)">Filters: <strong class="font-mono" style="color:var(--fg)">\${activeCalFilterCount} active</strong></span> + </div> + <div style="margin-top:12px;display:flex;align-items:center;gap:4px;font-size:14px;color:var(--primary);font-weight:500">Configure <span style="font-size:14px">→</span></div> + </div> + + <div class="card" style="cursor:pointer" onclick="switchTab('github')"> <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px"> <div style="display:flex;align-items:center;gap:8px"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"/></svg> <span style="font-weight:600;font-size:14px">GitHub</span> </div> - <span class="nav-badge-muted" style="font-size:12px">soon</span> + <span class="status-dot \${ghConnected ? 'status-dot-connected' : 'status-dot-disconnected'}"></span> + </div> + \${ghConnected && ghAccount && ghAccount.login ? '<p style="font-size:14px;color:var(--muted);margin-bottom:8px">@' + ghAccount.login + '</p>' : '<p style="font-size:14px;color:var(--muted);margin-bottom:8px">Not connected</p>'} + <div style="display:flex;align-items:center;justify-content:space-between"> + <span style="font-size:14px;color:var(--muted)">Repos: <strong class="font-mono" style="color:var(--fg)">\${enabledRepos} selected</strong></span> </div> - <p style="font-size:14px;color:var(--muted);margin-bottom:8px">Coming soon</p> + <div style="margin-top:12px;display:flex;align-items:center;gap:4px;font-size:14px;color:var(--primary);font-weight:500">Configure <span style="font-size:14px">→</span></div> </div> <div class="card" style="cursor:pointer" onclick="switchTab('settings')"> @@ -1106,6 +1237,136 @@ function getIndexHtml(): string { \`; } + function renderCalendarTab() { + var cal = state.sources.find(function(s) { return s.name === 'google_calendar'; }); + var realStaging = state.staging.filter(function(a) { return a.source === 'google_calendar'; }); + var calStaging = realStaging; + var pendingCount = calStaging.filter(function(a) { return a.status === 'pending'; }).length; + + var calFilters = (state.filters || []).filter(function(f) { return f.source === 'google_calendar'; }); + + var calConnected = cal && cal.connected; + var calAccount = cal && cal.accountInfo; + var accountEmail = calAccount && calAccount.email ? calAccount.email : ''; + + var events = state.realEvents || []; + var visibleEvents = events; + + // Disconnected state + if (!calConnected) { + return '<div style="max-width:480px;margin:60px auto;text-align:center">' + + '<h1 style="font-size:24px;font-weight:700;margin-bottom:8px">Calendar</h1>' + + '<p style="font-size:14px;color:var(--muted);margin-bottom:4px">Connect your Google Calendar account to control agent access to your events.</p>' + + '<p style="font-size:14px;color:var(--muted);margin-bottom:24px;opacity:0.7">Powered by OAuth — we never store your password.</p>' + + '<button class="btn btn-primary" onclick="startOAuth(\\'google_calendar\\')" style="gap:8px">' + + '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>' + + 'Connect Calendar</button></div>'; + } + + // Build event list + var eventListHtml = ''; + visibleEvents.forEach(function(ev) { + var safe = ev.id.replace(/'/g, "\\\\'"); + var dt = new Date(ev.start); + var timeStr = dt.toLocaleDateString(undefined, { month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' }); + + eventListHtml += '<div class="email-row" style="padding:12px 16px">'; + eventListHtml += '<div style="display:flex;gap:12px;width:100%">'; + eventListHtml += '<div class="email-row-vis email-row-vis-on"></div>'; + eventListHtml += '<div style="flex:1;min-width:0">'; + eventListHtml += '<div style="display:flex;align-items:center;gap:8px">'; + eventListHtml += '<span class="email-row-sender">' + escapeHtml(ev.title) + '</span>'; + eventListHtml += '<span class="email-row-date" style="margin-left:auto">' + timeStr + '</span>'; + eventListHtml += '</div>'; + if (ev.location) eventListHtml += '<div style="font-size:12px;color:var(--muted);margin-top:2px"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:middle;margin-right:4px"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>' + escapeHtml(ev.location) + '</div>'; + if (ev.body) eventListHtml += '<div class="email-row-snippet" style="margin-top:4px">' + escapeHtml(ev.body) + '</div>'; + eventListHtml += '</div>'; + eventListHtml += '</div>'; + eventListHtml += '</div>'; + }); + + // Build action cards + var actionHtml = ''; + calStaging.forEach(function(a) { + var data = typeof a.action_data === 'string' ? JSON.parse(a.action_data) : a.action_data; + var isPending = a.status === 'pending'; + var safe = a.action_id.replace(/'/g, "\\\\'"); + var borderClass = isPending ? 'border-left:3px solid var(--warning)' : a.status === 'approved' ? 'border-left:3px solid var(--success);opacity:0.6' : 'border-left:3px solid var(--destructive);opacity:0.6'; + var statusClass = isPending ? 'pending' : a.status === 'approved' ? 'connected' : 'rejected'; + var typeLabel = a.action_type.replace('_event', ''); + var time = new Date(a.proposed_at || a.createdAt); + var timeStr = time.toLocaleTimeString([], { hour:'2-digit', minute:'2-digit' }); + + actionHtml += '<div class="card" style="padding:16px;' + borderClass + '">'; + actionHtml += '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">'; + actionHtml += '<div style="display:flex;align-items:center;gap:6px">'; + actionHtml += '<span class="status ' + statusClass + '" style="font-size:14px;font-family:JetBrains Mono,monospace;text-transform:uppercase;padding:2px 8px">' + a.status + '</span>'; + actionHtml += '<span style="font-size:14px;font-family:JetBrains Mono,monospace;color:var(--muted);text-transform:uppercase">' + typeLabel + '</span>'; + actionHtml += '</div>'; + actionHtml += '<span style="font-size:14px;font-family:JetBrains Mono,monospace;color:var(--muted)">' + timeStr + '</span>'; + actionHtml += '</div>'; + if (a.purpose) actionHtml += '<p style="font-size:14px;color:var(--muted);margin-bottom:8px">' + escapeHtml(a.purpose) + '</p>'; + + actionHtml += '<div style="font-size:14px;display:flex;flex-direction:column;gap:4px">'; + actionHtml += '<div style="display:flex;gap:8px"><span style="color:var(--muted);width:48px;flex-shrink:0">Event:</span><span class="font-mono" style="color:var(--fg)">' + escapeHtml(data.title || '') + '</span></div>'; + if (data.start) actionHtml += '<div style="display:flex;gap:8px"><span style="color:var(--muted);width:48px;flex-shrink:0">Start:</span><span class="font-mono" style="color:var(--fg)">' + new Date(data.start).toLocaleString() + '</span></div>'; + actionHtml += '</div>'; + + if (isPending) { + actionHtml += '<div style="display:flex;align-items:center;gap:6px;margin-top:12px">'; + actionHtml += '<button class="btn btn-sm btn-outline" style="color:var(--destructive);border-color:rgba(239,68,68,0.3);gap:4px" onclick="resolveAction(\\'' + safe + '\\', \\'reject\\')">Deny</button>'; + actionHtml += '<button class="btn btn-sm" style="background:var(--success);color:#fff;gap:4px" onclick="resolveAction(\\'' + safe + '\\', \\'approve\\')">Approve</button>'; + actionHtml += '</div>'; + } + actionHtml += '</div>'; + }); + if (!actionHtml) actionHtml = '<div class="card" style="padding:24px;text-align:center;color:var(--muted);font-size:14px">No pending actions.</div>'; + + return \` + <div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:24px"> + <div style="display:flex;align-items:center;gap:16px"> + <div> + <h1 style="font-size:24px;font-weight:700;letter-spacing:-0.5px;color:var(--fg)">Calendar</h1> + \${accountEmail ? '<p style="font-size:13px;color:var(--muted);margin-top:2px">' + escapeHtml(accountEmail) + '</p>' : ''} + </div> + </div> + <button class="btn btn-outline btn-sm" style="color:var(--destructive);border-color:rgba(239,68,68,0.3)" onclick="if(confirm('Disconnect Calendar?')){disconnectSource('google_calendar')}">Disconnect</button> + </div> + + <div class="card" style="padding:20px;margin-bottom:16px"> + <label style="font-size:12px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:0.8px;display:block;margin-bottom:14px">Quick Filters</label> + \${renderCalendarFilterCards(calFilters)} + </div> + + <div class="gmail-grid"> + <div class="gmail-grid-left"> + <div class="action-review-header"> + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--muted)"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg> + <h2 style="margin:0">Agent Access Preview</h2> + </div> + <div class="card" style="padding:0;overflow:hidden"> + <div class="email-list-header"> + <span class="stat">Showing: <strong>\${visibleEvents.length}</strong> events</span> + \${calConnected && !state.eventsLoading ? '<button onclick="refreshCalendarEvents()" style="margin-left:auto;background:none;border:1px solid var(--border);border-radius:4px;padding:2px 10px;font-size:12px;color:var(--muted);cursor:pointer">Refresh</button>' : ''} + </div> + \${state.eventsLoading + ? '<div style="padding:40px;text-align:center"><p style="color:var(--muted);font-size:14px">Loading events...</p></div>' + : (eventListHtml || '<p class="empty" style="padding:40px">No events found.</p>')} + </div> + </div> + + <div class="gmail-grid-right"> + <div class="action-review-header"> + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--muted)"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> + <h2 style="margin:0">Agent Action Review</h2> + \${pendingCount ? '<span class="nav-badge">' + pendingCount + '</span>' : ''} + </div> + \${actionHtml} + </div> + </div> + \`; + } + function renderGitHubTab() { const github = state.sources.find(s => s.name === 'github'); var ghConnected = github && github.connected; @@ -1319,6 +1580,67 @@ function getIndexHtml(): string { return html; } + function renderCalendarFilterCards(filters) { + var types = state.filterTypes || {}; + var typeKeys = Object.keys(types).filter(function(k) { return k === 'time_after'; }); // Only time_after for calendar for now + if (!typeKeys.length) return '<p class="empty">Loading filter types...</p>'; + + var html = '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px">'; + typeKeys.forEach(function(typeKey) { + var meta = types[typeKey]; + var existing = filters.find(function(f) { return f.type === typeKey; }); + var isEnabled = existing ? !!existing.enabled : false; + var value = existing ? (existing.value || '') : ''; + var filterId = existing ? existing.id : ''; + var safeType = escapeAttr(typeKey); + var needsValue = meta.needsValue; + + html += '<div class="card" style="padding:14px;margin:0;border:1px solid ' + (isEnabled ? 'rgba(15,160,129,0.3)' : 'var(--border)') + ';transition:border-color 0.2s">'; + html += '<div style="display:flex;align-items:center;gap:10px;margin-bottom:' + (needsValue ? '10px' : '0') + '">'; + html += '<label style="position:relative;display:inline-block;width:36px;height:20px;margin:0;cursor:pointer;flex-shrink:0">'; + html += '<input type="checkbox" ' + (isEnabled ? 'checked' : '') + ' onchange="toggleCalendarFilter("' + safeType + '", this.checked, "' + escapeAttr(filterId) + '")" style="opacity:0;width:0;height:0">'; + html += '<span style="position:absolute;inset:0;background:' + (isEnabled ? 'var(--primary)' : '#ccc') + ';border-radius:10px;transition:background 0.2s"></span>'; + html += '<span style="position:absolute;left:' + (isEnabled ? '18px' : '2px') + ';top:2px;width:16px;height:16px;background:#fff;border-radius:50%;transition:left 0.2s;box-shadow:0 1px 3px rgba(0,0,0,0.2)"></span>'; + html += '</label>'; + html += '<span style="font-size:14px;font-weight:500;color:' + (isEnabled ? 'var(--fg)' : 'var(--muted)') + '">' + escapeHtml(meta.label) + '</span>'; + html += '</div>'; + if (needsValue) { + html += '<input type="' + (typeKey === 'time_after' ? 'date' : 'text') + '" id="cal-filter-val-' + safeType + '" value="' + escapeAttr(value) + '" placeholder="' + escapeAttr(meta.placeholder) + '" onchange="updateCalendarFilterValue("' + safeType + '", this.value, "' + escapeAttr(filterId) + '")" style="width:100%;font-size:13px;padding:6px 10px">'; + } + html += '</div>'; + }); + html += '</div>'; + return html; + } + + async function toggleCalendarFilter(type, enabled, existingId) { + var valEl = document.getElementById('cal-filter-val-' + type); + var value = valEl ? valEl.value : ''; + await fetch('/api/filters', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: existingId || undefined, source: 'google_calendar', type: type, value: value, enabled: enabled ? 1 : 0 }) + }); + state.realEvents = null; + await fetchData(); + } + + async function updateCalendarFilterValue(type, value, existingId) { + var filter = (state.filters || []).find(function(f) { return f.type === type && f.source === 'google_calendar'; }); + await fetch('/api/filters', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: existingId || undefined, source: 'google_calendar', type: type, value: value, enabled: filter ? filter.enabled : 0 }) + }); + if (filter && filter.enabled) { + state.realEvents = null; + await fetchData(); + } else { + var filtersData = await fetch('/api/filters').then(function(r) { return r.json(); }); + state.filters = filtersData.filters || []; + } + } + async function toggleFilter(type, enabled, existingId) { var valEl = document.getElementById('filter-val-' + type); var value = valEl ? valEl.value : ''; From 03f240dd681513849f259c822c700dd367a68eb5 Mon Sep 17 00:00:00 2001 From: fisik-yum <54129197+fisik-yum@users.noreply.github.com> Date: Fri, 13 Mar 2026 21:18:30 -0700 Subject: [PATCH 2/4] UI integration for gcal --- src/cli.ts | 6 ++ src/gateway/auth/oauth-routes.ts | 122 ++++++++++++++++++++++++++++++- src/gateway/auth/pkce.ts | 11 +++ src/gateway/gui/routes.ts | 48 +++++++++++- 4 files changed, 184 insertions(+), 3 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 712d44f..a4770b1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -139,6 +139,12 @@ export async function init(targetDir?: string, options?: InitOptions): Promise<I ' type: oauth2', ` clientId: "${oauthCreds.google.clientId}"`, ` clientSecret: "${oauthCreds.google.clientSecret}"`, + ' google_calendar:', + ' enabled: true', + ' owner_auth:', + ' type: oauth2', + ` clientId: "${oauthCreds.google.clientId}"`, + ` clientSecret: "${oauthCreds.google.clientSecret}"`, ); } diff --git a/src/gateway/auth/oauth-routes.ts b/src/gateway/auth/oauth-routes.ts index 56205bd..f8a97c8 100644 --- a/src/gateway/auth/oauth-routes.ts +++ b/src/gateway/auth/oauth-routes.ts @@ -7,8 +7,9 @@ import type { HubConfigParsed } from '../../config/schema.js'; import type { TokenManager } from './token-manager.js'; import { AuditLog } from '../audit/log.js'; import { GmailConnector } from '../connectors/gmail/connector.js'; +import { GoogleCalendarConnector } from '../connectors/calendar/connector.js'; import { GitHubConnector } from '../connectors/github/connector.js'; -import { generateCodeVerifier, computeCodeChallenge, getGmailCredentials, getGitHubCredentials } from './pkce.js'; +import { generateCodeVerifier, computeCodeChallenge, getGmailCredentials, getGitHubCredentials, getCalendarCredentials } from './pkce.js'; interface OAuthDeps { store: DataStore; @@ -22,6 +23,11 @@ const GMAIL_SCOPES = [ 'https://www.googleapis.com/auth/gmail.compose', ]; +const CALENDAR_SCOPES = [ + 'https://www.googleapis.com/auth/calendar.readonly', + 'https://www.googleapis.com/auth/calendar.events', +]; + export function getBaseUrl(config: HubConfigParsed): string { if (config.deployment?.base_url) return config.deployment.base_url; const port = config.port ?? 3000; @@ -173,6 +179,120 @@ export function createOAuthRoutes(deps: OAuthDeps): Hono { return c.json({ ok: true }); }); + // --- Google Calendar OAuth --- + + app.get('/google_calendar/start', async (c) => { + const calConfig = deps.config.sources.google_calendar; + if (!calConfig) { + return c.redirect('/?oauth_error=calendar_not_configured'); + } + + const { clientId, clientSecret } = getCalendarCredentials(deps.config); + if (!clientId || !clientSecret) { + return c.redirect('/?oauth_error=calendar_missing_credentials'); + } + + const codeVerifier = generateCodeVerifier(); + const codeChallenge = computeCodeChallenge(codeVerifier); + + const state = randomBytes(32).toString('hex'); + await deps.store.setOAuthState(state, { source: 'google_calendar', createdAt: Date.now(), codeVerifier }); + + const oauth2Client = new google.auth.OAuth2( + clientId, + clientSecret, + `${getBaseUrl(deps.config)}/oauth/google_calendar/callback`, + ); + + const authUrl = oauth2Client.generateAuthUrl({ + access_type: 'offline', + prompt: 'consent', + scope: CALENDAR_SCOPES, + state, + code_challenge: codeChallenge, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + code_challenge_method: 'S256' as any, + }); + + return c.redirect(authUrl); + }); + + app.get('/google_calendar/callback', async (c) => { + const code = c.req.query('code'); + const state = c.req.query('state'); + const error = c.req.query('error'); + + if (error) { + return c.redirect(`/?oauth_error=${encodeURIComponent(error)}`); + } + + if (!code || !state) { + return c.redirect('/?oauth_error=missing_code_or_state'); + } + + const pending = await deps.store.getAndDeleteOAuthState(state); + if (!pending || pending.source !== 'google_calendar') { + return c.redirect('/?oauth_error=invalid_state'); + } + const { codeVerifier } = pending; + + const { clientId, clientSecret } = getCalendarCredentials(deps.config); + + const oauth2Client = new google.auth.OAuth2( + clientId, + clientSecret, + `${getBaseUrl(deps.config)}/oauth/google_calendar/callback`, + ); + + try { + const { tokens } = await oauth2Client.getToken({ code, codeVerifier }); + oauth2Client.setCredentials(tokens); + + const expiresAt = tokens.expiry_date + ? new Date(tokens.expiry_date).toISOString() + : undefined; + + await deps.tokenManager.storeToken('google_calendar', { + access_token: tokens.access_token!, + refresh_token: tokens.refresh_token ?? undefined, + token_type: tokens.token_type ?? 'Bearer', + expires_at: expiresAt, + scopes: CALENDAR_SCOPES.join(' '), + }); + + const connector = new GoogleCalendarConnector({ + clientId, + clientSecret, + accessToken: tokens.access_token!, + refreshToken: tokens.refresh_token ?? undefined, + }); + deps.connectorRegistry.set('google_calendar', connector); + + connector.getAuth().on('tokens', async (newTokens) => { + if (newTokens.access_token) { + const newExpiry = newTokens.expiry_date + ? new Date(newTokens.expiry_date).toISOString() + : undefined; + await deps.tokenManager.updateAccessToken('google_calendar', newTokens.access_token, newExpiry); + } + }); + + await auditLog.insert('oauth_connected', 'google_calendar', {}); + + return c.redirect('/?oauth_success=google_calendar'); + } catch (err) { + const message = err instanceof Error ? err.message : 'unknown_error'; + return c.redirect(`/?oauth_error=${encodeURIComponent(message)}`); + } + }); + + app.post('/google_calendar/disconnect', async (c) => { + await deps.tokenManager.deleteToken('google_calendar'); + deps.connectorRegistry.delete('google_calendar'); + await auditLog.insert('oauth_disconnected', 'google_calendar', {}); + return c.json({ ok: true }); + }); + // --- GitHub OAuth --- app.get('/github/start', async (c) => { diff --git a/src/gateway/auth/pkce.ts b/src/gateway/auth/pkce.ts index f88590f..731b9a6 100644 --- a/src/gateway/auth/pkce.ts +++ b/src/gateway/auth/pkce.ts @@ -49,3 +49,14 @@ export function getGitHubCredentials(config: HubConfigParsed): ResolvedCredentia clientSecret: githubConfig?.owner_auth.clientSecret ?? '', }; } + +/** + * Returns Google Calendar OAuth credentials from config. + */ +export function getCalendarCredentials(config: HubConfigParsed): ResolvedCredentials { + const calConfig = config.sources.google_calendar; + return { + clientId: calConfig?.owner_auth.clientId ?? '', + clientSecret: calConfig?.owner_auth.clientSecret ?? '', + }; +} diff --git a/src/gateway/gui/routes.ts b/src/gateway/gui/routes.ts index 7bad484..6f917dc 100644 --- a/src/gateway/gui/routes.ts +++ b/src/gateway/gui/routes.ts @@ -890,6 +890,30 @@ function getIndexHtml(): string { }); } + // Fetch real calendar events if Google Calendar is connected + const cal = state.sources.find(s => s.name === 'google_calendar'); + if (cal && cal.connected && !state.realEvents && !state.eventsLoading) { + state.eventsLoading = true; + state.eventsError = null; + fetch('/api/calendar/preview?limit=20') + .then(function(r) { return r.json(); }) + .then(function(data) { + state.eventsLoading = false; + if (data.ok && data.events) { + state.realEvents = data.events; + state.eventsError = null; + } else { + state.eventsError = data.error || 'Failed to load events'; + } + render(); + }) + .catch(function(err) { + state.eventsLoading = false; + state.eventsError = err.message || 'Network error'; + render(); + }); + } + render(); } @@ -1250,7 +1274,11 @@ function getIndexHtml(): string { var accountEmail = calAccount && calAccount.email ? calAccount.email : ''; var events = state.realEvents || []; - var visibleEvents = events; + // Sort events by start date descending (most recent at top) + var sortedEvents = events.slice().sort(function(a, b) { + return new Date(b.start).getTime() - new Date(a.start).getTime(); + }); + var visibleEvents = sortedEvents; // Disconnected state if (!calConnected) { @@ -1588,6 +1616,9 @@ function getIndexHtml(): string { var html = '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px">'; typeKeys.forEach(function(typeKey) { var meta = types[typeKey]; + var label = meta.label; + if (typeKey === 'time_after') label = 'Only events after'; + var existing = filters.find(function(f) { return f.type === typeKey; }); var isEnabled = existing ? !!existing.enabled : false; var value = existing ? (existing.value || '') : ''; @@ -1602,7 +1633,7 @@ function getIndexHtml(): string { html += '<span style="position:absolute;inset:0;background:' + (isEnabled ? 'var(--primary)' : '#ccc') + ';border-radius:10px;transition:background 0.2s"></span>'; html += '<span style="position:absolute;left:' + (isEnabled ? '18px' : '2px') + ';top:2px;width:16px;height:16px;background:#fff;border-radius:50%;transition:left 0.2s;box-shadow:0 1px 3px rgba(0,0,0,0.2)"></span>'; html += '</label>'; - html += '<span style="font-size:14px;font-weight:500;color:' + (isEnabled ? 'var(--fg)' : 'var(--muted)') + '">' + escapeHtml(meta.label) + '</span>'; + html += '<span style="font-size:14px;font-weight:500;color:' + (isEnabled ? 'var(--fg)' : 'var(--muted)') + '">' + escapeHtml(label) + '</span>'; html += '</div>'; if (needsValue) { html += '<input type="' + (typeKey === 'time_after' ? 'date' : 'text') + '" id="cal-filter-val-' + safeType + '" value="' + escapeAttr(value) + '" placeholder="' + escapeAttr(meta.placeholder) + '" onchange="updateCalendarFilterValue("' + safeType + '", this.value, "' + escapeAttr(filterId) + '")" style="width:100%;font-size:13px;padding:6px 10px">'; @@ -1809,6 +1840,10 @@ function getIndexHtml(): string { state.realEmails = null; state.emailsLoading = false; } + if (source === 'google_calendar') { + state.realEvents = null; + state.eventsLoading = false; + } await fetchData(); } @@ -2014,10 +2049,19 @@ function getIndexHtml(): string { state.emailsLoading = false; fetchData(); }; + window.refreshCalendarEvents = function() { + state.realEvents = null; + state.eventsError = null; + state.eventsLoading = false; + fetchData(); + }; window.toggleEditAction = toggleEditAction; window.toggleFilter = toggleFilter; window.updateFilterValue = updateFilterValue; window.renderFilterCards = renderFilterCards; + window.toggleCalendarFilter = toggleCalendarFilter; + window.updateCalendarFilterValue = updateCalendarFilterValue; + window.renderCalendarFilterCards = renderCalendarFilterCards; window.sendAction = sendAction; // Handle OAuth redirect results From 0167c82c09a73207ca7e9dc6781de03b377e6ae7 Mon Sep 17 00:00:00 2001 From: fisik-yum <54129197+fisik-yum@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:20:49 -0700 Subject: [PATCH 3/4] Fix problems with calendar events not being comitted --- src/gateway/connectors/calendar/connector.ts | 4 ++-- src/gateway/gui/routes.ts | 14 +++++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/gateway/connectors/calendar/connector.ts b/src/gateway/connectors/calendar/connector.ts index 7678abc..7583b3b 100644 --- a/src/gateway/connectors/calendar/connector.ts +++ b/src/gateway/connectors/calendar/connector.ts @@ -98,11 +98,11 @@ export class GoogleCalendarConnector implements SourceConnector { location: data.location as string, start: { dateTime: data.start as string, - timeZone: data.timeZone as string, + timeZone: (data.timeZone as string) || 'UTC', }, end: { dateTime: data.end as string, - timeZone: data.timeZone as string, + timeZone: (data.timeZone as string) || 'UTC', }, attendees: (data.attendees as Array<{ email: string }>) ?? [], }; diff --git a/src/gateway/gui/routes.ts b/src/gateway/gui/routes.ts index 6f917dc..f0f7d4f 100644 --- a/src/gateway/gui/routes.ts +++ b/src/gateway/gui/routes.ts @@ -166,10 +166,18 @@ export function createGuiRoutes(deps: GuiDeps): Hono { const connector = deps.connectorRegistry.get(action.source); if (connector) { try { - // Always save as Gmail draft on approve — owner sends manually from Gmail + const actionData = JSON.parse(action.action_data); + let actionType = action.action_type; + + // If it's a gmail source and the user clicked 'Send' (which sets send: true in action_data), + // we override the actionType to 'send_email'. + if (action.source === 'gmail' && actionData.send) { + actionType = 'send_email'; + } + const result = await connector.executeAction( - 'draft_email', - JSON.parse(action.action_data), + actionType, + actionData, ); await deps.store.updateStagingStatus(actionId, 'committed'); await auditLog.logActionCommitted(actionId, action.source, result.success ? 'success' : 'failure'); From d8b2d71b2d31e8214cacdd3455071ad408606876 Mon Sep 17 00:00:00 2001 From: fisik-yum <54129197+fisik-yum@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:39:49 -0700 Subject: [PATCH 4/4] fix searching and deletion --- packages/personaldatahub/src/tools.ts | 46 +++++++++++++++----- src/gateway/connectors/calendar/connector.ts | 7 ++- src/gateway/gui/routes.ts | 7 +-- 3 files changed, 44 insertions(+), 16 deletions(-) diff --git a/packages/personaldatahub/src/tools.ts b/packages/personaldatahub/src/tools.ts index edebcd7..2e9cfec 100644 --- a/packages/personaldatahub/src/tools.ts +++ b/packages/personaldatahub/src/tools.ts @@ -40,34 +40,50 @@ export const PROPOSE_TOOL_SCHEMA = { properties: { source: { type: 'string' as const, - description: 'The source service for this action (e.g., "gmail")', + description: 'The source service for this action (e.g., "gmail", "google_calendar")', }, action_type: { type: 'string' as const, - description: 'The type of action to propose (e.g., "draft_email", "send_email", "reply_email")', + description: 'The type of action to propose (e.g., "draft_email", "send_email", "create_event")', }, to: { type: 'string' as const, - description: 'Recipient email address', + description: 'Recipient email address (for email actions)', }, subject: { type: 'string' as const, - description: 'Email subject line', + description: 'Email subject line (for email actions)', }, body: { type: 'string' as const, - description: 'Email body content', + description: 'Email body or event description', + }, + title: { + type: 'string' as const, + description: 'Event title (for calendar actions)', + }, + start: { + type: 'string' as const, + description: 'Event start time in ISO format (for calendar actions)', + }, + end: { + type: 'string' as const, + description: 'Event end time in ISO format (for calendar actions)', + }, + location: { + type: 'string' as const, + description: 'Event location (for calendar actions)', }, in_reply_to: { type: 'string' as const, - description: 'Message ID to reply to (for reply_email and threaded draft_email). Optional.', + description: 'Message ID to reply to (for email actions). Optional.', }, purpose: { type: 'string' as const, description: 'A clear description of why this action is being proposed. Required for transparency and audit.', }, }, - required: ['source', 'action_type', 'to', 'subject', 'body', 'purpose'] as const, + required: ['source', 'action_type', 'body', 'purpose'] as const, }; /** @@ -130,13 +146,19 @@ export function createProposeTool(client: HubClient) { const purpose = args.purpose as string; const action_data: Record<string, unknown> = { - to: args.to, - subject: args.subject, body: args.body, }; - if (args.in_reply_to) { - action_data.in_reply_to = args.in_reply_to; - } + + // Gmail fields + if (args.to) action_data.to = args.to; + if (args.subject) action_data.subject = args.subject; + if (args.in_reply_to) action_data.in_reply_to = args.in_reply_to; + + // Calendar fields + if (args.title) action_data.title = args.title; + if (args.start) action_data.start = args.start; + if (args.end) action_data.end = args.end; + if (args.location) action_data.location = args.location; const result = await client.propose({ source, diff --git a/src/gateway/connectors/calendar/connector.ts b/src/gateway/connectors/calendar/connector.ts index 7583b3b..5df9377 100644 --- a/src/gateway/connectors/calendar/connector.ts +++ b/src/gateway/connectors/calendar/connector.ts @@ -43,13 +43,18 @@ export class GoogleCalendarConnector implements SourceConnector { async fetch(boundary: SourceBoundary, params?: Record<string, unknown>): Promise<DataRow[]> { const listParams: calendar_v3.Params$Resource$Events$List = { calendarId: 'primary', - maxResults: (params?.limit as number) ?? 50, + maxResults: (params?.limit as number) ?? 100, singleEvents: true, orderBy: 'startTime', }; if (boundary.after) { listParams.timeMin = new Date(boundary.after).toISOString(); + } else { + // Default to showing events from 7 days ago to ensure recent/upcoming visibility + const sevenDaysAgo = new Date(); + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); + listParams.timeMin = sevenDaysAgo.toISOString(); } if (params?.query) { diff --git a/src/gateway/gui/routes.ts b/src/gateway/gui/routes.ts index f0f7d4f..21fddff 100644 --- a/src/gateway/gui/routes.ts +++ b/src/gateway/gui/routes.ts @@ -879,7 +879,7 @@ function getIndexHtml(): string { if (gm && gm.connected && !state.realEmails && !state.emailsLoading) { state.emailsLoading = true; state.emailsError = null; - fetch('/api/gmail/preview?limit=20') + fetch('/api/gmail/preview?limit=20&t=' + Date.now()) .then(function(r) { return r.json(); }) .then(function(data) { state.emailsLoading = false; @@ -903,7 +903,7 @@ function getIndexHtml(): string { if (cal && cal.connected && !state.realEvents && !state.eventsLoading) { state.eventsLoading = true; state.eventsError = null; - fetch('/api/calendar/preview?limit=20') + fetch('/api/calendar/preview?limit=20&t=' + Date.now()) .then(function(r) { return r.json(); }) .then(function(data) { state.eventsLoading = false; @@ -921,7 +921,6 @@ function getIndexHtml(): string { render(); }); } - render(); } @@ -2055,12 +2054,14 @@ function getIndexHtml(): string { state.realEmails = null; state.emailsError = null; state.emailsLoading = false; + render(); // Show loading state immediately fetchData(); }; window.refreshCalendarEvents = function() { state.realEvents = null; state.eventsError = null; state.eventsLoading = false; + render(); // Show loading state immediately fetchData(); }; window.toggleEditAction = toggleEditAction;