switch to ical.js - v4.0
Some checks failed
Build SilverBullet Plug / build (push) Failing after 1m11s

This commit is contained in:
2026-02-21 14:37:00 -08:00
parent f23b728542
commit de782e85e4
4 changed files with 257 additions and 239 deletions

View File

@@ -1,6 +1,6 @@
--- ---
name: Library/sstent/icalendar name: Library/sstent/icalendar
version: "0.3.34" version: "0.4.0"
tags: meta/library tags: meta/library
files: files:
- icalendar.plug.js - icalendar.plug.js

View File

@@ -1,6 +1,6 @@
{ {
"name": "icalendar-plug", "name": "icalendar-plug",
"version": "0.3.34", "version": "0.4.0",
"nodeModulesDir": "auto", "nodeModulesDir": "auto",
"tasks": { "tasks": {
"sync-version": "deno run -A scripts/sync-version.ts", "sync-version": "deno run -A scripts/sync-version.ts",
@@ -24,7 +24,7 @@
}, },
"imports": { "imports": {
"@silverbulletmd/silverbullet": "jsr:@silverbulletmd/silverbullet@^2.4.1", "@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" "rrule": "https://esm.sh/rrule@2.8.1"
} }
} }

View File

@@ -1,5 +1,5 @@
name: icalendar name: icalendar
version: 0.3.34 version: 0.4.0
author: sstent author: sstent
index: icalendar.ts index: icalendar.ts
# Legacy SilverBullet permission name # Legacy SilverBullet permission name

View File

