From 10a6db5893a8154820627521e89f2437b6d31aaa Mon Sep 17 00:00:00 2001 From: sstent Date: Thu, 19 Feb 2026 07:00:01 -0800 Subject: [PATCH] conductor(checkpoint): Checkpoint end of Phase 2: Core Logic --- icalendar.ts | 112 ++++++++++++++++++++++++++++------------------ icalendar_test.ts | 45 +++++++++++++++++++ 2 files changed, 113 insertions(+), 44 deletions(-) create mode 100644 icalendar_test.ts diff --git a/icalendar.ts b/icalendar.ts index b0beb1d..496c81f 100644 --- a/icalendar.ts +++ b/icalendar.ts @@ -1,25 +1,12 @@ import { clientStore, config, datastore, editor, index } from "@silverbulletmd/silverbullet/syscalls"; import { convertIcsCalendar } from "https://esm.sh/ts-ics@2.4.0"; +import { getUtcOffsetMs, resolveIanaName } from "./timezones.ts"; const VERSION = "0.3.25"; const CACHE_KEY = "icalendar:lastSync"; console.log(`[iCalendar] Plug script executing at top level (Version ${VERSION})`); -const TIMEZONE_OFFSETS: Record = { - "GMT Standard Time": 0, - "W. Europe Standard Time": 1, - "Central Europe Standard Time": 1, - "Romance Standard Time": 1, - "Central European Standard Time": 1, - "Eastern Standard Time": -5, - "Central Standard Time": -6, - "Mountain Standard Time": -7, - "Pacific Standard Time": -8, - "UTC": 0, - "None": 0 -}; - // ============================================================================ // Utility Functions // ============================================================================ @@ -86,18 +73,14 @@ function convertDatesToStrings(obj: T): any { // Configuration Functions // ============================================================================ -async function getSources(): Promise<{ sources: any[], tzShift: number }> { +async function getSources(): Promise { try { - const rawConfig = await config.get("icalendar", { sources: [] }); + const rawConfig = await config.get("icalendar", { sources: [] }) as any; console.log("[iCalendar] Raw config retrieved:", JSON.stringify(rawConfig)); let sources = rawConfig.sources || []; - let tzShift = rawConfig.tzShift || 0; if (sources && typeof sources === "object" && !Array.isArray(sources)) { - if (sources.tzShift !== undefined && tzShift === 0) { - tzShift = sources.tzShift; - } const sourceArray = []; for (const key in sources) { if (sources[key] && typeof sources[key].url === "string") { @@ -107,10 +90,10 @@ async function getSources(): Promise<{ sources: any[], tzShift: number }> { sources = sourceArray; } - return { sources, tzShift }; + return sources; } catch (e) { console.error("[iCalendar] Error in getSources:", e); - return { sources: [], tzShift: 0 }; + return []; } } @@ -118,7 +101,55 @@ async function getSources(): Promise<{ sources: any[], tzShift: number }> { // Calendar Fetching & Parsing // ============================================================================ -async function fetchAndParseCalendar(source: any, hourShift = 0): Promise { +/** + * 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; + + // 2. Resolve IANA timezone + const rawTz = obj.local?.timezone || (obj as any).timezone || "UTC"; + const ianaName = resolveIanaName(rawTz); + + // Strip any trailing Z — this is treated as wall-clock local time + wallClock = wallClock.replace(/Z$/, ""); + + if (!ianaName) { + console.warn(`[iCalendar] Unknown timezone: "${rawTz}" - falling back to UTC for event "${icsEvent.summary}"`); + // Fallback to parsing as UTC but mark it + 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())) { + console.error(`[iCalendar] Invalid wallClock date: ${wallClock}`); + 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); +} + +async function fetchAndParseCalendar(source: any): Promise { console.log(`[iCalendar] Fetching from: ${source.url}`); try { const response = await fetch(source.url); @@ -134,36 +165,29 @@ async function fetchAndParseCalendar(source: any, hourShift = 0): Promise const events: any[] = []; for (const icsEvent of calendar.events) { - const obj = icsEvent.start; - if (!obj) continue; - - let wallTimeStr = ""; - if (obj.local && obj.local.date) { - wallTimeStr = typeof obj.local.date === "string" ? obj.local.date : (obj.local.date instanceof Date ? obj.local.date.toISOString() : String(obj.local.date)); - } else if (obj.date) { - wallTimeStr = typeof obj.date === "string" ? obj.date : (obj.date instanceof Date ? obj.date.toISOString() : String(obj.date)); - } - - if (!wallTimeStr) continue; - - const baseDate = new Date(wallTimeStr.replace("Z", "") + "Z"); - const tzName = obj.local?.timezone || obj.timezone || "UTC"; - const sourceOffset = TIMEZONE_OFFSETS[tzName] ?? 0; - const utcMillis = baseDate.getTime() - (sourceOffset * 3600000); - const finalDate = new Date(utcMillis + (hourShift * 3600000)); + const finalDate = await resolveEventStart(icsEvent); + if (!finalDate) continue; const localIso = localDateString(finalDate); const uniqueKey = `${localIso}${icsEvent.uid || icsEvent.summary || ''}`; const ref = await sha256Hash(uniqueKey); - events.push(convertDatesToStrings({ + const eventData = { ...icsEvent, name: icsEvent.summary || "Untitled Event", start: localIso, ref, tag: "ical-event", sourceName: source.name - })); + }; + + // Add warning if timezone was unknown + const rawTz = icsEvent.start?.local?.timezone || (icsEvent.start as any)?.timezone || "UTC"; + if (rawTz !== "UTC" && rawTz !== "None" && !resolveIanaName(rawTz)) { + eventData.description = `(Warning: Unknown timezone "${rawTz}") ${eventData.description || ""}`; + } + + events.push(convertDatesToStrings(eventData)); } return events; } catch (err) { @@ -174,13 +198,13 @@ async function fetchAndParseCalendar(source: any, hourShift = 0): Promise export async function syncCalendars() { try { - const { sources, tzShift } = await getSources(); + const sources = 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, tzShift); + const events = await fetchAndParseCalendar(source); allEvents.push(...events); } await index.indexObjects("$icalendar", allEvents); diff --git a/icalendar_test.ts b/icalendar_test.ts new file mode 100644 index 0000000..5db29e2 --- /dev/null +++ b/icalendar_test.ts @@ -0,0 +1,45 @@ +import { assertEquals } from "jsr:@std/assert"; +import { resolveEventStart } from "./icalendar.ts"; + +Deno.test("resolveEventStart - local date with timezone", async () => { + const icsEvent = { + summary: "Test Event", + start: { + date: "2025-01-15T12:00:00.000", + local: { + date: "2025-01-15T07:00:00.000", + timezone: "Eastern Standard Time" + } + } + }; + + const result = await resolveEventStart(icsEvent); + assertEquals(result?.toISOString(), "2025-01-15T12:00:00.000Z"); +}); + +Deno.test("resolveEventStart - DST check (Summer)", async () => { + const icsEvent = { + summary: "Test Event DST", + start: { + date: "2025-07-15T11:00:00.000", + local: { + date: "2025-07-15T07:00:00.000", + timezone: "Eastern Standard Time" + } + } + }; + + const result = await resolveEventStart(icsEvent); + assertEquals(result?.toISOString(), "2025-07-15T11:00:00.000Z"); +}); + +Deno.test("resolveEventStart - UTC event", async () => { + const icsEvent = { + summary: "UTC Event", + start: { + date: "2025-01-15T12:00:00.000Z" + } + }; + const result = await resolveEventStart(icsEvent); + assertEquals(result?.toISOString(), "2025-01-15T12:00:00.000Z"); +});