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)
This commit is contained in:
Quorafind 2026-01-05 10:07:58 +08:00
parent d1f37fcd01
commit 97f28e7da4
4 changed files with 692 additions and 71 deletions

View file

@ -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<string, string | null> = 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");

View file

@ -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();

View file

@ -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<void> {
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);
};
}
}
}

View file

@ -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<AppleCaldavSourceC
await this.makePropfindRequest(
this.config.serverUrl || DEFAULT_CALDAV_SERVER,
0,
this.buildPropfindBody(["d:current-user-principal"])
this.buildPropfindBody(["d:current-user-principal"]),
);
this.updateStatus({ status: "idle" });
@ -107,7 +109,7 @@ export class AppleCaldavProvider extends CalendarProviderBase<AppleCaldavSourceC
if (!(await this.connect())) {
throw new ProviderError(
"Not authenticated with iCloud Calendar",
"auth"
"auth",
);
}
@ -132,42 +134,42 @@ export class AppleCaldavProvider extends CalendarProviderBase<AppleCaldavSourceC
const principalResponse = await this.makePropfindRequest(
this.config.serverUrl || DEFAULT_CALDAV_SERVER,
0,
this.buildPropfindBody(["d:current-user-principal"])
this.buildPropfindBody(["d:current-user-principal"]),
);
const principalHref = this.extractHref(
principalResponse,
"current-user-principal"
"current-user-principal",
);
if (!principalHref) {
throw new ProviderError(
"Could not discover user principal",
"not_found"
"not_found",
);
}
const principalUrl = new URL(
principalHref,
this.config.serverUrl
this.config.serverUrl,
).toString();
// Then, get the calendar home set from the principal
const homeSetResponse = await this.makePropfindRequest(
principalUrl,
0,
this.buildPropfindBody(["c:calendar-home-set"])
this.buildPropfindBody(["c:calendar-home-set"]),
);
const homeSetHref = this.extractHref(
homeSetResponse,
"calendar-home-set"
"calendar-home-set",
);
if (!homeSetHref) {
throw new ProviderError(
"Could not discover calendar home set",
"not_found"
"not_found",
);
}
@ -178,7 +180,7 @@ export class AppleCaldavProvider extends CalendarProviderBase<AppleCaldavSourceC
* List calendars in the calendar home set
*/
private async listCalendarsInHomeSet(
homeSetUrl: string
homeSetUrl: string,
): Promise<CalendarListEntry[]> {
const response = await this.makePropfindRequest(
homeSetUrl,
@ -189,7 +191,7 @@ export class AppleCaldavProvider extends CalendarProviderBase<AppleCaldavSourceC
"apple:calendar-color",
"c:calendar-description",
"cs:getctag",
])
]),
);
const calendars: CalendarListEntry[] = [];
@ -203,10 +205,13 @@ export class AppleCaldavProvider extends CalendarProviderBase<AppleCaldavSourceC
calendars.push({
id: resp.href,
name: resp.displayName || this.extractCalendarNameFromHref(resp.href),
name:
resp.displayName ||
this.extractCalendarNameFromHref(resp.href),
color: resp.color,
primary: false, // CalDAV doesn't have a concept of primary calendar
description: resp.description,
canWrite: true, // Assume writable for own calendars
});
}
@ -226,15 +231,12 @@ export class AppleCaldavProvider extends CalendarProviderBase<AppleCaldavSourceC
try {
// Determine which calendars to fetch
const calendarHrefs =
options.calendarIds?.length
? options.calendarIds
: this.config.calendarHrefs;
const calendarHrefs = options.calendarIds?.length
? options.calendarIds
: this.config.calendarHrefs;
if (calendarHrefs.length === 0) {
console.warn(
"[AppleCaldavProvider] No calendars configured"
);
console.warn("[AppleCaldavProvider] No calendars configured");
return [];
}
@ -248,13 +250,13 @@ export class AppleCaldavProvider extends CalendarProviderBase<AppleCaldavSourceC
try {
const events = await this.fetchEventsFromCalendar(
calHref,
options
options,
);
allEvents.push(...events);
} catch (error) {
console.error(
`[AppleCaldavProvider] Error fetching ${calHref}:`,
error
error,
);
}
}
@ -278,11 +280,11 @@ export class AppleCaldavProvider extends CalendarProviderBase<AppleCaldavSourceC
*/
private async fetchEventsFromCalendar(
calendarHref: string,
options: FetchEventsOptions
options: FetchEventsOptions,
): Promise<IcsEvent[]> {
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<AppleCaldavSourceC
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:getetag/>
<d:href/>
<c:calendar-data/>
</d:prop>
<c:filter>
@ -319,30 +322,43 @@ export class AppleCaldavProvider extends CalendarProviderBase<AppleCaldavSourceC
if (response.status === 401) {
throw new ProviderError(
"Authentication failed - check your App-Specific Password",
"auth"
"auth",
);
}
if (response.status >= 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<AppleCaldavSourceC
return events;
}
// =========================================================================
// Write Operations (Two-way Sync)
// =========================================================================
/**
* Check if this provider supports write operations
*/
override supportsWrite(): boolean {
return true;
}
/**
* Check if a specific calendar can be written to
*/
override canWriteToCalendar(calendarId?: string): boolean {
// For CalDAV, we assume all own calendars are writable
return !!this.config.appSpecificPassword;
}
/**
* Create a new event in the calendar
*/
override async createEvent(
event: IcsEvent,
calendarId?: string,
): Promise<WriteResult> {
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<WriteResult> {
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<string, string> = {
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<WriteResult> {
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<string, string> = {
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<AppleCaldavSourceC
private async makePropfindRequest(
url: string,
depth: number,
body: string
body: string,
): Promise<string> {
const response = await requestUrl({
url,
@ -385,14 +880,14 @@ export class AppleCaldavProvider extends CalendarProviderBase<AppleCaldavSourceC
if (response.status === 401) {
throw new ProviderError(
"Authentication failed - check your App-Specific Password",
"auth"
"auth",
);
}
if (response.status >= 400 && response.status !== 207) {
throw new ProviderError(
`CalDAV PROPFIND failed: ${response.status}`,
"unknown"
"unknown",
);
}
@ -431,7 +926,7 @@ export class AppleCaldavProvider extends CalendarProviderBase<AppleCaldavSourceC
// Look for the property container and extract the href
const propertyRegex = new RegExp(
`<[^>]*${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<AppleCaldavSourceC
// Extract href
const hrefMatch = responseContent.match(
/<d:href[^>]*>([^<]+)<\/d:href>/i
/<d:href[^>]*>([^<]+)<\/d:href>/i,
);
if (!hrefMatch) continue;
const href = hrefMatch[1].trim();
// Check if it's a calendar (has calendar resourcetype)
const isCalendar =
/<c:calendar\s*\/>|<cal:calendar\s*\/>/i.test(responseContent);
const isCalendar = /<c:calendar\s*\/>|<cal:calendar\s*\/>/i.test(
responseContent,
);
// Extract display name
const displayNameMatch = responseContent.match(
/<d:displayname[^>]*>([^<]*)<\/d:displayname>/i
/<d:displayname[^>]*>([^<]*)<\/d:displayname>/i,
);
// Extract calendar color (Apple specific)
const colorMatch = responseContent.match(
/<apple:calendar-color[^>]*>([^<]+)<\/apple:calendar-color>/i
/<apple:calendar-color[^>]*>([^<]+)<\/apple:calendar-color>/i,
);
// Extract description
const descriptionMatch = responseContent.match(
/<c:calendar-description[^>]*>([^<]*)<\/c:calendar-description>/i
/<c:calendar-description[^>]*>([^<]*)<\/c:calendar-description>/i,
);
// Extract ctag
const ctagMatch = responseContent.match(
/<cs:getctag[^>]*>([^<]+)<\/cs:getctag>/i
/<cs:getctag[^>]*>([^<]+)<\/cs:getctag>/i,
);
results.push({
@ -510,32 +1006,64 @@ export class AppleCaldavProvider extends CalendarProviderBase<AppleCaldavSourceC
}
/**
* Extract calendar-data (ICS content) from REPORT response
* Extract calendar-data with metadata (href, etag) from REPORT response
*/
private extractCalendarData(xml: string): string[] {
const results: string[] = [];
private extractCalendarDataWithMetadata(xml: string): Array<{
href: string;
etag?: string;
icsContent: string;
}> {
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 = /<d:response[^>]*>([\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(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'");
// Extract href
const hrefMatch = responseContent.match(
/<d:href[^>]*>([^<]+)<\/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[^>]*>([^<]+)<\/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(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.trim();
if (icsContent.startsWith("BEGIN:VCALENDAR")) {
results.push({
href,
etag,
icsContent,
});
}
}
}