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:
Quorafind 2025-12-17 15:43:36 +08:00
parent 4d3116cfca
commit cfa46714df
11 changed files with 8298 additions and 12 deletions

View file

@ -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[] {

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View 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(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&apos;/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";
}
}

View 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";
}

File diff suppressed because it is too large Load diff

244
src/providers/index.ts Normal file
View 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;
}
}

File diff suppressed because it is too large Load diff

View 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);
}

View 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,
};
}

View file

@ -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) {