From d3e4fc021b887a66ce625d87ec22ee591d1100b6 Mon Sep 17 00:00:00 2001 From: Alexandre Nicolaie Date: Sat, 18 Oct 2025 10:28:21 +0200 Subject: [PATCH] Migrate to SilverBullet v2 indexing system Replace deprecated query provider with index-based architecture. Events are now indexed using index.indexObjects() and queryable via Lua Integrated Query (LIQ). Breaking changes: - Plugin now requires SilverBullet v2 (use v0.1.0 for SB v1) - Old query syntax no longer works (use LIQ instead) - Manual sync required via 'iCalendar: Sync' command - Events cached for 6h by default (was real-time) Co-Authored-By: Claude Signed-off-by: Alexandre Nicolaie --- README.md | 34 ++++-- deno.jsonc | 9 +- icalendar.plug.yaml | 12 +- icalendar.ts | 261 +++++++++++++++++++++++++++++--------------- 4 files changed, 215 insertions(+), 101 deletions(-) diff --git a/README.md b/README.md index cf26a90..49ad7d8 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ `silverbullet-icalendar` is a [Plug](https://silverbullet.md/Plugs) for [SilverBullet](https://silverbullet.md/) which I made for my girlfriend. It reads external [iCalendar](https://en.wikipedia.org/wiki/ICalendar) data, also known as iCal and `.ics` format, used in CalDAV protocol. +**Note**: This version (0.2.0+) is compatible with **SilverBullet v2 only**. For SilverBullet v1, use version 0.1.0. + ## Installation Run the {[Plugs: Add]} command in SilverBullet and add paste this URI into the dialog box: @@ -42,27 +44,41 @@ Instructions to get the source URL for some calendar services: ## Usage -The plug provides the query source `ical-event`, which corresponds to `VEVENT` object +After configuration, run the `{[iCalendar: Sync]}` command to synchronize calendar events. The plug will cache the results for 6 hours by default (configurable via `cacheDuration` in config). + +Events are indexed with the tag `ical-event` and can be queried using Lua Integrated Query (LIQ). ### Examples -Select events that start on a given date +Select events that start on a given date: ~~~ -```query -ical-event -where start =~ /^2024-01-04/ -select summary, description +```md +${query[[ + from index.tag "ical-event" + where start:startsWith "2024-01-04" + select {summary=summary, description=description} +]]} +``` +~~~ + +Get the next 5 upcoming events: +```md +${query[[ + from index.tag "ical-event" + where start > os.date("%Y-%m-%d") + order by start + limit 5 +]]} ``` ~~~ ## Roadmap -- Cache the calendar according to `REFRESH-INTERVAL` or `X-PUBLISHED-TTL`, command for manual update -- More query sources: +- Cache the calendar according to `REFRESH-INTERVAL` or `X-PUBLISHED-TTL` +- More indexed object types: - `ical-todo` for `VTODO` components - `ical-calendar` showing information about configured calendars -- Describe the properties of query results - Support `file://` URL scheme (use an external script or filesystem instead of authentication on CalDAV) ## Contributing diff --git a/deno.jsonc b/deno.jsonc index 45b9b1c..682a14a 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -1,11 +1,14 @@ { "tasks": { "build": "silverbullet plug:compile -c deno.jsonc icalendar.plug.yaml", + "build:debug": "silverbullet plug:compile -c deno.jsonc icalendar.plug.yaml --debug", "watch": "silverbullet plug:compile -c deno.jsonc icalendar.plug.yaml -w" }, "lint": { "rules": { - "exclude": ["no-explicit-any"] + "exclude": [ + "no-explicit-any" + ] } }, "fmt": { @@ -16,7 +19,7 @@ ] }, "imports": { - "@silverbulletmd/silverbullet": "jsr:@silverbulletmd/silverbullet@^0.10.1", + "@silverbulletmd/silverbullet": "jsr:@silverbulletmd/silverbullet@^2.0.0", "ts-ics": "npm:ts-ics@1.6.5" } -} +} \ No newline at end of file diff --git a/icalendar.plug.yaml b/icalendar.plug.yaml index a74e6b6..b3f5a6a 100644 --- a/icalendar.plug.yaml +++ b/icalendar.plug.yaml @@ -2,15 +2,16 @@ name: icalendar requiredPermissions: - fetch functions: + syncCalendars: + path: ./icalendar.ts:syncCalendars + command: + name: "iCalendar: Sync" + priority: -1 showVersion: path: ./icalendar.ts:showVersion command: name: "iCalendar: Version" priority: -2 - queryEvents: - path: ./icalendar.ts:queryEvents - events: - - query:ical-event config: schema.config.properties.icalendar: type: object @@ -29,3 +30,6 @@ config: type: string name: type: string + cacheDuration: + type: number + description: "Interval between two calendar synchronizations (default: 21600 = 6 hours)" diff --git a/icalendar.ts b/icalendar.ts index ba67823..7baa066 100644 --- a/icalendar.ts +++ b/icalendar.ts @@ -1,127 +1,218 @@ -import { editor, system } from "@silverbulletmd/silverbullet/syscalls"; -import { QueryProviderEvent } from "@silverbulletmd/silverbullet/types"; -import { applyQuery } from "@silverbulletmd/silverbullet/lib/query"; +import { clientStore, config, datastore, editor, index } from "@silverbulletmd/silverbullet/syscalls"; +import { localDateString } from "@silverbulletmd/silverbullet/lib/dates"; import { parseIcsCalendar, type VCalendar } from "ts-ics"; -const VERSION = "0.1.0"; +const VERSION = "0.2.0"; +const CACHE_KEY = "icalendar:lastSync"; +const DEFAULT_CACHE_DURATION_SECONDS = 21600; // 6 hours -// Try to match SilverBullet properties where possible. -// Timestamps should be strings formatted with `localDateString` -interface Event { - // Typically available in calendar apps +/** + * Creates a SHA-256 hash of a string (hex encoded) + */ +async function sha256Hash(str: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(str); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map(b => b.toString(16).padStart(2, "0")).join(""); +} + +/** + * Configuration for a calendar source + */ +interface Source { + /** URL to the .ics file */ + url: string; + /** Optional name for the source (used in sourceName field) */ + name: string | undefined; +} + +/** + * Plugin configuration structure + */ +interface PlugConfig { + /** List of calendar sources to sync */ + sources: Source[]; + /** Cache duration in seconds (default: 21600 = 6 hours) */ + cacheDuration: number | undefined; +} + +/** + * Calendar event object indexed in SilverBullet + * Queryable via: query[from index.tag "ical-event" ...] + */ +interface CalendarEvent { + // Index metadata + /** Unique identifier (event UID or SHA-256 hash) */ + ref: string; + /** Object tag for LIQ queries */ + tag: "ical-event"; + + // Event details + /** Event title */ summary: string | undefined; + /** Event description/notes */ description: string | undefined; + /** Event location */ location: string | undefined; - // Same as SilverBullet pages - created: string | undefined; - lastModified: string | undefined; - // Keep consistent with dates above + // 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; + // Source tracking + /** Name of the calendar source */ sourceName: string | undefined; } -interface Source { - url: string; // Should be an .ics file - name: string | undefined; // Optional name that will be assigned to events -} +/** + * 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. + */ +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; -export async function queryEvents( - { query }: QueryProviderEvent, -): Promise { - const events: Event[] = []; - - const sources = await getSources(); - for (const source of sources) { - const identifier = (source.name === undefined || source.name === "") - ? source.url - : source.name; - - try { - const result = await fetch(source.url); - const icsData = await result.text(); - - const calendarParsed: VCalendar = parseIcsCalendar(icsData); - if (calendarParsed.events === undefined) { - throw new Error("Didn't parse events from ics data"); - } - - // The order here is the default order of columns without the select clause - for (const icsEvent of calendarParsed.events) { - events.push({ - summary: icsEvent.summary, - sourceName: source.name, - - location: icsEvent.location, - description: icsEvent.description, - - start: localDateString(icsEvent.start.date), - end: icsEvent.end ? localDateString(icsEvent.end.date) : undefined, - created: icsEvent.created - ? localDateString(icsEvent.created.date) - : undefined, - lastModified: icsEvent.lastModified - ? localDateString(icsEvent.lastModified.date) - : undefined, - }); - } - } catch (err) { - console.error( - `Getting events from ${identifier} failed with:`, - err, - ); + 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(); + + if (lastSync && (now - lastSync) < cacheDurationMs) { + const ageSeconds = Math.round((now - lastSync) / 1000); + console.log(`[iCalendar] Using cached data (${ageSeconds}s old)`); + return; + } + + console.log(`[iCalendar] Syncing ${sources.length} calendar source(s)...`); + await editor.flashNotification("Syncing calendars...", "info"); + + const allEvents: CalendarEvent[] = []; + let successCount = 0; + + for (const source of sources) { + const identifier = source.name || source.url; + + try { + const events = await fetchAndParseCalendar(source); + allEvents.push(...events); + successCount++; + } catch (err) { + console.error(`[iCalendar] Failed to sync "${identifier}":`, err); + await editor.flashNotification( + `Failed to sync "${identifier}"`, + "error" + ); + } + } + + // 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)`; + console.log(`[iCalendar] ${summary}`); + await editor.flashNotification(summary, "info"); + } catch (err) { + console.error("[iCalendar] Sync failed:", err); + await editor.flashNotification( + "Failed to sync calendars", + "error" + ); } - return applyQuery(query, events, {}, {}); } -async function getSources(): Promise { - const config = await system.getSpaceConfig("icalendar", {}); +/** + * Fetches and parses events from a single calendar source + */ +async function fetchAndParseCalendar(source: Source): Promise { + const response = await fetch(source.url); - if (!config.sources || !Array.isArray(config.sources)) { - // The queries are running on server, probably because of that, can't use editor.flashNotification - console.error("Configure icalendar.sources"); + 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 []; } - const sources = config.sources; + 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}`); - if (sources.length === 0) { - console.error("Empty icalendar.sources"); + 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 sources) { + for (const src of plugConfig.sources) { if (typeof src.url !== "string") { - console.error( - `Invalid iCalendar source`, - src, - ); + console.error("[iCalendar] Invalid source (missing url):", src); continue; } validated.push({ url: src.url, - name: (typeof src.name === "string") ? src.name : undefined, + name: typeof src.name === "string" ? src.name : undefined, }); } return validated; } -// Copied from @silverbulletmd/silverbullet/lib/dates.ts which is not exported in the package -export function localDateString(d: Date): string { - return d.getFullYear() + - "-" + String(d.getMonth() + 1).padStart(2, "0") + - "-" + String(d.getDate()).padStart(2, "0") + - "T" + String(d.getHours()).padStart(2, "0") + - ":" + String(d.getMinutes()).padStart(2, "0") + - ":" + String(d.getSeconds()).padStart(2, "0") + - "." + String(d.getMilliseconds()).padStart(3, "0"); } +/** + * Shows the plugin version + */ export async function showVersion() { - await editor.flashNotification(`iCalendar Plug ${VERSION}`); + await editor.flashNotification(`iCalendar Plug ${VERSION}`, "info"); }