@@ -1,66 +1,53 @@
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 ICAL from "ical.js";
import { RRule, RRuleSet } from "rrule"; 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"; 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})`);
/** // ============================================================================
* Mapping of verbose RRULE object keys to standard iCalendar shortened keys. // Types
*/ // ============================================================================
const RRULE_KEY_MAP: Record<string, string> = {
"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
};
/** interface CalendarEvent {
* Robustly formats an RRULE value for its string representation. ref: string;
* Handles: tag: "ical-event";
* - Arrays: joins elements with commas (recursive) sourceName: string;
* - Date objects: formats as YYYYMMDDTHHMMSSZ
* - Objects: extracts .date, .day, or .value properties (recursive) // Core properties
* - Primitives: stringifies directly uid: string;
*/ summary: string;
function formatRRuleValue(v: any): string { description?: string;
if (Array.isArray(v)) { location?: string;
return v.map((item) => formatRRuleValue(item)).join(",");
} // Dates - stored as ISO strings to preserve timezone
if (v instanceof Date) { start: string;
return v.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"; startLocal: string; // For queries: local representation
} end?: string;
if (typeof v === "object" && v !== null) {
const val = v.date || v.day || v.value; // Timezone info
if (val !== undefined) { timezone: string;
return formatRRuleValue(val); isAllDay: boolean;
}
} // Recurrence
return String(v); isRecurring: boolean;
rrule?: string;
recurrenceId?: string; // For individual occurrences
// Status
status?: string;
// People
organizer?: string;
attendees?: string[];
} }
// ============================================================================ // ============================================================================
// Utility Functions // Utility Functions
// ============================================================================ // ============================================================================
/**
* Creates a SHA-256 hash of a string (hex encoded)
*/
async function sha256Hash(str: string): Promise<string> { async function sha256Hash(str: string): Promise<string> {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const data = encoder.encode(str); const data = encoder.encode(str);
@@ -69,73 +56,66 @@ async function sha256Hash(str: string): Promise<string> {
return hashArray.map(b => b.toString(16).padStart(2, "0")).join(""); 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"); 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 * Converts ICAL.Time to ISO string and local string
* Handles nested objects like {date: Date, local: {date: Date, timezone: string}}
*/ */
function convertDatesToStrings<T>(obj: T): any { function parseIcalTime(time: ICAL.Time): { iso: string; local: string; timezone: string; isAllDay: boolean } {
if (obj === null || obj === undefined) { const isAllDay = time.isDate;
return obj;
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) { // Get timezone
return localDateString(obj); const timezone = time.zone?.tzid || "UTC";
}
if (typeof obj === 'object' && 'date' in obj && (obj as any).date instanceof Date) {
return localDateString((obj as any).date);
}
if (typeof obj === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(obj)) { // ISO representation (UTC)
try { const jsDate = time.toJSDate();
return localDateString(new Date(obj)); const iso = jsDate.toISOString();
} catch {
return obj;
}
}
if (Array.isArray(obj)) { // Local representation for querying
return obj.map(item => convertDatesToStrings(item)); const local = localDateString(jsDate);
}
if (typeof obj === 'object') { return { iso, local, timezone, isAllDay: false };
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;
} }
// ============================================================================ // ============================================================================
// Configuration Functions // Configuration
// ============================================================================ // ============================================================================
async function getSources(): Promise<{ sources: any[], syncWindowDays: number }> { async function getSources(): Promise<{ sources: any[], syncWindowDays: number }> {
try { try {
const rawConfig = await config.get("icalendar", { sources: [] }) as any; 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 || [];
const syncWindowDays = rawConfig.syncWindowDays || 365; const syncWindowDays = rawConfig.syncWindowDays || 365;
if (sources && typeof sources === "object" && !Array.isArray(sources)) { if (sources && typeof sources === "object" && !Array.isArray(sources)) {
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") {
sourceArray.push(sources[key]); sourceArray.push(sources[key]);
}
} }
sources = sourceArray; }
sources = sourceArray;
} }
return { sources, syncWindowDays }; 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<Date | null> { function expandRecurrences(
const obj = icsEvent.start; event: CalendarEvent,
if (!obj) return null; startDate: Date,
windowDays: number,
// 1. Extract the wall-clock local datetime string now = new Date()
let wallClock: string | null = null; ): CalendarEvent[] {
if (obj.local?.date) { if (!event.rrule || !event.isRecurring) {
const d = obj.local.date; return [event];
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;
// 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 { try {
const set = new RRuleSet(); 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); const ruleOptions = RRule.parseString(cleanRule);
ruleOptions.dtstart = dtstart; ruleOptions.dtstart = startDate;
set.rrule(new RRule(ruleOptions)); 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); const windowEnd = new Date(now.getTime() + windowDays * 86400000);
// Expand from the event's actual start date up to the window end. // Expand from event start (no lookback limit)
// This provides "no limit" lookback (bound only by the event's own start date). const occurrences = set.between(startDate, windowEnd, true);
const occurrences = set.between(dtstart, windowEnd, true);
return occurrences.map((occurrenceDate, idx) => ({
const mapped = occurrences ...event,
.map(occurrenceDate => { start: occurrenceDate.toISOString(),
const localIso = localDateString(occurrenceDate); startLocal: localDateString(occurrenceDate),
return { recurrenceId: occurrenceDate.toISOString(),
...icsEvent, rrule: undefined, // Remove rrule from occurrences
start: localIso, }));
recurrent: true,
rrule: undefined,
};
});
return mapped;
} catch (err) { } catch (err) {
console.error(`[iCalendar] Error expanding recurrence for ${icsEvent.summary}:`, err); console.error(`[iCalendar] Error expanding recurrence for ${event.summary}:`, err);
return [icsEvent]; return [event];
} }
} }
async function fetchAndParseCalendar(source: any, windowDays = 365): Promise<any[]> { // ============================================================================
// Calendar Parsing with ical.js
// ============================================================================
async function fetchAndParseCalendar(
source: any,
windowDays: number
): Promise<CalendarEvent[]> {
try { try {
const response = await fetch(source.url); const response = await fetch(source.url);
if (!response.ok) { 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 []; return [];
} }
const text = await response.text();
const calendar = convertIcsCalendar(undefined, text); const icsText = await response.text();
if (!calendar || !calendar.events) {
// 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 []; return [];
} }
const events: any[] = [];
for (const icsEvent of calendar.events) {
if (icsEvent.status?.toUpperCase() === "CANCELLED") continue;
const finalDate = await resolveEventStart(icsEvent); const events: CalendarEvent[] = [];
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
};
if (rawTz !== "UTC" && rawTz !== "None" && !resolveIanaName(rawTz)) { for (const vevent of vevents) {
baseEvent.description = `(Warning: Unknown timezone "${rawTz}") ${baseEvent.description || ""}`; try {
} const event = new ICAL.Event(vevent);
const expanded = expandRecurrences(baseEvent, windowDays); // Skip cancelled events
for (const occurrence of expanded) { const status = vevent.getFirstPropertyValue('status');
// Use summary in key to avoid collisions for meetings sharing UID/Start if (status === 'CANCELLED') continue;
const uniqueKey = `${occurrence.start}${occurrence.uid || ''}${occurrence.summary || ''}`;
occurrence.ref = await sha256Hash(uniqueKey); // Parse start time
events.push(convertDatesToStrings(occurrence)); 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; return events;
} catch (err: any) { } 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 []; return [];
} }
} }
// ============================================================================
// Exported Commands
// ============================================================================
export async function syncCalendars() { export async function syncCalendars() {
try { try {
const { sources, syncWindowDays } = await getSources(); const { sources, syncWindowDays } = 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: CalendarEvent[] = [];
let successCount = 0;
for (const source of sources) { for (const source of sources) {
const events = await fetchAndParseCalendar(source, syncWindowDays); const events = await fetchAndParseCalendar(source, syncWindowDays);
allEvents.push(...events); allEvents.push(...events);
if (events.length > 0) successCount++;
console.log(`[iCalendar] Synced ${events.length} events from "${source.name}"`);
} }
await index.indexObjects("$icalendar", allEvents); 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) { } catch (err) {
console.error("[iCalendar] syncCalendars failed:", err); console.error("[iCalendar] syncCalendars failed:", err);
await editor.flashNotification("Failed to sync calendars", "error");
} }
} }
export async function forceSync() { export async function forceSync() {
await clientStore.del(CACHE_KEY); await clientStore.del(CACHE_KEY);
console.log("[iCalendar] Forcing fresh sync");
await editor.flashNotification("Forcing fresh calendar sync...", "info");
await syncCalendars(); await syncCalendars();
} }
export async function clearCache() { export async function clearCache() {
if (!await editor.confirm("Clear all calendar events?")) return; if (!await editor.confirm("Clear all calendar events and cache?")) {
const pageKeys = await datastore.query({ prefix: ["ridx", "$icalendar"] }); return;
const allKeys: any[] = []; }
for (const { key } of pageKeys) {
allKeys.push(key); try {
allKeys.push(["idx", ...key.slice(2), "$icalendar"]); 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() { export async function showVersion() {
await editor.flashNotification(`iCalendar Plug ${VERSION}`, "info"); await editor.flashNotification(`iCalendar Plug ${VERSION}`, "info");
} }