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");