conductor(checkpoint): Checkpoint end of Phase 3: Features

This commit is contained in:
2026-02-19 07:05:02 -08:00
parent 4128c046d0
commit ffaef28332
3 changed files with 105 additions and 19 deletions

View File

@@ -1,5 +1,6 @@
import { clientStore, config, datastore, editor, index } from "@silverbulletmd/silverbullet/syscalls";
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.3.25";
@@ -120,16 +121,15 @@ export async function resolveEventStart(icsEvent: any): Promise<Date | null> {
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);
// 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;
@@ -137,10 +137,7 @@ export async function resolveEventStart(icsEvent: any): Promise<Date | null> {
// 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;
}
if (isNaN(wallClockAsUtc.getTime())) return null;
// 4. Get the DST-aware offset for this IANA zone at this instant
const offsetMs = getUtcOffsetMs(ianaName, wallClockAsUtc);
@@ -149,6 +146,56 @@ export async function resolveEventStart(icsEvent: any): Promise<Date | null> {
return new Date(wallClockAsUtc.getTime() - offsetMs);
}
/**
* Expands recurring events into individual occurrences.
*/
export function expandRecurrences(icsEvent: any, windowDays = 365): any[] {
const rruleStr = icsEvent.rrule || (icsEvent as any).recurrenceRule;
if (!rruleStr) return [icsEvent];
try {
const set = new RRuleSet();
const cleanRule = rruleStr.replace(/^RRULE:/i, "");
// 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];
}
const ruleOptions = RRule.parseString(cleanRule);
ruleOptions.dtstart = dtstart;
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(dtstart.getTime() + windowDays * 86400000);
// Expand from the event's start date, not from 'now', to ensure tests and past events work
const occurrences = set.between(dtstart, windowEnd, true);
if (occurrences.length === 0) return [icsEvent];
return occurrences.map(occurrenceDate => {
const localIso = localDateString(occurrenceDate);
return {
...icsEvent,
start: localIso,
recurrent: true,
rrule: undefined,
};
});
} catch (err) {
console.error(`[iCalendar] Error expanding recurrence for ${icsEvent.summary}:`, err);
return [icsEvent];
}
}
async function fetchAndParseCalendar(source: any): Promise<any[]> {
console.log(`[iCalendar] Fetching from: ${source.url}`);
try {
@@ -165,29 +212,31 @@ async function fetchAndParseCalendar(source: any): Promise<any[]> {
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 uniqueKey = `${localIso}${icsEvent.uid || icsEvent.summary || ''}`;
const ref = await sha256Hash(uniqueKey);
const eventData = {
const baseEvent = {
...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 || ""}`;
baseEvent.description = `(Warning: Unknown timezone "${rawTz}") ${baseEvent.description || ""}`;
}
events.push(convertDatesToStrings(eventData));
const expanded = expandRecurrences(baseEvent);
for (const occurrence of expanded) {
const uniqueKey = `${occurrence.start}${occurrence.uid || occurrence.summary || ''}`;
occurrence.ref = await sha256Hash(uniqueKey);
events.push(convertDatesToStrings(occurrence));
}
}
return events;
} catch (err) {
@@ -234,4 +283,4 @@ export async function clearCache() {
export async function showVersion() {
await editor.flashNotification(`iCalendar Plug ${VERSION}`, "info");
}
}