diff --git a/PLUG.md b/PLUG.md index 1177802..c921c73 100644 --- a/PLUG.md +++ b/PLUG.md @@ -1,6 +1,6 @@ --- name: Library/sstent/icalendar/PLUG -version: 0.2.10 +version: 0.2.11 tags: meta/library files: - icalendar.plug.js diff --git a/icalendar.ts b/icalendar.ts index 8f74488..463376c 100644 --- a/icalendar.ts +++ b/icalendar.ts @@ -1,15 +1,11 @@ import { clientStore, config, datastore, editor, index } from "@silverbulletmd/silverbullet/syscalls"; -import { localDateString } from "@silverbulletmd/silverbullet/lib/dates"; import { convertIcsCalendar, type IcsCalendar, type IcsEvent, type IcsDateObjects } from "ts-ics"; -const VERSION = "0.2.10"; +const VERSION = "0.2.11"; const CACHE_KEY = "icalendar:lastSync"; const DEFAULT_CACHE_DURATION_SECONDS = 21600; // 6 hours console.log(`[iCalendar] Plug loading (Version ${VERSION})...`); -console.log(`[iCalendar] Environment Timezone Offset: ${new Date().getTimezoneOffset()} minutes`); - -// ============================================================================ // ============================================================================ // Types @@ -38,13 +34,11 @@ interface Source { interface PlugConfig { sources: Source[]; cacheDuration: number | undefined; + tzShift: number | undefined; // Manual hour shift override } /** * 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; @@ -56,6 +50,27 @@ interface CalendarEvent extends DateToString { // Utility Functions // ============================================================================ +/** + * Custom date formatter that handles the local timezone shift correctly + */ +function localizeDate(d: Date, hourShift = 0): string { + // Apply any manual shift requested by the user + if (hourShift !== 0) { + d = new Date(d.getTime() + (hourShift * 3600000)); + } + + // Get local parts based on the environment's timezone + const pad = (n: number) => String(n).padStart(2, "0"); + + return d.getFullYear() + + "-" + pad(d.getMonth() + 1) + + "-" + pad(d.getDate()) + + "T" + pad(d.getHours()) + + ":" + pad(d.getMinutes()) + + ":" + pad(d.getSeconds()) + + "." + String(d.getMilliseconds()).padStart(3, "0"); +} + /** * Type guard for IcsDateObjects */ @@ -76,41 +91,37 @@ async function sha256Hash(str: string): Promise { /** * Recursively converts all Date objects and ISO date strings to strings - * Handles nested objects like {date: Date, local: {date: Date, timezone: string}} */ -function convertDatesToStrings(obj: T, timezones?: any): DateToString { +function convertDatesToStrings(obj: T, hourShift = 0): DateToString { if (obj === null || obj === undefined) { return obj as DateToString; } if (obj instanceof Date) { - const localized = localDateString(obj); - console.log(`[iCalendar] MATCH Date Object: ${obj.toISOString()} -> PST=${localized}`); - return localized as DateToString; + return localizeDate(obj, hourShift) as DateToString; } + if (isIcsDateObjects(obj) && obj.date instanceof Date) { - const localized = localDateString(obj.date); - console.log(`[iCalendar] MATCH ICS Date Object: ${obj.date.toISOString()} -> PST=${localized} (TZID: ${obj.timezone || "none"})`); - return localized as DateToString; + // If ts-ics gave us a nested 'local' object, prioritize it but treat it as the base time + const baseDate = (obj as any).local?.date instanceof Date ? (obj as any).local.date : obj.date; + return localizeDate(baseDate, hourShift) as DateToString; } if (typeof obj === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(obj)) { + // Ensure it's treated as UTC if it doesn't have a timezone marking const forcedUTC = obj.endsWith("Z") ? obj : obj + "Z"; - const dateObj = new Date(forcedUTC); - const localized = localDateString(dateObj); - console.log(`[iCalendar] MATCH ISO String: Raw=${obj} -> PST=${localized}`); - return localized as DateToString; + return localizeDate(new Date(forcedUTC), hourShift) as DateToString; } if (Array.isArray(obj)) { - return obj.map(item => convertDatesToStrings(item, timezones)) as DateToString; + return obj.map(item => convertDatesToStrings(item, hourShift)) 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], timezones); + result[key] = convertDatesToStrings((obj as any)[key], hourShift); } } return result as DateToString; @@ -123,35 +134,17 @@ function convertDatesToStrings(obj: T, timezones?: any): DateToString { // Configuration Functions // ============================================================================ -/** - * Retrieves and validates configured calendar sources - */ async function getSources(): Promise { - // Using system.getSpaceConfig as config.get is being phased out in syscalls const plugConfig = await config.get("icalendar", { sources: [] }); - - if (!plugConfig.sources) { - return []; - } - - // Handle case where user provides a single object instead of an array + if (!plugConfig.sources) return []; let sources = plugConfig.sources; - if (!Array.isArray(sources)) { - sources = [sources as unknown as Source]; - } - + if (!Array.isArray(sources)) sources = [sources as unknown as Source]; const validated: Source[] = []; for (const src of sources) { - if (typeof src.url !== "string") { - console.error("[iCalendar] Invalid source (missing url):", src); - continue; + if (typeof src.url === "string") { + validated.push({ url: src.url, name: src.name }); } - validated.push({ - url: src.url, - name: typeof src.name === "string" ? src.name : undefined, - }); } - return validated; } @@ -159,22 +152,9 @@ async function getSources(): Promise { // Calendar Fetching & Parsing // ============================================================================ -/** - * Fetches and parses events from a single calendar source - */ -async function fetchAndParseCalendar(source: Source): Promise { +async function fetchAndParseCalendar(source: Source, hourShift = 0): Promise { let url = source.url.trim(); - - // Handle any whitespace (space, tabs, non-breaking spaces, etc.) - const whitespaceRegex = /\s/g; - if (whitespaceRegex.test(url)) { - const matches = url.match(whitespaceRegex); - const charCodes = matches?.map(m => m.charCodeAt(0)).join(", "); - console.log(`[iCalendar] URL contains whitespace (charCodes: ${charCodes}), encoding: "${url}"`); - url = url.replace(/\s/g, (match) => encodeURIComponent(match)); - } - - console.log(`[iCalendar] Fetching: ${url}`); + if (url.includes(" ")) url = encodeURI(url); const response = await fetch(url, { headers: { @@ -182,41 +162,23 @@ async function fetchAndParseCalendar(source: Source): Promise { } }); - if (!response.ok) { - const body = await response.text(); - const errorDetail = body.slice(0, 200).replace(/\n/g, " "); - console.error(`[iCalendar] HTTP ${response.status} Error:`, { - url, - status: response.status, - statusText: response.statusText, - bodyPreview: errorDetail - }); - throw new Error(`HTTP ${response.status}: ${response.statusText || 'Error'} - ${errorDetail}`); - } + if (!response.ok) throw new Error(`HTTP ${response.status}`); const icsData = await response.text(); - console.log(`[iCalendar] Raw ICS Snippet (first 500 chars): ${icsData.slice(0, 500).replace(/\n/g, " ")}`); const calendar: IcsCalendar = convertIcsCalendar(undefined, icsData); - if (!calendar.events || calendar.events.length === 0) { - return []; - } + if (!calendar.events) return []; return await Promise.all(calendar.events.map(async (icsEvent: IcsEvent): Promise => { - if (icsEvent.uid === "040000008200E00074C5B7101A82E0080000000010E384DCAC84DC0100000000000000001000000014AC664AB867C74D85FC0B77E881C5AE") { - console.log(`[iCalendar] Found target UID event:`, JSON.stringify(icsEvent, null, 2)); - } - // 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, - }, calendar.timezones); + }, hourShift); })); } @@ -224,121 +186,62 @@ async function fetchAndParseCalendar(source: Source): Promise { // Exported Commands // ============================================================================ -/** - * Synchronizes calendar events from configured sources and indexes them - */ export async function syncCalendars() { try { const plugConfig = await config.get("icalendar", { sources: [] }); - const cacheDurationSeconds = plugConfig.cacheDuration ?? DEFAULT_CACHE_DURATION_SECONDS; - const cacheDurationMs = cacheDurationSeconds * 1000; + const hourShift = plugConfig.tzShift ?? 0; + const cacheDurationMs = (plugConfig.cacheDuration ?? DEFAULT_CACHE_DURATION_SECONDS) * 1000; const sources = await getSources(); - if (sources.length === 0) { - return; - } + if (sources.length === 0) return; 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; - } + if (lastSync && (now - lastSync) < cacheDurationMs) 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); + const events = await fetchAndParseCalendar(source, hourShift); allEvents.push(...events); - successCount++; } catch (err) { - console.error(`[iCalendar] Failed to sync "${identifier}":`, err); - await editor.flashNotification( - `Failed to sync "${identifier}"`, - "error" - ); + console.error(`[iCalendar] Failed to sync ${source.name}:`, err); } } await index.indexObjects("$icalendar", allEvents); 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"); + await editor.flashNotification(`Synced ${allEvents.length} events`, "info"); } catch (err) { console.error("[iCalendar] Sync failed:", err); - await editor.flashNotification("Failed to sync calendars", "error"); } } -/** - * Forces a fresh sync by clearing cache and syncing calendars - */ export async function forceSync() { await clientStore.del(CACHE_KEY); - console.log("[iCalendar] Cache cleared, forcing fresh sync"); - await editor.flashNotification("Forcing fresh calendar sync...", "info"); await syncCalendars(); } -/** - * Clears all indexed calendar events and cache - */ export async function clearCache() { - if (!await editor.confirm( - "Are you sure you want to clear all calendar events and cache? This will remove all indexed calendar data." - )) { - return; - } - + if (!await editor.confirm("Clear all calendar events and cache?")) return; try { - const fileName = "$icalendar"; - console.log("[iCalendar] Clearing index for", fileName); - - const indexKey = "idx"; - const pageKey = "ridx"; + const pageKeys = await datastore.query({ prefix: ["ridx", "$icalendar"] }); const allKeys: any[] = []; - - const pageKeys = await datastore.query({ - prefix: [pageKey, fileName], - }); - for (const { key } of pageKeys) { allKeys.push(key); - allKeys.push([indexKey, ...key.slice(2), fileName]); + allKeys.push(["idx", ...key.slice(2), "$icalendar"]); } - - if (allKeys.length > 0) { - await datastore.batchDel(allKeys); - console.log("[iCalendar] Deleted", allKeys.length, "entries"); - } - + if (allKeys.length > 0) await datastore.batchDel(allKeys); await clientStore.del(CACHE_KEY); - - console.log("[iCalendar] Calendar index and cache cleared"); - await editor.flashNotification("Calendar index and cache cleared", "info"); + await editor.flashNotification("Calendar index cleared", "info"); } catch (err) { console.error("[iCalendar] Failed to clear cache:", err); - await editor.flashNotification( - `Failed to clear cache: ${err instanceof Error ? err.message : String(err)}`, - "error" - ); } } -/** - * Shows the plugin version - */ export async function showVersion() { await editor.flashNotification(`iCalendar Plug ${VERSION}`, "info"); -} +} \ No newline at end of file