Files
silverbullet-icalendar/icalendar.ts
2026-02-21 15:31:22 -08:00

415 lines
14 KiB
TypeScript

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.4.1";
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<string, string> = {
"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<string> {
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 Intl.DateTimeFormat to properly handle timezone conversion
*/
export function dateToTimezoneString(date: Date, timezone: string = "America/Los_Angeles"): string {
try {
// Get date components in the target timezone
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
const parts = formatter.formatToParts(date);
const values: Record<string, string> = {};
for (const part of parts) {
if (part.type !== 'literal') {
values[part.type] = part.value;
}
}
// Format as ISO-like string: YYYY-MM-DDTHH:MM:SS
return `${values.year}-${values.month}-${values.day}T${values.hour}:${values.minute}:${values.second}`;
} 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<T>(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<Date | null> {
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<Date | null> {
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<any[]> {
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;
// Resolve start time (returns UTC Date)
const startDateUTC = await resolveEventStart(icsEvent);
if (!startDateUTC) continue;
// Resolve end time (returns UTC Date)
const endDateUTC = await resolveEventEnd(icsEvent);
const rawTz = icsEvent.start?.local?.timezone || (icsEvent.start as any)?.timezone || "UTC";
const baseEvent = {
...icsEvent,
name: icsEvent.summary || "Untitled Event",
// 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
};
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);
events.push(convertDatesToStrings(occurrence));
}
}
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}`);
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");
}