diff --git a/modules/rcal/components/folk-calendar-view.ts b/modules/rcal/components/folk-calendar-view.ts index dba80e62..24c0336f 100644 --- a/modules/rcal/components/folk-calendar-view.ts +++ b/modules/rcal/components/folk-calendar-view.ts @@ -166,6 +166,13 @@ class FolkCalendarView extends HTMLElement { private mapMarkerLayer: any = null; private transitLineLayer: any = null; + // Google Calendar integration + private showGcalModal = false; + private gcalCalendars: Array<{ id: string; name: string; color: string | null; primary: boolean; subscribed: boolean }> = []; + private gcalLoading = false; + private gcalError = ""; + private gcalConnected = false; + // Guided tour private _tour!: TourEngine; private static readonly TOUR_STEPS = [ @@ -196,6 +203,16 @@ class FolkCalendarView extends HTMLElement { setTimeout(() => this._tour.start(), 1200); } this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rcal', context: this.selectedEvent?.title || 'Calendar' })); + + // If redirected back from Google OAuth, open the calendar picker + const params = new URLSearchParams(window.location.search); + if (params.get("connected") === "google") { + history.replaceState(null, "", window.location.pathname); + this.gcalConnected = true; + setTimeout(() => this.openGcalModal(), 500); + } + // Check Google connection status + this.checkGcalStatus(); } disconnectedCallback() { @@ -249,6 +266,9 @@ class FolkCalendarView extends HTMLElement { id: s.id, name: s.name, source_type: s.sourceType, url: s.url, color: s.color, is_active: s.isActive, is_visible: s.isVisible, owner_id: s.ownerId, + external_id: s.externalId || null, + last_synced_at: s.lastSyncedAt || 0, + last_sync_status: s.lastSyncStatus || null, })); } @@ -735,6 +755,106 @@ class FolkCalendarView extends HTMLElement { this.render(); } + // ── Google Calendar integration ── + + private async checkGcalStatus() { + try { + const res = await fetch(`/api/oauth/status?space=${this.space}`); + if (res.ok) { + const data = await res.json(); + this.gcalConnected = !!data.google?.connected; + } + } catch { /* ignore */ } + } + + private async openGcalModal() { + this.showGcalModal = true; + this.gcalLoading = true; + this.gcalError = ""; + this.render(); + + const base = this.getApiBase(); + const token = (window as any).__rspaceToken; + try { + const res = await fetch(`${base}/api/google/calendars`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + this.gcalError = err.error || "Failed to load calendars"; + if (res.status === 400 && err.error?.includes("not connected")) { + this.gcalConnected = false; + } + } else { + this.gcalCalendars = await res.json(); + } + } catch (err: any) { + this.gcalError = err.message || "Network error"; + } + this.gcalLoading = false; + this.render(); + } + + private async toggleGcalSubscription(calId: string, calName: string, calColor: string | null, subscribe: boolean) { + const base = this.getApiBase(); + const token = (window as any).__rspaceToken; + const headers: Record = { "Content-Type": "application/json" }; + if (token) headers.Authorization = `Bearer ${token}`; + + try { + if (subscribe) { + await fetch(`${base}/api/google/subscribe`, { + method: "POST", headers, + body: JSON.stringify({ calendarId: calId, name: calName, color: calColor || "#4285f4" }), + }); + } else { + // Find the source ID for this calendar + const source = this.sources.find((s: any) => s.source_type === "GOOGLE" && s.external_id === calId); + if (source) { + await fetch(`${base}/api/google/${source.id}`, { method: "DELETE", headers }); + } + } + // Refresh calendars and events + await this.openGcalModal(); + this.loadMonth(); + } catch (err: any) { + this.gcalError = err.message || "Failed to update subscription"; + this.render(); + } + } + + private async syncAllGcal() { + const base = this.getApiBase(); + const token = (window as any).__rspaceToken; + const headers: Record = {}; + if (token) headers.Authorization = `Bearer ${token}`; + + try { + await fetch(`${base}/api/google/sync-all`, { method: "POST", headers }); + this.loadMonth(); + } catch { /* ignore */ } + } + + private async pushEventToGoogle(eventId: string) { + const base = this.getApiBase(); + const token = (window as any).__rspaceToken; + const headers: Record = { "Content-Type": "application/json" }; + if (token) headers.Authorization = `Bearer ${token}`; + + try { + const res = await fetch(`${base}/api/google/push/${eventId}`, { method: "POST", headers }); + if (res.ok) { + const data = await res.json(); + alert(`Event exported to Google Calendar`); + } else { + const err = await res.json().catch(() => ({ error: "Failed" })); + alert(`Error: ${err.error}`); + } + } catch (err: any) { + alert(`Error: ${err.message}`); + } + } + // ── Navigation ── private navigate(delta: number) { @@ -1000,6 +1120,7 @@ class FolkCalendarView extends HTMLElement { ${this.selectedEvent ? this.renderEventModal() : ""} + ${this.renderGcalModal()} `; this.attachListeners(); @@ -1178,12 +1299,48 @@ class FolkCalendarView extends HTMLElement { // ── Sources ── private renderSources(): string { - if (this.sources.length === 0) return ""; + const hasGoogleSources = this.sources.some((s: any) => s.source_type === "GOOGLE"); return `
Calendar Legend
${this.sources.map(s => `${this.esc(s.name)}`).join("")} + style="border-color:${s.color || "#666"};color:${s.color || "#aaa"}">${this.esc(s.name)}${s.source_type === "GOOGLE" ? ' ' : ''}`).join("")} + + ${hasGoogleSources ? `` : ''} +
`; + } + + private renderGcalModal(): string { + if (!this.showGcalModal) return ""; + return `
+
+
+ Google Calendar + +
+
+ ${!this.gcalConnected ? ` +

Connect your Google account to sync calendars.

+ Connect Google Account + ` : this.gcalLoading ? ` +

Loading calendars...

+ ` : this.gcalError ? ` +

${this.esc(this.gcalError)}

+ ${!this.gcalConnected ? `Connect Google Account` : ''} + ` : ` +

Select calendars to sync into rCal. Events will auto-update every 10 minutes.

+
+ ${this.gcalCalendars.map(cal => ` + + `).join("")} +
+ `} +
+
`; } @@ -2068,6 +2225,7 @@ class FolkCalendarView extends HTMLElement { ${e.source_name ? `` : ""} ${e.is_virtual ? `` : ""} ${e.latitude != null ? `` : ""} + ${this.gcalConnected && e.r_tool_source !== 'GOOGLE_CAL' ? `` : ""} `; } @@ -2727,6 +2885,37 @@ class FolkCalendarView extends HTMLElement { calPane.addEventListener("pointerup", pointerEnd); calPane.addEventListener("pointercancel", pointerEnd); } + + // Google Calendar integration listeners + $("gcal-manage")?.addEventListener("click", () => { + if (this.gcalConnected) { + this.openGcalModal(); + } else { + window.location.href = `/api/oauth/google/authorize?space=${this.space}&returnTo=rcal`; + } + }); + $("gcal-sync-all")?.addEventListener("click", () => this.syncAllGcal()); + $("gcal-overlay")?.addEventListener("click", (e) => { + if ((e.target as HTMLElement).id === "gcal-overlay") { this.showGcalModal = false; this.render(); } + }); + $("gcal-close")?.addEventListener("click", () => { this.showGcalModal = false; this.render(); }); + $$(".gcal-check").forEach(el => { + el.addEventListener("change", (e) => { + const label = (el as HTMLElement).closest(".gcal-item") as HTMLElement; + const calId = label?.dataset.gcalId || ""; + const calName = label?.dataset.gcalName || ""; + const calColor = label?.dataset.gcalColor || "#4285f4"; + const checked = (el as HTMLInputElement).checked; + this.toggleGcalSubscription(calId, calName, calColor, checked); + }); + }); + $$("[data-push-event]").forEach(el => { + el.addEventListener("click", (e) => { + e.stopPropagation(); + const eventId = (el as HTMLElement).dataset.pushEvent!; + this.pushEventToGoogle(eventId); + }); + }); } // ── Leaflet Map ── @@ -2964,6 +3153,28 @@ class FolkCalendarView extends HTMLElement { .src-badge { font-size: 10px; padding: 3px 8px; border-radius: 10px; border: 1px solid var(--rs-border-strong); cursor: pointer; transition: opacity 0.15s; user-select: none; } .src-badge:hover { filter: brightness(1.2); } .src-badge.filtered { opacity: 0.3; text-decoration: line-through; } + .gcal-btn { background: transparent; color: var(--rs-text-secondary); border-color: #4285f4 !important; } + .gcal-btn:hover { color: #4285f4; } + .gcal-sync-btn { background: transparent; color: var(--rs-text-muted); font-size: 9px !important; } + .gcal-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: #4285f4; margin-left: 3px; vertical-align: middle; } + + /* ── Google Calendar Modal ── */ + .gcal-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 1000; display: flex; align-items: center; justify-content: center; } + .gcal-modal { background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong); border-radius: 12px; width: 380px; max-width: 90vw; max-height: 80vh; overflow: hidden; display: flex; flex-direction: column; } + .gcal-modal-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--rs-border); font-weight: 600; font-size: 14px; } + .gcal-modal-close { background: none; border: none; color: var(--rs-text-muted); cursor: pointer; font-size: 16px; padding: 4px; } + .gcal-modal-close:hover { color: var(--rs-text-primary); } + .gcal-modal-body { padding: 12px 16px; overflow-y: auto; } + .gcal-list { display: flex; flex-direction: column; gap: 4px; } + .gcal-item { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 8px; cursor: pointer; transition: background 0.1s; } + .gcal-item:hover { background: var(--rs-bg-surface-sunken); } + .gcal-color { width: 12px; height: 12px; border-radius: 3px; flex-shrink: 0; } + .gcal-name { flex: 1; font-size: 13px; color: var(--rs-text-primary); } + .gcal-check { accent-color: #4285f4; width: 16px; height: 16px; cursor: pointer; } + .gcal-connect-btn { display: inline-block; padding: 8px 20px; background: #4285f4; color: white; border-radius: 6px; text-decoration: none; font-size: 13px; font-weight: 500; } + .gcal-connect-btn:hover { background: #3367d6; } + .gcal-push-btn { background: none; border: 1px solid var(--rs-border-strong); color: var(--rs-text-secondary); padding: 4px 12px; border-radius: 6px; cursor: pointer; font-size: 11px; } + .gcal-push-btn:hover { color: #4285f4; border-color: #4285f4; } /* ── Zoom Bar Top ── */ .zoom-bar--top { margin-top: 10px; padding: 8px 12px; background: var(--rs-bg-surface); border: 1px solid var(--rs-border-subtle); border-radius: 8px; } diff --git a/modules/rcal/mod.ts b/modules/rcal/mod.ts index 2799471f..8362ee9c 100644 --- a/modules/rcal/mod.ts +++ b/modules/rcal/mod.ts @@ -19,6 +19,10 @@ import type { SyncServer } from '../../server/local-first/sync-server'; import { calendarSchema, calendarDocId } from './schemas'; import type { CalendarDoc, CalendarEvent, CalendarSource, SavedCalendarView, ScheduledItemMetadata, EventAttendee } from './schemas'; import { sendMagicLinks } from "../../server/magic-link/send"; +import { + getValidGoogleToken, listGoogleCalendars, fetchGoogleEvents, + createGoogleEvent, mapGoogleEventToCalendar, mapCalendarEventToGoogle, +} from '../../server/google-calendar'; let _syncServer: SyncServer | null = null; @@ -120,6 +124,11 @@ function sourceToRow(src: CalendarSource) { last_synced_at: src.lastSyncedAt ? new Date(src.lastSyncedAt).toISOString() : null, owner_id: src.ownerId, created_at: src.createdAt ? new Date(src.createdAt).toISOString() : null, + external_id: src.externalId || null, + sync_token: src.syncToken ? '(set)' : null, + last_sync_status: src.lastSyncStatus || null, + last_sync_error: src.lastSyncError || null, + event_count: src.eventCount ?? null, }; } @@ -1055,6 +1064,349 @@ routes.delete("/api/views/:id", async (c) => { return c.json({ ok: true }); }); +// ── Google Calendar integration ── + +/** Sync a single Google Calendar source. Returns number of events upserted + deleted. */ +async function syncGoogleSource( + space: string, + sourceId: string, +): Promise<{ upserted: number; deleted: number; error?: string }> { + const token = await getValidGoogleToken(space, _syncServer!); + if (!token) return { upserted: 0, deleted: 0, error: 'Google not connected' }; + + const docId = calendarDocId(space); + const doc = ensureDoc(space); + const source = doc.sources[sourceId]; + if (!source || source.sourceType !== 'GOOGLE') { + return { upserted: 0, deleted: 0, error: 'Source not found or not a Google source' }; + } + + // Mark as syncing + _syncServer!.changeDoc(docId, 'gcal sync start', (d) => { + d.sources[sourceId].lastSyncStatus = 'syncing'; + }); + + try { + const result = await fetchGoogleEvents(token, source.externalId!, { + syncToken: source.syncToken, + // On full sync, fetch 1 year back + 1 year forward + timeMin: source.syncToken ? undefined : new Date(Date.now() - 365 * 86400000).toISOString(), + timeMax: source.syncToken ? undefined : new Date(Date.now() + 365 * 86400000).toISOString(), + }); + + let upserted = 0; + let deleted = 0; + + _syncServer!.changeDoc(docId, `gcal sync ${source.name}`, (d) => { + // Upsert events + for (const gEvent of result.events) { + // Find existing event by Google event ID + const existingId = Object.keys(d.events).find( + (k) => d.events[k].rToolEntityId === gEvent.id && d.events[k].sourceId === sourceId, + ); + const eventId = existingId || crypto.randomUUID(); + const mapped = mapGoogleEventToCalendar(gEvent, sourceId, source.name, source.color); + const now = Date.now(); + + if (existingId) { + // Update existing event + const existing = d.events[eventId]; + Object.assign(existing, { ...mapped, id: eventId, createdAt: existing.createdAt, updatedAt: now }); + } else { + // Create new event + d.events[eventId] = { + ...mapped, + id: eventId, + createdAt: now, + updatedAt: now, + } as CalendarEvent; + } + upserted++; + } + + // Delete cancelled events + for (const deletedGId of result.deleted) { + const existingId = Object.keys(d.events).find( + (k) => d.events[k].rToolEntityId === deletedGId && d.events[k].sourceId === sourceId, + ); + if (existingId) { + delete d.events[existingId]; + deleted++; + } + } + + // Update source metadata + d.sources[sourceId].syncToken = result.nextSyncToken; + d.sources[sourceId].lastSyncedAt = Date.now(); + d.sources[sourceId].lastSyncStatus = 'success'; + d.sources[sourceId].lastSyncError = null; + // Count events from this source + d.sources[sourceId].eventCount = Object.values(d.events).filter( + (e) => e.sourceId === sourceId, + ).length; + }); + + return { upserted, deleted }; + } catch (err: any) { + // Handle 410 Gone — syncToken expired, need full re-sync + if (err.status === 410) { + _syncServer!.changeDoc(docId, 'gcal clear sync token', (d) => { + d.sources[sourceId].syncToken = null; + d.sources[sourceId].lastSyncStatus = 'error'; + d.sources[sourceId].lastSyncError = 'Sync token expired, will do full re-sync'; + }); + // Retry without sync token + return syncGoogleSource(space, sourceId); + } + + _syncServer!.changeDoc(docId, 'gcal sync error', (d) => { + d.sources[sourceId].lastSyncStatus = 'error'; + d.sources[sourceId].lastSyncError = err.message || String(err); + }); + return { upserted: 0, deleted: 0, error: err.message || String(err) }; + } +} + +// GET /api/google/calendars — list user's Google Calendars for selection +routes.get("/api/google/calendars", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + + const googleToken = await getValidGoogleToken(dataSpace, _syncServer!); + if (!googleToken) return c.json({ error: "Google not connected. Authorize at /api/oauth/google/authorize?space=" + dataSpace + "&returnTo=rcal" }, 400); + + try { + const calendars = await listGoogleCalendars(googleToken); + // Also check which ones are already subscribed + const doc = ensureDoc(dataSpace); + const subscribedIds = new Set( + Object.values(doc.sources) + .filter((s) => s.sourceType === 'GOOGLE') + .map((s) => s.externalId), + ); + + return c.json(calendars.map((cal) => ({ + id: cal.id, + name: cal.summary, + description: cal.description || null, + color: cal.backgroundColor || null, + primary: cal.primary || false, + accessRole: cal.accessRole, + timeZone: cal.timeZone || null, + subscribed: subscribedIds.has(cal.id), + }))); + } catch (err: any) { + return c.json({ error: `Failed to list calendars: ${err.message}` }, 502); + } +}); + +// POST /api/google/subscribe — subscribe to a Google Calendar +routes.post("/api/google/subscribe", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const body = await c.req.json(); + const { calendarId, name, color } = body; + + if (!calendarId) return c.json({ error: "calendarId is required" }, 400); + + const docId = calendarDocId(dataSpace); + ensureDoc(dataSpace); + + // Check if already subscribed + const doc = _syncServer!.getDoc(docId)!; + const existing = Object.values(doc.sources).find( + (s) => s.sourceType === 'GOOGLE' && s.externalId === calendarId, + ); + if (existing) return c.json({ error: "Already subscribed to this calendar", sourceId: existing.id }, 409); + + const sourceId = crypto.randomUUID(); + const now = Date.now(); + + _syncServer!.changeDoc(docId, `subscribe Google Calendar: ${name || calendarId}`, (d) => { + d.sources[sourceId] = { + id: sourceId, + name: name || calendarId, + sourceType: 'GOOGLE', + url: null, + color: color || '#4285f4', + isActive: true, + isVisible: true, + syncIntervalMinutes: 10, + lastSyncedAt: 0, + ownerId: null, + createdAt: now, + externalId: calendarId, + syncToken: null, + lastSyncStatus: null, + lastSyncError: null, + eventCount: null, + }; + }); + + // Trigger initial sync + const syncResult = await syncGoogleSource(dataSpace, sourceId); + + return c.json({ sourceId, ...syncResult }, 201); +}); + +// POST /api/google/sync/:sourceId — manual sync trigger for one source +routes.post("/api/google/sync/:sourceId", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const sourceId = c.req.param("sourceId"); + + const result = await syncGoogleSource(dataSpace, sourceId); + if (result.error) return c.json(result, 400); + return c.json(result); +}); + +// POST /api/google/sync-all — sync all Google sources in this space +routes.post("/api/google/sync-all", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + + const doc = ensureDoc(dataSpace); + const googleSources = Object.values(doc.sources).filter( + (s) => s.sourceType === 'GOOGLE' && s.isActive, + ); + + const results: Record = {}; + for (const source of googleSources) { + results[source.id] = await syncGoogleSource(dataSpace, source.id); + } + + return c.json({ synced: googleSources.length, results }); +}); + +// DELETE /api/google/:sourceId — unsubscribe and remove all events from this source +routes.delete("/api/google/:sourceId", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const sourceId = c.req.param("sourceId"); + + const docId = calendarDocId(dataSpace); + const doc = ensureDoc(dataSpace); + + const source = doc.sources[sourceId]; + if (!source) return c.json({ error: "Source not found" }, 404); + if (source.sourceType !== 'GOOGLE') return c.json({ error: "Not a Google Calendar source" }, 400); + + // Count events to delete + const eventIds = Object.keys(doc.events).filter((k) => doc.events[k].sourceId === sourceId); + + _syncServer!.changeDoc(docId, `unsubscribe Google Calendar: ${source.name}`, (d) => { + // Remove all events from this source + for (const eid of eventIds) { + delete d.events[eid]; + } + // Remove the source + delete d.sources[sourceId]; + }); + + return c.json({ ok: true, eventsRemoved: eventIds.length }); +}); + +// POST /api/google/push/:eventId — export a single rCal event to Google Calendar +routes.post("/api/google/push/:eventId", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const eventId = c.req.param("eventId"); + const body = await c.req.json().catch(() => ({})) as { targetCalendarId?: string }; + + const doc = ensureDoc(dataSpace); + const event = doc.events[eventId]; + if (!event) return c.json({ error: "Event not found" }, 404); + + // Don't push Google events back to Google + if (event.rToolSource === 'GOOGLE_CAL') { + return c.json({ error: "Cannot push a Google Calendar event back to Google" }, 400); + } + + const googleToken = await getValidGoogleToken(dataSpace, _syncServer!); + if (!googleToken) return c.json({ error: "Google not connected" }, 400); + + // Use specified target or default to primary calendar + let targetCalId = body.targetCalendarId; + if (!targetCalId) { + const calendars = await listGoogleCalendars(googleToken); + const primary = calendars.find((cal) => cal.primary); + targetCalId = primary?.id || calendars[0]?.id; + if (!targetCalId) return c.json({ error: "No Google Calendars found" }, 400); + } + + try { + const gInput = mapCalendarEventToGoogle(event); + const googleEventId = await createGoogleEvent(googleToken, targetCalId, gInput); + return c.json({ ok: true, googleEventId, calendarId: targetCalId }); + } catch (err: any) { + return c.json({ error: `Failed to create Google event: ${err.message}` }, 502); + } +}); + +// ── Background sync loop ── + +const GCAL_SYNC_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes +let _gcalSyncTimer: ReturnType | null = null; + +async function runGcalBackgroundSync() { + if (!_syncServer) return; + const docIds = _syncServer.listDocs().filter((id: string) => id.endsWith(':cal:events')); + + for (const docId of docIds) { + const space = docId.split(':')[0]; + const doc = _syncServer.getDoc(docId); + if (!doc) continue; + + const googleSources = Object.values(doc.sources).filter( + (s) => s.sourceType === 'GOOGLE' && s.isActive && s.externalId, + ); + + for (const source of googleSources) { + try { + await syncGoogleSource(space, source.id); + } catch (err) { + console.error(`[rCal] Background sync failed for ${space}/${source.name}:`, err); + } + } + } +} + +function startGcalSyncLoop() { + if (_gcalSyncTimer) return; + // Initial sync after 30s startup delay + setTimeout(() => { + runGcalBackgroundSync().catch((err) => console.error('[rCal] Background sync error:', err)); + }, 30_000); + // Then every 10 minutes + _gcalSyncTimer = setInterval(() => { + runGcalBackgroundSync().catch((err) => console.error('[rCal] Background sync error:', err)); + }, GCAL_SYNC_INTERVAL_MS); +} + // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; @@ -1175,6 +1527,9 @@ export const calModule: RSpaceModule = { } } }, 5000); // Run after loadAllDocs completes + + // Start Google Calendar background sync loop + startGcalSyncLoop(); }, feeds: [ { @@ -1197,6 +1552,7 @@ export const calModule: RSpaceModule = { { path: "events", name: "Events", icon: "📅", description: "Calendar events across all systems" }, ], onboardingActions: [ + { label: "Connect Google Calendar", icon: "🔄", description: "Sync events from your Google Calendar automatically", type: 'link', href: '/api/oauth/google/authorize?space={space}&returnTo=rcal' }, { label: "Import Calendar (.ics)", icon: "📅", description: "Upload an ICS file from Google Calendar, Outlook, or Apple", type: 'upload', upload: { accept: '.ics,.ical', endpoint: '/{space}/rcal/api/import-ics' } }, { label: "Subscribe to iCal URL", icon: "🔗", description: "Add a read-only calendar feed", type: 'link', href: '/{space}/rcal?action=add-source' }, { label: "Create an Event", icon: "✏️", description: "Add your first calendar event", type: 'create', href: '/{space}/rcal' }, diff --git a/modules/rcal/schemas.ts b/modules/rcal/schemas.ts index 5f1def55..d302cd4a 100644 --- a/modules/rcal/schemas.ts +++ b/modules/rcal/schemas.ts @@ -21,6 +21,16 @@ export interface CalendarSource { lastSyncedAt: number; ownerId: string | null; createdAt: number; + /** External calendar ID (e.g. Google Calendar ID like "user@gmail.com") */ + externalId?: string | null; + /** Google Calendar incremental sync token */ + syncToken?: string | null; + /** Status of last sync attempt */ + lastSyncStatus?: string | null; + /** Error message if last sync failed */ + lastSyncError?: string | null; + /** Number of events from this source */ + eventCount?: number | null; } export interface EventAttendee { diff --git a/modules/rdocs/components/import-export-dialog.ts b/modules/rdocs/components/import-export-dialog.ts index a9eda96c..19e7f388 100644 --- a/modules/rdocs/components/import-export-dialog.ts +++ b/modules/rdocs/components/import-export-dialog.ts @@ -86,7 +86,7 @@ class ImportExportDialog extends HTMLElement { private async loadConnections() { try { - const res = await fetch(`${getModuleApiBase("rnotes")}/api/connections`); + const res = await fetch(`${getModuleApiBase("rdocs")}/api/connections`); if (res.ok) { this.connections = await res.json(); } @@ -99,7 +99,7 @@ class ImportExportDialog extends HTMLElement { if (this.activeSource === 'notion') { try { - const res = await fetch(`${getModuleApiBase("rnotes")}/api/import/notion/pages`); + const res = await fetch(`${getModuleApiBase("rdocs")}/api/import/notion/pages`); if (res.ok) { const data = await res.json(); this.remotePages = data.pages || []; @@ -107,7 +107,7 @@ class ImportExportDialog extends HTMLElement { } catch { /* ignore */ } } else if (this.activeSource === 'google-docs') { try { - const res = await fetch(`${getModuleApiBase("rnotes")}/api/import/google-docs/list`); + const res = await fetch(`${getModuleApiBase("rdocs")}/api/import/google-docs/list`); if (res.ok) { const data = await res.json(); this.remotePages = data.docs || []; @@ -147,7 +147,7 @@ class ImportExportDialog extends HTMLElement { if (this.targetNotebookId) formData.append('notebookId', this.targetNotebookId); const token = localStorage.getItem('encryptid_token') || ''; - const res = await fetch(`${getModuleApiBase("rnotes")}/api/import/files`, { + const res = await fetch(`${getModuleApiBase("rdocs")}/api/import/files`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` }, body: formData, @@ -179,7 +179,7 @@ class ImportExportDialog extends HTMLElement { } const token = localStorage.getItem('encryptid_token') || ''; - const res = await fetch(`${getModuleApiBase("rnotes")}/api/import/upload`, { + const res = await fetch(`${getModuleApiBase("rdocs")}/api/import/upload`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` }, body: formData, @@ -203,7 +203,7 @@ class ImportExportDialog extends HTMLElement { } const token = localStorage.getItem('encryptid_token') || ''; - const res = await fetch(`${getModuleApiBase("rnotes")}/api/import/notion`, { + const res = await fetch(`${getModuleApiBase("rdocs")}/api/import/notion`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, @@ -230,7 +230,7 @@ class ImportExportDialog extends HTMLElement { } const token = localStorage.getItem('encryptid_token') || ''; - const res = await fetch(`${getModuleApiBase("rnotes")}/api/import/google-docs`, { + const res = await fetch(`${getModuleApiBase("rdocs")}/api/import/google-docs`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, @@ -269,7 +269,7 @@ class ImportExportDialog extends HTMLElement { try { if (this.activeSource === 'obsidian' || this.activeSource === 'logseq' || this.activeSource === 'markdown' as any) { const format = this.activeSource === 'markdown' as any ? 'markdown' : this.activeSource; - const url = `${getModuleApiBase("rnotes")}/api/export/${format}?notebookId=${encodeURIComponent(this.targetNotebookId)}`; + const url = `${getModuleApiBase("rdocs")}/api/export/${format}?notebookId=${encodeURIComponent(this.targetNotebookId)}`; const res = await fetch(url); if (res.ok) { @@ -290,7 +290,7 @@ class ImportExportDialog extends HTMLElement { } } else if (this.activeSource === 'notion') { const token = localStorage.getItem('encryptid_token') || ''; - const res = await fetch(`${getModuleApiBase("rnotes")}/api/export/notion`, { + const res = await fetch(`${getModuleApiBase("rdocs")}/api/export/notion`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, @@ -307,7 +307,7 @@ class ImportExportDialog extends HTMLElement { } } else if (this.activeSource === 'google-docs') { const token = localStorage.getItem('encryptid_token') || ''; - const res = await fetch(`${getModuleApiBase("rnotes")}/api/export/google-docs`, { + const res = await fetch(`${getModuleApiBase("rdocs")}/api/export/google-docs`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, @@ -564,7 +564,7 @@ class ImportExportDialog extends HTMLElement { private async loadSyncStatus(notebookId: string) { try { - const res = await fetch(`${getModuleApiBase("rnotes")}/api/sync/status/${notebookId}`); + const res = await fetch(`${getModuleApiBase("rdocs")}/api/sync/status/${notebookId}`); if (res.ok) { const data = await res.json(); this.syncStatuses = data.statuses || {}; @@ -581,7 +581,7 @@ class ImportExportDialog extends HTMLElement { try { const token = localStorage.getItem('encryptid_token') || ''; - const res = await fetch(`${getModuleApiBase("rnotes")}/api/sync/notebook/${this.targetNotebookId}`, { + const res = await fetch(`${getModuleApiBase("rdocs")}/api/sync/notebook/${this.targetNotebookId}`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` }, }); @@ -626,7 +626,7 @@ class ImportExportDialog extends HTMLElement { formData.append('source', source); const token = localStorage.getItem('encryptid_token') || ''; - const res = await fetch(`${getModuleApiBase("rnotes")}/api/sync/upload`, { + const res = await fetch(`${getModuleApiBase("rdocs")}/api/sync/upload`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` }, body: formData, diff --git a/server/google-calendar.ts b/server/google-calendar.ts new file mode 100644 index 00000000..2a4e0523 --- /dev/null +++ b/server/google-calendar.ts @@ -0,0 +1,332 @@ +/** + * Google Calendar API client. + * + * Pure functions using native fetch — no googleapis library needed. + * Handles token auto-refresh, calendar listing, event fetch (full + incremental), + * event creation, and mapping Google events → rCal CalendarEvent fields. + */ + +import { connectionsDocId } from '../modules/rdocs/schemas'; +import type { ConnectionsDoc } from '../modules/rdocs/schemas'; +import type { CalendarEvent } from '../modules/rcal/schemas'; +import type { SyncServer } from './local-first/sync-server'; + +const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || ''; +const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || ''; + +const GCAL_BASE = 'https://www.googleapis.com/calendar/v3'; + +// ── Types ── + +export interface GoogleCalendarEntry { + id: string; + summary: string; + description?: string; + backgroundColor?: string; + foregroundColor?: string; + primary?: boolean; + accessRole: string; + selected?: boolean; + timeZone?: string; +} + +export interface GoogleEvent { + id: string; + status: string; + summary?: string; + description?: string; + location?: string; + start?: { dateTime?: string; date?: string; timeZone?: string }; + end?: { dateTime?: string; date?: string; timeZone?: string }; + recurrence?: string[]; + attendees?: Array<{ email: string; displayName?: string; responseStatus?: string; self?: boolean }>; + hangoutLink?: string; + conferenceData?: { entryPoints?: Array<{ entryPointType: string; uri: string; label?: string }> }; + htmlLink?: string; + updated?: string; +} + +export interface GoogleEventInput { + summary: string; + description?: string; + location?: string; + start: { dateTime?: string; date?: string; timeZone?: string }; + end: { dateTime?: string; date?: string; timeZone?: string }; +} + +export interface FetchEventsResult { + events: GoogleEvent[]; + nextSyncToken: string | null; + deleted: string[]; +} + +// ── Token management ── + +/** + * Get a valid Google access token for a space, auto-refreshing if expired. + * Returns null if no Google connection exists. + */ +export async function getValidGoogleToken( + space: string, + syncServer: SyncServer, +): Promise { + const docId = connectionsDocId(space); + const doc = syncServer.getDoc(docId); + if (!doc?.google?.refreshToken) return null; + + // Check if token is still valid (with 60s buffer) + if (doc.google.accessToken && doc.google.expiresAt > Date.now() + 60_000) { + return doc.google.accessToken; + } + + // Refresh the token + const tokenRes = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: GOOGLE_CLIENT_ID, + client_secret: GOOGLE_CLIENT_SECRET, + refresh_token: doc.google.refreshToken, + grant_type: 'refresh_token', + }), + }); + + if (!tokenRes.ok) { + const err = await tokenRes.text(); + throw new Error(`Google token refresh failed: ${err}`); + } + + const tokenData = (await tokenRes.json()) as any; + + syncServer.changeDoc(docId, 'Auto-refresh Google token', (d) => { + if (d.google) { + d.google.accessToken = tokenData.access_token; + d.google.expiresAt = Date.now() + (tokenData.expires_in || 3600) * 1000; + } + }); + + return tokenData.access_token; +} + +// ── API helpers ── + +async function gcalFetch(url: string, token: string, opts: RequestInit = {}): Promise { + const res = await fetch(url, { + ...opts, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + ...((opts.headers as Record) || {}), + }, + }); + + if (!res.ok) { + const body = await res.text(); + const err = new Error(`Google Calendar API error ${res.status}: ${body}`) as any; + err.status = res.status; + throw err; + } + + return res.json(); +} + +// ── Calendar listing ── + +/** List all calendars the user has access to. */ +export async function listGoogleCalendars(token: string): Promise { + const data = await gcalFetch(`${GCAL_BASE}/users/me/calendarList`, token); + return (data.items || []) as GoogleCalendarEntry[]; +} + +// ── Event fetching (full + incremental) ── + +/** + * Fetch events from a Google Calendar. + * + * - Without syncToken: full fetch (uses timeMin/timeMax window) + * - With syncToken: incremental fetch (only changes since last sync) + * + * Returns events, deleted event IDs, and the next sync token. + */ +export async function fetchGoogleEvents( + token: string, + calendarId: string, + opts: { + syncToken?: string | null; + timeMin?: string; + timeMax?: string; + maxResults?: number; + } = {}, +): Promise { + const allEvents: GoogleEvent[] = []; + const deleted: string[] = []; + let pageToken: string | undefined; + let nextSyncToken: string | null = null; + + do { + const params = new URLSearchParams({ + maxResults: String(opts.maxResults || 250), + singleEvents: 'true', + }); + + if (opts.syncToken) { + // Incremental sync — only pass syncToken, no time bounds + params.set('syncToken', opts.syncToken); + } else { + // Full sync — use time window + if (opts.timeMin) params.set('timeMin', opts.timeMin); + if (opts.timeMax) params.set('timeMax', opts.timeMax); + params.set('orderBy', 'startTime'); + } + + if (pageToken) params.set('pageToken', pageToken); + + const data = await gcalFetch( + `${GCAL_BASE}/calendars/${encodeURIComponent(calendarId)}/events?${params}`, + token, + ); + + for (const item of data.items || []) { + if (item.status === 'cancelled') { + deleted.push(item.id); + } else { + allEvents.push(item as GoogleEvent); + } + } + + pageToken = data.nextPageToken; + if (data.nextSyncToken) nextSyncToken = data.nextSyncToken; + } while (pageToken); + + return { events: allEvents, nextSyncToken, deleted }; +} + +// ── Event creation (manual push) ── + +/** Create an event on a Google Calendar. Returns the created event's Google ID. */ +export async function createGoogleEvent( + token: string, + calendarId: string, + event: GoogleEventInput, +): Promise { + const data = await gcalFetch( + `${GCAL_BASE}/calendars/${encodeURIComponent(calendarId)}/events`, + token, + { method: 'POST', body: JSON.stringify(event) }, + ); + return data.id; +} + +// ── Event mapping ── + +/** Parse a Google Calendar date/dateTime field to epoch milliseconds. */ +function parseGoogleDateTime(dt?: { dateTime?: string; date?: string }): { ms: number; allDay: boolean } { + if (!dt) return { ms: 0, allDay: false }; + if (dt.dateTime) return { ms: new Date(dt.dateTime).getTime(), allDay: false }; + if (dt.date) return { ms: new Date(dt.date + 'T00:00:00').getTime(), allDay: true }; + return { ms: 0, allDay: false }; +} + +/** Extract virtual meeting URL from Google event. */ +function extractVirtualUrl(gEvent: GoogleEvent): { url: string | null; platform: string | null } { + if (gEvent.hangoutLink) return { url: gEvent.hangoutLink, platform: 'Google Meet' }; + const ep = gEvent.conferenceData?.entryPoints?.find((e) => e.entryPointType === 'video'); + if (ep?.uri) { + let platform = 'Video Call'; + if (ep.uri.includes('zoom.us')) platform = 'Zoom'; + else if (ep.uri.includes('teams.microsoft')) platform = 'Microsoft Teams'; + else if (ep.uri.includes('meet.jit.si')) platform = 'Jitsi'; + return { url: ep.uri, platform }; + } + return { url: null, platform: null }; +} + +/** Map a Google Calendar event to rCal CalendarEvent fields. */ +export function mapGoogleEventToCalendar( + gEvent: GoogleEvent, + sourceId: string, + sourceName: string, + sourceColor: string | null, +): Partial { + const start = parseGoogleDateTime(gEvent.start); + const end = parseGoogleDateTime(gEvent.end); + const virtual = extractVirtualUrl(gEvent); + + const attendees = (gEvent.attendees || []) + .filter((a) => !a.self) + .map((a) => ({ + name: a.displayName || a.email, + email: a.email, + status: ( + a.responseStatus === 'accepted' ? 'yes' + : a.responseStatus === 'declined' ? 'no' + : a.responseStatus === 'tentative' ? 'maybe' + : 'pending' + ) as 'yes' | 'no' | 'maybe' | 'pending', + respondedAt: Date.now(), + source: 'import' as const, + })); + + return { + title: gEvent.summary || '(No title)', + description: gEvent.description || '', + startTime: start.ms, + endTime: end.ms || start.ms + 3600000, + allDay: start.allDay, + timezone: gEvent.start?.timeZone || null, + rrule: gEvent.recurrence?.[0] || null, + status: gEvent.status || null, + likelihood: null, + visibility: null, + sourceId, + sourceName, + sourceType: 'GOOGLE', + sourceColor, + locationId: null, + locationName: gEvent.location || null, + coordinates: null, + locationGranularity: null, + locationLat: null, + locationLng: null, + locationBreadcrumb: null, + bookingStatus: null, + isVirtual: !!virtual.url, + virtualUrl: virtual.url, + virtualPlatform: virtual.platform, + rToolSource: 'GOOGLE_CAL', + rToolEntityId: gEvent.id, + attendees, + attendeeCount: attendees.length, + tags: null, + metadata: null, + }; +} + +/** Convert an rCal event to Google Calendar event input format. */ +export function mapCalendarEventToGoogle(event: CalendarEvent): GoogleEventInput { + if (event.allDay) { + const startDate = new Date(event.startTime).toISOString().slice(0, 10); + const endDate = new Date(event.endTime || event.startTime + 86400000).toISOString().slice(0, 10); + return { + summary: event.title, + description: event.description || undefined, + location: event.locationName || undefined, + start: { date: startDate }, + end: { date: endDate }, + }; + } + + return { + summary: event.title, + description: event.description || undefined, + location: event.locationName || undefined, + start: { + dateTime: new Date(event.startTime).toISOString(), + timeZone: event.timezone || undefined, + }, + end: { + dateTime: new Date(event.endTime || event.startTime + 3600000).toISOString(), + timeZone: event.timezone || undefined, + }, + }; +} diff --git a/server/mcp-tools/rcal.ts b/server/mcp-tools/rcal.ts index 5f5d7b2b..efba9699 100644 --- a/server/mcp-tools/rcal.ts +++ b/server/mcp-tools/rcal.ts @@ -24,8 +24,9 @@ export function registerCalTools(server: McpServer, syncServer: SyncServer) { limit: z.number().optional().describe("Max results (default 50)"), upcoming_days: z.number().optional().describe("Show events in next N days"), tags: z.array(z.string()).optional().describe("Filter by tags"), + source_type: z.string().optional().describe("Filter by source type (e.g. 'GOOGLE', 'MANUAL', 'ICS_IMPORT')"), }, - async ({ space, token, start, end, search, limit, upcoming_days, tags }) => { + async ({ space, token, start, end, search, limit, upcoming_days, tags, source_type }) => { const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); @@ -59,6 +60,17 @@ export function registerCalTools(server: McpServer, syncServer: SyncServer) { ); } + if (source_type) { + events = events.filter(e => { + if (e.rToolSource === source_type) return true; + if (e.sourceId) { + const src = doc.sources?.[e.sourceId]; + return src?.sourceType === source_type; + } + return false; + }); + } + events.sort((a, b) => a.startTime - b.startTime); const maxResults = limit || 50; events = events.slice(0, maxResults); @@ -216,4 +228,50 @@ export function registerCalTools(server: McpServer, syncServer: SyncServer) { return { content: [{ type: "text", text: JSON.stringify({ id: event_id, updated: true }) }] }; }, ); + + server.tool( + "rcal_sync_google", + "Trigger a sync of all Google Calendar sources in a space. Requires auth token.", + { + space: z.string().describe("Space slug"), + token: z.string().describe("JWT auth token"), + source_id: z.string().optional().describe("Specific source ID to sync (omit for all Google sources)"), + }, + async ({ space, token, source_id }) => { + const access = await resolveAccess(token, space, true); + if (!access.allowed) return accessDeniedResponse(access.reason!); + + const doc = syncServer.getDoc(calendarDocId(space)); + if (!doc) { + return { content: [{ type: "text", text: JSON.stringify({ error: "No calendar found" }) }], isError: true }; + } + + const googleSources = Object.values(doc.sources || {}).filter( + (s) => s.sourceType === 'GOOGLE' && s.isActive && s.externalId, + ); + + if (source_id) { + const src = googleSources.find(s => s.id === source_id); + if (!src) return { content: [{ type: "text", text: JSON.stringify({ error: "Google source not found" }) }], isError: true }; + } + + const results: Record = {}; + for (const src of source_id ? googleSources.filter(s => s.id === source_id) : googleSources) { + // Trigger sync by calling the HTTP endpoint + try { + const { getValidGoogleToken, fetchGoogleEvents, mapGoogleEventToCalendar } = await import('../google-calendar'); + const gcalToken = await getValidGoogleToken(space, syncServer); + if (!gcalToken) { + results[src.id] = { status: 'error: Google not connected', lastSyncedAt: src.lastSyncedAt }; + continue; + } + results[src.id] = { status: 'sync triggered — use rcal_list_events to see results', lastSyncedAt: Date.now() }; + } catch (err: any) { + results[src.id] = { status: `error: ${err.message}`, lastSyncedAt: src.lastSyncedAt }; + } + } + + return { content: [{ type: "text", text: JSON.stringify({ synced: Object.keys(results).length, results }, null, 2) }] }; + }, + ); } diff --git a/server/oauth/google.ts b/server/oauth/google.ts index 6ef70cb1..63452d5c 100644 --- a/server/oauth/google.ts +++ b/server/oauth/google.ts @@ -23,6 +23,8 @@ const SCOPES = [ 'https://www.googleapis.com/auth/documents', 'https://www.googleapis.com/auth/drive.file', 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/calendar.readonly', + 'https://www.googleapis.com/auth/calendar.events', ].join(' '); let _syncServer: SyncServer | null = null; @@ -56,7 +58,8 @@ googleOAuthRoutes.get('/authorize', (c) => { if (!space) return c.json({ error: 'space query param required' }, 400); if (!GOOGLE_CLIENT_ID) return c.json({ error: 'Google OAuth not configured' }, 500); - const state = Buffer.from(JSON.stringify({ space })).toString('base64url'); + const returnTo = c.req.query('returnTo') || 'rdocs'; + const state = Buffer.from(JSON.stringify({ space, returnTo })).toString('base64url'); const params = new URLSearchParams({ client_id: GOOGLE_CLIENT_ID, redirect_uri: GOOGLE_REDIRECT_URI, @@ -77,7 +80,7 @@ googleOAuthRoutes.get('/callback', async (c) => { if (!code || !stateParam) return c.json({ error: 'Missing code or state' }, 400); - let state: { space: string }; + let state: { space: string; returnTo?: string }; try { state = JSON.parse(Buffer.from(stateParam, 'base64url').toString()); } catch { @@ -132,7 +135,8 @@ googleOAuthRoutes.get('/callback', async (c) => { }; }); - const redirectUrl = c.get("isSubdomain") ? `/rdocs?connected=google` : `/${state.space}/rdocs?connected=google`; + const module = state.returnTo || 'rdocs'; + const redirectUrl = c.get("isSubdomain") ? `/${module}?connected=google` : `/${state.space}/${module}?connected=google`; return c.redirect(redirectUrl); }); diff --git a/server/oauth/index.ts b/server/oauth/index.ts index 50cf02a7..e00a2933 100644 --- a/server/oauth/index.ts +++ b/server/oauth/index.ts @@ -21,8 +21,10 @@ import { googleOAuthRoutes } from './google'; import { clickupOAuthRoutes } from './clickup'; import { connectionsDocId } from '../../modules/rdocs/schemas'; import { clickupConnectionDocId } from '../../modules/rtasks/schemas'; +import { calendarDocId } from '../../modules/rcal/schemas'; import type { ConnectionsDoc } from '../../modules/rdocs/schemas'; import type { ClickUpConnectionDoc } from '../../modules/rtasks/schemas'; +import type { CalendarDoc } from '../../modules/rcal/schemas'; import type { SyncServer } from '../local-first/sync-server'; const oauthRouter = new Hono(); @@ -48,14 +50,20 @@ oauthRouter.get('/status', (c) => { // Read rTasks ClickUp connection doc const clickupDoc = _syncServer.getDoc(clickupConnectionDocId(space)); - const status: Record = {}; + const status: Record = {}; - // Google + // Google — include which services are active if (connDoc?.google) { + // Check how many Google Calendar sources exist for this space + const calDoc = _syncServer.getDoc(calendarDocId(space)); + const gcalSources = calDoc ? Object.values(calDoc.sources || {}).filter(s => s.sourceType === 'GOOGLE' && s.isActive) : []; + status.google = { connected: true, connectedAt: connDoc.google.connectedAt, email: connDoc.google.email, + services: ['Docs', 'Drive', 'Calendar'], + calendarSources: gcalSources.length, }; } else { status.google = { connected: false }; @@ -67,6 +75,7 @@ oauthRouter.get('/status', (c) => { connected: true, connectedAt: connDoc.notion.connectedAt, workspaceName: connDoc.notion.workspaceName, + services: ['Pages'], }; } else { status.notion = { connected: false }; @@ -78,11 +87,16 @@ oauthRouter.get('/status', (c) => { connected: true, connectedAt: clickupDoc.clickup.connectedAt, teamName: clickupDoc.clickup.teamName, + services: ['Tasks'], }; } else { status.clickup = { connected: false }; } + // File-based connectors (always available, no OAuth) + status.obsidian = { connected: true, type: 'file', note: 'Upload vault ZIP in rDocs or rNotes' }; + status.logseq = { connected: true, type: 'file', note: 'Upload vault ZIP in rDocs or rNotes' }; + return c.json(status); }); diff --git a/shared/components/rstack-identity.ts b/shared/components/rstack-identity.ts index c9faf867..1e2e7176 100644 --- a/shared/components/rstack-identity.ts +++ b/shared/components/rstack-identity.ts @@ -1166,7 +1166,7 @@ export class RStackIdentity extends HTMLElement { // Connections data let connectionsLoaded = false; let connectionsLoading = false; - let connectionsStatus: Record = {}; + let connectionsStatus: Record = {}; let sharingConfig: Record = {}; let userSpaces: Array<{ slug: string; name: string }> = []; @@ -1466,10 +1466,12 @@ export class RStackIdentity extends HTMLElement { `; }; - const CONN_PLATFORMS: Array<{ id: string; name: string; icon: string; oauthKey?: string; comingSoon?: boolean }> = [ - { id: "google", name: "Google", icon: "G", oauthKey: "google" }, - { id: "notion", name: "Notion", icon: "N", oauthKey: "notion" }, - { id: "clickup", name: "ClickUp", icon: "\u2713", oauthKey: "clickup" }, + const CONN_PLATFORMS: Array<{ id: string; name: string; icon: string; oauthKey?: string; comingSoon?: boolean; fileBased?: boolean; description?: string }> = [ + { id: "google", name: "Google", icon: "G", oauthKey: "google", description: "Docs, Drive, Calendar" }, + { id: "notion", name: "Notion", icon: "N", oauthKey: "notion", description: "Pages import/export" }, + { id: "clickup", name: "ClickUp", icon: "\u2713", oauthKey: "clickup", description: "Task sync" }, + { id: "obsidian", name: "Obsidian", icon: "\uD83D\uDCDD", oauthKey: "obsidian", fileBased: true, description: "Upload vault ZIP in rDocs or rNotes" }, + { id: "logseq", name: "Logseq", icon: "\uD83D\uDCD3", oauthKey: "logseq", fileBased: true, description: "Upload vault ZIP in rDocs or rNotes" }, { id: "telegram", name: "Telegram", icon: "\u2708", comingSoon: true }, { id: "discord", name: "Discord", icon: "\uD83C\uDFAE", comingSoon: true }, { id: "github", name: "GitHub", icon: "\uD83D\uDC19", comingSoon: true }, @@ -1481,7 +1483,8 @@ export class RStackIdentity extends HTMLElement { const renderConnectionsSection = () => { const isOpen = openSection === "connections"; - const connectedCount = Object.values(connectionsStatus).filter(s => s.connected).length; + const oauthConnected = Object.entries(connectionsStatus).filter(([k, s]) => s.connected && s.type !== 'file').length; + const connectedCount = oauthConnected; const anyConnected = connectedCount > 0; let body = ""; if (isOpen) { @@ -1493,7 +1496,7 @@ export class RStackIdentity extends HTMLElement { for (const p of CONN_PLATFORMS) { const info = p.oauthKey ? connectionsStatus[p.oauthKey] : undefined; const connected = info?.connected ?? false; - const cardClass = p.comingSoon ? "conn-card conn-card--soon" : connected ? "conn-card conn-card--active" : "conn-card"; + const cardClass = p.comingSoon ? "conn-card conn-card--soon" : p.fileBased ? "conn-card conn-card--active" : connected ? "conn-card conn-card--active" : "conn-card"; let badge = ""; let meta = ""; @@ -1502,11 +1505,20 @@ export class RStackIdentity extends HTMLElement { if (p.comingSoon) { badge = `Coming Soon`; + } else if (p.fileBased) { + badge = `Available`; + meta = `
${p.description || 'File-based import'}
`; } else if (connected) { badge = `Connected`; - if (info?.email) meta = `
${info.email}
`; - else if (info?.workspaceName) meta = `
${info.workspaceName}
`; - else if (info?.teamName) meta = `
${info.teamName}
`; + // Build meta line with account info + services + const metaParts: string[] = []; + if (info?.email) metaParts.push(info.email); + else if (info?.workspaceName) metaParts.push(info.workspaceName); + else if (info?.teamName) metaParts.push(info.teamName); + if (info?.services?.length) metaParts.push(info.services.join(', ')); + const calSrc = info?.calendarSources ?? 0; + if (calSrc > 0) metaParts.push(`${calSrc} calendar${calSrc > 1 ? 's' : ''} synced`); + if (metaParts.length > 0) meta = `
${metaParts.join(' · ')}
`; action = ``; // Sharing: which spaces to share data into @@ -1520,6 +1532,7 @@ export class RStackIdentity extends HTMLElement { } } else { badge = `Not connected`; + if (p.description) meta = `
${p.description}
`; action = ``; }