forked from GitHubMirrors/silverbullet-icalendar
370 lines
11 KiB
TypeScript
370 lines
11 KiB
TypeScript
import { clientStore, config, datastore, editor, index } from "@silverbulletmd/silverbullet/syscalls";
|
|
import ICAL from "ical.js";
|
|
import { RRule, RRuleSet } from "rrule";
|
|
|
|
const VERSION = "0.4.0";
|
|
const CACHE_KEY = "icalendar:lastSync";
|
|
|
|
console.log(`[iCalendar] Plug script executing at top level (Version ${VERSION})`);
|
|
|
|
// ============================================================================
|
|
// Types
|
|
// ============================================================================
|
|
|
|
interface CalendarEvent {
|
|
ref: string;
|
|
tag: "ical-event";
|
|
sourceName: string;
|
|
|
|
// Core properties
|
|
uid: string;
|
|
summary: string;
|
|
description?: string;
|
|
location?: string;
|
|
|
|
// Dates - stored as ISO strings to preserve timezone
|
|
start: string;
|
|
startLocal: string; // For queries: local representation
|
|
end?: string;
|
|
|
|
// Timezone info
|
|
timezone: string;
|
|
isAllDay: boolean;
|
|
|
|
// Recurrence
|
|
isRecurring: boolean;
|
|
rrule?: string;
|
|
recurrenceId?: string; // For individual occurrences
|
|
|
|
// Status
|
|
status?: string;
|
|
|
|
// People
|
|
organizer?: string;
|
|
attendees?: string[];
|
|
}
|
|
|
|
// ============================================================================
|
|
// Utility Functions
|
|
// ============================================================================
|
|
|
|
async function sha256Hash(str: string): Promise<string> {
|
|
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("");
|
|
}
|
|
|
|
function localDateString(date: Date): string {
|
|
const pad = (n: number) => String(n).padStart(2, "0");
|
|
return date.getFullYear() + "-" +
|
|
pad(date.getMonth() + 1) + "-" +
|
|
pad(date.getDate()) + "T" +
|
|
pad(date.getHours()) + ":" +
|
|
pad(date.getMinutes()) + ":" +
|
|
pad(date.getSeconds());
|
|
}
|
|
|
|
/**
|
|
* Converts ICAL.Time to ISO string and local string
|
|
*/
|
|
function parseIcalTime(time: ICAL.Time): { iso: string; local: string; timezone: string; isAllDay: boolean } {
|
|
const isAllDay = time.isDate;
|
|
|
|
if (isAllDay) {
|
|
// Date-only events
|
|
const dateStr = time.toJSDate().toISOString().split('T')[0];
|
|
return {
|
|
iso: dateStr,
|
|
local: dateStr,
|
|
timezone: "date",
|
|
isAllDay: true
|
|
};
|
|
}
|
|
|
|
// Get timezone
|
|
const timezone = time.zone?.tzid || "UTC";
|
|
|
|
// ISO representation (UTC)
|
|
const jsDate = time.toJSDate();
|
|
const iso = jsDate.toISOString();
|
|
|
|
// Local representation for querying
|
|
const local = localDateString(jsDate);
|
|
|
|
return { iso, local, timezone, isAllDay: false };
|
|
}
|
|
|
|
// ============================================================================
|
|
// Configuration
|
|
// ============================================================================
|
|
|
|
async function getSources(): Promise<{ sources: any[], syncWindowDays: number }> {
|
|
try {
|
|
const rawConfig = await config.get("icalendar", { sources: [] }) as any;
|
|
console.log("[iCalendar] Raw config retrieved:", JSON.stringify(rawConfig));
|
|
|
|
let sources = rawConfig.sources || [];
|
|
const syncWindowDays = rawConfig.syncWindowDays || 365;
|
|
|
|
if (sources && typeof sources === "object" && !Array.isArray(sources)) {
|
|
const sourceArray = [];
|
|
for (const key in sources) {
|
|
if (sources[key] && typeof sources[key].url === "string") {
|
|
sourceArray.push(sources[key]);
|
|
}
|
|
}
|
|
sources = sourceArray;
|
|
}
|
|
|
|
return { sources, syncWindowDays };
|
|
} catch (e) {
|
|
console.error("[iCalendar] Error in getSources:", e);
|
|
return { sources: [], syncWindowDays: 365 };
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Recurrence Expansion (keeping your logic)
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Expands recurring events using rrule
|
|
* Keeps your no-lookback-limit approach
|
|
*/
|
|
function expandRecurrences(
|
|
event: CalendarEvent,
|
|
startDate: Date,
|
|
windowDays: number,
|
|
now = new Date()
|
|
): CalendarEvent[] {
|
|
if (!event.rrule || !event.isRecurring) {
|
|
return [event];
|
|
}
|
|
|
|
try {
|
|
const set = new RRuleSet();
|
|
|
|
// Parse the RRULE string
|
|
const cleanRule = event.rrule.replace(/^RRULE:/i, "");
|
|
const ruleOptions = RRule.parseString(cleanRule);
|
|
ruleOptions.dtstart = startDate;
|
|
|
|
set.rrule(new RRule(ruleOptions));
|
|
|
|
const windowEnd = new Date(now.getTime() + windowDays * 86400000);
|
|
|
|
// Expand from event start (no lookback limit)
|
|
const occurrences = set.between(startDate, windowEnd, true);
|
|
|
|
return occurrences.map((occurrenceDate, idx) => ({
|
|
...event,
|
|
start: occurrenceDate.toISOString(),
|
|
startLocal: localDateString(occurrenceDate),
|
|
recurrenceId: occurrenceDate.toISOString(),
|
|
rrule: undefined, // Remove rrule from occurrences
|
|
}));
|
|
} catch (err) {
|
|
console.error(`[iCalendar] Error expanding recurrence for ${event.summary}:`, err);
|
|
return [event];
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Calendar Parsing with ical.js
|
|
// ============================================================================
|
|
|
|
async function fetchAndParseCalendar(
|
|
source: any,
|
|
windowDays: number
|
|
): Promise<CalendarEvent[]> {
|
|
try {
|
|
const response = await fetch(source.url);
|
|
if (!response.ok) {
|
|
console.error(`[iCalendar] Fetch failed for ${source.name}: ${response.status}`);
|
|
return [];
|
|
}
|
|
|
|
const icsText = await response.text();
|
|
|
|
// Parse with ical.js
|
|
const jcalData = ICAL.parse(icsText);
|
|
const vcalendar = new ICAL.Component(jcalData);
|
|
const vevents = vcalendar.getAllSubcomponents('vevent');
|
|
|
|
if (vevents.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const events: CalendarEvent[] = [];
|
|
|
|
for (const vevent of vevents) {
|
|
try {
|
|
const event = new ICAL.Event(vevent);
|
|
|
|
// Skip cancelled events
|
|
const status = vevent.getFirstPropertyValue('status');
|
|
if (status === 'CANCELLED') continue;
|
|
|
|
// Parse start time
|
|
const startTime = event.startDate;
|
|
if (!startTime) continue;
|
|
|
|
const { iso, local, timezone, isAllDay } = parseIcalTime(startTime);
|
|
|
|
// Parse end time
|
|
let endIso: string | undefined;
|
|
if (event.endDate) {
|
|
endIso = parseIcalTime(event.endDate).iso;
|
|
}
|
|
|
|
// Get organizer
|
|
const organizerProp = vevent.getFirstProperty('organizer');
|
|
const organizer = organizerProp
|
|
? organizerProp.getFirstValue()?.replace(/^mailto:/i, '')
|
|
: undefined;
|
|
|
|
// Get attendees
|
|
const attendees: string[] = [];
|
|
const attendeeProps = vevent.getAllProperties('attendee');
|
|
for (const prop of attendeeProps) {
|
|
const value = prop.getFirstValue();
|
|
if (typeof value === 'string') {
|
|
attendees.push(value.replace(/^mailto:/i, ''));
|
|
}
|
|
}
|
|
|
|
// Get RRULE as string
|
|
let rruleStr: string | undefined;
|
|
const rruleProp = vevent.getFirstProperty('rrule');
|
|
if (rruleProp) {
|
|
const rruleValue = rruleProp.getFirstValue();
|
|
rruleStr = rruleValue ? rruleValue.toString() : undefined;
|
|
}
|
|
|
|
const baseEvent: CalendarEvent = {
|
|
ref: "", // Will be set per occurrence
|
|
tag: "ical-event",
|
|
sourceName: source.name,
|
|
|
|
uid: event.uid,
|
|
summary: event.summary || "Untitled Event",
|
|
description: event.description,
|
|
location: event.location,
|
|
|
|
start: iso,
|
|
startLocal: local,
|
|
end: endIso,
|
|
|
|
timezone,
|
|
isAllDay,
|
|
|
|
isRecurring: event.isRecurring(),
|
|
rrule: rruleStr,
|
|
|
|
status,
|
|
organizer,
|
|
attendees: attendees.length > 0 ? attendees : undefined,
|
|
};
|
|
|
|
// Expand recurrences or use single event
|
|
const startJsDate = startTime.toJSDate();
|
|
const expandedEvents = baseEvent.isRecurring
|
|
? expandRecurrences(baseEvent, startJsDate, windowDays)
|
|
: [baseEvent];
|
|
|
|
// Generate refs
|
|
for (const evt of expandedEvents) {
|
|
const uniqueKey = evt.recurrenceId
|
|
? `${evt.uid}:${evt.recurrenceId}`
|
|
: `${evt.uid}:${evt.start}`;
|
|
evt.ref = await sha256Hash(uniqueKey);
|
|
events.push(evt);
|
|
}
|
|
} catch (eventErr) {
|
|
console.error(`[iCalendar] Error parsing event:`, eventErr);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return events;
|
|
} catch (err: any) {
|
|
console.error(`[iCalendar] Error fetching/parsing ${source.name}:`,
|
|
err.message || err, err.stack || "");
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Exported Commands
|
|
// ============================================================================
|
|
|
|
export async function syncCalendars() {
|
|
try {
|
|
const { sources, syncWindowDays } = await getSources();
|
|
if (sources.length === 0) return;
|
|
|
|
await editor.flashNotification("Syncing calendars...", "info");
|
|
|
|
const allEvents: CalendarEvent[] = [];
|
|
let successCount = 0;
|
|
|
|
for (const source of sources) {
|
|
const events = await fetchAndParseCalendar(source, syncWindowDays);
|
|
allEvents.push(...events);
|
|
if (events.length > 0) successCount++;
|
|
console.log(`[iCalendar] Synced ${events.length} events from "${source.name}"`);
|
|
}
|
|
|
|
await index.indexObjects("$icalendar", allEvents);
|
|
await clientStore.set(CACHE_KEY, Date.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] syncCalendars failed:", err);
|
|
await editor.flashNotification("Failed to sync calendars", "error");
|
|
}
|
|
}
|
|
|
|
export async function forceSync() {
|
|
await clientStore.del(CACHE_KEY);
|
|
console.log("[iCalendar] Forcing fresh sync");
|
|
await editor.flashNotification("Forcing fresh calendar sync...", "info");
|
|
await syncCalendars();
|
|
}
|
|
|
|
export async function clearCache() {
|
|
if (!await editor.confirm("Clear all calendar events and cache?")) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const pageKeys = await datastore.query({
|
|
prefix: ["ridx", "$icalendar"]
|
|
});
|
|
|
|
const allKeys: any[] = [];
|
|
for (const { key } of pageKeys) {
|
|
allKeys.push(key);
|
|
allKeys.push(["idx", ...key.slice(2), "$icalendar"]);
|
|
}
|
|
|
|
if (allKeys.length > 0) {
|
|
await datastore.batchDel(allKeys);
|
|
console.log(`[iCalendar] Deleted ${allKeys.length} entries`);
|
|
}
|
|
|
|
await clientStore.del(CACHE_KEY);
|
|
await editor.flashNotification("Calendar index cleared", "info");
|
|
} catch (err) {
|
|
console.error("[iCalendar] Failed to clear cache:", err);
|
|
await editor.flashNotification("Failed to clear cache", "error");
|
|
}
|
|
}
|
|
|
|
export async function showVersion() {
|
|
await editor.flashNotification(`iCalendar Plug ${VERSION}`, "info");
|
|
} |