Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m8s
Details
CI/CD / deploy (push) Successful in 2m8s
Details
This commit is contained in:
commit
7ccc80662d
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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) }] };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue