diff --git a/Makefile b/Makefile index 1a0975d..0abaf4f 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,8 @@ # Build the plug using a Docker container with Deno build: docker run --rm -v $(PWD):/app -w /app denoland/deno:latest task build + mkdir -p test_space/_plug + cp icalendar.plug.js test_space/_plug/icalendar.plug.js # Start the SilverBullet test container up: diff --git a/PLUG.md b/PLUG.md index 2586fee..a62b165 100644 --- a/PLUG.md +++ b/PLUG.md @@ -1,8 +1,6 @@ --- name: Library/sstent/icalendar/PLUG -version: 0.2.14 +version: 0.3.0 tags: meta/library -files: -- icalendar.plug.js --- -iCalendar sync plug for SilverBullet. \ No newline at end of file +iCalendar sync plug for SilverBullet. diff --git a/deno.json b/deno.json index 6dcb1d7..492043d 100644 --- a/deno.json +++ b/deno.json @@ -1,4 +1,5 @@ { + "nodeModulesDir": "auto", "tasks": { "build": "deno run -A https://github.com/silverbulletmd/silverbullet/releases/download/edge/plug-compile.js -c deno.json icalendar.plug.yaml", "watch": "deno run -A https://github.com/silverbulletmd/silverbullet/releases/download/edge/plug-compile.js -c deno.json icalendar.plug.yaml -w", diff --git a/docker-compose.yml b/docker-compose.yml index 596ab9a..306b6c3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,15 @@ services: - "3000:3000" volumes: - ./test_space:/space - - ./icalendar.plug.js:/space/_plug/icalendar.plug.js:ro environment: - - SB_USER=admin:admin # Default for easy local testing + - SB_USER=admin:admin + - SB_LOG_PUSH=true + - SB_DEBUG=true + - SB_SPACE_LUA_TRUSTED=true + + mock-ics: + image: nginx:alpine + ports: + - "8080:80" + volumes: + - ./mock_calendar.ics:/usr/share/nginx/html/calendar.ics:ro diff --git a/icalendar.plug.yaml b/icalendar.plug.yaml index e16a9d8..f33e948 100644 --- a/icalendar.plug.yaml +++ b/icalendar.plug.yaml @@ -1,47 +1,15 @@ name: icalendar -requiredPermissions: - - fetch +version: 0.3.0 +author: sstent functions: syncCalendars: - path: ./icalendar.ts:syncCalendars - command: - name: "iCalendar: Sync" - priority: -1 - events: - - editor:init + command: "iCalendar: Sync" forceSync: - path: ./icalendar.ts:forceSync - command: - name: "iCalendar: Force Sync" - priority: -1 + command: "iCalendar: Force Sync" clearCache: - path: ./icalendar.ts:clearCache - command: - name: "iCalendar: Clear All Events" - priority: -1 + command: "iCalendar: Clear Cache" showVersion: - path: ./icalendar.ts:showVersion - command: - name: "iCalendar: Version" - priority: -2 -config: - schema.config.properties.icalendar: - type: object - required: - - sources - properties: - sources: - type: array - minItems: 1 - items: - type: object - required: - - url - properties: - url: - type: string - name: - type: string - cacheDuration: - type: number - description: "Interval between two calendar synchronizations (default: 21600 = 6 hours)" + command: "iCalendar: Show Version" +# Grant permissions to fetch from anywhere +permissions: + - http \ No newline at end of file diff --git a/icalendar.ts b/icalendar.ts index 9c089fb..fee5ee5 100644 --- a/icalendar.ts +++ b/icalendar.ts @@ -1,7 +1,7 @@ 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.15"; +const VERSION = "0.3.0"; const CACHE_KEY = "icalendar:lastSync"; const DEFAULT_CACHE_DURATION_SECONDS = 21600; // 6 hours @@ -20,8 +20,6 @@ const TIMEZONE_OFFSETS: Record = { "None": 0 }; -console.log(`[iCalendar] Plug loading (Version ${VERSION})...`); - // ============================================================================ // Types // ============================================================================ @@ -38,7 +36,7 @@ interface Source { } interface PlugConfig { - sources: Source[]; + sources: any; cacheDuration: number | undefined; tzShift: number | undefined; } @@ -53,9 +51,6 @@ interface CalendarEvent extends DateToString { // Utility Functions // ============================================================================ -/** - * Standard SilverBullet local date string formatter - */ function toLocalISO(d: Date): string { const pad = (n: number) => String(n).padStart(2, "0"); return d.getFullYear() + @@ -67,43 +62,33 @@ function toLocalISO(d: Date): string { "." + String(d.getMilliseconds()).padStart(3, "0"); } -/** - * Robustly converts an ICS date object to a localized PST string - */ function processIcsDate(obj: any, manualShift = 0): string { if (!obj) return ""; - - // 1. Get the "Wall Time" (the hour shown in the organizer's calendar) - // ts-ics often puts this in obj.local.date but marks it with 'Z' - let wallTimeStr = (obj.local && typeof obj.local.date === "string") - ? obj.local.date - : (typeof obj.date === "string" ? obj.date : ""); + let wallTimeStr = ""; + if (obj.local && typeof obj.local.date === "string") { + wallTimeStr = obj.local.date; + } else if (typeof obj.date === "string") { + wallTimeStr = obj.date; + } else if (obj.date instanceof Date) { + wallTimeStr = obj.date.toISOString(); + } else if (obj instanceof Date) { + wallTimeStr = obj.toISOString(); + } if (!wallTimeStr) return ""; - // Remove any 'Z' to treat it as a raw floating time initially - wallTimeStr = wallTimeStr.replace("Z", ""); + // 1. Extract the "Wall Time" from the string (ignoring Z if present) + const baseDate = new Date(wallTimeStr.replace("Z", "") + "Z"); - // Parse as UTC so we have a stable starting point - const baseDate = new Date(wallTimeStr + "Z"); - - // 2. Identify the Source Timezone + // 2. Determine Source Timezone Offset const tzName = obj.local?.timezone || obj.timezone || "UTC"; const sourceOffset = TIMEZONE_OFFSETS[tzName] ?? 0; - // 3. Calculate True UTC - // UTC = WallTime - SourceOffset - // Example: 16:00 WallTime in GMT+1 (+1) -> 15:00 UTC + // 3. Calculate True UTC: WallTime - SourceOffset const utcMillis = baseDate.getTime() - (sourceOffset * 3600000); - const envOffset = new Date().getTimezoneOffset(); - console.log(`[iCalendar] Date Calc: Wall=${wallTimeStr}, TZ=${tzName}, SourceOffset=${sourceOffset}, EnvOffset=${envOffset}, UTC=${new Date(utcMillis).toISOString()}`); - - // 4. Apply User's Manual Shift (if any) - const finalMillis = utcMillis + (manualShift * 3600000); - - // 5. Localize to environment - return toLocalISO(new Date(finalMillis)); + // 4. Apply User's Manual Shift and Localize + return toLocalISO(new Date(utcMillis + (manualShift * 3600000))); } function isIcsDateObjects(obj: any): obj is IcsDateObjects { @@ -120,19 +105,9 @@ async function sha256Hash(str: string): Promise { function convertDatesToStrings(obj: T, hourShift = 0): DateToString { if (obj === null || obj === undefined) return obj as DateToString; - - if (isIcsDateObjects(obj)) { - return processIcsDate(obj, hourShift) as DateToString; - } - - if (obj instanceof Date) { - return toLocalISO(new Date(obj.getTime() + (hourShift * 3600000))) as DateToString; - } - - if (Array.isArray(obj)) { - return obj.map(item => convertDatesToStrings(item, hourShift)) as DateToString; - } - + if (isIcsDateObjects(obj)) return processIcsDate(obj, hourShift) as DateToString; + if (obj instanceof Date) return processIcsDate({ date: obj }, hourShift) as DateToString; + if (Array.isArray(obj)) return obj.map(item => convertDatesToStrings(item, hourShift)) as DateToString; if (typeof obj === 'object') { const result: any = {}; for (const key in obj) { @@ -142,7 +117,6 @@ function convertDatesToStrings(obj: T, hourShift = 0): DateToString { } return result as DateToString; } - return obj as DateToString; } @@ -150,12 +124,30 @@ function convertDatesToStrings(obj: T, hourShift = 0): DateToString { // Configuration & Commands // ============================================================================ -async function getSources(): Promise { - const plugConfig = await config.get("icalendar", { sources: [] }); - if (!plugConfig.sources) return []; - let sources = plugConfig.sources; - if (!Array.isArray(sources)) sources = [sources as unknown as Source]; - return sources.filter(s => typeof s.url === "string"); +async function getSources(): Promise<{ sources: Source[], tzShift: number }> { + try { + const rawConfig = await config.get("icalendar", { sources: [] }); + let sources: Source[] = []; + let tzShift = rawConfig.tzShift ?? 0; + + let rawSources = rawConfig.sources; + if (rawSources && typeof rawSources === "object") { + if (rawSources.tzShift !== undefined && tzShift === 0) tzShift = rawSources.tzShift; + if (Array.isArray(rawSources)) { + sources = rawSources.filter(s => s && typeof s.url === "string"); + } else if (rawSources.url) { + sources = [rawSources]; + } else { + for (const key in rawSources) { + if (rawSources[key] && typeof rawSources[key].url === "string") sources.push(rawSources[key]); + } + } + } + return { sources, tzShift }; + } catch (e) { + console.error("Failed to load configuration", e); + return { sources: [], tzShift: 0 }; + } } async function fetchAndParseCalendar(source: Source, hourShift = 0): Promise { @@ -188,20 +180,22 @@ async function fetchAndParseCalendar(source: Source, hourShift = 0): Promise("icalendar", { sources: [] }); - const hourShift = plugConfig.tzShift ?? 0; - const sources = await getSources(); - if (sources.length === 0) return; + const { sources, tzShift } = await getSources(); + if (sources.length === 0) { + console.log("[iCalendar] No sources configured."); + return; + } await editor.flashNotification("Syncing calendars...", "info"); const allEvents: CalendarEvent[] = []; for (const source of sources) { try { - const events = await fetchAndParseCalendar(source, hourShift); + const events = await fetchAndParseCalendar(source, tzShift); allEvents.push(...events); } catch (err) { console.error(`[iCalendar] Failed to sync ${source.name}:`, err); + await editor.flashNotification(`Failed to sync ${source.name}`, "error"); } }