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 { clientStore, config, datastore, editor, index } from "@silverbulletmd/silverbullet/syscalls";
|
||||||
import { convertIcsCalendar } from "https://esm.sh/ts-ics@2.4.0";
|
import { convertIcsCalendar } from "https://esm.sh/ts-ics@2.4.0";
|
||||||
|
import { getUtcOffsetMs, resolveIanaName } from "./timezones.ts";
|
||||||
|
|
||||||
const VERSION = "0.3.25";
|
const VERSION = "0.3.25";
|
||||||
const CACHE_KEY = "icalendar:lastSync";
|
const CACHE_KEY = "icalendar:lastSync";
|
||||||
|
|
||||||
console.log(`[iCalendar] Plug script executing at top level (Version ${VERSION})`);
|
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
|
// Utility Functions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -86,18 +73,14 @@ function convertDatesToStrings<T>(obj: T): any {
|
|||||||
// Configuration Functions
|
// Configuration Functions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
async function getSources(): Promise<{ sources: any[], tzShift: number }> {
|
async function getSources(): Promise<any[]> {
|
||||||
try {
|
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));
|
console.log("[iCalendar] Raw config retrieved:", JSON.stringify(rawConfig));
|
||||||
|
|
||||||
let sources = rawConfig.sources || [];
|
let sources = rawConfig.sources || [];
|
||||||
let tzShift = rawConfig.tzShift || 0;
|
|
||||||
|
|
||||||
if (sources && typeof sources === "object" && !Array.isArray(sources)) {
|
if (sources && typeof sources === "object" && !Array.isArray(sources)) {
|
||||||
if (sources.tzShift !== undefined && tzShift === 0) {
|
|
||||||
tzShift = sources.tzShift;
|
|
||||||
}
|
|
||||||
const sourceArray = [];
|
const sourceArray = [];
|
||||||
for (const key in sources) {
|
for (const key in sources) {
|
||||||
if (sources[key] && typeof sources[key].url === "string") {
|
if (sources[key] && typeof sources[key].url === "string") {
|
||||||
@@ -107,10 +90,10 @@ async function getSources(): Promise<{ sources: any[], tzShift: number }> {
|
|||||||
sources = sourceArray;
|
sources = sourceArray;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { sources, tzShift };
|
return sources;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[iCalendar] Error in getSources:", 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
|
// 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}`);
|
console.log(`[iCalendar] Fetching from: ${source.url}`);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(source.url);
|
const response = await fetch(source.url);
|
||||||
@@ -134,36 +165,29 @@ async function fetchAndParseCalendar(source: any, hourShift = 0): Promise<any[]>
|
|||||||
|
|
||||||
const events: any[] = [];
|
const events: any[] = [];
|
||||||
for (const icsEvent of calendar.events) {
|
for (const icsEvent of calendar.events) {
|
||||||
const obj = icsEvent.start;
|
const finalDate = await resolveEventStart(icsEvent);
|
||||||
if (!obj) continue;
|
if (!finalDate) 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 localIso = localDateString(finalDate);
|
const localIso = localDateString(finalDate);
|
||||||
const uniqueKey = `${localIso}${icsEvent.uid || icsEvent.summary || ''}`;
|
const uniqueKey = `${localIso}${icsEvent.uid || icsEvent.summary || ''}`;
|
||||||
const ref = await sha256Hash(uniqueKey);
|
const ref = await sha256Hash(uniqueKey);
|
||||||
|
|
||||||
events.push(convertDatesToStrings({
|
const eventData = {
|
||||||
...icsEvent,
|
...icsEvent,
|
||||||
name: icsEvent.summary || "Untitled Event",
|
name: icsEvent.summary || "Untitled Event",
|
||||||
start: localIso,
|
start: localIso,
|
||||||
ref,
|
ref,
|
||||||
tag: "ical-event",
|
tag: "ical-event",
|
||||||
sourceName: source.name
|
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;
|
return events;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -174,13 +198,13 @@ async function fetchAndParseCalendar(source: any, hourShift = 0): Promise<any[]>
|
|||||||
|
|
||||||
export async function syncCalendars() {
|
export async function syncCalendars() {
|
||||||
try {
|
try {
|
||||||
const { sources, tzShift } = await getSources();
|
const sources = await getSources();
|
||||||
if (sources.length === 0) return;
|
if (sources.length === 0) return;
|
||||||
|
|
||||||
await editor.flashNotification("Syncing calendars...", "info");
|
await editor.flashNotification("Syncing calendars...", "info");
|
||||||
const allEvents: any[] = [];
|
const allEvents: any[] = [];
|
||||||
for (const source of sources) {
|
for (const source of sources) {
|
||||||
const events = await fetchAndParseCalendar(source, tzShift);
|
const events = await fetchAndParseCalendar(source);
|
||||||
allEvents.push(...events);
|
allEvents.push(...events);
|
||||||
}
|
}
|
||||||
await index.indexObjects("$icalendar", allEvents);
|
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