From de782e85e48b85b6e23ba1f822527b9094cfb6fe Mon Sep 17 00:00:00 2001 From: sstent Date: Sat, 21 Feb 2026 14:37:00 -0800 Subject: [PATCH] switch to ical.js - v4.0 --- PLUG.md | 2 +- deno.json | 4 +- icalendar.plug.yaml | 2 +- icalendar.ts | 488 +++++++++++++++++++++++--------------------- 4 files changed, 257 insertions(+), 239 deletions(-) diff --git a/PLUG.md b/PLUG.md index 354a4d4..35f1893 100644 --- a/PLUG.md +++ b/PLUG.md @@ -1,6 +1,6 @@ --- name: Library/sstent/icalendar -version: "0.3.34" +version: "0.4.0" tags: meta/library files: - icalendar.plug.js diff --git a/deno.json b/deno.json index af87421..abba3e4 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "icalendar-plug", - "version": "0.3.34", + "version": "0.4.0", "nodeModulesDir": "auto", "tasks": { "sync-version": "deno run -A scripts/sync-version.ts", @@ -24,7 +24,7 @@ }, "imports": { "@silverbulletmd/silverbullet": "jsr:@silverbulletmd/silverbullet@^2.4.1", - "ts-ics": "npm:ts-ics@2.4.0", + "ical.js": "npm: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 dc653d2..fd62ffb 100644 --- a/icalendar.plug.yaml +++ b/icalendar.plug.yaml @@ -1,5 +1,5 @@ name: icalendar -version: 0.3.34 +version: 0.4.0 author: sstent index: icalendar.ts # Legacy SilverBullet permission name diff --git a/icalendar.ts b/icalendar.ts index 734cc6e..3a9f9af 100644 --- a/icalendar.ts +++ b/icalendar.ts @@ -1,66 +1,53 @@ import { clientStore, config, datastore, editor, index } from "@silverbulletmd/silverbullet/syscalls"; -import { convertIcsCalendar } from "https://esm.sh/ts-ics@2.4.0"; +import ICAL from "ical.js"; import { RRule, RRuleSet } from "rrule"; -import { getUtcOffsetMs, resolveIanaName } from "./timezones.ts"; -const VERSION = "0.3.34"; +const VERSION = "0.4.0"; 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", // Just in case -}; +// ============================================================================ +// Types +// ============================================================================ -/** - * Robustly formats an RRULE value for its string representation. - * Handles: - * - Arrays: joins elements with commas (recursive) - * - Date objects: formats as YYYYMMDDTHHMMSSZ - * - Objects: extracts .date, .day, or .value properties (recursive) - * - Primitives: stringifies directly - */ -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); +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 // ============================================================================ -/** - * 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); @@ -69,73 +56,66 @@ async function sha256Hash(str: string): Promise { return hashArray.map(b => b.toString(16).padStart(2, "0")).join(""); } -export function localDateString(date: Date): string { +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()); } /** - * Recursively converts all Date objects and ISO date strings to strings - * Handles nested objects like {date: Date, local: {date: Date, timezone: string}} + * Converts ICAL.Time to ISO string and local string */ -function convertDatesToStrings(obj: T): any { - if (obj === null || obj === undefined) { - return obj; +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 + }; } - 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); - } + // Get timezone + const timezone = time.zone?.tzid || "UTC"; - 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; - } - } + // ISO representation (UTC) + const jsDate = time.toJSDate(); + const iso = jsDate.toISOString(); - if (Array.isArray(obj)) { - return obj.map(item => convertDatesToStrings(item)); - } + // Local representation for querying + const local = localDateString(jsDate); - 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; + return { iso, local, timezone, isAllDay: false }; } // ============================================================================ -// Configuration Functions +// 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]); - } + const sourceArray = []; + for (const key in sources) { + if (sources[key] && typeof sources[key].url === "string") { + sourceArray.push(sources[key]); } - sources = sourceArray; + } + sources = sourceArray; } return { sources, syncWindowDays }; @@ -146,207 +126,245 @@ async function getSources(): Promise<{ sources: any[], syncWindowDays: number }> } // ============================================================================ -// Calendar Fetching & Parsing +// Recurrence Expansion (keeping your logic) // ============================================================================ /** - * Resolves the event start as a UTC Date object using DST-aware resolution. + * Expands recurring events using rrule + * Keeps your no-lookback-limit approach */ -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); +function expandRecurrences( + event: CalendarEvent, + startDate: Date, + windowDays: number, + now = new Date() +): CalendarEvent[] { + if (!event.rrule || !event.isRecurring) { + return [event]; } - 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, 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) { - // 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}=${formatRRuleValue(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]; - } + // Parse the RRULE string + const cleanRule = event.rrule.replace(/^RRULE:/i, ""); const ruleOptions = RRule.parseString(cleanRule); - ruleOptions.dtstart = dtstart; - + ruleOptions.dtstart = startDate; + set.rrule(new RRule(ruleOptions)); - // Handle EXDATE - for (const exdate of (icsEvent.exdate || [])) { - set.exdate(new Date(exdate.includes("Z") ? exdate : exdate + "Z")); - } - const windowEnd = new Date(now.getTime() + windowDays * 86400000); - - // Expand from the event's actual start date up to the window end. - // This provides "no limit" lookback (bound only by the event's own start date). - const occurrences = set.between(dtstart, windowEnd, true); - - const mapped = occurrences - .map(occurrenceDate => { - const localIso = localDateString(occurrenceDate); - return { - ...icsEvent, - start: localIso, - recurrent: true, - rrule: undefined, - }; - }); - return mapped; + + // 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 ${icsEvent.summary}:`, err); - return [icsEvent]; + console.error(`[iCalendar] Error expanding recurrence for ${event.summary}:`, err); + return [event]; } } -async function fetchAndParseCalendar(source: any, windowDays = 365): Promise { +// ============================================================================ +// 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} ${response.statusText}`); + console.error(`[iCalendar] Fetch failed for ${source.name}: ${response.status}`); return []; } - const text = await response.text(); - const calendar = convertIcsCalendar(undefined, text); - if (!calendar || !calendar.events) { + + 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: 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 rawTz = icsEvent.start?.local?.timezone || (icsEvent.start as any)?.timezone || "UTC"; - const baseEvent = { - ...icsEvent, - name: icsEvent.summary || "Untitled Event", - start: localIso, - tag: "ical-event", - sourceName: source.name, - timezone: rawTz - }; + const events: CalendarEvent[] = []; - if (rawTz !== "UTC" && rawTz !== "None" && !resolveIanaName(rawTz)) { - baseEvent.description = `(Warning: Unknown timezone "${rawTz}") ${baseEvent.description || ""}`; - } + for (const vevent of vevents) { + try { + const event = new ICAL.Event(vevent); - const expanded = expandRecurrences(baseEvent, windowDays); - for (const occurrence of expanded) { - // Use summary in key to avoid collisions for meetings sharing UID/Start - const uniqueKey = `${occurrence.start}${occurrence.uid || ''}${occurrence.summary || ''}`; - occurrence.ref = await sha256Hash(uniqueKey); - events.push(convertDatesToStrings(occurrence)); + // 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 || ""); + 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: any[] = []; + + 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 editor.flashNotification(`Synced ${allEvents.length} events`, "info"); + 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?")) 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 (!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 (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