diff --git a/PLUG.md b/PLUG.md index 35f1893..54eaf0b 100644 --- a/PLUG.md +++ b/PLUG.md @@ -1,6 +1,6 @@ --- name: Library/sstent/icalendar -version: "0.4.0" +version: "0.4.1" tags: meta/library files: - icalendar.plug.js diff --git a/deno.json b/deno.json index f835fd4..f71b6a3 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "icalendar-plug", - "version": "0.4.0", + "version": "0.4.1", "nodeModulesDir": "auto", "tasks": { "sync-version": "deno run -A scripts/sync-version.ts", @@ -27,4 +27,4 @@ "ical.js": "https://esm.sh/ical.js@2.0.1", "rrule": "https://esm.sh/rrule@2.8.1" } -} \ No newline at end of file +} diff --git a/icalendar.plug.yaml b/icalendar.plug.yaml index fd62ffb..50c3a25 100644 --- a/icalendar.plug.yaml +++ b/icalendar.plug.yaml @@ -1,5 +1,5 @@ name: icalendar -version: 0.4.0 +version: 0.4.1 author: sstent index: icalendar.ts # Legacy SilverBullet permission name diff --git a/icalendar.ts b/icalendar.ts index 3a9f9af..35323b1 100644 --- a/icalendar.ts +++ b/icalendar.ts @@ -1,53 +1,61 @@ import { clientStore, config, datastore, editor, index } from "@silverbulletmd/silverbullet/syscalls"; -import ICAL from "ical.js"; +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.0"; +const VERSION = "0.4.1"; const CACHE_KEY = "icalendar:lastSync"; console.log(`[iCalendar] Plug script executing at top level (Version ${VERSION})`); -// ============================================================================ -// Types -// ============================================================================ +/** + * 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", +}; -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[]; +/** + * 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); @@ -56,55 +64,69 @@ async function sha256Hash(str: string): Promise { return hashArray.map(b => b.toString(16).padStart(2, "0")).join(""); } -function localDateString(date: Date): string { +/** + * Converts Date to local time string (browser's timezone) + */ +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()); + 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 + * Recursively converts all Date objects and ISO date strings to strings */ -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 - }; +function convertDatesToStrings(obj: T): any { + if (obj === null || obj === undefined) { + return obj; } - // Get timezone - const timezone = time.zone?.tzid || "UTC"; + 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(); + } - // ISO representation (UTC) - const jsDate = time.toJSDate(); - const iso = jsDate.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; + } + } - // Local representation for querying - const local = localDateString(jsDate); + if (Array.isArray(obj)) { + return obj.map(item => convertDatesToStrings(item)); + } - return { iso, local, timezone, isAllDay: false }; + 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 +// 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; @@ -126,245 +148,235 @@ async function getSources(): Promise<{ sources: any[], syncWindowDays: number }> } // ============================================================================ -// Recurrence Expansion (keeping your logic) +// Calendar Fetching & Parsing // ============================================================================ /** - * Expands recurring events using rrule - * Keeps your no-lookback-limit approach + * Resolves the event start as a UTC Date object using DST-aware resolution. */ -function expandRecurrences( - event: CalendarEvent, - startDate: Date, - windowDays: number, - now = new Date() -): CalendarEvent[] { - if (!event.rrule || !event.isRecurring) { - return [event]; +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, 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]; + } - // Parse the RRULE string - const cleanRule = event.rrule.replace(/^RRULE:/i, ""); const ruleOptions = RRule.parseString(cleanRule); - ruleOptions.dtstart = startDate; - + 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 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 - })); + + // 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: localDateString(occurrenceDate), + end: endDate ? endDate.toISOString() : undefined, + endLocal: endDate ? localDateString(endDate) : undefined, + recurrent: true, + rrule: undefined, + }; + }); + + return mapped; } catch (err) { - console.error(`[iCalendar] Error expanding recurrence for ${event.summary}:`, err); - return [event]; + console.error(`[iCalendar] Error expanding recurrence for ${icsEvent.summary}:`, err); + return [icsEvent]; } } -// ============================================================================ -// Calendar Parsing with ical.js -// ============================================================================ - -async function fetchAndParseCalendar( - source: any, - windowDays: number -): Promise { +async function fetchAndParseCalendar(source: any, windowDays = 365): Promise { try { const response = await fetch(source.url); if (!response.ok) { - console.error(`[iCalendar] Fetch failed for ${source.name}: ${response.status}`); + console.error(`[iCalendar] Fetch failed for ${source.name}: ${response.status} ${response.statusText}`); 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) { + 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 events: CalendarEvent[] = []; + // 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: localDateString(startDateUTC), + end: endDateUTC ? endDateUTC.toISOString() : undefined, + endLocal: endDateUTC ? localDateString(endDateUTC) : undefined, + tag: "ical-event", + sourceName: source.name, + timezone: rawTz + }; - for (const vevent of vevents) { - try { - const event = new ICAL.Event(vevent); + if (rawTz !== "UTC" && rawTz !== "None" && !resolveIanaName(rawTz)) { + baseEvent.description = `(Warning: Unknown timezone "${rawTz}") ${baseEvent.description || ""}`; + } - // 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; + const expanded = expandRecurrences(baseEvent, windowDays); + 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 || ""); + 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; - + const allEvents: any[] = []; 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"); + await editor.flashNotification(`Synced ${allEvents.length} events`, "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"); + 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"); -} \ No newline at end of file +}