import { clientStore, config, datastore, editor, index } from "@silverbulletmd/silverbullet/syscalls"; import ICAL from "ical.js"; import { RRule, RRuleSet } from "rrule"; const VERSION = "0.4.0"; const CACHE_KEY = "icalendar:lastSync"; console.log(`[iCalendar] Plug script executing at top level (Version ${VERSION})`); // ============================================================================ // Types // ============================================================================ interface CalendarEvent { ref: string; tag: "ical-event"; sourceName: string; // Core properties uid: string; summary: string; description?: string; location?: string; // Dates - stored as ISO strings to preserve timezone start: string; startLocal: string; // For queries: local representation end?: string; // Timezone info timezone: string; isAllDay: boolean; // Recurrence isRecurring: boolean; rrule?: string; recurrenceId?: string; // For individual occurrences // Status status?: string; // People organizer?: string; attendees?: string[]; } // ============================================================================ // Utility Functions // ============================================================================ async function sha256Hash(str: string): Promise { const encoder = new TextEncoder(); const data = encoder.encode(str); const hashBuffer = await crypto.subtle.digest("SHA-256", data); const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map(b => b.toString(16).padStart(2, "0")).join(""); } function localDateString(date: Date): string { const pad = (n: number) => String(n).padStart(2, "0"); return date.getFullYear() + "-" + pad(date.getMonth() + 1) + "-" + pad(date.getDate()) + "T" + pad(date.getHours()) + ":" + pad(date.getMinutes()) + ":" + pad(date.getSeconds()); } /** * Converts ICAL.Time to ISO string and local string */ function parseIcalTime(time: ICAL.Time): { iso: string; local: string; timezone: string; isAllDay: boolean } { const isAllDay = time.isDate; if (isAllDay) { // Date-only events const dateStr = time.toJSDate().toISOString().split('T')[0]; return { iso: dateStr, local: dateStr, timezone: "date", isAllDay: true }; } // Get timezone const timezone = time.zone?.tzid || "UTC"; // ISO representation (UTC) const jsDate = time.toJSDate(); const iso = jsDate.toISOString(); // Local representation for querying const local = localDateString(jsDate); return { iso, local, timezone, isAllDay: false }; } // ============================================================================ // Configuration // ============================================================================ async function getSources(): Promise<{ sources: any[], syncWindowDays: number }> { try { const rawConfig = await config.get("icalendar", { sources: [] }) as any; console.log("[iCalendar] Raw config retrieved:", JSON.stringify(rawConfig)); let sources = rawConfig.sources || []; const syncWindowDays = rawConfig.syncWindowDays || 365; if (sources && typeof sources === "object" && !Array.isArray(sources)) { const sourceArray = []; for (const key in sources) { if (sources[key] && typeof sources[key].url === "string") { sourceArray.push(sources[key]); } } sources = sourceArray; } return { sources, syncWindowDays }; } catch (e) { console.error("[iCalendar] Error in getSources:", e); return { sources: [], syncWindowDays: 365 }; } } // ============================================================================ // Recurrence Expansion (keeping your logic) // ============================================================================ /** * Expands recurring events using rrule * Keeps your no-lookback-limit approach */ function expandRecurrences( event: CalendarEvent, startDate: Date, windowDays: number, now = new Date() ): CalendarEvent[] { if (!event.rrule || !event.isRecurring) { return [event]; } try { const set = new RRuleSet(); // Parse the RRULE string const cleanRule = event.rrule.replace(/^RRULE:/i, ""); const ruleOptions = RRule.parseString(cleanRule); ruleOptions.dtstart = startDate; set.rrule(new RRule(ruleOptions)); const windowEnd = new Date(now.getTime() + windowDays * 86400000); // Expand from event start (no lookback limit) const occurrences = set.between(startDate, windowEnd, true); return occurrences.map((occurrenceDate, idx) => ({ ...event, start: occurrenceDate.toISOString(), startLocal: localDateString(occurrenceDate), recurrenceId: occurrenceDate.toISOString(), rrule: undefined, // Remove rrule from occurrences })); } catch (err) { console.error(`[iCalendar] Error expanding recurrence for ${event.summary}:`, err); return [event]; } } // ============================================================================ // Calendar Parsing with ical.js // ============================================================================ async function fetchAndParseCalendar( source: any, windowDays: number ): Promise { try { const response = await fetch(source.url); if (!response.ok) { console.error(`[iCalendar] Fetch failed for ${source.name}: ${response.status}`); return []; } const icsText = await response.text(); // Parse with ical.js const jcalData = ICAL.parse(icsText); const vcalendar = new ICAL.Component(jcalData); const vevents = vcalendar.getAllSubcomponents('vevent'); if (vevents.length === 0) { return []; } const events: CalendarEvent[] = []; for (const vevent of vevents) { try { const event = new ICAL.Event(vevent); // Skip cancelled events const status = vevent.getFirstPropertyValue('status'); if (status === 'CANCELLED') continue; // Parse start time const startTime = event.startDate; if (!startTime) continue; const { iso, local, timezone, isAllDay } = parseIcalTime(startTime); // Parse end time let endIso: string | undefined; if (event.endDate) { endIso = parseIcalTime(event.endDate).iso; } // Get organizer const organizerProp = vevent.getFirstProperty('organizer'); const organizer = organizerProp ? organizerProp.getFirstValue()?.replace(/^mailto:/i, '') : undefined; // Get attendees const attendees: string[] = []; const attendeeProps = vevent.getAllProperties('attendee'); for (const prop of attendeeProps) { const value = prop.getFirstValue(); if (typeof value === 'string') { attendees.push(value.replace(/^mailto:/i, '')); } } // Get RRULE as string let rruleStr: string | undefined; const rruleProp = vevent.getFirstProperty('rrule'); if (rruleProp) { const rruleValue = rruleProp.getFirstValue(); rruleStr = rruleValue ? rruleValue.toString() : undefined; } const baseEvent: CalendarEvent = { ref: "", // Will be set per occurrence tag: "ical-event", sourceName: source.name, uid: event.uid, summary: event.summary || "Untitled Event", description: event.description, location: event.location, start: iso, startLocal: local, end: endIso, timezone, isAllDay, isRecurring: event.isRecurring(), rrule: rruleStr, status, organizer, attendees: attendees.length > 0 ? attendees : undefined, }; // Expand recurrences or use single event const startJsDate = startTime.toJSDate(); const expandedEvents = baseEvent.isRecurring ? expandRecurrences(baseEvent, startJsDate, windowDays) : [baseEvent]; // Generate refs for (const evt of expandedEvents) { const uniqueKey = evt.recurrenceId ? `${evt.uid}:${evt.recurrenceId}` : `${evt.uid}:${evt.start}`; evt.ref = await sha256Hash(uniqueKey); events.push(evt); } } catch (eventErr) { console.error(`[iCalendar] Error parsing event:`, eventErr); continue; } } return events; } catch (err: any) { console.error(`[iCalendar] Error fetching/parsing ${source.name}:`, err.message || err, err.stack || ""); return []; } } // ============================================================================ // Exported Commands // ============================================================================ export async function syncCalendars() { try { const { sources, syncWindowDays } = await getSources(); if (sources.length === 0) return; await editor.flashNotification("Syncing calendars...", "info"); const allEvents: CalendarEvent[] = []; let successCount = 0; for (const source of sources) { const events = await fetchAndParseCalendar(source, syncWindowDays); allEvents.push(...events); if (events.length > 0) successCount++; console.log(`[iCalendar] Synced ${events.length} events from "${source.name}"`); } await index.indexObjects("$icalendar", allEvents); await clientStore.set(CACHE_KEY, Date.now()); const summary = `Synced ${allEvents.length} events from ${successCount}/${sources.length} source(s)`; console.log(`[iCalendar] ${summary}`); await editor.flashNotification(summary, "info"); } catch (err) { console.error("[iCalendar] syncCalendars failed:", err); await editor.flashNotification("Failed to sync calendars", "error"); } } export async function forceSync() { await clientStore.del(CACHE_KEY); console.log("[iCalendar] Forcing fresh sync"); await editor.flashNotification("Forcing fresh calendar sync...", "info"); await syncCalendars(); } export async function clearCache() { if (!await editor.confirm("Clear all calendar events and cache?")) { return; } try { const pageKeys = await datastore.query({ prefix: ["ridx", "$icalendar"] }); const allKeys: any[] = []; for (const { key } of pageKeys) { allKeys.push(key); allKeys.push(["idx", ...key.slice(2), "$icalendar"]); } if (allKeys.length > 0) { await datastore.batchDel(allKeys); console.log(`[iCalendar] Deleted ${allKeys.length} entries`); } await clientStore.del(CACHE_KEY); await editor.flashNotification("Calendar index cleared", "info"); } catch (err) { console.error("[iCalendar] Failed to clear cache:", err); await editor.flashNotification("Failed to clear cache", "error"); } } export async function showVersion() { await editor.flashNotification(`iCalendar Plug ${VERSION}`, "info"); }