forked from GitHubMirrors/silverbullet-icalendar
switch to ical.js - v4.1
All checks were successful
Build SilverBullet Plug / build (push) Successful in 1m1s
All checks were successful
Build SilverBullet Plug / build (push) Successful in 1m1s
This commit is contained in:
2
PLUG.md
2
PLUG.md
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: Library/sstent/icalendar
|
name: Library/sstent/icalendar
|
||||||
version: "0.4.0"
|
version: "0.4.1"
|
||||||
tags: meta/library
|
tags: meta/library
|
||||||
files:
|
files:
|
||||||
- icalendar.plug.js
|
- icalendar.plug.js
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "icalendar-plug",
|
"name": "icalendar-plug",
|
||||||
"version": "0.4.0",
|
"version": "0.4.1",
|
||||||
"nodeModulesDir": "auto",
|
"nodeModulesDir": "auto",
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"sync-version": "deno run -A scripts/sync-version.ts",
|
"sync-version": "deno run -A scripts/sync-version.ts",
|
||||||
@@ -27,4 +27,4 @@
|
|||||||
"ical.js": "https://esm.sh/ical.js@2.0.1",
|
"ical.js": "https://esm.sh/ical.js@2.0.1",
|
||||||
"rrule": "https://esm.sh/rrule@2.8.1"
|
"rrule": "https://esm.sh/rrule@2.8.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: icalendar
|
name: icalendar
|
||||||
version: 0.4.0
|
version: 0.4.1
|
||||||
author: sstent
|
author: sstent
|
||||||
index: icalendar.ts
|
index: icalendar.ts
|
||||||
# Legacy SilverBullet permission name
|
# Legacy SilverBullet permission name
|
||||||
|
|||||||
506
icalendar.ts
506
icalendar.ts
@@ -1,53 +1,61 @@
|
|||||||
import { clientStore, config, datastore, editor, index } from "@silverbulletmd/silverbullet/syscalls";
|
import { clientStore, config, datastore, editor, index } from "@silverbulletmd/silverbullet/syscalls";
|
||||||
import ICAL from "ical.js";
|
import { convertIcsCalendar } from "https://esm.sh/ts-ics@2.4.0";
|
||||||
import { RRule, RRuleSet } from "rrule";
|
import { RRule, RRuleSet } from "rrule";
|
||||||
|
import { getUtcOffsetMs, resolveIanaName } from "./timezones.ts";
|
||||||
|
|
||||||
const VERSION = "0.4.0";
|
const VERSION = "0.4.1";
|
||||||
const CACHE_KEY = "icalendar:lastSync";
|
const CACHE_KEY = "icalendar:lastSync";
|
||||||
|
|
||||||
console.log(`[iCalendar] Plug script executing at top level (Version ${VERSION})`);
|
console.log(`[iCalendar] Plug script executing at top level (Version ${VERSION})`);
|
||||||
|
|
||||||
// ============================================================================
|
/**
|
||||||
// Types
|
* Mapping of verbose RRULE object keys to standard iCalendar shortened keys.
|
||||||
// ============================================================================
|
*/
|
||||||
|
const RRULE_KEY_MAP: Record<string, string> = {
|
||||||
|
"frequency": "FREQ",
|
||||||
|
"until": "UNTIL",
|
||||||
|
"count": "COUNT",
|
||||||
|
"interval": "INTERVAL",
|
||||||
|
"bysecond": "BYSECOND",
|
||||||
|
"byminute": "BYMINUTE",
|
||||||
|
"byhour": "BYHOUR",
|
||||||
|
"byday": "BYDAY",
|
||||||
|
"bymonthday": "BYMONTHDAY",
|
||||||
|
"byyearday": "BYYEARDAY",
|
||||||
|
"byweekno": "BYWEEKNO",
|
||||||
|
"bymonth": "BYMONTH",
|
||||||
|
"bysetpos": "BYSETPOS",
|
||||||
|
"wkst": "WKST",
|
||||||
|
"workweekstart": "WKST",
|
||||||
|
"freq": "FREQ",
|
||||||
|
};
|
||||||
|
|
||||||
interface CalendarEvent {
|
/**
|
||||||
ref: string;
|
* Robustly formats an RRULE value for its string representation.
|
||||||
tag: "ical-event";
|
*/
|
||||||
sourceName: string;
|
function formatRRuleValue(v: any): string {
|
||||||
|
if (Array.isArray(v)) {
|
||||||
// Core properties
|
return v.map((item) => formatRRuleValue(item)).join(",");
|
||||||
uid: string;
|
}
|
||||||
summary: string;
|
if (v instanceof Date) {
|
||||||
description?: string;
|
return v.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
|
||||||
location?: string;
|
}
|
||||||
|
if (typeof v === "object" && v !== null) {
|
||||||
// Dates - stored as ISO strings to preserve timezone
|
const val = v.date || v.day || v.value;
|
||||||
start: string;
|
if (val !== undefined) {
|
||||||
startLocal: string; // For queries: local representation
|
return formatRRuleValue(val);
|
||||||
end?: string;
|
}
|
||||||
|
}
|
||||||
// Timezone info
|
return String(v);
|
||||||
timezone: string;
|
|
||||||
isAllDay: boolean;
|
|
||||||
|
|
||||||
// Recurrence
|
|
||||||
isRecurring: boolean;
|
|
||||||
rrule?: string;
|
|
||||||
recurrenceId?: string; // For individual occurrences
|
|
||||||
|
|
||||||
// Status
|
|
||||||
status?: string;
|
|
||||||
|
|
||||||
// People
|
|
||||||
organizer?: string;
|
|
||||||
attendees?: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Utility Functions
|
// Utility Functions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a SHA-256 hash of a string (hex encoded)
|
||||||
|
*/
|
||||||
async function sha256Hash(str: string): Promise<string> {
|
async function sha256Hash(str: string): Promise<string> {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const data = encoder.encode(str);
|
const data = encoder.encode(str);
|
||||||
@@ -56,55 +64,69 @@ async function sha256Hash(str: string): Promise<string> {
|
|||||||
return hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
|
return hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function localDateString(date: Date): string {
|
/**
|
||||||
|
* Converts Date to local time string (browser's timezone)
|
||||||
|
*/
|
||||||
|
export function localDateString(date: Date): string {
|
||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
return date.getFullYear() + "-" +
|
return date.getFullYear() + "-" +
|
||||||
pad(date.getMonth() + 1) + "-" +
|
pad(date.getMonth() + 1) + "-" +
|
||||||
pad(date.getDate()) + "T" +
|
pad(date.getDate()) + "T" +
|
||||||
pad(date.getHours()) + ":" +
|
pad(date.getHours()) + ":" +
|
||||||
pad(date.getMinutes()) + ":" +
|
pad(date.getMinutes()) + ":" +
|
||||||
pad(date.getSeconds());
|
pad(date.getSeconds());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts ICAL.Time to ISO string and local string
|
* Recursively converts all Date objects and ISO date strings to strings
|
||||||
*/
|
*/
|
||||||
function parseIcalTime(time: ICAL.Time): { iso: string; local: string; timezone: string; isAllDay: boolean } {
|
function convertDatesToStrings<T>(obj: T): any {
|
||||||
const isAllDay = time.isDate;
|
if (obj === null || obj === undefined) {
|
||||||
|
return obj;
|
||||||
if (isAllDay) {
|
|
||||||
// Date-only events
|
|
||||||
const dateStr = time.toJSDate().toISOString().split('T')[0];
|
|
||||||
return {
|
|
||||||
iso: dateStr,
|
|
||||||
local: dateStr,
|
|
||||||
timezone: "date",
|
|
||||||
isAllDay: true
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get timezone
|
if (obj instanceof Date) {
|
||||||
const timezone = time.zone?.tzid || "UTC";
|
return obj.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof obj === 'object' && 'date' in obj && (obj as any).date instanceof Date) {
|
||||||
|
return (obj as any).date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
// ISO representation (UTC)
|
if (typeof obj === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(obj)) {
|
||||||
const jsDate = time.toJSDate();
|
try {
|
||||||
const iso = jsDate.toISOString();
|
return new Date(obj).toISOString();
|
||||||
|
} catch {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Local representation for querying
|
if (Array.isArray(obj)) {
|
||||||
const local = localDateString(jsDate);
|
return obj.map(item => convertDatesToStrings(item));
|
||||||
|
}
|
||||||
|
|
||||||
return { iso, local, timezone, isAllDay: false };
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Configuration
|
// Configuration Functions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
async function getSources(): Promise<{ sources: any[], syncWindowDays: number }> {
|
async function getSources(): Promise<{ sources: any[], syncWindowDays: number }> {
|
||||||
try {
|
try {
|
||||||
const rawConfig = await config.get("icalendar", { sources: [] }) as any;
|
const rawConfig = await config.get("icalendar", { sources: [] }) as any;
|
||||||
console.log("[iCalendar] Raw config retrieved:", JSON.stringify(rawConfig));
|
console.log("[iCalendar] Raw config retrieved:", JSON.stringify(rawConfig));
|
||||||
|
|
||||||
let sources = rawConfig.sources || [];
|
let sources = rawConfig.sources || [];
|
||||||
const syncWindowDays = rawConfig.syncWindowDays || 365;
|
const syncWindowDays = rawConfig.syncWindowDays || 365;
|
||||||
|
|
||||||
@@ -126,245 +148,235 @@ async function getSources(): Promise<{ sources: any[], syncWindowDays: number }>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Recurrence Expansion (keeping your logic)
|
// Calendar Fetching & Parsing
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expands recurring events using rrule
|
* Resolves the event start as a UTC Date object using DST-aware resolution.
|
||||||
* Keeps your no-lookback-limit approach
|
|
||||||
*/
|
*/
|
||||||
function expandRecurrences(
|
export async function resolveEventStart(icsEvent: any): Promise<Date | null> {
|
||||||
event: CalendarEvent,
|
const obj = icsEvent.start;
|
||||||
startDate: Date,
|
if (!obj) return null;
|
||||||
windowDays: number,
|
|
||||||
now = new Date()
|
// 1. Extract the wall-clock local datetime string
|
||||||
): CalendarEvent[] {
|
let wallClock: string | null = null;
|
||||||
if (!event.rrule || !event.isRecurring) {
|
if (obj.local?.date) {
|
||||||
return [event];
|
const d = obj.local.date;
|
||||||
|
wallClock = d instanceof Date ? d.toISOString() : String(d);
|
||||||
|
} else if (obj.date) {
|
||||||
|
const d = obj.date;
|
||||||
|
wallClock = d instanceof Date ? d.toISOString() : String(d);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!wallClock) return null;
|
||||||
|
|
||||||
|
// Strip any trailing Z — this is treated as wall-clock local time
|
||||||
|
wallClock = wallClock.replace(/Z$/, "");
|
||||||
|
|
||||||
|
// 2. Resolve IANA timezone
|
||||||
|
const rawTz = obj.local?.timezone || (obj as any).timezone || "UTC";
|
||||||
|
const ianaName = resolveIanaName(rawTz);
|
||||||
|
|
||||||
|
if (!ianaName) {
|
||||||
|
console.warn(`[iCalendar] Unknown timezone: "${rawTz}" - falling back to UTC for event "${icsEvent.summary}"`);
|
||||||
|
const utcDate = new Date(wallClock + (wallClock.includes("T") ? "" : "T00:00:00") + "Z");
|
||||||
|
if (isNaN(utcDate.getTime())) return null;
|
||||||
|
return utcDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Parse the wall-clock time as a UTC instant (no offset yet)
|
||||||
|
const wallClockAsUtc = new Date(wallClock + (wallClock.includes("T") ? "" : "T00:00:00") + "Z");
|
||||||
|
if (isNaN(wallClockAsUtc.getTime())) return null;
|
||||||
|
|
||||||
|
// 4. Get the DST-aware offset for this IANA zone at this instant
|
||||||
|
const offsetMs = getUtcOffsetMs(ianaName, wallClockAsUtc);
|
||||||
|
|
||||||
|
// 5. Convert: UTC = wall-clock - offset
|
||||||
|
return new Date(wallClockAsUtc.getTime() - offsetMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves event end time
|
||||||
|
*/
|
||||||
|
async function resolveEventEnd(icsEvent: any): Promise<Date | null> {
|
||||||
|
if (!icsEvent.end) return null;
|
||||||
|
|
||||||
|
// Create a temporary event object with end as start
|
||||||
|
const tempEvent = {
|
||||||
|
...icsEvent,
|
||||||
|
start: icsEvent.end
|
||||||
|
};
|
||||||
|
|
||||||
|
return await resolveEventStart(tempEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expands recurring events into individual occurrences.
|
||||||
|
*/
|
||||||
|
export function expandRecurrences(icsEvent: any, windowDays = 365, now = new Date()): any[] {
|
||||||
|
const rruleStr = icsEvent.rrule || (icsEvent as any).recurrenceRule;
|
||||||
|
if (!rruleStr) return [icsEvent];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const set = new RRuleSet();
|
const set = new RRuleSet();
|
||||||
|
|
||||||
|
let cleanRule = "";
|
||||||
|
if (typeof rruleStr === "string") {
|
||||||
|
cleanRule = rruleStr.replace(/^RRULE:/i, "");
|
||||||
|
} else if (typeof rruleStr === "object" && rruleStr !== null) {
|
||||||
|
cleanRule = Object.entries(rruleStr)
|
||||||
|
.map(([k, v]) => {
|
||||||
|
const standardKey = RRULE_KEY_MAP[k.toLowerCase()] || k.toUpperCase();
|
||||||
|
return `${standardKey}=${formatRRuleValue(v)}`;
|
||||||
|
})
|
||||||
|
.join(";");
|
||||||
|
} else {
|
||||||
|
console.warn(`[iCalendar] Invalid rrule type (${typeof rruleStr}) for event "${icsEvent.summary || "Untitled"}". Treating as non-recurring.`);
|
||||||
|
return [icsEvent];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the stored UTC time (don't add Z, it's already there)
|
||||||
|
const dtstart = new Date(icsEvent.start);
|
||||||
|
if (isNaN(dtstart.getTime())) {
|
||||||
|
console.error(`[iCalendar] Invalid start date for recurrence: ${icsEvent.start}`);
|
||||||
|
return [icsEvent];
|
||||||
|
}
|
||||||
|
|
||||||
// Parse the RRULE string
|
|
||||||
const cleanRule = event.rrule.replace(/^RRULE:/i, "");
|
|
||||||
const ruleOptions = RRule.parseString(cleanRule);
|
const ruleOptions = RRule.parseString(cleanRule);
|
||||||
ruleOptions.dtstart = startDate;
|
ruleOptions.dtstart = dtstart;
|
||||||
|
|
||||||
set.rrule(new RRule(ruleOptions));
|
set.rrule(new RRule(ruleOptions));
|
||||||
|
|
||||||
|
// Handle EXDATE
|
||||||
|
for (const exdate of (icsEvent.exdate || [])) {
|
||||||
|
set.exdate(new Date(exdate));
|
||||||
|
}
|
||||||
|
|
||||||
const windowEnd = new Date(now.getTime() + windowDays * 86400000);
|
const windowEnd = new Date(now.getTime() + windowDays * 86400000);
|
||||||
|
|
||||||
// Expand from event start (no lookback limit)
|
// Expand from the event's actual start date up to the window end
|
||||||
const occurrences = set.between(startDate, windowEnd, true);
|
const occurrences = set.between(dtstart, windowEnd, true);
|
||||||
|
|
||||||
return occurrences.map((occurrenceDate, idx) => ({
|
// Calculate duration for recurring events
|
||||||
...event,
|
const duration = icsEvent.end ?
|
||||||
start: occurrenceDate.toISOString(),
|
new Date(icsEvent.end).getTime() - dtstart.getTime() :
|
||||||
startLocal: localDateString(occurrenceDate),
|
0;
|
||||||
recurrenceId: occurrenceDate.toISOString(),
|
|
||||||
rrule: undefined, // Remove rrule from occurrences
|
const mapped = occurrences.map(occurrenceDate => {
|
||||||
}));
|
const endDate = duration > 0 ? new Date(occurrenceDate.getTime() + duration) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...icsEvent,
|
||||||
|
start: occurrenceDate.toISOString(),
|
||||||
|
startLocal: localDateString(occurrenceDate),
|
||||||
|
end: endDate ? endDate.toISOString() : undefined,
|
||||||
|
endLocal: endDate ? localDateString(endDate) : undefined,
|
||||||
|
recurrent: true,
|
||||||
|
rrule: undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapped;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[iCalendar] Error expanding recurrence for ${event.summary}:`, err);
|
console.error(`[iCalendar] Error expanding recurrence for ${icsEvent.summary}:`, err);
|
||||||
return [event];
|
return [icsEvent];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
async function fetchAndParseCalendar(source: any, windowDays = 365): Promise<any[]> {
|
||||||
// Calendar Parsing with ical.js
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
async function fetchAndParseCalendar(
|
|
||||||
source: any,
|
|
||||||
windowDays: number
|
|
||||||
): Promise<CalendarEvent[]> {
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(source.url);
|
const response = await fetch(source.url);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error(`[iCalendar] Fetch failed for ${source.name}: ${response.status}`);
|
console.error(`[iCalendar] Fetch failed for ${source.name}: ${response.status} ${response.statusText}`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
const text = await response.text();
|
||||||
const icsText = await response.text();
|
const calendar = convertIcsCalendar(undefined, text);
|
||||||
|
if (!calendar || !calendar.events) {
|
||||||
// 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 [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const events: any[] = [];
|
||||||
|
for (const icsEvent of calendar.events) {
|
||||||
|
if (icsEvent.status?.toUpperCase() === "CANCELLED") continue;
|
||||||
|
|
||||||
const events: CalendarEvent[] = [];
|
// Resolve start time (returns UTC Date)
|
||||||
|
const startDateUTC = await resolveEventStart(icsEvent);
|
||||||
|
if (!startDateUTC) continue;
|
||||||
|
|
||||||
|
// Resolve end time (returns UTC Date)
|
||||||
|
const endDateUTC = await resolveEventEnd(icsEvent);
|
||||||
|
|
||||||
|
const rawTz = icsEvent.start?.local?.timezone || (icsEvent.start as any)?.timezone || "UTC";
|
||||||
|
|
||||||
|
const baseEvent = {
|
||||||
|
...icsEvent,
|
||||||
|
name: icsEvent.summary || "Untitled Event",
|
||||||
|
// Store both UTC (for sorting/comparison) and local (for display)
|
||||||
|
start: startDateUTC.toISOString(),
|
||||||
|
startLocal: localDateString(startDateUTC),
|
||||||
|
end: endDateUTC ? endDateUTC.toISOString() : undefined,
|
||||||
|
endLocal: endDateUTC ? localDateString(endDateUTC) : undefined,
|
||||||
|
tag: "ical-event",
|
||||||
|
sourceName: source.name,
|
||||||
|
timezone: rawTz
|
||||||
|
};
|
||||||
|
|
||||||
for (const vevent of vevents) {
|
if (rawTz !== "UTC" && rawTz !== "None" && !resolveIanaName(rawTz)) {
|
||||||
try {
|
baseEvent.description = `(Warning: Unknown timezone "${rawTz}") ${baseEvent.description || ""}`;
|
||||||
const event = new ICAL.Event(vevent);
|
}
|
||||||
|
|
||||||
// Skip cancelled events
|
const expanded = expandRecurrences(baseEvent, windowDays);
|
||||||
const status = vevent.getFirstPropertyValue('status');
|
for (const occurrence of expanded) {
|
||||||
if (status === 'CANCELLED') continue;
|
// Use summary in key to avoid collisions
|
||||||
|
const uniqueKey = `${occurrence.start}${occurrence.uid || ''}${occurrence.summary || ''}`;
|
||||||
// Parse start time
|
occurrence.ref = await sha256Hash(uniqueKey);
|
||||||
const startTime = event.startDate;
|
events.push(convertDatesToStrings(occurrence));
|
||||||
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;
|
return events;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(`[iCalendar] Error fetching/parsing ${source.name}:`,
|
console.error(`[iCalendar] Error fetching/parsing ${source.name}:`, err.message || err, err.stack || "");
|
||||||
err.message || err, err.stack || "");
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Exported Commands
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export async function syncCalendars() {
|
export async function syncCalendars() {
|
||||||
try {
|
try {
|
||||||
const { sources, syncWindowDays } = await getSources();
|
const { sources, syncWindowDays } = await getSources();
|
||||||
if (sources.length === 0) return;
|
if (sources.length === 0) return;
|
||||||
|
|
||||||
await editor.flashNotification("Syncing calendars...", "info");
|
await editor.flashNotification("Syncing calendars...", "info");
|
||||||
|
const allEvents: any[] = [];
|
||||||
const allEvents: CalendarEvent[] = [];
|
|
||||||
let successCount = 0;
|
|
||||||
|
|
||||||
for (const source of sources) {
|
for (const source of sources) {
|
||||||
const events = await fetchAndParseCalendar(source, syncWindowDays);
|
const events = await fetchAndParseCalendar(source, syncWindowDays);
|
||||||
allEvents.push(...events);
|
allEvents.push(...events);
|
||||||
if (events.length > 0) successCount++;
|
|
||||||
console.log(`[iCalendar] Synced ${events.length} events from "${source.name}"`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await index.indexObjects("$icalendar", allEvents);
|
await index.indexObjects("$icalendar", allEvents);
|
||||||
await clientStore.set(CACHE_KEY, Date.now());
|
await editor.flashNotification(`Synced ${allEvents.length} events`, "info");
|
||||||
|
|
||||||
const summary = `Synced ${allEvents.length} events from ${successCount}/${sources.length} source(s)`;
|
|
||||||
console.log(`[iCalendar] ${summary}`);
|
|
||||||
await editor.flashNotification(summary, "info");
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[iCalendar] syncCalendars failed:", err);
|
console.error("[iCalendar] syncCalendars failed:", err);
|
||||||
await editor.flashNotification("Failed to sync calendars", "error");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function forceSync() {
|
export async function forceSync() {
|
||||||
await clientStore.del(CACHE_KEY);
|
await clientStore.del(CACHE_KEY);
|
||||||
console.log("[iCalendar] Forcing fresh sync");
|
|
||||||
await editor.flashNotification("Forcing fresh calendar sync...", "info");
|
|
||||||
await syncCalendars();
|
await syncCalendars();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearCache() {
|
export async function clearCache() {
|
||||||
if (!await editor.confirm("Clear all calendar events and cache?")) {
|
if (!await editor.confirm("Clear all calendar events?")) return;
|
||||||
return;
|
const pageKeys = await datastore.query({ prefix: ["ridx", "$icalendar"] });
|
||||||
}
|
const allKeys: any[] = [];
|
||||||
|
for (const { key } of pageKeys) {
|
||||||
try {
|
allKeys.push(key);
|
||||||
const pageKeys = await datastore.query({
|
allKeys.push(["idx", ...key.slice(2), "$icalendar"]);
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
|
if (allKeys.length > 0) await datastore.batchDel(allKeys);
|
||||||
|
await clientStore.del(CACHE_KEY);
|
||||||
|
await editor.flashNotification("Calendar index cleared", "info");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function showVersion() {
|
export async function showVersion() {
|
||||||
await editor.flashNotification(`iCalendar Plug ${VERSION}`, "info");
|
await editor.flashNotification(`iCalendar Plug ${VERSION}`, "info");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user