mirror of
https://github.com/taskgenius/taskgenius-plugin.git
synced 2026-07-22 06:40:25 +00:00
feat(calendar): add Google/Outlook Calendar OAuth integration
- Add CalendarAuthManager with PKCE OAuth 2.0 flow - Add Google and Outlook calendar provider implementations - Add calendar settings tab for OAuth connection management - Enhance CalendarComponent with external text event drag-drop - Improve task-to-calendar event click handling with ID fallback - Add calendar-provider types for provider configuration - Add calendar settings styles
This commit is contained in:
parent
4d3116cfca
commit
cfa46714df
11 changed files with 8298 additions and 12 deletions
|
|
@ -90,6 +90,7 @@ export class CalendarComponent extends Component {
|
|||
public containerEl: HTMLElement;
|
||||
private tasks: Task[] = [];
|
||||
private events: CalendarEvent[] = [];
|
||||
private externalTextEvents: AdapterCalendarEvent[] = [];
|
||||
private currentViewMode: CalendarViewMode = "month";
|
||||
private currentDate: moment.Moment = moment();
|
||||
|
||||
|
|
@ -194,6 +195,7 @@ export class CalendarComponent extends Component {
|
|||
super.onload();
|
||||
this.processTasks();
|
||||
this.render();
|
||||
this.registerExternalTextDropHandlers();
|
||||
|
||||
// Listen for calendar views configuration changes
|
||||
this.registerEvent(
|
||||
|
|
@ -968,7 +970,14 @@ export class CalendarComponent extends Component {
|
|||
// ============================================
|
||||
|
||||
private handleTGEventClick(event: AdapterCalendarEvent) {
|
||||
const task = getTaskFromEvent(event as any);
|
||||
let task = getTaskFromEvent(event as any);
|
||||
|
||||
// Fallback: try to find task by ID if metadata is missing
|
||||
// This fixes issues where the event object is reconstructed/cloned by the library
|
||||
if (!task && event.id) {
|
||||
task = this.tasks.find((t) => t.id === event.id) || null;
|
||||
}
|
||||
|
||||
if (task) {
|
||||
this.params?.onTaskSelected?.(task);
|
||||
}
|
||||
|
|
@ -980,17 +989,28 @@ export class CalendarComponent extends Component {
|
|||
* Based on CalendarEventComponent implementation in event-renderer.ts
|
||||
*/
|
||||
private handleTGRenderEvent(ctx: EventRenderContext) {
|
||||
console.log(
|
||||
"[Calendar] handleTGRenderEvent called",
|
||||
ctx.event.id,
|
||||
ctx.viewType,
|
||||
);
|
||||
|
||||
// First render the default content
|
||||
ctx.defaultRender();
|
||||
|
||||
const { event, el } = ctx;
|
||||
|
||||
// Backup click handler to ensure selection always works
|
||||
// This guards against cases where the library's click handler fails
|
||||
if (!el.hasAttribute("data-click-handler")) {
|
||||
el.setAttribute("data-click-handler", "true");
|
||||
this.registerDomEvent(el, "click", (ev) => {
|
||||
// Ignore clicks on the checkbox (it handles its own logic)
|
||||
if (
|
||||
(ev.target as HTMLElement).closest(
|
||||
".task-list-item-checkbox",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.handleTGEventClick(event);
|
||||
});
|
||||
}
|
||||
|
||||
// Skip if checkbox already added
|
||||
if (el.querySelector(".task-list-item-checkbox")) {
|
||||
return;
|
||||
|
|
@ -1081,15 +1101,22 @@ export class CalendarComponent extends Component {
|
|||
newStart: Date | string,
|
||||
newEnd: Date | string,
|
||||
) {
|
||||
if (event.metadata?.externalDrop === true) {
|
||||
this.updateExternalTextEventTime(event, newStart, newEnd);
|
||||
return;
|
||||
}
|
||||
|
||||
const task = getTaskFromEvent(event as any);
|
||||
if (!task) {
|
||||
new Notice(t("Failed to update task: Task not found"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't allow dragging ICS tasks (external calendar events)
|
||||
// Don't allow dragging read-only ICS tasks (URL-based external calendar events)
|
||||
// OAuth providers (Google, Outlook, Apple) support two-way sync and can be dragged
|
||||
const isIcsTask = (task as any).source?.type === "ics";
|
||||
if (isIcsTask) {
|
||||
const isReadOnly = (task as any).readonly === true;
|
||||
if (isIcsTask && isReadOnly) {
|
||||
new Notice(
|
||||
t(
|
||||
"Cannot move external calendar events. Please update them in the original calendar.",
|
||||
|
|
@ -1309,16 +1336,26 @@ export class CalendarComponent extends Component {
|
|||
newStart: Date | string,
|
||||
newEnd: Date | string,
|
||||
) {
|
||||
if (event.metadata?.externalDrop === true) {
|
||||
this.updateExternalTextEventTime(event, newStart, newEnd);
|
||||
return;
|
||||
}
|
||||
|
||||
const task = getTaskFromEvent(event as any);
|
||||
if (!task) {
|
||||
new Notice(t("Failed to update task: Task not found"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't allow resizing read-only ICS tasks (URL-based external calendar events)
|
||||
// OAuth providers (Google, Outlook, Apple) support two-way sync and can be resized
|
||||
const isIcsTask = (task as any).source?.type === "ics";
|
||||
if (isIcsTask) {
|
||||
const isReadOnly = (task as any).readonly === true;
|
||||
if (isIcsTask && isReadOnly) {
|
||||
new Notice(
|
||||
"In current version, cannot resize external calendar events",
|
||||
t(
|
||||
"Cannot resize external calendar events. Please update them in the original calendar.",
|
||||
),
|
||||
);
|
||||
setTimeout(() => {
|
||||
this.processTasks();
|
||||
|
|
@ -1559,6 +1596,205 @@ export class CalendarComponent extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
private registerExternalTextDropHandlers(): void {
|
||||
const supportsTextDrop = (
|
||||
dt: DataTransfer | null,
|
||||
): dt is DataTransfer => {
|
||||
if (!dt) return false;
|
||||
const types = Array.from(dt.types ?? []).map((t) =>
|
||||
String(t).toLowerCase(),
|
||||
);
|
||||
const hasStringItem = Array.from(dt.items ?? []).some(
|
||||
(item) => item.kind === "string",
|
||||
);
|
||||
return (
|
||||
hasStringItem ||
|
||||
types.includes("text/plain") ||
|
||||
types.includes("text") ||
|
||||
types.includes("text/unicode") ||
|
||||
types.includes("text/html") ||
|
||||
types.includes("text/uri-list")
|
||||
);
|
||||
};
|
||||
|
||||
const readDroppedText = (dt: DataTransfer): string => {
|
||||
const raw =
|
||||
dt.getData("text/plain") ||
|
||||
dt.getData("text") ||
|
||||
dt.getData("Text") ||
|
||||
dt.getData("text/unicode") ||
|
||||
dt.getData("text/uri-list") ||
|
||||
"";
|
||||
return raw.trim();
|
||||
};
|
||||
|
||||
this.registerDomEvent(
|
||||
this.viewContainerEl,
|
||||
"dragover",
|
||||
(e: DragEvent) => {
|
||||
if (!supportsTextDrop(e.dataTransfer)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
},
|
||||
);
|
||||
|
||||
this.registerDomEvent(this.viewContainerEl, "drop", (e: DragEvent) => {
|
||||
if (!supportsTextDrop(e.dataTransfer)) return;
|
||||
|
||||
const text = readDroppedText(e.dataTransfer);
|
||||
if (!text) return;
|
||||
|
||||
const target = this.resolveExternalDropTarget(e);
|
||||
if (!target) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
this.addExternalTextEvent(text, target);
|
||||
});
|
||||
}
|
||||
|
||||
private resolveExternalDropTarget(
|
||||
e: DragEvent,
|
||||
): { start: Date; end: Date; allDay: boolean } | null {
|
||||
const targetEl = e.target instanceof HTMLElement ? e.target : null;
|
||||
if (!targetEl) return null;
|
||||
|
||||
// Week/Day view: drop into a time column -> snap start time to the slot
|
||||
const dayColumn = targetEl.closest<HTMLElement>(
|
||||
".tg-day-column[data-date]",
|
||||
);
|
||||
if (dayColumn?.dataset.date) {
|
||||
const rect = dayColumn.getBoundingClientRect();
|
||||
const relY = e.clientY - rect.top;
|
||||
|
||||
// Keep in sync with our TGCalendar config (theme.cellHeight / draggable.snapMinutes)
|
||||
const cellHeightPx = 60;
|
||||
const snapMinutes = 15;
|
||||
|
||||
const rawMinutes = (relY / cellHeightPx) * 60;
|
||||
const snappedMinutes = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1440,
|
||||
Math.round(rawMinutes / snapMinutes) * snapMinutes,
|
||||
),
|
||||
);
|
||||
|
||||
const baseDate = dateFns.parseISO(dayColumn.dataset.date);
|
||||
const start = dateFns.setMinutes(
|
||||
dateFns.setHours(baseDate, Math.floor(snappedMinutes / 60)),
|
||||
snappedMinutes % 60,
|
||||
);
|
||||
const end = dateFns.addMinutes(start, 30);
|
||||
|
||||
return { start, end, allDay: false };
|
||||
}
|
||||
|
||||
// Month view: drop into a day cell -> place at that day (all-day)
|
||||
const monthCell = targetEl.closest<HTMLElement>(".tg-month-cell");
|
||||
const monthRow = monthCell?.closest<HTMLElement>(
|
||||
".tg-month-row[data-date]",
|
||||
);
|
||||
if (monthCell && monthRow?.dataset.date) {
|
||||
const cells = Array.from(
|
||||
monthRow.querySelectorAll<HTMLElement>(".tg-month-cell"),
|
||||
);
|
||||
const index = cells.indexOf(monthCell);
|
||||
if (index < 0) return null;
|
||||
|
||||
const rowStart = dateFns.parseISO(monthRow.dataset.date);
|
||||
const date = addDays(rowStart, index);
|
||||
const start = startOfDay(date);
|
||||
|
||||
return { start, end: start, allDay: true };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private addExternalTextEvent(
|
||||
text: string,
|
||||
target: { start: Date; end: Date; allDay: boolean },
|
||||
): void {
|
||||
const title =
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.find((l) => l.length > 0) ?? t("New event");
|
||||
|
||||
const id = `external-drop:${Date.now()}:${Math.random().toString(16).slice(2)}`;
|
||||
const toDateTime = (d: Date) => dateFns.format(d, "yyyy-MM-dd HH:mm");
|
||||
|
||||
const normalizedStart = target.allDay
|
||||
? startOfDay(target.start)
|
||||
: target.start;
|
||||
const normalizedEnd = target.allDay
|
||||
? startOfDay(target.end)
|
||||
: target.end;
|
||||
|
||||
const event: AdapterCalendarEvent = {
|
||||
id,
|
||||
title,
|
||||
start: toDateTime(normalizedStart),
|
||||
end: toDateTime(normalizedEnd),
|
||||
color: "var(--interactive-accent)",
|
||||
allDay: target.allDay,
|
||||
metadata: {
|
||||
externalDrop: true,
|
||||
rawText: text,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
this.externalTextEvents.push(event);
|
||||
this.tgCalendar?.addEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the time of an external text event when dragged
|
||||
*/
|
||||
private updateExternalTextEventTime(
|
||||
event: AdapterCalendarEvent,
|
||||
newStart: Date | string,
|
||||
newEnd: Date | string,
|
||||
): void {
|
||||
const index = this.externalTextEvents.findIndex(
|
||||
(e) => e.id === event.id,
|
||||
);
|
||||
if (index < 0) return;
|
||||
|
||||
const startDate =
|
||||
newStart instanceof Date ? newStart : new Date(newStart);
|
||||
const endInput = newEnd ?? newStart;
|
||||
const endDate =
|
||||
endInput instanceof Date ? endInput : new Date(endInput);
|
||||
|
||||
if (
|
||||
Number.isNaN(startDate.getTime()) ||
|
||||
Number.isNaN(endDate.getTime())
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isAllDayEvent = event.allDay === true;
|
||||
const normalizedStart = isAllDayEvent
|
||||
? startOfDay(startDate)
|
||||
: startDate;
|
||||
const normalizedEnd = isAllDayEvent ? startOfDay(endDate) : endDate;
|
||||
const toDateTime = (d: Date) => dateFns.format(d, "yyyy-MM-dd HH:mm");
|
||||
|
||||
const current = this.externalTextEvents[index];
|
||||
if (!current) return;
|
||||
|
||||
this.externalTextEvents[index] = {
|
||||
...current,
|
||||
start: toDateTime(normalizedStart),
|
||||
end: toDateTime(normalizedEnd),
|
||||
allDay: isAllDayEvent,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// TGCalendar Interaction Handlers (v0.6.0+)
|
||||
// ============================================
|
||||
|
|
@ -1775,7 +2011,10 @@ export class CalendarComponent extends Component {
|
|||
const tasksWithDates = this.tasks.filter((task) =>
|
||||
hasDateInformation(task),
|
||||
);
|
||||
return tasksToCalendarEvents(tasksWithDates);
|
||||
return [
|
||||
...tasksToCalendarEvents(tasksWithDates),
|
||||
...this.externalTextEvents,
|
||||
];
|
||||
}
|
||||
|
||||
private getTasksForDate(date: Date): Task[] {
|
||||
|
|
|
|||
2274
src/components/features/settings/tabs/CalendarSettingsTab.ts
Normal file
2274
src/components/features/settings/tabs/CalendarSettingsTab.ts
Normal file
File diff suppressed because it is too large
Load diff
1030
src/managers/calendar-auth-manager.ts
Normal file
1030
src/managers/calendar-auth-manager.ts
Normal file
File diff suppressed because it is too large
Load diff
570
src/providers/apple-caldav-provider.ts
Normal file
570
src/providers/apple-caldav-provider.ts
Normal file
|
|
@ -0,0 +1,570 @@
|
|||
/**
|
||||
* Apple iCloud Calendar Provider (CalDAV)
|
||||
*
|
||||
* Implementation of CalDAV protocol for Apple iCloud Calendar integration.
|
||||
* Uses Basic Authentication with App-Specific Password.
|
||||
*
|
||||
* CalDAV Specification: RFC 4791
|
||||
* iCloud CalDAV: https://caldav.icloud.com/
|
||||
*
|
||||
* IMPORTANT: Users must use an App-Specific Password generated from
|
||||
* https://appleid.apple.com/account/manage - NOT their Apple ID password.
|
||||
*
|
||||
* @module apple-caldav-provider
|
||||
*/
|
||||
|
||||
import { requestUrl } from "obsidian";
|
||||
import {
|
||||
CalendarProviderBase,
|
||||
CalendarListEntry,
|
||||
FetchEventsOptions,
|
||||
ProviderError,
|
||||
formatDateForCaldav,
|
||||
} from "./calendar-provider-base";
|
||||
import { AppleCaldavSourceConfig } from "../types/calendar-provider";
|
||||
import { IcsEvent } from "../types/ics";
|
||||
import { IcsParser } from "../parsers/ics-parser";
|
||||
|
||||
// ============================================================================
|
||||
// CalDAV Configuration
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Default iCloud CalDAV server URL
|
||||
*/
|
||||
const DEFAULT_CALDAV_SERVER = "https://caldav.icloud.com/";
|
||||
|
||||
/**
|
||||
* CalDAV XML namespaces
|
||||
*/
|
||||
const NS = {
|
||||
DAV: "DAV:",
|
||||
CALDAV: "urn:ietf:params:xml:ns:caldav",
|
||||
APPLE: "http://apple.com/ns/ical/",
|
||||
CS: "http://calendarserver.org/ns/",
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Apple CalDAV Provider
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Provider implementation for Apple iCloud Calendar via CalDAV
|
||||
*/
|
||||
export class AppleCaldavProvider extends CalendarProviderBase<AppleCaldavSourceConfig> {
|
||||
constructor(config: AppleCaldavSourceConfig) {
|
||||
super(config);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Connection Management
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Connect and validate credentials
|
||||
*/
|
||||
async connect(): Promise<boolean> {
|
||||
if (!this.config.appSpecificPassword) {
|
||||
this.updateStatus({
|
||||
status: "error",
|
||||
error: "App-specific password not configured",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Simple connectivity check with PROPFIND on root
|
||||
await this.makePropfindRequest(
|
||||
this.config.serverUrl || DEFAULT_CALDAV_SERVER,
|
||||
0,
|
||||
this.buildPropfindBody(["d:current-user-principal"])
|
||||
);
|
||||
|
||||
this.updateStatus({ status: "idle" });
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.handleError(error, "Connection");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect (no-op for Basic Auth)
|
||||
*/
|
||||
async disconnect(): Promise<void> {
|
||||
// Nothing to revoke for Basic Auth
|
||||
this.updateStatus({ status: "disabled" });
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Calendar Operations
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* List all accessible calendars
|
||||
*/
|
||||
async listCalendars(): Promise<CalendarListEntry[]> {
|
||||
if (!(await this.connect())) {
|
||||
throw new ProviderError(
|
||||
"Not authenticated with iCloud Calendar",
|
||||
"auth"
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Discover calendar home set
|
||||
const homeSetUrl = await this.discoverCalendarHomeSet();
|
||||
|
||||
// Step 2: List calendars in home set
|
||||
const calendars = await this.listCalendarsInHomeSet(homeSetUrl);
|
||||
|
||||
return calendars;
|
||||
} catch (error) {
|
||||
throw ProviderError.from(error, "List calendars");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover the calendar home set URL
|
||||
*/
|
||||
private async discoverCalendarHomeSet(): Promise<string> {
|
||||
// First, get the current user principal
|
||||
const principalResponse = await this.makePropfindRequest(
|
||||
this.config.serverUrl || DEFAULT_CALDAV_SERVER,
|
||||
0,
|
||||
this.buildPropfindBody(["d:current-user-principal"])
|
||||
);
|
||||
|
||||
const principalHref = this.extractHref(
|
||||
principalResponse,
|
||||
"current-user-principal"
|
||||
);
|
||||
|
||||
if (!principalHref) {
|
||||
throw new ProviderError(
|
||||
"Could not discover user principal",
|
||||
"not_found"
|
||||
);
|
||||
}
|
||||
|
||||
const principalUrl = new URL(
|
||||
principalHref,
|
||||
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"])
|
||||
);
|
||||
|
||||
const homeSetHref = this.extractHref(
|
||||
homeSetResponse,
|
||||
"calendar-home-set"
|
||||
);
|
||||
|
||||
if (!homeSetHref) {
|
||||
throw new ProviderError(
|
||||
"Could not discover calendar home set",
|
||||
"not_found"
|
||||
);
|
||||
}
|
||||
|
||||
return new URL(homeSetHref, this.config.serverUrl).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* List calendars in the calendar home set
|
||||
*/
|
||||
private async listCalendarsInHomeSet(
|
||||
homeSetUrl: string
|
||||
): Promise<CalendarListEntry[]> {
|
||||
const response = await this.makePropfindRequest(
|
||||
homeSetUrl,
|
||||
1, // Depth 1 to get children
|
||||
this.buildPropfindBody([
|
||||
"d:resourcetype",
|
||||
"d:displayname",
|
||||
"apple:calendar-color",
|
||||
"c:calendar-description",
|
||||
"cs:getctag",
|
||||
])
|
||||
);
|
||||
|
||||
const calendars: CalendarListEntry[] = [];
|
||||
|
||||
// Parse multi-status response
|
||||
const responses = this.parseMultiStatusResponses(response);
|
||||
|
||||
for (const resp of responses) {
|
||||
// Check if this is a calendar collection
|
||||
if (!resp.isCalendar) continue;
|
||||
|
||||
calendars.push({
|
||||
id: 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,
|
||||
});
|
||||
}
|
||||
|
||||
return calendars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch events within the specified options
|
||||
*/
|
||||
async getEvents(options: FetchEventsOptions): Promise<IcsEvent[]> {
|
||||
if (!(await this.connect())) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allEvents: IcsEvent[] = [];
|
||||
this.setSyncing(true);
|
||||
|
||||
try {
|
||||
// Determine which calendars to fetch
|
||||
const calendarHrefs =
|
||||
options.calendarIds?.length
|
||||
? options.calendarIds
|
||||
: this.config.calendarHrefs;
|
||||
|
||||
if (calendarHrefs.length === 0) {
|
||||
console.warn(
|
||||
"[AppleCaldavProvider] No calendars configured"
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fetch events from each calendar
|
||||
for (const calHref of calendarHrefs) {
|
||||
// Check for cancellation
|
||||
if (options.signal?.aborted) {
|
||||
throw new ProviderError("Request cancelled", "unknown");
|
||||
}
|
||||
|
||||
try {
|
||||
const events = await this.fetchEventsFromCalendar(
|
||||
calHref,
|
||||
options
|
||||
);
|
||||
allEvents.push(...events);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[AppleCaldavProvider] Error fetching ${calHref}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.updateStatus({
|
||||
status: "idle",
|
||||
lastSync: Date.now(),
|
||||
eventCount: allEvents.length,
|
||||
});
|
||||
} catch (error) {
|
||||
this.handleError(error, "Fetch events");
|
||||
} finally {
|
||||
this.setSyncing(false);
|
||||
}
|
||||
|
||||
return allEvents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch events from a single calendar using CalDAV REPORT
|
||||
*/
|
||||
private async fetchEventsFromCalendar(
|
||||
calendarHref: string,
|
||||
options: FetchEventsOptions
|
||||
): Promise<IcsEvent[]> {
|
||||
const calendarUrl = new URL(
|
||||
calendarHref,
|
||||
this.config.serverUrl
|
||||
).toString();
|
||||
|
||||
// Build calendar-query REPORT
|
||||
const startStr = formatDateForCaldav(options.range.start);
|
||||
const endStr = formatDateForCaldav(options.range.end);
|
||||
|
||||
const reportBody = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:prop>
|
||||
<d:getetag/>
|
||||
<c:calendar-data/>
|
||||
</d:prop>
|
||||
<c:filter>
|
||||
<c:comp-filter name="VCALENDAR">
|
||||
<c:comp-filter name="VEVENT">
|
||||
<c:time-range start="${startStr}" end="${endStr}"/>
|
||||
</c:comp-filter>
|
||||
</c:comp-filter>
|
||||
</c:filter>
|
||||
</c:calendar-query>`;
|
||||
|
||||
const response = await requestUrl({
|
||||
url: calendarUrl,
|
||||
method: "REPORT",
|
||||
headers: {
|
||||
Authorization: this.getAuthHeader(),
|
||||
"Content-Type": "application/xml; charset=utf-8",
|
||||
Depth: "1",
|
||||
},
|
||||
body: reportBody,
|
||||
throw: false,
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new ProviderError(
|
||||
"Authentication failed - check your App-Specific Password",
|
||||
"auth"
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status >= 400) {
|
||||
throw new ProviderError(
|
||||
`CalDAV REPORT failed: ${response.status}`,
|
||||
"unknown"
|
||||
);
|
||||
}
|
||||
|
||||
// Parse the multi-status response and extract ICS data
|
||||
const icsBlocks = this.extractCalendarData(response.text);
|
||||
const events: IcsEvent[] = [];
|
||||
|
||||
for (const icsContent of icsBlocks) {
|
||||
try {
|
||||
// Use existing IcsParser to parse the ICS content
|
||||
const parsed = IcsParser.parse(icsContent, this.config as any);
|
||||
events.push(...parsed.events);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[AppleCaldavProvider] Failed to parse ICS block:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// CalDAV Request Helpers
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Generate Basic Auth header
|
||||
*/
|
||||
private getAuthHeader(): string {
|
||||
const credentials = `${this.config.username}:${this.config.appSpecificPassword}`;
|
||||
return `Basic ${btoa(credentials)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a PROPFIND request
|
||||
*/
|
||||
private async makePropfindRequest(
|
||||
url: string,
|
||||
depth: number,
|
||||
body: string
|
||||
): Promise<string> {
|
||||
const response = await requestUrl({
|
||||
url,
|
||||
method: "PROPFIND",
|
||||
headers: {
|
||||
Authorization: this.getAuthHeader(),
|
||||
"Content-Type": "application/xml; charset=utf-8",
|
||||
Depth: depth.toString(),
|
||||
},
|
||||
body,
|
||||
throw: false,
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new ProviderError(
|
||||
"Authentication failed - check your App-Specific Password",
|
||||
"auth"
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status >= 400 && response.status !== 207) {
|
||||
throw new ProviderError(
|
||||
`CalDAV PROPFIND failed: ${response.status}`,
|
||||
"unknown"
|
||||
);
|
||||
}
|
||||
|
||||
return response.text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build PROPFIND request body
|
||||
*/
|
||||
private buildPropfindBody(props: string[]): string {
|
||||
const propElements = props
|
||||
.map((prop) => {
|
||||
const [prefix, name] = prop.includes(":")
|
||||
? prop.split(":")
|
||||
: ["d", prop];
|
||||
return `<${prefix}:${name}/>`;
|
||||
})
|
||||
.join("\n\t\t");
|
||||
|
||||
return `<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:apple="http://apple.com/ns/ical/" xmlns:cs="http://calendarserver.org/ns/">
|
||||
<d:prop>
|
||||
${propElements}
|
||||
</d:prop>
|
||||
</d:propfind>`;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// XML Parsing Helpers
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Extract href from a specific property in XML response
|
||||
*/
|
||||
private extractHref(xml: string, propertyName: string): string | null {
|
||||
// Look for the property container and extract the href
|
||||
const propertyRegex = new RegExp(
|
||||
`<[^>]*${propertyName}[^>]*>\\s*<[^>]*href[^>]*>([^<]+)<`,
|
||||
"i"
|
||||
);
|
||||
const match = xml.match(propertyRegex);
|
||||
return match ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse multi-status response into structured objects
|
||||
*/
|
||||
private parseMultiStatusResponses(xml: string): Array<{
|
||||
href: string;
|
||||
isCalendar: boolean;
|
||||
displayName?: string;
|
||||
color?: string;
|
||||
description?: string;
|
||||
ctag?: string;
|
||||
}> {
|
||||
const results: Array<{
|
||||
href: string;
|
||||
isCalendar: boolean;
|
||||
displayName?: string;
|
||||
color?: string;
|
||||
description?: string;
|
||||
ctag?: string;
|
||||
}> = [];
|
||||
|
||||
// Split by response elements
|
||||
const responseRegex = /<d:response[^>]*>([\s\S]*?)<\/d:response>/gi;
|
||||
let responseMatch;
|
||||
|
||||
while ((responseMatch = responseRegex.exec(xml)) !== null) {
|
||||
const responseContent = responseMatch[1];
|
||||
|
||||
// Extract href
|
||||
const hrefMatch = responseContent.match(
|
||||
/<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);
|
||||
|
||||
// Extract display name
|
||||
const displayNameMatch = responseContent.match(
|
||||
/<d:displayname[^>]*>([^<]*)<\/d:displayname>/i
|
||||
);
|
||||
|
||||
// Extract calendar color (Apple specific)
|
||||
const colorMatch = responseContent.match(
|
||||
/<apple:calendar-color[^>]*>([^<]+)<\/apple:calendar-color>/i
|
||||
);
|
||||
|
||||
// Extract description
|
||||
const descriptionMatch = responseContent.match(
|
||||
/<c:calendar-description[^>]*>([^<]*)<\/c:calendar-description>/i
|
||||
);
|
||||
|
||||
// Extract ctag
|
||||
const ctagMatch = responseContent.match(
|
||||
/<cs:getctag[^>]*>([^<]+)<\/cs:getctag>/i
|
||||
);
|
||||
|
||||
results.push({
|
||||
href,
|
||||
isCalendar,
|
||||
displayName: displayNameMatch?.[1]?.trim(),
|
||||
color: this.normalizeAppleColor(colorMatch?.[1]?.trim()),
|
||||
description: descriptionMatch?.[1]?.trim(),
|
||||
ctag: ctagMatch?.[1]?.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract calendar-data (ICS content) from REPORT response
|
||||
*/
|
||||
private extractCalendarData(xml: string): string[] {
|
||||
const results: 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;
|
||||
|
||||
while ((match = regex.exec(xml)) !== null) {
|
||||
let icsContent = match[1];
|
||||
|
||||
// Decode XML entities
|
||||
icsContent = icsContent
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
|
||||
// Trim whitespace but preserve the ICS structure
|
||||
icsContent = icsContent.trim();
|
||||
|
||||
if (icsContent.startsWith("BEGIN:VCALENDAR")) {
|
||||
results.push(icsContent);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Apple calendar color format
|
||||
* Apple uses #RRGGBBAA format, we convert to standard #RRGGBB
|
||||
*/
|
||||
private normalizeAppleColor(color?: string): string | undefined {
|
||||
if (!color) return undefined;
|
||||
|
||||
// Remove any whitespace
|
||||
color = color.trim();
|
||||
|
||||
// If it's 9 characters (#RRGGBBAA), strip the alpha
|
||||
if (color.length === 9 && color.startsWith("#")) {
|
||||
return color.substring(0, 7);
|
||||
}
|
||||
|
||||
return color;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract calendar name from href path
|
||||
*/
|
||||
private extractCalendarNameFromHref(href: string): string {
|
||||
const parts = href.split("/").filter(Boolean);
|
||||
return parts[parts.length - 1] || "Calendar";
|
||||
}
|
||||
}
|
||||
546
src/providers/calendar-provider-base.ts
Normal file
546
src/providers/calendar-provider-base.ts
Normal file
|
|
@ -0,0 +1,546 @@
|
|||
/**
|
||||
* Calendar Provider Base Class
|
||||
*
|
||||
* Abstract base class defining the common interface and behaviors for all
|
||||
* calendar providers (Google, Outlook, Apple CalDAV, URL ICS).
|
||||
*
|
||||
* Design principles:
|
||||
* - Unified interface for different calendar backends
|
||||
* - Built-in status management and error handling
|
||||
* - Event conversion to IcsEvent format
|
||||
* - Lifecycle management via Obsidian's Component
|
||||
*
|
||||
* @module calendar-provider-base
|
||||
*/
|
||||
|
||||
import { Component, Notice } from "obsidian";
|
||||
import { CalendarSource } from "../types/calendar-provider";
|
||||
import { IcsEvent, IcsSyncStatus } from "../types/ics";
|
||||
import { t } from "../translations/helper";
|
||||
|
||||
// ============================================================================
|
||||
// Provider Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Represents a calendar entry from a provider's calendar list
|
||||
*/
|
||||
export interface CalendarListEntry {
|
||||
/** Unique identifier for this calendar */
|
||||
id: string;
|
||||
/** Display name */
|
||||
name: string;
|
||||
/** Calendar color (hex or provider-specific) */
|
||||
color?: string;
|
||||
/** Whether this is the primary/default calendar */
|
||||
primary: boolean;
|
||||
/** Whether user can write to this calendar */
|
||||
canWrite?: boolean;
|
||||
/** Calendar description */
|
||||
description?: string;
|
||||
/** Timezone of the calendar */
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Date range for event queries
|
||||
*/
|
||||
export interface DateRange {
|
||||
/** Start of the range (inclusive) */
|
||||
start: Date;
|
||||
/** End of the range (exclusive) */
|
||||
end: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for fetching events
|
||||
*/
|
||||
export interface FetchEventsOptions {
|
||||
/** Date range for events */
|
||||
range: DateRange;
|
||||
/** Specific calendar IDs to fetch (empty = all configured) */
|
||||
calendarIds?: string[];
|
||||
/** Maximum number of events to return */
|
||||
maxResults?: number;
|
||||
/** Whether to expand recurring events */
|
||||
expandRecurring?: boolean;
|
||||
/** Abort signal for cancellation */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a write operation (create/update/delete)
|
||||
*/
|
||||
export interface WriteResult {
|
||||
success: boolean;
|
||||
/** The updated/created event (undefined for delete) */
|
||||
event?: IcsEvent;
|
||||
/** Error message if failed */
|
||||
error?: string;
|
||||
/** Whether a conflict was detected (remote was modified) */
|
||||
conflict?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for updating an event
|
||||
*/
|
||||
export interface UpdateEventOptions {
|
||||
/** The event with updated fields */
|
||||
event: IcsEvent;
|
||||
/** The original event (for ETag/conflict detection) */
|
||||
originalEvent?: IcsEvent;
|
||||
/** Specific calendar ID (if not embedded in event) */
|
||||
calendarId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a fetch operation
|
||||
*/
|
||||
export interface FetchResult<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: Error;
|
||||
/** Whether the result came from cache */
|
||||
fromCache?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback type for status changes
|
||||
*/
|
||||
export type StatusChangeCallback = (status: IcsSyncStatus) => void;
|
||||
|
||||
// ============================================================================
|
||||
// Error Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Categorized provider errors
|
||||
*/
|
||||
export type ProviderErrorType =
|
||||
| "auth" // Authentication/authorization failed
|
||||
| "network" // Network connectivity issue
|
||||
| "rate_limit" // API rate limit exceeded
|
||||
| "not_found" // Resource not found
|
||||
| "permission" // Permission denied
|
||||
| "parse" // Response parsing failed
|
||||
| "timeout" // Request timed out
|
||||
| "unknown"; // Unknown error
|
||||
|
||||
/**
|
||||
* Enhanced error with categorization
|
||||
*/
|
||||
export class ProviderError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly type: ProviderErrorType,
|
||||
public readonly originalError?: Error,
|
||||
public readonly retryable: boolean = false,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ProviderError";
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ProviderError from an unknown error
|
||||
*/
|
||||
static from(error: unknown, context: string): ProviderError {
|
||||
if (error instanceof ProviderError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const originalError = error instanceof Error ? error : undefined;
|
||||
|
||||
// Categorize based on error message patterns
|
||||
let type: ProviderErrorType = "unknown";
|
||||
let retryable = false;
|
||||
|
||||
const lowerMessage = message.toLowerCase();
|
||||
|
||||
if (
|
||||
lowerMessage.includes("401") ||
|
||||
lowerMessage.includes("unauthorized") ||
|
||||
lowerMessage.includes("authentication") ||
|
||||
lowerMessage.includes("invalid_grant")
|
||||
) {
|
||||
type = "auth";
|
||||
} else if (
|
||||
lowerMessage.includes("403") ||
|
||||
lowerMessage.includes("forbidden") ||
|
||||
lowerMessage.includes("permission")
|
||||
) {
|
||||
type = "permission";
|
||||
} else if (
|
||||
lowerMessage.includes("404") ||
|
||||
lowerMessage.includes("not found")
|
||||
) {
|
||||
type = "not_found";
|
||||
} else if (
|
||||
lowerMessage.includes("429") ||
|
||||
lowerMessage.includes("rate limit") ||
|
||||
lowerMessage.includes("too many requests")
|
||||
) {
|
||||
type = "rate_limit";
|
||||
retryable = true;
|
||||
} else if (
|
||||
lowerMessage.includes("timeout") ||
|
||||
lowerMessage.includes("timed out")
|
||||
) {
|
||||
type = "timeout";
|
||||
retryable = true;
|
||||
} else if (
|
||||
lowerMessage.includes("network") ||
|
||||
lowerMessage.includes("connection") ||
|
||||
lowerMessage.includes("econnrefused") ||
|
||||
lowerMessage.includes("fetch failed")
|
||||
) {
|
||||
type = "network";
|
||||
retryable = true;
|
||||
} else if (
|
||||
lowerMessage.includes("parse") ||
|
||||
lowerMessage.includes("json") ||
|
||||
lowerMessage.includes("xml")
|
||||
) {
|
||||
type = "parse";
|
||||
}
|
||||
|
||||
return new ProviderError(
|
||||
`${context}: ${message}`,
|
||||
type,
|
||||
originalError,
|
||||
retryable,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Base Provider Class
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Abstract base class for calendar providers
|
||||
*
|
||||
* @template T - The specific CalendarSource configuration type
|
||||
*/
|
||||
export abstract class CalendarProviderBase<
|
||||
T extends CalendarSource,
|
||||
> extends Component {
|
||||
/** Current configuration */
|
||||
protected config: T;
|
||||
|
||||
/** Status change callback */
|
||||
protected onStatusChange?: StatusChangeCallback;
|
||||
|
||||
/** Current sync status */
|
||||
protected currentStatus: IcsSyncStatus;
|
||||
|
||||
/** Whether provider is currently syncing */
|
||||
protected isSyncing = false;
|
||||
|
||||
constructor(config: T) {
|
||||
super();
|
||||
this.config = config;
|
||||
this.currentStatus = {
|
||||
sourceId: config.id,
|
||||
status: "idle",
|
||||
};
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Abstract Methods (must be implemented by subclasses)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Establish connection and validate credentials
|
||||
* @returns true if connection is successful and credentials are valid
|
||||
*/
|
||||
abstract connect(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Disconnect and clean up resources
|
||||
*/
|
||||
abstract disconnect(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Fetch events within the specified options
|
||||
*/
|
||||
abstract getEvents(options: FetchEventsOptions): Promise<IcsEvent[]>;
|
||||
|
||||
/**
|
||||
* List available calendars from the provider
|
||||
*/
|
||||
abstract listCalendars(): Promise<CalendarListEntry[]>;
|
||||
|
||||
// =========================================================================
|
||||
// Write Methods (Optional - for providers supporting two-way sync)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Check if this provider supports write operations
|
||||
* Override in subclasses that support writing
|
||||
*/
|
||||
supportsWrite(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific calendar can be written to
|
||||
* @param calendarId - The calendar ID to check
|
||||
*/
|
||||
canWriteToCalendar(calendarId?: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new event
|
||||
* @param event - The event to create
|
||||
* @param calendarId - The calendar to create the event in
|
||||
* @returns WriteResult with the created event
|
||||
*/
|
||||
async createEvent(
|
||||
event: IcsEvent,
|
||||
calendarId?: string,
|
||||
): Promise<WriteResult> {
|
||||
return {
|
||||
success: false,
|
||||
error: "Write operations not supported by this provider",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing event
|
||||
* Uses PATCH semantics - only modified fields are sent
|
||||
* @param options - Update options including the event and original for conflict detection
|
||||
* @returns WriteResult with the updated event
|
||||
*/
|
||||
async updateEvent(options: UpdateEventOptions): Promise<WriteResult> {
|
||||
return {
|
||||
success: false,
|
||||
error: "Write operations not supported by this provider",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an event
|
||||
* @param eventId - The event ID to delete
|
||||
* @param calendarId - The calendar containing the event
|
||||
* @param etag - Optional ETag for conflict detection
|
||||
* @returns WriteResult indicating success/failure
|
||||
*/
|
||||
async deleteEvent(
|
||||
eventId: string,
|
||||
calendarId?: string,
|
||||
etag?: string,
|
||||
): Promise<WriteResult> {
|
||||
return {
|
||||
success: false,
|
||||
error: "Write operations not supported by this provider",
|
||||
};
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Configuration
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Get current configuration
|
||||
*/
|
||||
getConfig(): T {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update provider configuration
|
||||
*/
|
||||
updateConfig(newConfig: T): void {
|
||||
this.config = newConfig;
|
||||
this.currentStatus.sourceId = newConfig.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider display name
|
||||
*/
|
||||
getDisplayName(): string {
|
||||
return this.config.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider type
|
||||
*/
|
||||
getProviderType(): T["type"] {
|
||||
return this.config.type;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Status Management
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Set status change listener
|
||||
*/
|
||||
setStatusListener(callback: StatusChangeCallback): void {
|
||||
this.onStatusChange = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current sync status
|
||||
*/
|
||||
getStatus(): IcsSyncStatus {
|
||||
return { ...this.currentStatus };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update and notify status
|
||||
*/
|
||||
protected updateStatus(
|
||||
updates: Partial<Omit<IcsSyncStatus, "sourceId">>,
|
||||
): void {
|
||||
this.currentStatus = {
|
||||
...this.currentStatus,
|
||||
...updates,
|
||||
};
|
||||
|
||||
if (this.onStatusChange) {
|
||||
this.onStatusChange(this.currentStatus);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set syncing state
|
||||
*/
|
||||
protected setSyncing(syncing: boolean): void {
|
||||
this.isSyncing = syncing;
|
||||
this.updateStatus({
|
||||
status: syncing ? "syncing" : "idle",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently syncing
|
||||
*/
|
||||
isBusy(): boolean {
|
||||
return this.isSyncing;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Error Handling
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Handle and report an error
|
||||
*/
|
||||
protected handleError(error: unknown, context: string): ProviderError {
|
||||
const providerError = ProviderError.from(error, context);
|
||||
|
||||
console.error(
|
||||
`[${this.config.type}:${this.config.name}] ${providerError.message}`,
|
||||
providerError.originalError,
|
||||
);
|
||||
|
||||
this.updateStatus({
|
||||
status: "error",
|
||||
error: providerError.message,
|
||||
lastSync: Date.now(),
|
||||
});
|
||||
|
||||
// Show user-friendly notice
|
||||
this.showErrorNotice(providerError);
|
||||
|
||||
return providerError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show user-friendly error notification
|
||||
*/
|
||||
protected showErrorNotice(error: ProviderError): void {
|
||||
let message: string;
|
||||
|
||||
switch (error.type) {
|
||||
case "auth":
|
||||
message = t(
|
||||
"Authentication failed. Please reconnect your calendar.",
|
||||
);
|
||||
break;
|
||||
case "network":
|
||||
message = t(
|
||||
"Network error. Please check your internet connection.",
|
||||
);
|
||||
break;
|
||||
case "rate_limit":
|
||||
message = t("Too many requests. Please try again later.");
|
||||
break;
|
||||
case "permission":
|
||||
message = t(
|
||||
"Permission denied. Please check calendar permissions.",
|
||||
);
|
||||
break;
|
||||
case "timeout":
|
||||
message = t("Request timed out. Please try again.");
|
||||
break;
|
||||
default:
|
||||
message = `${this.config.name}: ${error.message}`;
|
||||
}
|
||||
|
||||
new Notice(message);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Lifecycle
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Clean up on unload
|
||||
*/
|
||||
override onunload(): void {
|
||||
this.onStatusChange = undefined;
|
||||
super.onunload();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a default date range (current month)
|
||||
*/
|
||||
export function createDefaultDateRange(): DateRange {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a date range for a specific number of days around today
|
||||
*/
|
||||
export function createDateRangeAroundToday(
|
||||
daysBefore: number,
|
||||
daysAfter: number,
|
||||
): DateRange {
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
|
||||
const start = new Date(now);
|
||||
start.setDate(start.getDate() - daysBefore);
|
||||
|
||||
const end = new Date(now);
|
||||
end.setDate(end.getDate() + daysAfter);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for API requests (ISO 8601)
|
||||
*/
|
||||
export function formatDateForApi(date: Date): string {
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for CalDAV requests (compact format: 20231225T120000Z)
|
||||
*/
|
||||
export function formatDateForCaldav(date: Date): string {
|
||||
return date.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
|
||||
}
|
||||
1027
src/providers/google-calendar-provider.ts
Normal file
1027
src/providers/google-calendar-provider.ts
Normal file
File diff suppressed because it is too large
Load diff
244
src/providers/index.ts
Normal file
244
src/providers/index.ts
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
/**
|
||||
* Calendar Provider Factory and Exports
|
||||
*
|
||||
* This module provides a unified factory for creating calendar providers
|
||||
* and exports all provider-related types and classes.
|
||||
*
|
||||
* @module providers
|
||||
*/
|
||||
|
||||
import {
|
||||
CalendarSource,
|
||||
isGoogleSource,
|
||||
isOutlookSource,
|
||||
isAppleSource,
|
||||
isUrlIcsSource,
|
||||
} from "../types/calendar-provider";
|
||||
import { CalendarProviderBase } from "./calendar-provider-base";
|
||||
import { GoogleCalendarProvider } from "./google-calendar-provider";
|
||||
import { OutlookCalendarProvider } from "./outlook-calendar-provider";
|
||||
import { AppleCaldavProvider } from "./apple-caldav-provider";
|
||||
import { CalendarAuthManager } from "../managers/calendar-auth-manager";
|
||||
|
||||
// ============================================================================
|
||||
// Re-exports
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
CalendarProviderBase,
|
||||
createDefaultDateRange,
|
||||
createDateRangeAroundToday,
|
||||
formatDateForApi,
|
||||
formatDateForCaldav,
|
||||
ProviderError,
|
||||
} from "./calendar-provider-base";
|
||||
|
||||
export type {
|
||||
CalendarListEntry,
|
||||
DateRange,
|
||||
FetchEventsOptions,
|
||||
FetchResult,
|
||||
StatusChangeCallback,
|
||||
ProviderErrorType,
|
||||
WriteResult,
|
||||
UpdateEventOptions,
|
||||
} from "./calendar-provider-base";
|
||||
|
||||
export { GoogleCalendarProvider } from "./google-calendar-provider";
|
||||
export { OutlookCalendarProvider } from "./outlook-calendar-provider";
|
||||
export { AppleCaldavProvider } from "./apple-caldav-provider";
|
||||
|
||||
// ============================================================================
|
||||
// Provider Factory
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Factory options for creating providers
|
||||
*/
|
||||
export interface ProviderFactoryOptions {
|
||||
/** Authentication manager for OAuth providers */
|
||||
authManager: CalendarAuthManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for creating calendar provider instances
|
||||
*/
|
||||
export class ProviderFactory {
|
||||
private authManager: CalendarAuthManager;
|
||||
|
||||
constructor(options: ProviderFactoryOptions) {
|
||||
this.authManager = options.authManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a provider instance based on the source configuration
|
||||
*
|
||||
* @param source - The calendar source configuration
|
||||
* @returns The appropriate provider instance
|
||||
* @throws Error if the source type is unknown
|
||||
*/
|
||||
createProvider(
|
||||
source: CalendarSource,
|
||||
): CalendarProviderBase<CalendarSource> {
|
||||
if (isGoogleSource(source)) {
|
||||
return new GoogleCalendarProvider(source, this.authManager);
|
||||
}
|
||||
|
||||
if (isOutlookSource(source)) {
|
||||
return new OutlookCalendarProvider(source, this.authManager);
|
||||
}
|
||||
|
||||
if (isAppleSource(source)) {
|
||||
return new AppleCaldavProvider(source);
|
||||
}
|
||||
|
||||
if (isUrlIcsSource(source)) {
|
||||
// For URL ICS sources, we delegate to the existing IcsManager
|
||||
// This is handled separately in CalendarSourceManager
|
||||
throw new Error(
|
||||
"URL ICS sources should be handled by IcsManager directly",
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Unknown calendar provider type: ${(source as any).type}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a provider type requires OAuth authentication
|
||||
*/
|
||||
static requiresOAuth(type: CalendarSource["type"]): boolean {
|
||||
return type === "google" || type === "outlook";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a provider type requires manual credentials
|
||||
*/
|
||||
static requiresCredentials(type: CalendarSource["type"]): boolean {
|
||||
return type === "apple-caldav" || type === "url-ics";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display name for a provider type
|
||||
*/
|
||||
static getProviderDisplayName(type: CalendarSource["type"]): string {
|
||||
switch (type) {
|
||||
case "google":
|
||||
return "Google Calendar";
|
||||
case "outlook":
|
||||
return "Outlook / Microsoft 365";
|
||||
case "apple-caldav":
|
||||
return "Apple iCloud Calendar";
|
||||
case "url-ics":
|
||||
return "ICS/iCal URL";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the icon name for a provider type (Lucide icons)
|
||||
*/
|
||||
static getProviderIcon(type: CalendarSource["type"]): string {
|
||||
switch (type) {
|
||||
case "google":
|
||||
return "mail"; // Google uses Gmail colors typically
|
||||
case "outlook":
|
||||
return "cloud";
|
||||
case "apple-caldav":
|
||||
return "apple";
|
||||
case "url-ics":
|
||||
return "calendar";
|
||||
default:
|
||||
return "calendar";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Provider Manager
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Manages multiple calendar provider instances
|
||||
*/
|
||||
export class CalendarSourceManager {
|
||||
private factory: ProviderFactory;
|
||||
private providers: Map<string, CalendarProviderBase<CalendarSource>> =
|
||||
new Map();
|
||||
private authManager: CalendarAuthManager;
|
||||
|
||||
constructor(authManager: CalendarAuthManager) {
|
||||
this.authManager = authManager;
|
||||
this.factory = new ProviderFactory({ authManager });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a provider for the given source
|
||||
*/
|
||||
getProvider(source: CalendarSource): CalendarProviderBase<CalendarSource> {
|
||||
// Check if we already have a provider for this source
|
||||
let provider = this.providers.get(source.id);
|
||||
|
||||
if (provider) {
|
||||
// Update the provider's config in case it changed
|
||||
provider.updateConfig(source);
|
||||
return provider;
|
||||
}
|
||||
|
||||
// Create a new provider
|
||||
provider = this.factory.createProvider(source);
|
||||
this.providers.set(source.id, provider);
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a provider
|
||||
*/
|
||||
async removeProvider(sourceId: string): Promise<void> {
|
||||
const provider = this.providers.get(sourceId);
|
||||
if (provider) {
|
||||
await provider.disconnect();
|
||||
provider.unload();
|
||||
this.providers.delete(sourceId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active providers
|
||||
*/
|
||||
getAllProviders(): CalendarProviderBase<CalendarSource>[] {
|
||||
return Array.from(this.providers.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect and clean up all providers
|
||||
*/
|
||||
async disconnectAll(): Promise<void> {
|
||||
const disconnectPromises = Array.from(this.providers.values()).map(
|
||||
async (provider) => {
|
||||
try {
|
||||
await provider.disconnect();
|
||||
provider.unload();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[CalendarSourceManager] Error disconnecting provider:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await Promise.all(disconnectPromises);
|
||||
this.providers.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the auth manager
|
||||
*/
|
||||
getAuthManager(): CalendarAuthManager {
|
||||
return this.authManager;
|
||||
}
|
||||
}
|
||||
1221
src/providers/outlook-calendar-provider.ts
Normal file
1221
src/providers/outlook-calendar-provider.ts
Normal file
File diff suppressed because it is too large
Load diff
455
src/styles/calendar-settings.scss
Normal file
455
src/styles/calendar-settings.scss
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
/**
|
||||
* Calendar Settings Styles
|
||||
* Styles for the calendar integration settings interface
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Container
|
||||
// ============================================================================
|
||||
|
||||
.calendar-settings-container {
|
||||
padding: 0;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 24px 0 12px 0;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.calendar-description {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-header-container {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Sources List
|
||||
// ============================================================================
|
||||
|
||||
.calendar-sources-list {
|
||||
margin-bottom: 24px;
|
||||
|
||||
h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-empty-state {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
background: var(--background-secondary);
|
||||
border-radius: 8px;
|
||||
border: 1px dashed var(--background-modifier-border);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Source Card
|
||||
// ============================================================================
|
||||
|
||||
.calendar-source-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 8px;
|
||||
background: var(--background-primary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 8px;
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--interactive-accent);
|
||||
}
|
||||
|
||||
.source-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.source-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.source-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.source-name {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.source-type-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.source-type {
|
||||
font-size: var(--font-ui-smaller);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.source-status {
|
||||
font-size: var(--font-ui-smaller);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
|
||||
&.enabled {
|
||||
color: var(--text-success);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: var(--background-modifier-error);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.source-last-sync {
|
||||
font-size: var(--font-ui-smaller);
|
||||
color: var(--text-faint);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.source-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.toggle-component {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.clickable-icon {
|
||||
&.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Add Source Button
|
||||
// ============================================================================
|
||||
|
||||
.calendar-add-source-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.calendar-add-button span {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Source Modal
|
||||
// ============================================================================
|
||||
|
||||
.calendar-source-modal {
|
||||
/* max-width: 600px; */
|
||||
width: 70vw;
|
||||
|
||||
.modal-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-back-btn {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-description {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Type Selection Grid
|
||||
// ============================================================================
|
||||
|
||||
.calendar-type-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.type-card {
|
||||
padding: 20px;
|
||||
background: var(--background-secondary);
|
||||
border: 2px solid var(--background-modifier-border);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--interactive-accent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.type-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 0 auto 12px;
|
||||
background: var(--interactive-accent);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
}
|
||||
|
||||
.type-name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.type-desc {
|
||||
font-size: var(--font-ui-smaller);
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Settings Sections
|
||||
// ============================================================================
|
||||
|
||||
.settings-section {
|
||||
margin-bottom: 24px;
|
||||
padding: 16px;
|
||||
background: var(--background-secondary);
|
||||
border-radius: 8px;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 16px 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 16px 0 12px 0;
|
||||
font-size: var(--font-ui-small);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OAuth Status
|
||||
// ============================================================================
|
||||
|
||||
.oauth-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&.connected {
|
||||
}
|
||||
|
||||
&.disconnected {
|
||||
background: var(--background-primary);
|
||||
border: 1px dashed var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.connected-info,
|
||||
.disconnected-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
display: flex;
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.account-email {
|
||||
color: var(--text-muted);
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.oauth-hint {
|
||||
font-size: var(--font-ui-smaller);
|
||||
color: var(--text-faint);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Apple Notice
|
||||
// ============================================================================
|
||||
|
||||
.apple-notice {
|
||||
padding: 12px;
|
||||
background: var(--background-modifier-warning);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
font-size: var(--font-ui-small);
|
||||
|
||||
strong {
|
||||
color: var(--text-warning);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-accent);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Calendar Selector
|
||||
// ============================================================================
|
||||
|
||||
.calendar-selector {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background: var(--background-primary);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
|
||||
h4 {
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.empty-calendars {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.calendar-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.primary-badge {
|
||||
font-size: var(--font-ui-smaller);
|
||||
padding: 2px 6px;
|
||||
background: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
border-radius: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Auth Section
|
||||
// ============================================================================
|
||||
|
||||
.auth-section {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Modal Buttons
|
||||
// ============================================================================
|
||||
|
||||
.modal-button-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
651
src/types/calendar-provider.ts
Normal file
651
src/types/calendar-provider.ts
Normal file
|
|
@ -0,0 +1,651 @@
|
|||
/**
|
||||
* Calendar Provider Architecture Types
|
||||
*
|
||||
* This module defines the unified type system for multiple calendar sources,
|
||||
* including traditional ICS/iCal URLs and OAuth-based providers (Google, Outlook, Apple).
|
||||
*
|
||||
* Key design principles:
|
||||
* - Discriminated unions for type-safe provider handling
|
||||
* - Separation of configuration and authentication data
|
||||
* - Backward compatibility with existing IcsSource
|
||||
* - Secure token storage patterns
|
||||
*
|
||||
* @module calendar-provider
|
||||
*/
|
||||
|
||||
import {
|
||||
IcsAuthConfig,
|
||||
IcsEventFilter,
|
||||
IcsHolidayConfig,
|
||||
IcsStatusMapping,
|
||||
IcsTextReplacement,
|
||||
} from "./ics";
|
||||
|
||||
// ============================================================================
|
||||
// Provider Type Enumeration
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Supported calendar provider types
|
||||
* - `url-ics`: Traditional ICS/iCal URL (webcal://, http://, https://)
|
||||
* - `google`: Google Calendar via OAuth 2.0 + PKCE
|
||||
* - `outlook`: Microsoft Outlook/365 via Microsoft Graph API + OAuth 2.0
|
||||
* - `apple-caldav`: Apple iCloud Calendar via CalDAV + App-Specific Password
|
||||
*/
|
||||
export type CalendarProviderType =
|
||||
| "url-ics"
|
||||
| "google"
|
||||
| "outlook"
|
||||
| "apple-caldav";
|
||||
|
||||
/**
|
||||
* Provider display metadata for UI rendering
|
||||
*/
|
||||
export const CalendarProviderMeta: Record<
|
||||
CalendarProviderType,
|
||||
{
|
||||
displayName: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
authType: "none" | "basic" | "oauth2" | "caldav";
|
||||
}
|
||||
> = {
|
||||
"url-ics": {
|
||||
displayName: "ICS/iCal URL",
|
||||
icon: "calendar",
|
||||
description: "Import from any ICS/iCal URL (webcal://, https://)",
|
||||
authType: "basic",
|
||||
},
|
||||
google: {
|
||||
displayName: "Google Calendar",
|
||||
icon: "mail", // Lucide doesn't have Google icon, use similar
|
||||
description: "Connect to Google Calendar via OAuth 2.0",
|
||||
authType: "oauth2",
|
||||
},
|
||||
outlook: {
|
||||
displayName: "Outlook / Microsoft 365",
|
||||
icon: "cloud",
|
||||
description: "Connect to Outlook Calendar via Microsoft Graph API",
|
||||
authType: "oauth2",
|
||||
},
|
||||
"apple-caldav": {
|
||||
displayName: "Apple iCloud Calendar",
|
||||
icon: "apple",
|
||||
description: "Connect to iCloud Calendar via CalDAV",
|
||||
authType: "caldav",
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// OAuth 2.0 Token Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* OAuth 2.0 token data structure
|
||||
*
|
||||
* SECURITY NOTE: This data is stored in Obsidian's data.json file,
|
||||
* which is NOT encrypted. Users should be warned not to share their
|
||||
* data.json if OAuth connections are configured.
|
||||
*/
|
||||
export interface OAuthTokenData {
|
||||
/** The access token for API requests */
|
||||
accessToken: string;
|
||||
|
||||
/** The refresh token for obtaining new access tokens */
|
||||
refreshToken: string;
|
||||
|
||||
/** Unix timestamp (ms) when the access token expires */
|
||||
expiresAt: number;
|
||||
|
||||
/** OAuth scope granted by the user */
|
||||
scope: string;
|
||||
|
||||
/** Token type (typically "Bearer") */
|
||||
tokenType: string;
|
||||
|
||||
/** Unix timestamp (ms) when the tokens were issued */
|
||||
issuedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth connection state for UI display
|
||||
*/
|
||||
export type OAuthConnectionState =
|
||||
| "disconnected"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "expired"
|
||||
| "error";
|
||||
|
||||
/**
|
||||
* OAuth provider-specific endpoints configuration
|
||||
*/
|
||||
export interface OAuthEndpoints {
|
||||
authorizationUrl: string;
|
||||
tokenUrl: string;
|
||||
revokeUrl?: string;
|
||||
userInfoUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* PKCE (Proof Key for Code Exchange) data
|
||||
* Used during OAuth flow for public clients
|
||||
*/
|
||||
export interface PKCEData {
|
||||
codeVerifier: string;
|
||||
codeChallenge: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Base Calendar Source Configuration
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Base configuration shared by all calendar source types
|
||||
*/
|
||||
export interface BaseCalendarSource {
|
||||
/** Unique identifier for this calendar source */
|
||||
id: string;
|
||||
|
||||
/** User-defined display name */
|
||||
name: string;
|
||||
|
||||
/** Provider type discriminator */
|
||||
type: CalendarProviderType;
|
||||
|
||||
/** Whether this source is enabled for syncing */
|
||||
enabled: boolean;
|
||||
|
||||
/** Custom color for events from this source (hex format) */
|
||||
color?: string;
|
||||
|
||||
/** Sync interval in minutes (default: 60) */
|
||||
refreshInterval: number;
|
||||
|
||||
/** Last successful sync timestamp (ms) */
|
||||
lastSynced?: number;
|
||||
|
||||
/** How to display events: as badges on dates or full events */
|
||||
showType: "badge" | "event";
|
||||
|
||||
/** Include all-day events */
|
||||
showAllDayEvents: boolean;
|
||||
|
||||
/** Include timed events */
|
||||
showTimedEvents: boolean;
|
||||
|
||||
/** Task status mapping rules */
|
||||
statusMapping?: IcsStatusMapping;
|
||||
|
||||
/** Text replacement rules for event titles/descriptions */
|
||||
textReplacements?: IcsTextReplacement[];
|
||||
|
||||
/** Holiday detection configuration */
|
||||
holidayConfig?: IcsHolidayConfig;
|
||||
|
||||
/**
|
||||
* Creation timestamp
|
||||
* @default Date.now() - For backward compatibility with legacy IcsSource
|
||||
*/
|
||||
createdAt?: number;
|
||||
|
||||
/**
|
||||
* Last modification timestamp
|
||||
* @default Date.now() - For backward compatibility with legacy IcsSource
|
||||
*/
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Provider-Specific Configurations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Configuration for traditional ICS/iCal URL sources
|
||||
* This maintains backward compatibility with the existing IcsSource interface
|
||||
*/
|
||||
export interface UrlIcsSourceConfig extends BaseCalendarSource {
|
||||
type: "url-ics";
|
||||
|
||||
/** URL to the ICS file (webcal://, http://, https://) */
|
||||
url: string;
|
||||
|
||||
/** Legacy authentication (Basic/Bearer/Custom Headers) */
|
||||
auth?: IcsAuthConfig;
|
||||
|
||||
/** Event filtering rules */
|
||||
filters?: IcsEventFilter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for Google Calendar
|
||||
* Uses OAuth 2.0 with PKCE for authentication
|
||||
*/
|
||||
export interface GoogleCalendarSourceConfig extends BaseCalendarSource {
|
||||
type: "google";
|
||||
|
||||
/** Connected Google account email (for display purposes) */
|
||||
accountEmail?: string;
|
||||
|
||||
/** List of specific calendar IDs to sync (empty = all calendars) */
|
||||
calendarIds: string[];
|
||||
|
||||
/**
|
||||
* User-friendly labels for selected calendars
|
||||
* Map: calendarId -> displayName
|
||||
*/
|
||||
calendarLabels?: Record<string, string>;
|
||||
|
||||
/** OAuth tokens - managed by AuthManager */
|
||||
auth?: OAuthTokenData;
|
||||
|
||||
/** Whether to include primary calendar automatically */
|
||||
includePrimaryCalendar: boolean;
|
||||
|
||||
/** Whether to include shared calendars */
|
||||
includeSharedCalendars: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for Outlook/Microsoft 365 Calendar
|
||||
* Uses Microsoft Graph API with OAuth 2.0 + PKCE
|
||||
*/
|
||||
export interface OutlookCalendarSourceConfig extends BaseCalendarSource {
|
||||
type: "outlook";
|
||||
|
||||
/** Connected Microsoft account email (for display purposes) */
|
||||
accountEmail?: string;
|
||||
|
||||
/** List of specific calendar IDs to sync (empty = all calendars) */
|
||||
calendarIds: string[];
|
||||
|
||||
/**
|
||||
* User-friendly labels for selected calendars
|
||||
* Map: calendarId -> displayName
|
||||
*/
|
||||
calendarLabels?: Record<string, string>;
|
||||
|
||||
/** OAuth tokens - managed by AuthManager */
|
||||
auth?: OAuthTokenData;
|
||||
|
||||
/**
|
||||
* Azure AD tenant ID
|
||||
* - "common" for multi-tenant (personal + work accounts)
|
||||
* - "consumers" for personal accounts only
|
||||
* - "organizations" for work/school accounts only
|
||||
* - Specific tenant GUID for enterprise scenarios
|
||||
*/
|
||||
tenantId: string;
|
||||
|
||||
/** Whether to include primary calendar automatically */
|
||||
includePrimaryCalendar: boolean;
|
||||
|
||||
/** Whether to include shared calendars */
|
||||
includeSharedCalendars: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for Apple iCloud Calendar via CalDAV
|
||||
* Uses App-Specific Password for authentication
|
||||
*/
|
||||
export interface AppleCaldavSourceConfig extends BaseCalendarSource {
|
||||
type: "apple-caldav";
|
||||
|
||||
/** CalDAV server URL (default: https://caldav.icloud.com/) */
|
||||
serverUrl: string;
|
||||
|
||||
/** Apple ID (email) */
|
||||
username: string;
|
||||
|
||||
/**
|
||||
* App-Specific Password generated from Apple ID settings
|
||||
*
|
||||
* SECURITY NOTE: This must be an App-Specific Password,
|
||||
* NOT the Apple ID password. Users should generate this at:
|
||||
* https://appleid.apple.com/account/manage
|
||||
*/
|
||||
appSpecificPassword?: string;
|
||||
|
||||
/** List of calendar collection hrefs to sync */
|
||||
calendarHrefs: string[];
|
||||
|
||||
/**
|
||||
* User-friendly labels for selected calendars
|
||||
* Map: calendarHref -> displayName
|
||||
*/
|
||||
calendarLabels?: Record<string, string>;
|
||||
|
||||
/** Principal URL for CalDAV discovery */
|
||||
principalUrl?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Discriminated Union Type
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Discriminated union of all calendar source configurations
|
||||
* Use type guards below for type-safe handling
|
||||
*/
|
||||
export type CalendarSource =
|
||||
| UrlIcsSourceConfig
|
||||
| GoogleCalendarSourceConfig
|
||||
| OutlookCalendarSourceConfig
|
||||
| AppleCaldavSourceConfig;
|
||||
|
||||
// ============================================================================
|
||||
// Type Guards
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Type guard: Check if source is a URL ICS source
|
||||
*/
|
||||
export function isUrlIcsSource(
|
||||
source: CalendarSource,
|
||||
): source is UrlIcsSourceConfig {
|
||||
return source.type === "url-ics";
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard: Check if source is a Google Calendar source
|
||||
*/
|
||||
export function isGoogleSource(
|
||||
source: CalendarSource,
|
||||
): source is GoogleCalendarSourceConfig {
|
||||
return source.type === "google";
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard: Check if source is an Outlook Calendar source
|
||||
*/
|
||||
export function isOutlookSource(
|
||||
source: CalendarSource,
|
||||
): source is OutlookCalendarSourceConfig {
|
||||
return source.type === "outlook";
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard: Check if source is an Apple CalDAV source
|
||||
*/
|
||||
export function isAppleSource(
|
||||
source: CalendarSource,
|
||||
): source is AppleCaldavSourceConfig {
|
||||
return source.type === "apple-caldav";
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard: Check if source uses OAuth authentication
|
||||
*/
|
||||
export function isOAuthSource(
|
||||
source: CalendarSource,
|
||||
): source is GoogleCalendarSourceConfig | OutlookCalendarSourceConfig {
|
||||
return source.type === "google" || source.type === "outlook";
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard: Check if source has valid OAuth tokens
|
||||
*/
|
||||
export function hasValidTokens(source: CalendarSource): source is (
|
||||
| GoogleCalendarSourceConfig
|
||||
| OutlookCalendarSourceConfig
|
||||
) & {
|
||||
auth: OAuthTokenData;
|
||||
} {
|
||||
if (!isOAuthSource(source) || !source.auth) {
|
||||
return false;
|
||||
}
|
||||
// Token is valid if it exists and hasn't expired
|
||||
// (with 5 minute buffer for clock skew)
|
||||
const bufferMs = 5 * 60 * 1000;
|
||||
return source.auth.expiresAt > Date.now() + bufferMs;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Configuration Manager Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Calendar integration settings (stored in plugin settings)
|
||||
*/
|
||||
export interface CalendarIntegrationConfig {
|
||||
/** All configured calendar sources */
|
||||
sources: CalendarSource[];
|
||||
|
||||
/** Global sync interval in minutes (default for new sources) */
|
||||
globalRefreshInterval: number;
|
||||
|
||||
/** Maximum cache age in hours */
|
||||
maxCacheAge: number;
|
||||
|
||||
/** Enable automatic background sync */
|
||||
enableBackgroundRefresh: boolean;
|
||||
|
||||
/** Network request timeout in seconds */
|
||||
networkTimeout: number;
|
||||
|
||||
/** Maximum events to load per source */
|
||||
maxEventsPerSource: number;
|
||||
|
||||
/** Show calendar events in calendar views */
|
||||
showInCalendar: boolean;
|
||||
|
||||
/** Show calendar events in task lists */
|
||||
showInTaskLists: boolean;
|
||||
|
||||
/** Default color for events without custom color */
|
||||
defaultEventColor: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default configuration for CalendarIntegrationConfig
|
||||
*/
|
||||
export const DEFAULT_CALENDAR_INTEGRATION_CONFIG: CalendarIntegrationConfig = {
|
||||
sources: [],
|
||||
globalRefreshInterval: 60,
|
||||
maxCacheAge: 24,
|
||||
enableBackgroundRefresh: true,
|
||||
networkTimeout: 30,
|
||||
maxEventsPerSource: 1000,
|
||||
showInCalendar: true,
|
||||
showInTaskLists: true,
|
||||
defaultEventColor: "#3b82f6",
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Factory Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a new URL ICS source with default values
|
||||
*/
|
||||
export function createUrlIcsSource(
|
||||
partial: Partial<UrlIcsSourceConfig> & { name: string; url: string },
|
||||
): UrlIcsSourceConfig {
|
||||
const now = Date.now();
|
||||
return {
|
||||
id: `ics-${now}-${Math.random().toString(36).substring(2, 11)}`,
|
||||
type: "url-ics",
|
||||
enabled: true,
|
||||
refreshInterval: 60,
|
||||
showType: "event",
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Google Calendar source with default values
|
||||
*/
|
||||
export function createGoogleSource(
|
||||
partial: Partial<GoogleCalendarSourceConfig> & { name: string },
|
||||
): GoogleCalendarSourceConfig {
|
||||
const now = Date.now();
|
||||
return {
|
||||
id: `google-${now}-${Math.random().toString(36).substring(2, 11)}`,
|
||||
type: "google",
|
||||
enabled: true,
|
||||
refreshInterval: 60,
|
||||
showType: "event",
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
calendarIds: [],
|
||||
includePrimaryCalendar: true,
|
||||
includeSharedCalendars: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Outlook Calendar source with default values
|
||||
*/
|
||||
export function createOutlookSource(
|
||||
partial: Partial<OutlookCalendarSourceConfig> & { name: string },
|
||||
): OutlookCalendarSourceConfig {
|
||||
const now = Date.now();
|
||||
return {
|
||||
id: `outlook-${now}-${Math.random().toString(36).substring(2, 11)}`,
|
||||
type: "outlook",
|
||||
enabled: true,
|
||||
refreshInterval: 60,
|
||||
showType: "event",
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
calendarIds: [],
|
||||
tenantId: "common",
|
||||
includePrimaryCalendar: true,
|
||||
includeSharedCalendars: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Apple CalDAV source with default values
|
||||
*/
|
||||
export function createAppleSource(
|
||||
partial: Partial<AppleCaldavSourceConfig> & {
|
||||
name: string;
|
||||
username: string;
|
||||
},
|
||||
): AppleCaldavSourceConfig {
|
||||
const now = Date.now();
|
||||
return {
|
||||
id: `apple-${now}-${Math.random().toString(36).substring(2, 11)}`,
|
||||
type: "apple-caldav",
|
||||
enabled: true,
|
||||
refreshInterval: 60,
|
||||
showType: "event",
|
||||
showAllDayEvents: true,
|
||||
showTimedEvents: true,
|
||||
serverUrl: "https://caldav.icloud.com/",
|
||||
calendarHrefs: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Migration Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Legacy IcsSource type for backward compatibility
|
||||
* This represents the old format before multi-provider support
|
||||
*/
|
||||
export interface LegacyIcsSource {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
color?: string;
|
||||
refreshInterval: number;
|
||||
showType: "badge" | "event";
|
||||
showAllDayEvents: boolean;
|
||||
showTimedEvents: boolean;
|
||||
lastFetched?: number;
|
||||
auth?: IcsAuthConfig;
|
||||
filters?: IcsEventFilter;
|
||||
holidayConfig?: IcsHolidayConfig;
|
||||
statusMapping?: IcsStatusMapping;
|
||||
textReplacements?: IcsTextReplacement[];
|
||||
type?: CalendarProviderType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type that accepts both legacy and new calendar sources
|
||||
* Used for reading configuration that may contain old formats
|
||||
*/
|
||||
export type AnyCalendarSource = CalendarSource | LegacyIcsSource;
|
||||
|
||||
/**
|
||||
* Type guard: Check if source is a legacy IcsSource (lacks required type field)
|
||||
*/
|
||||
export function isLegacySource(
|
||||
source: AnyCalendarSource,
|
||||
): source is LegacyIcsSource {
|
||||
return (
|
||||
!source.type ||
|
||||
(source.type === "url-ics" &&
|
||||
"url" in source &&
|
||||
!("createdAt" in source))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize any calendar source to ensure it has all required fields
|
||||
* This handles both legacy IcsSource and new CalendarSource formats
|
||||
*/
|
||||
export function normalizeCalendarSource(
|
||||
source: AnyCalendarSource,
|
||||
): CalendarSource {
|
||||
// If already a proper CalendarSource with type, return as-is (with defaults for optional fields)
|
||||
if (source.type && !isLegacySource(source)) {
|
||||
return source as CalendarSource;
|
||||
}
|
||||
|
||||
// Migrate legacy source to UrlIcsSourceConfig
|
||||
const now = Date.now();
|
||||
const legacySource = source as LegacyIcsSource;
|
||||
return {
|
||||
...legacySource,
|
||||
type: "url-ics",
|
||||
createdAt: legacySource.lastFetched || now,
|
||||
updatedAt: now,
|
||||
lastSynced: legacySource.lastFetched,
|
||||
} as UrlIcsSourceConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an array of sources (handles mixed legacy and new formats)
|
||||
*/
|
||||
export function normalizeCalendarSources(
|
||||
sources: AnyCalendarSource[],
|
||||
): CalendarSource[] {
|
||||
return sources.map(normalizeCalendarSource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate legacy IcsSource to CalendarSource (UrlIcsSourceConfig)
|
||||
* This ensures backward compatibility with existing configurations
|
||||
* @deprecated Use normalizeCalendarSource instead
|
||||
*/
|
||||
export function migrateIcsSourceToCalendarSource(
|
||||
legacySource: LegacyIcsSource,
|
||||
): UrlIcsSourceConfig {
|
||||
const now = Date.now();
|
||||
return {
|
||||
...legacySource,
|
||||
type: "url-ics",
|
||||
createdAt: legacySource.lastFetched || now,
|
||||
updatedAt: now,
|
||||
lastSynced: legacySource.lastFetched,
|
||||
};
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
*/
|
||||
|
||||
import { Task, StandardTaskMetadata } from "@/types/task";
|
||||
import { IcsTask } from "@/types/ics";
|
||||
import { format } from "date-fns";
|
||||
import { isDateOnly } from "@/utils/date/date-utils";
|
||||
|
||||
|
|
@ -43,6 +44,34 @@ interface TaskDateRange {
|
|||
* @returns CalendarEvent object or null if task has no date
|
||||
*/
|
||||
export function taskToCalendarEvent(task: Task): CalendarEvent | null {
|
||||
// Check if this is an ICS task - use icsEvent times directly for accuracy
|
||||
const isIcsTask = (task as any).source?.type === "ics";
|
||||
const icsTask = isIcsTask ? (task as unknown as IcsTask) : null;
|
||||
|
||||
if (icsTask?.icsEvent) {
|
||||
const { dtstart, dtend, allDay } = icsTask.icsEvent;
|
||||
const startFormatted = formatDateForCalendar(dtstart.getTime(), allDay);
|
||||
const endFormatted = dtend
|
||||
? formatDateForCalendar(dtend.getTime(), allDay)
|
||||
: startFormatted;
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
title: task.content,
|
||||
start: startFormatted,
|
||||
end: endFormatted,
|
||||
allDay: allDay,
|
||||
color: icsTask.icsEvent.source?.color || getTaskColor(task),
|
||||
metadata: {
|
||||
originalTask: task,
|
||||
priority: task.metadata.priority,
|
||||
tags: task.metadata.tags,
|
||||
project: task.metadata.project,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Standard task processing
|
||||
const dateRange = calculateTaskDateRange(task);
|
||||
|
||||
if (!dateRange.start) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue