forked from GitHubMirrors/silverbullet-icalendar
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:
@@ -20,6 +20,6 @@
|
|||||||
},
|
},
|
||||||
"imports": {
|
"imports": {
|
||||||
"@silverbulletmd/silverbullet": "jsr:@silverbulletmd/silverbullet@^2.0.0",
|
"@silverbulletmd/silverbullet": "jsr:@silverbulletmd/silverbullet@^2.0.0",
|
||||||
"ts-ics": "npm:ts-ics@1.6.5"
|
"ts-ics": "npm:ts-ics@2.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
299
icalendar.ts
299
icalendar.ts
@@ -1,11 +1,67 @@
|
|||||||
import { clientStore, config, datastore, editor, index } from "@silverbulletmd/silverbullet/syscalls";
|
import { clientStore, config, datastore, editor, index } from "@silverbulletmd/silverbullet/syscalls";
|
||||||
import { localDateString } from "@silverbulletmd/silverbullet/lib/dates";
|
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 CACHE_KEY = "icalendar:lastSync";
|
||||||
const DEFAULT_CACHE_DURATION_SECONDS = 21600; // 6 hours
|
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)
|
* 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 {
|
function convertDatesToStrings<T>(obj: T): DateToString<T> {
|
||||||
/** URL to the .ics file */
|
if (obj === null || obj === undefined) {
|
||||||
url: string;
|
return obj as DateToString<T>;
|
||||||
/** Optional name for the source (used in sourceName field) */
|
}
|
||||||
name: string | undefined;
|
|
||||||
|
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 {
|
async function getSources(): Promise<Source[]> {
|
||||||
/** List of calendar sources to sync */
|
const plugConfig = await config.get<PlugConfig>("icalendar", { sources: [] });
|
||||||
sources: Source[];
|
|
||||||
/** Cache duration in seconds (default: 21600 = 6 hours) */
|
if (!plugConfig.sources || !Array.isArray(plugConfig.sources)) {
|
||||||
cacheDuration: number | undefined;
|
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
|
* Fetches and parses events from a single calendar source
|
||||||
* Queryable via: query[from index.tag "ical-event" ...]
|
|
||||||
*/
|
*/
|
||||||
interface CalendarEvent {
|
async function fetchAndParseCalendar(source: Source): Promise<CalendarEvent[]> {
|
||||||
// Index metadata
|
const response = await fetch(source.url);
|
||||||
/** Unique identifier (event UID or SHA-256 hash) */
|
|
||||||
ref: string;
|
|
||||||
/** Object tag for LIQ queries */
|
|
||||||
tag: "ical-event";
|
|
||||||
|
|
||||||
// Event details
|
if (!response.ok) {
|
||||||
/** Event title */
|
const error = new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
summary: string | undefined;
|
console.error(`[iCalendar] HTTP error:`, { source, status: response.status, statusText: response.statusText });
|
||||||
/** Event description/notes */
|
throw error;
|
||||||
description: string | undefined;
|
}
|
||||||
/** Event location */
|
|
||||||
location: string | undefined;
|
|
||||||
|
|
||||||
// Timestamps (formatted with localDateString)
|
const icsData = await response.text();
|
||||||
/** Event start date/time */
|
const calendar: IcsCalendar = convertIcsCalendar(undefined, icsData);
|
||||||
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
|
if (!calendar.events || calendar.events.length === 0) {
|
||||||
/** Name of the calendar source */
|
return [];
|
||||||
sourceName: string | undefined;
|
}
|
||||||
|
|
||||||
|
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.
|
* 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() {
|
export async function syncCalendars() {
|
||||||
try {
|
try {
|
||||||
// Get configuration (including cache duration)
|
|
||||||
const plugConfig = await config.get<PlugConfig>("icalendar", { sources: [] });
|
const plugConfig = await config.get<PlugConfig>("icalendar", { sources: [] });
|
||||||
const cacheDurationSeconds = plugConfig.cacheDuration ?? DEFAULT_CACHE_DURATION_SECONDS;
|
const cacheDurationSeconds = plugConfig.cacheDuration ?? DEFAULT_CACHE_DURATION_SECONDS;
|
||||||
const cacheDurationMs = cacheDurationSeconds * 1000;
|
const cacheDurationMs = cacheDurationSeconds * 1000;
|
||||||
|
|
||||||
const sources = await getSources();
|
const sources = await getSources();
|
||||||
if (sources.length === 0) {
|
if (sources.length === 0) {
|
||||||
// Ignore processing if no sources are declared
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check cache to avoid too frequent syncs
|
|
||||||
const lastSync = await clientStore.get(CACHE_KEY);
|
const lastSync = await clientStore.get(CACHE_KEY);
|
||||||
const now = Date.now();
|
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);
|
await index.indexObjects("$icalendar", allEvents);
|
||||||
|
|
||||||
// Update cache timestamp
|
|
||||||
await clientStore.set(CACHE_KEY, now);
|
await clientStore.set(CACHE_KEY, now);
|
||||||
|
|
||||||
const summary = `Synced ${allEvents.length} events from ${successCount}/${sources.length} source(s)`;
|
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");
|
await editor.flashNotification(summary, "info");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[iCalendar] Sync failed:", err);
|
console.error("[iCalendar] Sync failed:", err);
|
||||||
await editor.flashNotification(
|
await editor.flashNotification("Failed to sync calendars", "error");
|
||||||
"Failed to sync calendars",
|
|
||||||
"error"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches and parses events from a single calendar source
|
* Forces a fresh sync by clearing cache and syncing calendars
|
||||||
*/
|
|
||||||
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
|
|
||||||
*/
|
*/
|
||||||
export async function forceSync() {
|
export async function forceSync() {
|
||||||
await clientStore.del(CACHE_KEY);
|
await clientStore.del(CACHE_KEY);
|
||||||
@@ -219,12 +254,9 @@ export async function forceSync() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears the calendar cache by removing the indexed events page
|
* Clears all indexed calendar events and cache
|
||||||
* Implementation based on SilverBullet's clearFileIndex:
|
|
||||||
* https://github.com/silverbulletmd/silverbullet/blob/main/plugs/index/api.ts#L49-L69
|
|
||||||
*/
|
*/
|
||||||
export async function clearCache() {
|
export async function clearCache() {
|
||||||
// Ask for confirmation before clearing the cache
|
|
||||||
if (!await editor.confirm(
|
if (!await editor.confirm(
|
||||||
"Are you sure you want to clear all calendar events and cache? This will remove all indexed calendar data."
|
"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";
|
const fileName = "$icalendar";
|
||||||
console.log("[iCalendar] Clearing index for", fileName);
|
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 indexKey = "idx";
|
||||||
const pageKey = "ridx";
|
const pageKey = "ridx";
|
||||||
|
|
||||||
// Query all keys for this file
|
|
||||||
const allKeys: any[] = [];
|
const allKeys: any[] = [];
|
||||||
|
|
||||||
// Get all page keys for this file: [pageKey, $icalendar, ...key]
|
|
||||||
const pageKeys = await datastore.query({
|
const pageKeys = await datastore.query({
|
||||||
prefix: [pageKey, fileName],
|
prefix: [pageKey, fileName],
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const { key } of pageKeys) {
|
for (const { key } of pageKeys) {
|
||||||
allKeys.push(key);
|
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]);
|
allKeys.push([indexKey, ...key.slice(2), fileName]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batch delete all found keys
|
|
||||||
if (allKeys.length > 0) {
|
if (allKeys.length > 0) {
|
||||||
await datastore.batchDel(allKeys);
|
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);
|
await clientStore.del(CACHE_KEY);
|
||||||
|
|
||||||
console.log("[iCalendar] Calendar index and cache cleared");
|
console.log("[iCalendar] Calendar index and cache cleared");
|
||||||
|
|||||||
Reference in New Issue
Block a user