From 97f28e7da4de3b02539a017dc756910b6ffb6a67 Mon Sep 17 00:00:00 2001 From: Quorafind Date: Mon, 5 Jan 2026 10:07:58 +0800 Subject: [PATCH] feat(calendar): add Apple CalDAV two-way sync with write operations - Implement createEvent, updateEvent, deleteEvent methods in AppleCaldavProvider - Add ICS generation for CalDAV PUT requests with proper RFC 5545 escaping - Extract event metadata (href, etag) for optimistic locking and conflict detection - Add syncAppleSource method to CalendarSettingsTab - Mark Google, Outlook, Apple CalDAV providers as "Coming Soon" in source modal perf(kanban): cache CSS custom property tag colors - Prevent layout thrashing from repeated getComputedStyle calls - Clear cache on css-change event (theme switch, snippet toggle) --- src/components/features/kanban/kanban-card.ts | 33 +- src/components/features/kanban/kanban.ts | 8 + .../settings/tabs/CalendarSettingsTab.ts | 68 +- src/providers/apple-caldav-provider.ts | 654 ++++++++++++++++-- 4 files changed, 692 insertions(+), 71 deletions(-) diff --git a/src/components/features/kanban/kanban-card.ts b/src/components/features/kanban/kanban-card.ts index 746d7a2d..70c32401 100644 --- a/src/components/features/kanban/kanban-card.ts +++ b/src/components/features/kanban/kanban-card.ts @@ -7,6 +7,21 @@ import { createTaskCheckbox } from "@/components/features/task/view/details"; import { getEffectiveProject } from "@/utils/task/task-operations"; import { sanitizePriorityForClass } from "@/utils/task/priority-utils"; +/** + * Cache for CSS custom property tag colors to prevent layout thrashing. + * Reading getComputedStyle() in a render loop causes forced synchronous reflow. + * This cache stores resolved tag colors so we only read the DOM once per tag. + */ +const tagColorCache: Map = new Map(); + +/** + * Clears the tag color cache. Should be called when CSS changes + * (e.g., theme switch, CSS snippet toggle) to ensure fresh values. + */ +export function clearTagColorCache(): void { + tagColorCache.clear(); +} + export class KanbanCardComponent extends Component { public element: HTMLElement; private task: Task; @@ -317,14 +332,24 @@ export class KanbanCardComponent extends Component { const color = tagColors[tagName]; tagEl.style.setProperty("--tag-color", color); tagEl.classList.add("colored-tag"); + return; // Color found from plugin, no need to check CSS vars } } // Fallback: check for CSS custom properties set by other tag color plugins - const computedStyle = getComputedStyle(document.body); - const tagColorVar = computedStyle.getPropertyValue( - `--tag-color-${tagName}`, - ); + // Use cache to prevent layout thrashing from repeated getComputedStyle calls + let tagColorVar = tagColorCache.get(tagName); + + if (tagColorVar === undefined) { + // Cache miss: read from DOM once and cache the result + const computedStyle = getComputedStyle(document.body); + const val = computedStyle + .getPropertyValue(`--tag-color-${tagName}`) + .trim(); + tagColorVar = val || null; + tagColorCache.set(tagName, tagColorVar); + } + if (tagColorVar) { tagEl.style.setProperty("--tag-color", tagColorVar); tagEl.classList.add("colored-tag"); diff --git a/src/components/features/kanban/kanban.ts b/src/components/features/kanban/kanban.ts index 5ce26060..d10d176d 100644 --- a/src/components/features/kanban/kanban.ts +++ b/src/components/features/kanban/kanban.ts @@ -9,6 +9,7 @@ import { import TaskProgressBarPlugin from "@/index"; // Adjust path as needed import { Task } from "@/types/task"; // Adjust path as needed import { KanbanColumnComponent } from "./kanban-column"; +import { clearTagColorCache } from "./kanban-card"; // import { DragManager, DragMoveEvent, DragEndEvent } from "@/components/ui/behavior/DragManager"; import Sortable from "sortablejs"; import "@/styles/kanban/kanban.scss"; @@ -136,6 +137,13 @@ export class KanbanComponent extends Component { this.containerEl.empty(); this.containerEl.addClass("tg-kanban-view"); + // Clear tag color cache on CSS changes (theme switch, snippet toggle) + this.registerEvent( + this.app.workspace.on("css-change", () => { + clearTagColorCache(); + }) + ); + // Load configuration settings this.loadKanbanConfig(); this.loadCycleSelection(); diff --git a/src/components/features/settings/tabs/CalendarSettingsTab.ts b/src/components/features/settings/tabs/CalendarSettingsTab.ts index d981bba7..a081db2a 100644 --- a/src/components/features/settings/tabs/CalendarSettingsTab.ts +++ b/src/components/features/settings/tabs/CalendarSettingsTab.ts @@ -319,6 +319,9 @@ export class CalendarSettingsComponent { } else if (isOutlookSource(source)) { // Use OutlookCalendarProvider for Outlook sources await this.syncOutlookSource(source); + } else if (isAppleSource(source)) { + // Use AppleCaldavProvider for Apple CalDAV sources + await this.syncAppleSource(source); } else if (isUrlIcsSource(source)) { // Use IcsManager for URL-based sources const icsManager = this.plugin.getIcsManager(); @@ -520,6 +523,49 @@ export class CalendarSettingsComponent { new Notice(t("Sync completed") + `: ${events.length} ` + t("events")); } + + /** + * Sync an Apple CalDAV source using AppleCaldavProvider + */ + private async syncAppleSource( + source: AppleCaldavSourceConfig, + ): Promise { + if (!source.appSpecificPassword) { + throw new Error("Not authenticated with iCloud Calendar"); + } + + if (!source.calendarHrefs || source.calendarHrefs.length === 0) { + throw new Error("No calendars selected for sync"); + } + + // Import the provider dynamically to avoid circular deps + const { AppleCaldavProvider } = + await import("@/providers/apple-caldav-provider"); + + // Create provider instance + const provider = new AppleCaldavProvider(source); + + // Fetch events for the configured date range (default: last 30 days to next 90 days) + const now = new Date(); + const start = new Date(now); + start.setDate(start.getDate() - 30); + const end = new Date(now); + end.setDate(end.getDate() + 90); + + const events = await provider.getEvents({ + range: { start, end }, + expandRecurring: true, + }); + + // Update cache in IcsManager + const icsManager = this.plugin.getIcsManager(); + if (icsManager && events.length > 0) { + // Store events in the IcsManager cache + icsManager.updateCacheForSource(source.id, events); + } + + new Notice(t("Sync completed") + `: ${events.length} ` + t("events")); + } } // ============================================================================ @@ -621,9 +667,19 @@ class CalendarSourceModal extends Modal { "apple-caldav", ]; + // Temporarily disabled providers (not yet fully implemented) + const disabledProviders: CalendarProviderType[] = [ + "google", + "outlook", + "apple-caldav", + ]; + for (const type of providerTypes) { const meta = CalendarProviderMeta[type]; - const card = grid.createDiv("type-card"); + const isDisabled = disabledProviders.includes(type); + const card = grid.createDiv({ + cls: `type-card${isDisabled ? " type-card-disabled" : ""}`, + }); const iconDiv = card.createDiv("type-icon"); setIcon(iconDiv, meta.icon); @@ -631,9 +687,13 @@ class CalendarSourceModal extends Modal { card.createDiv("type-name").setText(meta.displayName); card.createDiv("type-desc").setText(meta.description); - card.onclick = () => { - this.selectType(type); - }; + if (isDisabled) { + card.createDiv("type-badge").setText(t("Coming Soon")); + } else { + card.onclick = () => { + this.selectType(type); + }; + } } } diff --git a/src/providers/apple-caldav-provider.ts b/src/providers/apple-caldav-provider.ts index 0c2999d0..4ff9e0ac 100644 --- a/src/providers/apple-caldav-provider.ts +++ b/src/providers/apple-caldav-provider.ts @@ -19,6 +19,8 @@ import { CalendarListEntry, FetchEventsOptions, ProviderError, + WriteResult, + UpdateEventOptions, formatDateForCaldav, } from "./calendar-provider-base"; import { AppleCaldavSourceConfig } from "../types/calendar-provider"; @@ -35,7 +37,7 @@ import { IcsParser } from "../parsers/ics-parser"; const DEFAULT_CALDAV_SERVER = "https://caldav.icloud.com/"; /** - * CalDAV XML namespaces + * CalDAV XML namespaces (kept for reference) */ const NS = { DAV: "DAV:", @@ -77,7 +79,7 @@ export class AppleCaldavProvider extends CalendarProviderBase { const response = await this.makePropfindRequest( homeSetUrl, @@ -189,7 +191,7 @@ export class AppleCaldavProvider extends CalendarProviderBase { const calendarUrl = new URL( calendarHref, - this.config.serverUrl + this.config.serverUrl, ).toString(); // Build calendar-query REPORT @@ -293,6 +295,7 @@ export class AppleCaldavProvider extends CalendarProviderBase + @@ -319,30 +322,43 @@ export class AppleCaldavProvider extends CalendarProviderBase= 400) { throw new ProviderError( `CalDAV REPORT failed: ${response.status}`, - "unknown" + "unknown", ); } - // Parse the multi-status response and extract ICS data - const icsBlocks = this.extractCalendarData(response.text); + // Parse the multi-status response and extract ICS data with metadata + const eventDataList = this.extractCalendarDataWithMetadata( + response.text, + ); const events: IcsEvent[] = []; - for (const icsContent of icsBlocks) { + for (const eventData of eventDataList) { try { // Use existing IcsParser to parse the ICS content - const parsed = IcsParser.parse(icsContent, this.config as any); - events.push(...parsed.events); + const parsed = IcsParser.parse( + eventData.icsContent, + this.config as any, + ); + + // Enhance events with CalDAV metadata for write operations + for (const event of parsed.events) { + event.providerEventId = eventData.href; + event.providerCalendarId = calendarHref; + event.etag = eventData.etag; + event.canEdit = true; + events.push(event); + } } catch (error) { console.warn( "[AppleCaldavProvider] Failed to parse ICS block:", - error + error, ); } } @@ -350,6 +366,485 @@ export class AppleCaldavProvider extends CalendarProviderBase { + if (!(await this.connect())) { + return { success: false, error: "Not authenticated" }; + } + + const targetCalendarId = + calendarId || + event.providerCalendarId || + this.config.calendarHrefs[0]; + + if (!targetCalendarId) { + return { success: false, error: "No calendar specified" }; + } + + // Generate UID if missing + if (!event.uid) { + event.uid = this.generateUUID(); + } + + try { + const calendarUrl = new URL( + targetCalendarId, + this.config.serverUrl, + ).toString(); + + // Construct resource URL: calendar URL + UID + .ics + const resourceUrl = `${calendarUrl}${calendarUrl.endsWith("/") ? "" : "/"}${event.uid}.ics`; + + const icsBody = this.generateIcsString(event); + + const response = await requestUrl({ + url: resourceUrl, + method: "PUT", + headers: { + Authorization: this.getAuthHeader(), + "Content-Type": "text/calendar; charset=utf-8", + "If-None-Match": "*", // Ensure we don't overwrite existing + }, + body: icsBody, + throw: false, + }); + + if ( + response.status === 201 || + response.status === 204 || + response.status === 200 + ) { + // Fetch the ETag from response if available + const newEtag = + response.headers["etag"] || response.headers["ETag"]; + + console.log( + `[AppleCaldavProvider] Created event: ${event.uid}`, + ); + + return { + success: true, + event: { + ...event, + providerEventId: resourceUrl, + providerCalendarId: targetCalendarId, + etag: newEtag, + }, + }; + } else if (response.status === 412) { + return { + success: false, + error: "Event already exists (UID conflict)", + conflict: true, + }; + } else if (response.status === 401) { + return { + success: false, + error: "Authentication failed - check your App-Specific Password", + }; + } else if (response.status === 403) { + return { + success: false, + error: "Permission denied - you may not have write access to this calendar", + }; + } else { + return { + success: false, + error: `Create failed: HTTP ${response.status}`, + }; + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error( + "[AppleCaldavProvider] Failed to create event:", + error, + ); + return { success: false, error: errorMessage }; + } + } + + /** + * Update an existing event + */ + override async updateEvent( + options: UpdateEventOptions, + ): Promise { + if (!(await this.connect())) { + return { success: false, error: "Not authenticated" }; + } + + const { event, originalEvent, calendarId } = options; + + // Determine the resource URL + let resourceUrl = event.providerEventId; + + // Fallback: construct it from calendarId + uid if providerEventId is missing + if (!resourceUrl && (calendarId || event.providerCalendarId)) { + const targetCalendarId = calendarId || event.providerCalendarId; + if (targetCalendarId && event.uid) { + const baseUrl = new URL( + targetCalendarId, + this.config.serverUrl, + ).toString(); + resourceUrl = `${baseUrl}${baseUrl.endsWith("/") ? "" : "/"}${event.uid}.ics`; + } + } + + if (!resourceUrl) { + return { + success: false, + error: "Cannot determine event URL for update", + }; + } + + // Resolve absolute URL + const fullUrl = new URL(resourceUrl, this.config.serverUrl).toString(); + + try { + const icsBody = this.generateIcsString(event); + + const headers: Record = { + Authorization: this.getAuthHeader(), + "Content-Type": "text/calendar; charset=utf-8", + }; + + // Optimistic locking with ETag + const etag = originalEvent?.etag || event.etag; + if (etag) { + headers["If-Match"] = etag; + } + + const response = await requestUrl({ + url: fullUrl, + method: "PUT", + headers, + body: icsBody, + throw: false, + }); + + if (response.status === 204 || response.status === 200) { + const newEtag = + response.headers["etag"] || response.headers["ETag"]; + + console.log( + `[AppleCaldavProvider] Updated event: ${event.uid}`, + ); + + return { + success: true, + event: { + ...event, + etag: newEtag, + }, + }; + } else if (response.status === 412) { + return { + success: false, + error: "Conflict: The event was modified on the server. Please refresh and try again.", + conflict: true, + }; + } else if (response.status === 401) { + return { + success: false, + error: "Authentication failed - check your App-Specific Password", + }; + } else if (response.status === 403) { + return { + success: false, + error: "Permission denied - you may not have write access to this calendar", + }; + } else if (response.status === 404) { + return { + success: false, + error: "Event not found - it may have been deleted", + }; + } else { + return { + success: false, + error: `Update failed: HTTP ${response.status}`, + }; + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error( + "[AppleCaldavProvider] Failed to update event:", + error, + ); + return { success: false, error: errorMessage }; + } + } + + /** + * Delete an event from the calendar + */ + override async deleteEvent( + eventId: string, + calendarId?: string, + etag?: string, + ): Promise { + if (!(await this.connect())) { + return { success: false, error: "Not authenticated" }; + } + + // Determine resource URL + let resourceUrl = eventId; + + // If eventId looks like a UID (no slashes) and we have calendarId, construct URL + if (!eventId.includes("/") && calendarId) { + const baseUrl = new URL( + calendarId, + this.config.serverUrl, + ).toString(); + resourceUrl = `${baseUrl}${baseUrl.endsWith("/") ? "" : "/"}${eventId}.ics`; + } + + const fullUrl = new URL(resourceUrl, this.config.serverUrl).toString(); + + try { + const headers: Record = { + Authorization: this.getAuthHeader(), + }; + + // Optimistic locking with ETag + if (etag) { + headers["If-Match"] = etag; + } + + const response = await requestUrl({ + url: fullUrl, + method: "DELETE", + headers, + throw: false, + }); + + if (response.status === 204 || response.status === 200) { + console.log(`[AppleCaldavProvider] Deleted event: ${eventId}`); + return { success: true }; + } else if (response.status === 404) { + // Already deleted - consider success + return { success: true }; + } else if (response.status === 412) { + return { + success: false, + error: "Conflict: The event was modified on the server", + conflict: true, + }; + } else if (response.status === 401) { + return { + success: false, + error: "Authentication failed - check your App-Specific Password", + }; + } else if (response.status === 403) { + return { + success: false, + error: "Permission denied - you may not have write access to this calendar", + }; + } else { + return { + success: false, + error: `Delete failed: HTTP ${response.status}`, + }; + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error( + "[AppleCaldavProvider] Failed to delete event:", + error, + ); + return { success: false, error: errorMessage }; + } + } + + // ========================================================================= + // ICS Generation + // ========================================================================= + + /** + * Generate VCALENDAR string from IcsEvent + */ + private generateIcsString(event: IcsEvent): string { + const lines: string[] = []; + + lines.push("BEGIN:VCALENDAR"); + lines.push("VERSION:2.0"); + lines.push("PRODID:-//Task Genius//Obsidian Plugin//EN"); + lines.push("CALSCALE:GREGORIAN"); + lines.push("METHOD:PUBLISH"); + lines.push("BEGIN:VEVENT"); + + // Required fields + lines.push(`UID:${event.uid}`); + lines.push(`DTSTAMP:${formatDateForCaldav(new Date())}`); + + // Date handling + if (event.allDay) { + // VALUE=DATE format: YYYYMMDD + const startDate = this.formatDateOnly(event.dtstart); + lines.push(`DTSTART;VALUE=DATE:${startDate}`); + + if (event.dtend) { + // For all-day events, end date is exclusive in ICS + // Add one day to make it exclusive + const endDate = new Date(event.dtend); + endDate.setDate(endDate.getDate() + 1); + lines.push(`DTEND;VALUE=DATE:${this.formatDateOnly(endDate)}`); + } + } else { + // Standard DateTime in UTC + lines.push(`DTSTART:${formatDateForCaldav(event.dtstart)}`); + if (event.dtend) { + lines.push(`DTEND:${formatDateForCaldav(event.dtend)}`); + } + } + + // Optional fields + if (event.summary) { + lines.push(`SUMMARY:${this.escapeIcsText(event.summary)}`); + } + if (event.description) { + lines.push(`DESCRIPTION:${this.escapeIcsText(event.description)}`); + } + if (event.location) { + lines.push(`LOCATION:${this.escapeIcsText(event.location)}`); + } + if (event.status) { + lines.push(`STATUS:${event.status.toUpperCase()}`); + } + if (event.transp) { + lines.push(`TRANSP:${event.transp.toUpperCase()}`); + } + if (event.priority !== undefined) { + lines.push(`PRIORITY:${event.priority}`); + } + + // Recurrence rule + if (event.rrule) { + // RRULE might already include the prefix + const rrule = event.rrule.startsWith("RRULE:") + ? event.rrule.substring(6) + : event.rrule; + lines.push(`RRULE:${rrule}`); + } + + // Categories + if (event.categories && event.categories.length > 0) { + lines.push(`CATEGORIES:${event.categories.join(",")}`); + } + + // Organizer + if (event.organizer?.email) { + const orgName = event.organizer.name + ? `;CN=${this.escapeIcsText(event.organizer.name)}` + : ""; + lines.push(`ORGANIZER${orgName}:mailto:${event.organizer.email}`); + } + + // Attendees + if (event.attendees && event.attendees.length > 0) { + for (const attendee of event.attendees) { + if (attendee.email) { + let attendeeLine = "ATTENDEE"; + if (attendee.name) { + attendeeLine += `;CN=${this.escapeIcsText(attendee.name)}`; + } + if (attendee.role) { + attendeeLine += `;ROLE=${attendee.role}`; + } + if (attendee.status) { + attendeeLine += `;PARTSTAT=${attendee.status}`; + } + attendeeLine += `:mailto:${attendee.email}`; + lines.push(attendeeLine); + } + } + } + + // Created/Modified timestamps + if (event.created) { + lines.push(`CREATED:${formatDateForCaldav(event.created)}`); + } + if (event.lastModified) { + lines.push( + `LAST-MODIFIED:${formatDateForCaldav(event.lastModified)}`, + ); + } + + lines.push("END:VEVENT"); + lines.push("END:VCALENDAR"); + + // Join with CRLF as per ICS spec + return lines.join("\r\n"); + } + + /** + * Format date as YYYYMMDD for all-day events + */ + private formatDateOnly(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}${month}${day}`; + } + + /** + * Escape special characters for ICS text fields + * Per RFC 5545, these characters need escaping: backslash, semicolon, comma, newline + */ + private escapeIcsText(text: string): string { + return text + .replace(/\\/g, "\\\\") + .replace(/;/g, "\\;") + .replace(/,/g, "\\,") + .replace(/\r?\n/g, "\\n"); + } + + /** + * Generate a UUID for new events + */ + private generateUUID(): string { + // Use crypto.randomUUID if available, otherwise fallback + if (typeof crypto !== "undefined" && crypto.randomUUID) { + return crypto.randomUUID(); + } + + // Fallback UUID v4 generation + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace( + /[xy]/g, + function (c) { + const r = (Math.random() * 16) | 0; + const v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }, + ); + } + // ========================================================================= // CalDAV Request Helpers // ========================================================================= @@ -368,7 +863,7 @@ export class AppleCaldavProvider extends CalendarProviderBase { const response = await requestUrl({ url, @@ -385,14 +880,14 @@ export class AppleCaldavProvider extends CalendarProviderBase= 400 && response.status !== 207) { throw new ProviderError( `CalDAV PROPFIND failed: ${response.status}`, - "unknown" + "unknown", ); } @@ -431,7 +926,7 @@ export class AppleCaldavProvider extends CalendarProviderBase]*${propertyName}[^>]*>\\s*<[^>]*href[^>]*>([^<]+)<`, - "i" + "i", ); const match = xml.match(propertyRegex); return match ? match[1].trim() : null; @@ -466,34 +961,35 @@ export class AppleCaldavProvider extends CalendarProviderBase]*>([^<]+)<\/d:href>/i + /]*>([^<]+)<\/d:href>/i, ); if (!hrefMatch) continue; const href = hrefMatch[1].trim(); // Check if it's a calendar (has calendar resourcetype) - const isCalendar = - /|/i.test(responseContent); + const isCalendar = /|/i.test( + responseContent, + ); // Extract display name const displayNameMatch = responseContent.match( - /]*>([^<]*)<\/d:displayname>/i + /]*>([^<]*)<\/d:displayname>/i, ); // Extract calendar color (Apple specific) const colorMatch = responseContent.match( - /]*>([^<]+)<\/apple:calendar-color>/i + /]*>([^<]+)<\/apple:calendar-color>/i, ); // Extract description const descriptionMatch = responseContent.match( - /]*>([^<]*)<\/c:calendar-description>/i + /]*>([^<]*)<\/c:calendar-description>/i, ); // Extract ctag const ctagMatch = responseContent.match( - /]*>([^<]+)<\/cs:getctag>/i + /]*>([^<]+)<\/cs:getctag>/i, ); results.push({ @@ -510,32 +1006,64 @@ export class AppleCaldavProvider extends CalendarProviderBase { + const results: Array<{ + href: string; + etag?: string; + icsContent: string; + }> = []; - // Match c:calendar-data or cal:calendar-data elements - const regex = - /<(?:c|cal):calendar-data[^>]*>([\s\S]*?)<\/(?:c|cal):calendar-data>/gi; - let match; + // Split by response elements + const responseRegex = /]*>([\s\S]*?)<\/d:response>/gi; + let responseMatch; - while ((match = regex.exec(xml)) !== null) { - let icsContent = match[1]; + while ((responseMatch = responseRegex.exec(xml)) !== null) { + const responseContent = responseMatch[1]; - // Decode XML entities - icsContent = icsContent - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/&/g, "&") - .replace(/"/g, '"') - .replace(/'/g, "'"); + // Extract href + const hrefMatch = responseContent.match( + /]*>([^<]+)<\/d:href>/i, + ); + if (!hrefMatch) continue; - // Trim whitespace but preserve the ICS structure - icsContent = icsContent.trim(); + const href = hrefMatch[1].trim(); - if (icsContent.startsWith("BEGIN:VCALENDAR")) { - results.push(icsContent); + // Extract etag + const etagMatch = responseContent.match( + /]*>([^<]+)<\/d:getetag>/i, + ); + const etag = etagMatch?.[1]?.trim(); + + // Extract calendar-data + const calDataMatch = responseContent.match( + /<(?:c|cal):calendar-data[^>]*>([\s\S]*?)<\/(?:c|cal):calendar-data>/i, + ); + + if (calDataMatch) { + let icsContent = calDataMatch[1]; + + // Decode XML entities + icsContent = icsContent + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&") + .replace(/"/g, '"') + .replace(/'/g, "'") + .trim(); + + if (icsContent.startsWith("BEGIN:VCALENDAR")) { + results.push({ + href, + etag, + icsContent, + }); + } } }