import { clientStore, config, datastore, editor, index } from "@silverbulletmd/silverbullet/syscalls"; import { convertIcsCalendar } from "https://esm.sh/ts-ics@2.4.0"; import { RRule, RRuleSet } from "rrule"; import { getUtcOffsetMs, resolveIanaName } from "./timezones.ts"; const VERSION = "0.3.31"; 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", "freq": "FREQ", // Just in case }; // ============================================================================ // 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(""); } export 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()); } /** * Recursively converts all Date objects and ISO date strings to strings * Handles nested objects like {date: Date, local: {date: Date, timezone: string}} */ function convertDatesToStrings(obj: T): any { if (obj === null || obj === undefined) { return obj; } if (obj instanceof Date) { return localDateString(obj); } if (typeof obj === 'object' && 'date' in obj && (obj as any).date instanceof Date) { return localDateString((obj as any).date); } if (typeof obj === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(obj)) { try { return localDateString(new Date(obj)); } 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 }> { 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 }; } } // ============================================================================ // 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); } /** * Expands recurring events into individual occurrences. */ export function expandRecurrences(icsEvent: any, windowDays = 365): 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) { // Handle object rrule (e.g. from ts-ics) by converting back to string cleanRule = Object.entries(rruleStr) .map(([k, v]) => { const standardKey = RRULE_KEY_MAP[k.toLowerCase()] || k.toUpperCase(); return `${standardKey}=${v}`; }) .join(";"); } else { console.warn(`[iCalendar] Invalid rrule type (${typeof rruleStr}) for event "${icsEvent.summary || "Untitled"}". Treating as non-recurring.`); return [icsEvent]; } // We need to provide DTSTART if it's not in the string // We need to provide DTSTART if it's not in the string const dtstart = new Date(icsEvent.start.includes("Z") ? icsEvent.start : icsEvent.start + "Z"); 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.includes("Z") ? exdate : exdate + "Z")); } const now = new Date(); // Start our visible window 7 days ago to catch recent past events const filterStart = new Date(now.getTime() - 7 * 86400000); const windowEnd = new Date(now.getTime() + windowDays * 86400000); // Expand from the event's actual start date to ensure all recurrences are calculated correctly // but only take occurrences between (now - 7 days) and (now + windowDays) const occurrences = set.between(dtstart, windowEnd, true); return occurrences .filter(occurrenceDate => occurrenceDate >= filterStart) .map(occurrenceDate => { const localIso = localDateString(occurrenceDate); return { ...icsEvent, start: localIso, recurrent: true, rrule: undefined, }; }); } catch (err) { console.error(`[iCalendar] Error expanding recurrence for ${icsEvent.summary}:`, err); return [icsEvent]; } } async function fetchAndParseCalendar(source: any, windowDays = 365): Promise { console.log(`[iCalendar] Fetching from: ${source.url}`); 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 calendar = convertIcsCalendar(undefined, text); if (!calendar || !calendar.events) { return []; } const events: any[] = []; for (const icsEvent of calendar.events) { if (icsEvent.status?.toUpperCase() === "CANCELLED") continue; const finalDate = await resolveEventStart(icsEvent); if (!finalDate) continue; const localIso = localDateString(finalDate); const baseEvent = { ...icsEvent, name: icsEvent.summary || "Untitled Event", start: localIso, tag: "ical-event", sourceName: source.name }; const rawTz = icsEvent.start?.local?.timezone || (icsEvent.start as any)?.timezone || "UTC"; if (rawTz !== "UTC" && rawTz !== "None" && !resolveIanaName(rawTz)) { baseEvent.description = `(Warning: Unknown timezone "${rawTz}") ${baseEvent.description || ""}`; } const expanded = expandRecurrences(baseEvent, windowDays); for (const occurrence of expanded) { const uniqueKey = `${occurrence.start}${occurrence.uid || occurrence.summary || ''}`; occurrence.ref = await sha256Hash(uniqueKey); events.push(convertDatesToStrings(occurrence)); } } return events; } catch (err) { console.error(`[iCalendar] Error fetching/parsing ${source.name}:`, err); return []; } } export async function syncCalendars() { try { const { sources, syncWindowDays } = await getSources(); if (sources.length === 0) return; await editor.flashNotification("Syncing calendars...", "info"); const allEvents: any[] = []; for (const source of sources) { const events = await fetchAndParseCalendar(source, syncWindowDays); 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"); }