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 <noreply@anthropic.com>
Signed-off-by: Alexandre Nicolaie <xunleii@users.noreply.github.com>
This commit is contained in:
Alexandre Nicolaie
2025-10-18 11:32:55 +02:00
parent 904c1b9d94
commit deb30ab6b3
2 changed files with 162 additions and 139 deletions

View File

@@ -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"
}
}

View File

@@ -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> = T extends Date ? string
: T extends IcsDateObjects ? string
: T extends object ? { [K in keyof T]: DateToString<T[K]> }
: T extends Array<infer U> ? Array<DateToString<U>>
: 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<IcsEvent> {
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<string> {
}
/**
* 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<T>(obj: T): DateToString<T> {
if (obj === null || obj === undefined) {
return obj as DateToString<T>;
}
if (obj instanceof Date) {
return localDateString(obj) as DateToString<T>;
}
if (isIcsDateObjects(obj) && obj.date instanceof Date) {
return localDateString(obj.date) as DateToString<T>;
}
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<T>;
}
if (Array.isArray(obj)) {
return obj.map(item => convertDatesToStrings(item)) as DateToString<T>;
}
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<T>;
}
return obj as DateToString<T>;
}
// ============================================================================
// 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<Source[]> {
const plugConfig = await config.get<PlugConfig>("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<CalendarEvent[]> {
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<CalendarEvent> => {
// 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<PlugConfig>("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<CalendarEvent[]> {
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<CalendarEvent> => {
// 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<Source[]> {
const plugConfig = await config.get<PlugConfig>("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");