mirror of
https://github.com/taskgenius/taskgenius-plugin.git
synced 2026-07-22 06:40:25 +00:00
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:
parent
d1f37fcd01
commit
97f28e7da4
4 changed files with 692 additions and 71 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.trim();
|
||||
|
||||
if (icsContent.startsWith("BEGIN:VCALENDAR")) {
|
||||
results.push({
|
||||
href,
|
||||
etag,
|
||||
icsContent,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue