From deb30ab6b323862329f2c8e61580b5104d94c40e Mon Sep 17 00:00:00 2001 From: Alexandre Nicolaie Date: Sat, 18 Oct 2025 11:32:55 +0200 Subject: [PATCH] Migrate to ts-ics 2.4.0 API and fix duplicate recurring events ts-ics 2.4.0 changed API from parseIcsCalendar to convertIcsCalendar and VCalendar to IcsCalendar. The new API returns Date objects and nested date structures that require recursive conversion to strings for SilverBullet indexing. Recurring events were creating duplicate refs because the hash only used the UID, which is identical across occurrences. Including the start date in the unique key ensures each occurrence gets a distinct ref. Co-authored-by: Claude Signed-off-by: Alexandre Nicolaie --- deno.jsonc | 2 +- icalendar.ts | 299 +++++++++++++++++++++++++++------------------------ 2 files changed, 162 insertions(+), 139 deletions(-) diff --git a/deno.jsonc b/deno.jsonc index 682a14a..0a481cf 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -20,6 +20,6 @@ }, "imports": { "@silverbulletmd/silverbullet": "jsr:@silverbulletmd/silverbullet@^2.0.0", - "ts-ics": "npm:ts-ics@1.6.5" + "ts-ics": "npm:ts-ics@2.4.0" } } \ No newline at end of file diff --git a/icalendar.ts b/icalendar.ts index 8d1b6d0..fbc3e41 100644 --- a/icalendar.ts +++ b/icalendar.ts @@ -1,11 +1,67 @@ import { clientStore, config, datastore, editor, index } from "@silverbulletmd/silverbullet/syscalls"; import { localDateString } from "@silverbulletmd/silverbullet/lib/dates"; -import { parseIcsCalendar, type VCalendar } from "ts-ics"; +import { convertIcsCalendar, type IcsCalendar, type IcsEvent, type IcsDateObjects } from "ts-ics"; -const VERSION = "0.2.0"; +// ============================================================================ +// Constants +// ============================================================================ + +const VERSION = "0.2.1"; const CACHE_KEY = "icalendar:lastSync"; const DEFAULT_CACHE_DURATION_SECONDS = 21600; // 6 hours +// ============================================================================ +// Types +// ============================================================================ + +/** + * Recursively converts all Date objects to strings in a type + */ +type DateToString = T extends Date ? string + : T extends IcsDateObjects ? string + : T extends object ? { [K in keyof T]: DateToString } + : T extends Array ? Array> + : T; + +/** + * Configuration for a calendar source + */ +interface Source { + url: string; + name: string | undefined; +} + +/** + * Plugin configuration structure + */ +interface PlugConfig { + sources: Source[]; + cacheDuration: number | undefined; +} + +/** + * Calendar event object indexed in SilverBullet + * Queryable via: `ical-event` from index + * + * Extends IcsEvent with all Date fields converted to strings recursively + */ +interface CalendarEvent extends DateToString { + ref: string; + tag: "ical-event"; + sourceName: string | undefined; +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** + * Type guard for IcsDateObjects + */ +function isIcsDateObjects(obj: any): obj is IcsDateObjects { + return obj && typeof obj === 'object' && ('date' in obj && 'type' in obj); +} + /** * Creates a SHA-256 hash of a string (hex encoded) */ @@ -18,78 +74,132 @@ async function sha256Hash(str: string): Promise { } /** - * Configuration for a calendar source + * Recursively converts all Date objects and ISO date strings to strings + * Handles nested objects like {date: Date, local: {date: Date, timezone: string}} */ -interface Source { - /** URL to the .ics file */ - url: string; - /** Optional name for the source (used in sourceName field) */ - name: string | undefined; +function convertDatesToStrings(obj: T): DateToString { + if (obj === null || obj === undefined) { + return obj as DateToString; + } + + if (obj instanceof Date) { + return localDateString(obj) as DateToString; + } + if (isIcsDateObjects(obj) && obj.date instanceof Date) { + return localDateString(obj.date) as DateToString; + } + + if (typeof obj === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(obj)) { + return localDateString(new Date(obj)) as DateToString; + } + + if (Array.isArray(obj)) { + return obj.map(item => convertDatesToStrings(item)) as DateToString; + } + + if (typeof obj === 'object') { + const result: any = {}; + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + result[key] = convertDatesToStrings((obj as any)[key]); + } + } + return result as DateToString; + } + + return obj as DateToString; } +// ============================================================================ +// Configuration Functions +// ============================================================================ + /** - * Plugin configuration structure + * Retrieves and validates configured calendar sources */ -interface PlugConfig { - /** List of calendar sources to sync */ - sources: Source[]; - /** Cache duration in seconds (default: 21600 = 6 hours) */ - cacheDuration: number | undefined; +async function getSources(): Promise { + const plugConfig = await config.get("icalendar", { sources: [] }); + + if (!plugConfig.sources || !Array.isArray(plugConfig.sources)) { + console.error("[iCalendar] Invalid configuration:", { plugConfig }); + return []; + } + + if (plugConfig.sources.length === 0) { + return []; + } + + const validated: Source[] = []; + for (const src of plugConfig.sources) { + if (typeof src.url !== "string") { + console.error("[iCalendar] Invalid source (missing url):", src); + continue; + } + validated.push({ + url: src.url, + name: typeof src.name === "string" ? src.name : undefined, + }); + } + + return validated; } +// ============================================================================ +// Calendar Fetching & Parsing +// ============================================================================ + /** - * Calendar event object indexed in SilverBullet - * Queryable via: query[from index.tag "ical-event" ...] + * Fetches and parses events from a single calendar source */ -interface CalendarEvent { - // Index metadata - /** Unique identifier (event UID or SHA-256 hash) */ - ref: string; - /** Object tag for LIQ queries */ - tag: "ical-event"; +async function fetchAndParseCalendar(source: Source): Promise { + const response = await fetch(source.url); - // Event details - /** Event title */ - summary: string | undefined; - /** Event description/notes */ - description: string | undefined; - /** Event location */ - location: string | undefined; + if (!response.ok) { + const error = new Error(`HTTP ${response.status}: ${response.statusText}`); + console.error(`[iCalendar] HTTP error:`, { source, status: response.status, statusText: response.statusText }); + throw error; + } - // Timestamps (formatted with localDateString) - /** Event start date/time */ - start: string | undefined; - /** Event end date/time */ - end: string | undefined; - /** Event creation date/time */ - created: string | undefined; - /** Last modification date/time */ - lastModified: string | undefined; + const icsData = await response.text(); + const calendar: IcsCalendar = convertIcsCalendar(undefined, icsData); - // Source tracking - /** Name of the calendar source */ - sourceName: string | undefined; + if (!calendar.events || calendar.events.length === 0) { + return []; + } + + return await Promise.all(calendar.events.map(async (icsEvent: IcsEvent): Promise => { + // Create unique ref by start date with UID or summary (handles recurring events) + const uniqueKey = `${icsEvent.start?.date || ''}${icsEvent.uid || icsEvent.summary || ''}`; + const ref = await sha256Hash(uniqueKey); + + return convertDatesToStrings({ + ...icsEvent, + + ref, + tag: "ical-event" as const, + sourceName: source.name, + }); + })); } +// ============================================================================ +// Exported Commands +// ============================================================================ + /** - * Synchronizes calendar events from configured sources and indexes them. - * This command fetches events from all configured iCalendar sources and - * makes them queryable via Lua Integrated Query. + * Synchronizes calendar events from configured sources and indexes them */ export async function syncCalendars() { try { - // Get configuration (including cache duration) const plugConfig = await config.get("icalendar", { sources: [] }); const cacheDurationSeconds = plugConfig.cacheDuration ?? DEFAULT_CACHE_DURATION_SECONDS; const cacheDurationMs = cacheDurationSeconds * 1000; const sources = await getSources(); if (sources.length === 0) { - // Ignore processing if no sources are declared return; } - // Check cache to avoid too frequent syncs const lastSync = await clientStore.get(CACHE_KEY); const now = Date.now(); @@ -121,11 +231,7 @@ export async function syncCalendars() { } } - // Index all events in SilverBullet's object store - // Using a virtual page "$icalendar" to store external calendar data await index.indexObjects("$icalendar", allEvents); - - // Update cache timestamp await clientStore.set(CACHE_KEY, now); const summary = `Synced ${allEvents.length} events from ${successCount}/${sources.length} source(s)`; @@ -133,83 +239,12 @@ export async function syncCalendars() { await editor.flashNotification(summary, "info"); } catch (err) { console.error("[iCalendar] Sync failed:", err); - await editor.flashNotification( - "Failed to sync calendars", - "error" - ); + await editor.flashNotification("Failed to sync calendars", "error"); } } /** - * Fetches and parses events from a single calendar source - */ -async function fetchAndParseCalendar(source: Source): Promise { - const response = await fetch(source.url); - - if (!response.ok) { - const error = new Error(`HTTP ${response.status}: ${response.statusText}`); - console.error(`[iCalendar] HTTP error:`, { source, status: response.status, statusText: response.statusText }); - throw error; - } - - const icsData = await response.text(); - const calendar: VCalendar = parseIcsCalendar(icsData); - - if (!calendar.events || calendar.events.length === 0) { - return []; - } - - return await Promise.all(calendar.events.map(async (icsEvent): Promise => { - // Create a unique ref using UID if available, otherwise hash unique fields - const ref = icsEvent.uid || await sha256Hash(`${icsEvent.uid || ''}${icsEvent.start}${icsEvent.summary}`); - - return { - ref, - tag: "ical-event" as const, - summary: icsEvent.summary, - description: icsEvent.description, - location: icsEvent.location, - start: icsEvent.start ? localDateString(icsEvent.start.date) : undefined, - end: icsEvent.end ? localDateString(icsEvent.end.date) : undefined, - created: icsEvent.created ? localDateString(icsEvent.created.date) : undefined, - lastModified: icsEvent.lastModified ? localDateString(icsEvent.lastModified.date) : undefined, - sourceName: source.name, - }; - })); -} - -/** - * Retrieves configured calendar sources from CONFIG - */ -async function getSources(): Promise { - const plugConfig = await config.get("icalendar", { sources: [] }); - - if (!plugConfig.sources || !Array.isArray(plugConfig.sources)) { - console.error("[iCalendar] Invalid configuration:", { plugConfig }); - return []; - } - - if (plugConfig.sources.length === 0) { - return []; - } - - const validated: Source[] = []; - for (const src of plugConfig.sources) { - if (typeof src.url !== "string") { - console.error("[iCalendar] Invalid source (missing url):", src); - continue; - } - validated.push({ - url: src.url, - name: typeof src.name === "string" ? src.name : undefined, - }); - } - - return validated; -} - -/** - * Forces a fresh sync by clearing cache and then syncing calendars + * Forces a fresh sync by clearing cache and syncing calendars */ export async function forceSync() { await clientStore.del(CACHE_KEY); @@ -219,12 +254,9 @@ export async function forceSync() { } /** - * Clears the calendar cache by removing the indexed events page - * Implementation based on SilverBullet's clearFileIndex: - * https://github.com/silverbulletmd/silverbullet/blob/main/plugs/index/api.ts#L49-L69 + * Clears all indexed calendar events and cache */ export async function clearCache() { - // Ask for confirmation before clearing the cache if (!await editor.confirm( "Are you sure you want to clear all calendar events and cache? This will remove all indexed calendar data." )) { @@ -235,33 +267,24 @@ export async function clearCache() { const fileName = "$icalendar"; console.log("[iCalendar] Clearing index for", fileName); - // Implementation based on SilverBullet's clearFileIndex function - // https://github.com/silverbulletmd/silverbullet/blob/main/plugs/index/api.ts#L49-L69 const indexKey = "idx"; const pageKey = "ridx"; - - // Query all keys for this file const allKeys: any[] = []; - // Get all page keys for this file: [pageKey, $icalendar, ...key] const pageKeys = await datastore.query({ prefix: [pageKey, fileName], }); for (const { key } of pageKeys) { allKeys.push(key); - // Also add corresponding index keys: [indexKey, tag, ref, $icalendar] - // where tag is "ical-event" and ref is the event reference allKeys.push([indexKey, ...key.slice(2), fileName]); } - // Batch delete all found keys if (allKeys.length > 0) { await datastore.batchDel(allKeys); - console.log("[iCalendar] Deleted", allKeys.length, "events"); + console.log("[iCalendar] Deleted", allKeys.length, "entries"); } - // Also clear the sync timestamp cache await clientStore.del(CACHE_KEY); console.log("[iCalendar] Calendar index and cache cleared");