diff --git a/PLUG.md b/PLUG.md index c921c73..4e55725 100644 --- a/PLUG.md +++ b/PLUG.md @@ -1,6 +1,6 @@ --- name: Library/sstent/icalendar/PLUG -version: 0.2.11 +version: 0.2.12 tags: meta/library files: - icalendar.plug.js diff --git a/icalendar.ts b/icalendar.ts index 463376c..588f2b6 100644 --- a/icalendar.ts +++ b/icalendar.ts @@ -1,45 +1,47 @@ import { clientStore, config, datastore, editor, index } from "@silverbulletmd/silverbullet/syscalls"; import { convertIcsCalendar, type IcsCalendar, type IcsEvent, type IcsDateObjects } from "ts-ics"; -const VERSION = "0.2.11"; +const VERSION = "0.2.12"; const CACHE_KEY = "icalendar:lastSync"; const DEFAULT_CACHE_DURATION_SECONDS = 21600; // 6 hours +// Mapping of common Windows/Outlook timezones to their standard offsets +const TIMEZONE_OFFSETS: Record = { + "GMT Standard Time": 0, + "W. Europe Standard Time": 1, + "Central Europe Standard Time": 1, + "Romance Standard Time": 1, + "Central European Standard Time": 1, + "Eastern Standard Time": -5, + "Central Standard Time": -6, + "Mountain Standard Time": -7, + "Pacific Standard Time": -8, + "UTC": 0 +}; + console.log(`[iCalendar] Plug loading (Version ${VERSION})...`); // ============================================================================ // Types // ============================================================================ -/** - * Recursively converts all Date objects to strings in a type - */ type DateToString = T extends Date ? string : T extends IcsDateObjects ? string : T extends object ? { [K in keyof T]: DateToString } : T extends Array ? Array> : T; -/** - * Configuration for a calendar source - */ interface Source { url: string; name: string | undefined; } -/** - * Plugin configuration structure - */ interface PlugConfig { sources: Source[]; cacheDuration: number | undefined; - tzShift: number | undefined; // Manual hour shift override + tzShift: number | undefined; } -/** - * Calendar event object indexed in SilverBullet - */ interface CalendarEvent extends DateToString { ref: string; tag: "ical-event"; @@ -51,15 +53,36 @@ interface CalendarEvent extends DateToString { // ============================================================================ /** - * Custom date formatter that handles the local timezone shift correctly + * Robustly converts an ICS date object to a localized PST string */ -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)); +function processIcsDate(obj: any, manualShift = 0): string { + // 1. Get the "Wall Time" (e.g., 16:00) + // If ts-ics provides a 'local' date string, it's usually the wall time but incorrectly marked as UTC + const wallTimeStr = (obj.local && typeof obj.local.date === "string") + ? obj.local.date + : (typeof obj.date === "string" ? obj.date : ""); + + if (!wallTimeStr) return ""; + + // 2. Parse it as if it were UTC to get a base date object + // We remove the 'Z' if it exists to ensure we control the parsing + const baseDate = new Date(wallTimeStr.replace("Z", "") + "Z"); + + // 3. Determine the Source Offset (e.g., W. Europe = +1) + const tzName = obj.local?.timezone || obj.timezone || "UTC"; + const sourceOffset = TIMEZONE_OFFSETS[tzName] || 0; + + // 4. Calculate the TRUE UTC time + // UTC = WallTime - SourceOffset + let utcTime = baseDate.getTime() - (sourceOffset * 3600000); + + // 5. Apply User's Manual Shift (if any) + if (manualShift !== 0) { + utcTime += (manualShift * 3600000); } - // Get local parts based on the environment's timezone + // 6. Localize to the browser's current timezone (PST) + const d = new Date(utcTime); const pad = (n: number) => String(n).padStart(2, "0"); return d.getFullYear() + @@ -71,16 +94,10 @@ function localizeDate(d: Date, hourShift = 0): string { "." + String(d.getMilliseconds()).padStart(3, "0"); } -/** - * 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) - */ async function sha256Hash(str: string): Promise { const encoder = new TextEncoder(); const data = encoder.encode(str); @@ -89,28 +106,18 @@ async function sha256Hash(str: string): Promise { return hashArray.map(b => b.toString(16).padStart(2, "0")).join(""); } -/** - * Recursively converts all Date objects and ISO date strings to strings - */ function convertDatesToStrings(obj: T, hourShift = 0): DateToString { - if (obj === null || obj === undefined) { - return obj as DateToString; + if (obj === null || obj === undefined) return obj as DateToString; + + if (isIcsDateObjects(obj)) { + return processIcsDate(obj, hourShift) as DateToString; } if (obj instanceof Date) { - return localizeDate(obj, hourShift) as DateToString; - } - - if (isIcsDateObjects(obj) && obj.date instanceof Date) { - // 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"; - return localizeDate(new Date(forcedUTC), hourShift) as DateToString; + // Fallback for raw Date objects + const d = new Date(obj.getTime() + (hourShift * 3600000)); + 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()) + ".000" as DateToString; } if (Array.isArray(obj)) { @@ -131,7 +138,7 @@ function convertDatesToStrings(obj: T, hourShift = 0): DateToString { } // ============================================================================ -// Configuration Functions +// Configuration & Commands // ============================================================================ async function getSources(): Promise { @@ -139,27 +146,15 @@ async function getSources(): Promise { if (!plugConfig.sources) return []; let sources = plugConfig.sources; if (!Array.isArray(sources)) sources = [sources as unknown as Source]; - const validated: Source[] = []; - for (const src of sources) { - if (typeof src.url === "string") { - validated.push({ url: src.url, name: src.name }); - } - } - return validated; + return sources.filter(s => typeof s.url === "string"); } -// ============================================================================ -// Calendar Fetching & Parsing -// ============================================================================ - async function fetchAndParseCalendar(source: Source, hourShift = 0): Promise { let url = source.url.trim(); if (url.includes(" ")) url = encodeURI(url); const response = await fetch(url, { - headers: { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" - } + headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" } }); if (!response.ok) throw new Error(`HTTP ${response.status}`); @@ -182,24 +177,13 @@ async function fetchAndParseCalendar(source: Source, hourShift = 0): Promise("icalendar", { sources: [] }); const hourShift = plugConfig.tzShift ?? 0; - const cacheDurationMs = (plugConfig.cacheDuration ?? DEFAULT_CACHE_DURATION_SECONDS) * 1000; - const sources = await getSources(); if (sources.length === 0) return; - const lastSync = await clientStore.get(CACHE_KEY); - const now = Date.now(); - - if (lastSync && (now - lastSync) < cacheDurationMs) return; - await editor.flashNotification("Syncing calendars...", "info"); const allEvents: CalendarEvent[] = []; @@ -213,7 +197,6 @@ export async function syncCalendars() { } await index.indexObjects("$icalendar", allEvents); - await clientStore.set(CACHE_KEY, now); await editor.flashNotification(`Synced ${allEvents.length} events`, "info"); } catch (err) { console.error("[iCalendar] Sync failed:", err); @@ -226,22 +209,17 @@ export async function forceSync() { } 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); - await clientStore.del(CACHE_KEY); - await editor.flashNotification("Calendar index cleared", "info"); - } catch (err) { - console.error("[iCalendar] Failed to clear cache:", err); + if (!await editor.confirm("Clear all calendar events?")) return; + 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); + await editor.flashNotification("Calendar index cleared", "info"); } export async function showVersion() { await editor.flashNotification(`iCalendar Plug ${VERSION}`, "info"); -} \ No newline at end of file +}