import { clientStore, config, datastore, editor, index } from "@silverbulletmd/silverbullet/syscalls"; import ICAL from "ical.js"; import { RRule, RRuleSet } from "rrule"; import { getUtcOffsetMs, resolveIanaName } from "./timezones.ts"; const VERSION = "0.4.6"; const CACHE_KEY = "icalendar:lastSync"; console.log(`[iCalendar] Plug script executing at top level (Version ${VERSION})`); /** * Mapping of verbose RRULE object keys to standard iCalendar shortened keys. */ const RRULE_KEY_MAP: Record = { "frequency": "FREQ", "until": "UNTIL", "count": "COUNT", "interval": "INTERVAL", "bysecond": "BYSECOND", "byminute": "BYMINUTE", "byhour": "BYHOUR", "byday": "BYDAY", "bymonthday": "BYMONTHDAY", "byyearday": "BYYEARDAY", "byweekno": "BYWEEKNO", "bymonth": "BYMONTH", "bysetpos": "BYSETPOS", "wkst": "WKST", "workweekstart": "WKST", "freq": "FREQ", }; /** * Robustly formats an RRULE value for its string representation. */ function formatRRuleValue(v: any): string { if (Array.isArray(v)) { return v.map((item) => formatRRuleValue(item)).join(","); } if (v instanceof Date) { return v.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"; } if (typeof v === "object" && v !== null) { const val = v.date || v.day || v.value; if (val !== undefined) { return formatRRuleValue(val); } } return String(v); } // ============================================================================ // Utility Functions // ============================================================================ /** * Creates a SHA-256 hash of a string (hex encoded) */ 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(""); } /** * Converts UTC Date to a specific timezone string * Uses toLocaleString for better compatibility */ export function dateToTimezoneString(date: Date, timezone: string = "America/Los_Angeles"): string { try { // Use toLocaleString which has better worker support const localeString = date.toLocaleString('en-US', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); console.log(`[iCalendar] Converting ${date.toISOString()} to ${timezone}: ${localeString}`); // Parse the result: "MM/DD/YYYY, HH:MM:SS" const match = localeString.match(/(\d{2})\/(\d{2})\/(\d{4}),\s*(\d{2}):(\d{2}):(\d{2})/); if (match) { const [_, month, day, year, hour, minute, second] = match; return `${year}-${month}-${day}T${hour}:${minute}:${second}`; } throw new Error("Failed to parse toLocaleString result"); } catch (err) { console.error(`[iCalendar] Error converting to timezone ${timezone}:`, err); // Fallback to UTC const pad = (n: number) => String(n).padStart(2, "0"); return date.getUTCFullYear() + "-" + pad(date.getUTCMonth() + 1) + "-" + pad(date.getUTCDate()) + "T" + pad(date.getUTCHours()) + ":" + pad(date.getUTCMinutes()) + ":" + pad(date.getUTCSeconds()); } } /** * Recursively converts all Date objects and ISO date strings to strings */ function convertDatesToStrings(obj: T): any { if (obj === null || obj === undefined) { return obj; } if (obj instanceof Date) { return obj.toISOString(); } if (typeof obj === 'object' && 'date' in obj && (obj as any).date instanceof Date) { return (obj as any).date.toISOString(); } if (typeof obj === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(obj)) { try { return new Date(obj).toISOString(); } catch { return obj; } } if (Array.isArray(obj)) { return obj.map(item => convertDatesToStrings(item)); } if (typeof obj === 'object') { const result: any = {}; for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { result[key] = convertDatesToStrings((obj as any)[key]); } } return result; } return obj; } // ============================================================================ // Configuration Functions // ============================================================================ async function getSources(): Promise<{ sources: any[], syncWindowDays: number, displayTimezone: string }> { 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; // Get user's display timezone, default to America/Los_Angeles (PST) const displayTimezone = rawConfig.displayTimezone || "America/Los_Angeles"; 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, displayTimezone }; } catch (e) { console.error("[iCalendar] Error in getSources:", e); return { sources: [], syncWindowDays: 365, displayTimezone: "America/Los_Angeles" }; } } // ============================================================================ // Calendar Fetching & Parsing // ============================================================================ /** * Resolves the event start as a UTC Date object using DST-aware resolution. */ export async function resolveEventStart(icsEvent: any): Promise { const obj = icsEvent.start; if (!obj) return null; // 1. Extract the wall-clock local datetime string let wallClock: string | null = null; if (obj.local?.date) { const d = obj.local.date; wallClock = d instanceof Date ? d.toISOString() : String(d); } else if (obj.date) { const d = obj.date; wallClock = d instanceof Date ? d.toISOString() : String(d); } if (!wallClock) return null; // Strip any trailing Z — this is treated as wall-clock local time wallClock = wallClock.replace(/Z$/, ""); // 2. Resolve IANA timezone const rawTz = obj.local?.timezone || (obj as any).timezone || "UTC"; const ianaName = resolveIanaName(rawTz); if (!ianaName) { console.warn(`[iCalendar] Unknown timezone: "${rawTz}" - falling back to UTC for event "${icsEvent.summary}"`); const utcDate = new Date(wallClock + (wallClock.includes("T") ? "" : "T00:00:00") + "Z"); if (isNaN(utcDate.getTime())) return null; return utcDate; } // 3. Parse the wall-clock time as a UTC instant (no offset yet) const wallClockAsUtc = new Date(wallClock + (wallClock.includes("T") ? "" : "T00:00:00") + "Z"); if (isNaN(wallClockAsUtc.getTime())) return null; // 4. Get the DST-aware offset for this IANA zone at this instant const offsetMs = getUtcOffsetMs(ianaName, wallClockAsUtc); // 5. Convert: UTC = wall-clock - offset return new Date(wallClockAsUtc.getTime() - offsetMs); } /** * Resolves event end time */ async function resolveEventEnd(icsEvent: any): Promise { if (!icsEvent.end) return null; // Create a temporary event object with end as start const tempEvent = { ...icsEvent, start: icsEvent.end }; return await resolveEventStart(tempEvent); } /** * Expands recurring events into individual occurrences. */ export function expandRecurrences(icsEvent: any, windowDays = 365, displayTimezone = "America/Los_Angeles", now = new Date()): any[] { const rruleStr = icsEvent.rrule || (icsEvent as any).recurrenceRule; if (!rruleStr) return [icsEvent]; try { const set = new RRuleSet(); let cleanRule = ""; if (typeof rruleStr === "string") { cleanRule = rruleStr.replace(/^RRULE:/i, ""); } else if (typeof rruleStr === "object" && rruleStr !== null) { cleanRule = Object.entries(rruleStr) .map(([k, v]) => { const standardKey = RRULE_KEY_MAP[k.toLowerCase()] || k.toUpperCase(); return `${standardKey}=${formatRRuleValue(v)}`; }) .join(";"); } else { console.warn(`[iCalendar] Invalid rrule type (${typeof rruleStr}) for event "${icsEvent.summary || "Untitled"}". Treating as non-recurring.`); return [icsEvent]; } // Parse the stored UTC time (don't add Z, it's already there) const dtstart = new Date(icsEvent.start); if (isNaN(dtstart.getTime())) { console.error(`[iCalendar] Invalid start date for recurrence: ${icsEvent.start}`); return [icsEvent]; } const ruleOptions = RRule.parseString(cleanRule); ruleOptions.dtstart = dtstart; set.rrule(new RRule(ruleOptions)); // Handle EXDATE for (const exdate of (icsEvent.exdate || [])) { set.exdate(new Date(exdate)); } const windowEnd = new Date(now.getTime() + windowDays * 86400000); // Expand from the event's actual start date up to the window end const occurrences = set.between(dtstart, windowEnd, true); // Calculate duration for recurring events const duration = icsEvent.end ? new Date(icsEvent.end).getTime() - dtstart.getTime() : 0; const mapped = occurrences.map(occurrenceDate => { const endDate = duration > 0 ? new Date(occurrenceDate.getTime() + duration) : null; return { ...icsEvent, start: occurrenceDate.toISOString(), startLocal: dateToTimezoneString(occurrenceDate, displayTimezone), end: endDate ? endDate.toISOString() : undefined, endLocal: endDate ? dateToTimezoneString(endDate, displayTimezone) : undefined, recurrent: true, rrule: undefined, }; }); return mapped; } catch (err) { console.error(`[iCalendar] Error expanding recurrence for ${icsEvent.summary}:`, err); return [icsEvent]; } } async function fetchAndParseCalendar(source: any, windowDays = 365, displayTimezone = "America/Los_Angeles"): Promise { try { const response = await fetch(source.url); if (!response.ok) { console.error(`[iCalendar] Fetch failed for ${source.name}: ${response.status} ${response.statusText}`); return []; } const text = await response.text(); const jcalData = ICAL.parse(text); const vcalendar = new ICAL.Component(jcalData); const vevents = vcalendar.getAllSubcomponents("vevent"); const events: any[] = []; for (const vevent of vevents) { const icsEvent = new ICAL.Event(vevent); const status = vevent.getFirstPropertyValue("status") as string | null; if (status?.toUpperCase() === "CANCELLED") continue; // Extract raw properties for recurrence expansion const summary = icsEvent.summary; const uid = icsEvent.uid; const description = icsEvent.description; const location = icsEvent.location; const rrule = vevent.getFirstPropertyValue("rrule"); const exdates = vevent.getAllProperties("exdate").map((p: any) => p.getFirstValue().toJSDate().toISOString()); // Resolve start/end times const startDateUTC = icsEvent.startDate.toJSDate(); const endDateUTC = icsEvent.endDate ? icsEvent.endDate.toJSDate() : null; const rawTz = icsEvent.startDate.timezone || "UTC"; const baseEvent = { uid, summary, name: summary || "Untitled Event", description, location, // Store both UTC (for sorting/comparison) and local (for display) start: startDateUTC.toISOString(), startLocal: dateToTimezoneString(startDateUTC, displayTimezone), end: endDateUTC ? endDateUTC.toISOString() : undefined, endLocal: endDateUTC ? dateToTimezoneString(endDateUTC, displayTimezone) : undefined, tag: "ical-event", sourceName: source.name, timezone: rawTz, rrule: rrule ? rrule.toString() : undefined, exdate: exdates.length > 0 ? exdates : undefined }; if (rawTz !== "UTC" && rawTz !== "None" && !resolveIanaName(rawTz)) { baseEvent.description = `(Warning: Unknown timezone "${rawTz}") ${baseEvent.description || ""}`; } const expanded = expandRecurrences(baseEvent, windowDays, displayTimezone); for (const occurrence of expanded) { // Use summary in key to avoid collisions const uniqueKey = `${occurrence.start}${occurrence.uid || ''}${occurrence.summary || ''}`; occurrence.ref = await sha256Hash(uniqueKey); // Save our correctly formatted time strings const savedTimes = { start: occurrence.start, startLocal: occurrence.startLocal, end: occurrence.end, endLocal: occurrence.endLocal }; // Convert any remaining Date objects in other fields const converted = convertDatesToStrings(occurrence); // Restore our time strings (don't let them get reconverted) converted.start = savedTimes.start; converted.startLocal = savedTimes.startLocal; converted.end = savedTimes.end; converted.endLocal = savedTimes.endLocal; events.push(converted); } } return events; } catch (err: any) { console.error(`[iCalendar] Error fetching/parsing ${source.name}:`, err.message || err, err.stack || ""); return []; } } export async function syncCalendars() { try { const { sources, syncWindowDays, displayTimezone } = await getSources(); if (sources.length === 0) return; console.log(`[iCalendar] Using display timezone: ${displayTimezone}`); // Test timezone conversion const testDate = new Date("2026-02-21T14:00:00.000Z"); // 14:00 UTC const converted = dateToTimezoneString(testDate, displayTimezone); console.log(`[iCalendar] Timezone test: ${testDate.toISOString()} → ${converted} (should be 06:00 PST)`); await editor.flashNotification("Syncing calendars...", "info"); const allEvents: any[] = []; for (const source of sources) { const events = await fetchAndParseCalendar(source, syncWindowDays, displayTimezone); allEvents.push(...events); } await index.indexObjects("$icalendar", allEvents); await editor.flashNotification(`Synced ${allEvents.length} events`, "info"); } catch (err) { console.error("[iCalendar] syncCalendars failed:", err); } } export async function forceSync() { await clientStore.del(CACHE_KEY); await syncCalendars(); } export async function clearCache() { if (!await editor.confirm("Clear all calendar events?")) return; 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); await clientStore.del(CACHE_KEY); await editor.flashNotification("Calendar index cleared", "info"); } export async function showVersion() { await editor.flashNotification(`iCalendar Plug ${VERSION}`, "info"); }