feat(rcal): add Google Calendar sync integration with connectors menu

- Google Calendar API client (server/google-calendar.ts): token auto-refresh,
  list calendars, fetch events with incremental sync, create events, mapping
- OAuth scopes: added calendar.readonly + calendar.events, returnTo param
- rCal routes: subscribe, sync, sync-all, unsubscribe, push-to-Google endpoints
- Background sync loop: 10-minute interval with incremental sync tokens
- Frontend: calendar picker modal, sync button, per-event Google export
- MCP: source_type filter on rcal_list_events, new rcal_sync_google tool
- Connectors menu: Google shows services (Docs/Drive/Calendar) + calendar count,
  added Obsidian & Logseq as file-based connectors, Notion shows services
- Fix: import-export dialog API base corrected from rnotes to rdocs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-10 19:45:52 -04:00
parent 885f0baeb1
commit c6cb875ba4
9 changed files with 1029 additions and 31 deletions

View File

@ -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<string, string> = { "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<string, string> = {};
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<string, string> = { "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 {
</div>
${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 `<div class="sources">
<div class="sources-heading">Calendar Legend</div>
${this.sources.map(s => `<span class="src-badge ${this.filteredSources.has(s.name) ? "filtered" : ""}"
data-source="${this.esc(s.name)}"
style="border-color:${s.color || "#666"};color:${s.color || "#aaa"}">${this.esc(s.name)}</span>`).join("")}
style="border-color:${s.color || "#666"};color:${s.color || "#aaa"}">${this.esc(s.name)}${s.source_type === "GOOGLE" ? ' <span class="gcal-dot" title="Google Calendar"></span>' : ''}</span>`).join("")}
<button class="src-badge gcal-btn" id="gcal-manage" title="${this.gcalConnected ? 'Manage Google Calendars' : 'Connect Google Calendar'}">${this.gcalConnected ? '\u{1F504} Google' : '\u{1F517} Google Cal'}</button>
${hasGoogleSources ? `<button class="src-badge gcal-sync-btn" id="gcal-sync-all" title="Sync all Google Calendars now">\u{1F504} Sync</button>` : ''}
</div>`;
}
private renderGcalModal(): string {
if (!this.showGcalModal) return "";
return `<div class="gcal-modal-overlay" id="gcal-overlay">
<div class="gcal-modal">
<div class="gcal-modal-header">
<span>Google Calendar</span>
<button class="gcal-modal-close" id="gcal-close">\u2715</button>
</div>
<div class="gcal-modal-body">
${!this.gcalConnected ? `
<p style="color:var(--rs-text-secondary);margin:12px 0">Connect your Google account to sync calendars.</p>
<a class="gcal-connect-btn" href="/api/oauth/google/authorize?space=${this.space}&returnTo=rcal">Connect Google Account</a>
` : this.gcalLoading ? `
<p style="color:var(--rs-text-secondary);text-align:center;padding:24px 0">Loading calendars...</p>
` : this.gcalError ? `
<p style="color:var(--rs-error);margin:12px 0">${this.esc(this.gcalError)}</p>
${!this.gcalConnected ? `<a class="gcal-connect-btn" href="/api/oauth/google/authorize?space=${this.space}&returnTo=rcal">Connect Google Account</a>` : ''}
` : `
<p style="color:var(--rs-text-secondary);margin:0 0 12px;font-size:12px">Select calendars to sync into rCal. Events will auto-update every 10 minutes.</p>
<div class="gcal-list">
${this.gcalCalendars.map(cal => `
<label class="gcal-item" data-gcal-id="${this.esc(cal.id)}" data-gcal-name="${this.esc(cal.name)}" data-gcal-color="${cal.color || '#4285f4'}">
<span class="gcal-color" style="background:${cal.color || '#4285f4'}"></span>
<span class="gcal-name">${this.esc(cal.name)}${cal.primary ? ' <span style="opacity:0.5;font-size:10px">(primary)</span>' : ''}</span>
<input type="checkbox" class="gcal-check" ${cal.subscribed ? 'checked' : ''}>
</label>
`).join("")}
</div>
`}
</div>
</div>
</div>`;
}
@ -2068,6 +2225,7 @@ class FolkCalendarView extends HTMLElement {
${e.source_name ? `<div class="modal-field" style="margin-top:8px"><span class="src-badge" style="border-color:${e.source_color || "#666"};color:${e.source_color || "#aaa"}">${this.esc(e.source_name)}</span></div>` : ""}
${e.is_virtual ? `<div class="modal-field">\u{1F4BB} ${this.esc(e.virtual_platform || "Virtual")} ${e.virtual_url ? `<a href="${e.virtual_url}" target="_blank" style="color:var(--rs-primary-hover)">Join</a>` : ""}</div>` : ""}
${e.latitude != null ? `<div class="modal-field" style="font-size:11px;color:var(--rs-text-muted)">\u{1F4CD} ${e.latitude.toFixed(4)}, ${e.longitude.toFixed(4)}</div>` : ""}
${this.gcalConnected && e.r_tool_source !== 'GOOGLE_CAL' ? `<div class="modal-field" style="margin-top:12px"><button class="gcal-push-btn" data-push-event="${e.id}">\u{1F4E4} Export to Google Calendar</button></div>` : ""}
</div>
</div>`;
}
@ -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; }

View File

@ -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<CalendarDoc>(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<CalendarDoc>(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<CalendarDoc>(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<CalendarDoc>(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<CalendarDoc>(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<CalendarDoc>(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<string, { upserted: number; deleted: number; error?: string }> = {};
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<CalendarDoc>(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<typeof setInterval> | 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<CalendarDoc>(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' },

View File

@ -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 {

View File

@ -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,

332
server/google-calendar.ts Normal file
View File

@ -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<string | null> {
const docId = connectionsDocId(space);
const doc = syncServer.getDoc<ConnectionsDoc>(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<ConnectionsDoc>(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<any> {
const res = await fetch(url, {
...opts,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
...((opts.headers as Record<string, string>) || {}),
},
});
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<GoogleCalendarEntry[]> {
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<FetchEventsResult> {
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<string> {
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<CalendarEvent> {
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,
},
};
}

View File

@ -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<CalendarDoc>(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<string, { status: string; lastSyncedAt: number }> = {};
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) }] };
},
);
}

View File

@ -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);
});

View File

@ -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<ClickUpConnectionDoc>(clickupConnectionDocId(space));
const status: Record<string, { connected: boolean; connectedAt?: number; email?: string; workspaceName?: string; teamName?: string }> = {};
const status: Record<string, any> = {};
// Google
// Google — include which services are active
if (connDoc?.google) {
// Check how many Google Calendar sources exist for this space
const calDoc = _syncServer.getDoc<CalendarDoc>(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);
});

View File

@ -1166,7 +1166,7 @@ export class RStackIdentity extends HTMLElement {
// Connections data
let connectionsLoaded = false;
let connectionsLoading = false;
let connectionsStatus: Record<string, { connected: boolean; email?: string; workspaceName?: string; teamName?: string; connectedAt?: number }> = {};
let connectionsStatus: Record<string, { connected: boolean; email?: string; workspaceName?: string; teamName?: string; connectedAt?: number; services?: string[]; calendarSources?: number; type?: string; note?: string }> = {};
let sharingConfig: Record<string, { spaces: string[] }> = {};
let userSpaces: Array<{ slug: string; name: string }> = [];
@ -1466,10 +1466,12 @@ export class RStackIdentity extends HTMLElement {
</div>`;
};
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 = `<span class="conn-badge conn-badge--soon">Coming Soon</span>`;
} else if (p.fileBased) {
badge = `<span class="conn-badge conn-badge--connected">Available</span>`;
meta = `<div class="conn-meta">${p.description || 'File-based import'}</div>`;
} else if (connected) {
badge = `<span class="conn-badge conn-badge--connected">Connected</span>`;
if (info?.email) meta = `<div class="conn-meta">${info.email}</div>`;
else if (info?.workspaceName) meta = `<div class="conn-meta">${info.workspaceName}</div>`;
else if (info?.teamName) meta = `<div class="conn-meta">${info.teamName}</div>`;
// 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 = `<div class="conn-meta">${metaParts.join(' · ')}</div>`;
action = `<button class="conn-btn conn-btn--disconnect" data-provider="${p.oauthKey}">Disconnect</button>`;
// Sharing: which spaces to share data into
@ -1520,6 +1532,7 @@ export class RStackIdentity extends HTMLElement {
}
} else {
badge = `<span class="conn-badge conn-badge--disconnected">Not connected</span>`;
if (p.description) meta = `<div class="conn-meta" style="opacity:0.6">${p.description}</div>`;
action = `<button class="conn-btn conn-btn--connect" data-provider="${p.oauthKey}" data-space="${username}">Connect</button>`;
}