forked from GitHubMirrors/silverbullet-icalendar
conductor(checkpoint): Checkpoint end of Phase 2: Core Logic
This commit is contained in:
112
icalendar.ts
112
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<string, number> = {
|
||||
"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<T>(obj: T): any {
|
||||
// Configuration Functions
|
||||
// ============================================================================
|
||||
|
||||
async function getSources(): Promise<{ sources: any[], tzShift: number }> {
|
||||
async function getSources(): Promise<any[]> {
|
||||
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<any[]> {
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// 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<any[]> {
|
||||
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<any[]>
|
||||
|
||||
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<any[]>
|
||||
|
||||
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);
|
||||
|
||||
45
icalendar_test.ts
Normal file
45
icalendar_test.ts
Normal file
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user