From c456c38201a68116639371dd57e3da182f859bb9 Mon Sep 17 00:00:00 2001 From: Quorafind Date: Wed, 17 Dec 2025 15:45:14 +0800 Subject: [PATCH] feat(ics): enhance ICS calendar integration - Extend IcsManager with improved event parsing and caching - Add new ICS source configuration options - Improve IcsSettingsTab UI with better source management - Update ICS types with additional metadata fields - Remove unused styles from ics-settings.scss --- .../features/settings/tabs/IcsSettingsTab.ts | 234 +++--- src/dataflow/sources/IcsSource.ts | 2 +- src/managers/ics-manager.ts | 698 ++++++++++++++++-- src/styles/ics-settings.scss | 5 - src/types/ics.ts | 70 +- 5 files changed, 824 insertions(+), 185 deletions(-) diff --git a/src/components/features/settings/tabs/IcsSettingsTab.ts b/src/components/features/settings/tabs/IcsSettingsTab.ts index b32d9268..1409f3bd 100644 --- a/src/components/features/settings/tabs/IcsSettingsTab.ts +++ b/src/components/features/settings/tabs/IcsSettingsTab.ts @@ -20,6 +20,11 @@ import { IcsTextReplacement, IcsHolidayConfig, } from "@/types/ics"; +import { + isUrlIcsSource, + LegacyIcsSource, + AnyCalendarSource, +} from "@/types/calendar-provider"; import { t } from "@/translations/helper"; import TaskProgressBarPlugin from "@/index"; import "@/styles/ics-settings.scss"; @@ -35,7 +40,7 @@ export class IcsSettingsComponent { constructor( plugin: TaskProgressBarPlugin, containerEl: HTMLElement, - onBack?: () => void + onBack?: () => void, ) { this.plugin = plugin; this.containerEl = containerEl; @@ -47,47 +52,6 @@ export class IcsSettingsComponent { this.containerEl.empty(); this.containerEl.addClass("ics-settings-container"); - const backheader = this.containerEl.createDiv( - "settings-tab-section-header" - ); - // Header with back button - const headerContainer = this.containerEl.createDiv( - "ics-header-container" - ); - - if (this.onBack) { - const button = new ButtonComponent(backheader) - .setClass("header-button") - .onClick(() => { - this.onBack?.(); - }); - - button.buttonEl.createEl( - "span", - { - cls: "header-button-icon", - }, - (el) => { - setIcon(el, "arrow-left"); - } - ); - button.buttonEl.createEl("span", { - cls: "header-button-text", - text: t("Back to main settings"), - }); - } - - headerContainer.createEl("h2", { - text: t("ICS Calendar Integration"), - }); - - headerContainer.createEl("p", { - text: t( - "Configure external calendar sources to display events in your task views." - ), - cls: "ics-description", - }); - // Global settings this.displayGlobalSettings(); @@ -96,7 +60,7 @@ export class IcsSettingsComponent { // Add source button in a styled container const addSourceContainer = this.containerEl.createDiv( - "ics-add-source-container" + "ics-add-source-container", ); const addButton = addSourceContainer.createEl("button", { text: "+ " + t("Add New Calendar Source"), @@ -111,7 +75,7 @@ export class IcsSettingsComponent { private displayGlobalSettings(): void { const globalContainer = this.containerEl.createDiv( - "ics-global-settings" + "ics-global-settings", ); globalContainer.createEl("h3", { text: t("Global Settings") }); @@ -119,7 +83,7 @@ export class IcsSettingsComponent { new Setting(globalContainer) .setName(t("Enable Background Refresh")) .setDesc( - t("Automatically refresh calendar sources in the background") + t("Automatically refresh calendar sources in the background"), ) .addToggle((toggle) => { toggle @@ -208,21 +172,45 @@ export class IcsSettingsComponent { }); } + /** + * Check if a source is a URL-based ICS source (legacy or new format) + */ + private isUrlBasedSource( + source: AnyCalendarSource, + ): source is IcsSource | LegacyIcsSource { + // Legacy sources always have url, new sources need type check + return "url" in source && typeof source.url === "string"; + } + private displaySourcesList(): void { const sourcesContainer = this.containerEl.createDiv("ics-sources-list"); sourcesContainer.createEl("h3", { text: t("Calendar Sources") }); - if (this.config.sources.length === 0) { + // Filter to only show URL-based ICS sources (legacy tab) + const urlSources = this.config.sources.filter((s) => + this.isUrlBasedSource(s), + ); + + if (urlSources.length === 0) { const emptyState = sourcesContainer.createDiv("ics-empty-state"); emptyState.createEl("p", { text: t( - "No calendar sources configured. Add a source to get started." + "No calendar sources configured. Add a source to get started.", ), }); return; } - this.config.sources.forEach((source, index) => { + urlSources.forEach((source) => { + // Find the original index in config.sources for updates + const index = this.config.sources.findIndex( + (s) => s.id === source.id, + ); + if (index === -1) return; + + // Type assertion since we filtered for URL sources + const urlSource = source as IcsSource; + const sourceContainer = sourcesContainer.createDiv("ics-source-item"); @@ -230,37 +218,37 @@ export class IcsSettingsComponent { const sourceHeader = sourceContainer.createDiv("ics-source-header"); const titleContainer = sourceHeader.createDiv("ics-source-title"); - titleContainer.createEl("strong", { text: source.name }); + titleContainer.createEl("strong", { text: urlSource.name }); const statusEl = sourceHeader.createEl("span", { cls: "ics-source-status", }); statusEl.setText( - source.enabled ? t("ICS Enabled") : t("ICS Disabled") + urlSource.enabled ? t("ICS Enabled") : t("ICS Disabled"), ); statusEl.addClass( - source.enabled ? "status-enabled" : "status-disabled" + urlSource.enabled ? "status-enabled" : "status-disabled", ); // Source details const sourceDetails = sourceContainer.createDiv("ics-source-details"); sourceDetails.createEl("div", { - text: `${t("URL")}: ${this.truncateUrl(source.url)}`, - title: source.url, // Show full URL on hover + text: `${t("URL")}: ${this.truncateUrl(urlSource.url)}`, + title: urlSource.url, // Show full URL on hover }); sourceDetails.createEl("div", { - text: `${t("Refresh")}: ${source.refreshInterval}${t("min")}`, + text: `${t("Refresh")}: ${urlSource.refreshInterval}${t("min")}`, }); - if (source.color) { + if (urlSource.color) { const colorDiv = sourceDetails.createEl("div"); colorDiv.createSpan({ text: `${t("Color")}: ` }); colorDiv.createEl("span", { attr: { - style: `display: inline-block; width: 12px; height: 12px; background: ${source.color}; border-radius: 2px; margin-left: 4px; vertical-align: middle;`, + style: `display: inline-block; width: 12px; height: 12px; background: ${urlSource.color}; border-radius: 2px; margin-left: 4px; vertical-align: middle;`, }, }); - colorDiv.createSpan({ text: ` ${source.color}` }); + colorDiv.createSpan({ text: ` ${urlSource.color}` }); } // Source actions - reorganized for better UX @@ -283,7 +271,7 @@ export class IcsSettingsComponent { this.config.sources[index] = updatedSource; this.saveAndRefresh(); }, - source + urlSource, ).open(); }; @@ -302,14 +290,16 @@ export class IcsSettingsComponent { try { const icsManager = this.plugin.getIcsManager(); if (icsManager) { - const result = await icsManager.syncSource(source.id); + const result = await icsManager.syncSource( + urlSource.id, + ); if (result.success) { new Notice(t("Sync completed successfully")); syncButton.removeClass("syncing"); syncButton.addClass("success"); setTimeout( () => syncButton.removeClass("success"), - 2000 + 2000, ); } else { new Notice(t("Sync failed: ") + result.error); @@ -317,7 +307,7 @@ export class IcsSettingsComponent { syncButton.addClass("error"); setTimeout( () => syncButton.removeClass("error"), - 2000 + 2000, ); } } @@ -338,8 +328,8 @@ export class IcsSettingsComponent { // Toggle button const toggleButton = secondaryActions.createEl("button", { - text: source.enabled ? t("Disable") : t("Enable"), - title: source.enabled + text: urlSource.enabled ? t("Disable") : t("Enable"), + title: urlSource.enabled ? t("Disable this source") : t("Enable this source"), }); @@ -359,8 +349,8 @@ export class IcsSettingsComponent { if ( confirm( t( - "Are you sure you want to delete this calendar source?" - ) + "Are you sure you want to delete this calendar source?", + ), ) ) { this.config.sources.splice(index, 1); @@ -403,7 +393,7 @@ class IcsSourceModal extends Modal { constructor( app: App, onSave: (source: IcsSource) => void, - existingSource?: IcsSource + existingSource?: IcsSource, ) { super(app); this.onSave = onSave; @@ -452,12 +442,12 @@ class IcsSourceModal extends Modal { .setName(t("ICS URL")) .setDesc( t( - "URL to the ICS/iCal file (supports http://, https://, and webcal:// protocols)" - ) + "URL to the ICS/iCal file (supports http://, https://, and webcal:// protocols)", + ), ) .addText((text) => { text.setPlaceholder( - "https://example.com/calendar.ics or webcal://example.com/calendar.ics" + "https://example.com/calendar.ics or webcal://example.com/calendar.ics", ) .setValue(this.source.url) .onChange((value) => { @@ -470,16 +460,16 @@ class IcsSourceModal extends Modal { if (conversionResult.success) { const description = WebcalUrlConverter.getConversionDescription( - conversionResult + conversionResult, ); // Find the description element and update it const descEl = text.inputEl.parentElement?.querySelector( - ".setting-item-description" + ".setting-item-description", ); if (descEl) { descEl.textContent = `${t( - "URL to the ICS/iCal file (supports http://, https://, and webcal:// protocols)" + "URL to the ICS/iCal file (supports http://, https://, and webcal:// protocols)", )} - ${description}`; } } @@ -487,11 +477,11 @@ class IcsSourceModal extends Modal { // Reset description for non-webcal URLs const descEl = text.inputEl.parentElement?.querySelector( - ".setting-item-description" + ".setting-item-description", ); if (descEl) { descEl.textContent = t( - "URL to the ICS/iCal file (supports http://, https://, and webcal:// protocols)" + "URL to the ICS/iCal file (supports http://, https://, and webcal:// protocols)", ); } } @@ -541,7 +531,7 @@ class IcsSourceModal extends Modal { new Setting(contentEl) .setName(t("Show Type")) .setDesc( - t("How to display events from this source in calendar views") + t("How to display events from this source in calendar views"), ) .addDropdown((dropdown) => { dropdown @@ -645,7 +635,7 @@ class IcsSourceModal extends Modal { }); textReplacementsContainer.createEl("p", { text: t( - "Configure rules to modify event text using regular expressions" + "Configure rules to modify event text using regular expressions", ), cls: "setting-item-description", }); @@ -657,7 +647,7 @@ class IcsSourceModal extends Modal { // Container for replacement rules const rulesContainer = textReplacementsContainer.createDiv( - "text-replacements-list" + "text-replacements-list", ); const refreshRulesList = () => { @@ -665,7 +655,7 @@ class IcsSourceModal extends Modal { if (this.source.textReplacements!.length === 0) { const emptyState = rulesContainer.createDiv( - "text-replacements-empty" + "text-replacements-empty", ); emptyState.createEl("p", { text: t("No text replacement rules configured"), @@ -674,12 +664,12 @@ class IcsSourceModal extends Modal { } else { this.source.textReplacements!.forEach((rule, index) => { const ruleContainer = rulesContainer.createDiv( - "text-replacement-rule" + "text-replacement-rule", ); // Rule header const ruleHeader = ruleContainer.createDiv( - "text-replacement-header" + "text-replacement-header", ); const titleEl = ruleHeader.createEl("strong", { text: rule.name || `Rule ${index + 1}`, @@ -694,7 +684,7 @@ class IcsSourceModal extends Modal { // Rule details const ruleDetails = ruleContainer.createDiv( - "text-replacement-details" + "text-replacement-details", ); ruleDetails.createEl("div", { text: `${t("Target")}: ${rule.target}`, @@ -710,7 +700,7 @@ class IcsSourceModal extends Modal { // Rule actions const ruleActions = ruleContainer.createDiv( - "text-replacement-actions" + "text-replacement-actions", ); const editButton = ruleActions.createEl("button", { @@ -725,7 +715,7 @@ class IcsSourceModal extends Modal { updatedRule; refreshRulesList(); }, - rule + rule, ).open(); }; @@ -746,8 +736,8 @@ class IcsSourceModal extends Modal { if ( confirm( t( - "Are you sure you want to delete this text replacement rule?" - ) + "Are you sure you want to delete this text replacement rule?", + ), ) ) { this.source.textReplacements!.splice(index, 1); @@ -762,7 +752,7 @@ class IcsSourceModal extends Modal { // Add rule button const addRuleContainer = textReplacementsContainer.createDiv( - "text-replacement-add" + "text-replacement-add", ); const addButton = addRuleContainer.createEl("button", { text: "+ " + t("Add Text Replacement Rule"), @@ -791,7 +781,7 @@ class IcsSourceModal extends Modal { .setClass("auth-field") .addText((text) => { text.setValue( - this.source.auth?.username || "" + this.source.auth?.username || "", ).onChange((value) => { if (this.source.auth) { this.source.auth.username = value; @@ -804,7 +794,7 @@ class IcsSourceModal extends Modal { .setClass("auth-field") .addText((text) => { text.setValue( - this.source.auth?.password || "" + this.source.auth?.password || "", ).onChange((value) => { if (this.source.auth) { this.source.auth.password = value; @@ -824,7 +814,7 @@ class IcsSourceModal extends Modal { if (this.source.auth) { this.source.auth.token = value; } - } + }, ); }); break; @@ -839,8 +829,8 @@ class IcsSourceModal extends Modal { JSON.stringify( this.source.auth?.headers || {}, null, - 2 - ) + 2, + ), ).onChange((value) => { try { const headers = JSON.parse(value); @@ -910,7 +900,7 @@ class IcsSourceModal extends Modal { new Setting(statusContainer) .setName(t("Enable Status Mapping")) .setDesc( - t("Automatically map ICS events to specific task statuses") + t("Automatically map ICS events to specific task statuses"), ) .addToggle((toggle) => { toggle @@ -955,7 +945,7 @@ class IcsSourceModal extends Modal { new Setting(container) .setName(t("Maximum Gap Days")) .setDesc( - t("Maximum days between events to consider them consecutive") + t("Maximum days between events to consider them consecutive"), ) .setClass("holiday-setting") .addText((text) => { @@ -1003,14 +993,14 @@ class IcsSourceModal extends Modal { new Setting(patternsContainer) .setName(t("Summary Patterns")) .setDesc( - t("Regex patterns to match in event titles (one per line)") + t("Regex patterns to match in event titles (one per line)"), ) .addTextArea((text) => { text.setValue( ( this.source.holidayConfig!.detectionPatterns.summary || [] - ).join("\n") + ).join("\n"), ).onChange((value) => { this.source.holidayConfig!.detectionPatterns.summary = value .split("\n") @@ -1028,7 +1018,7 @@ class IcsSourceModal extends Modal { ( this.source.holidayConfig!.detectionPatterns.keywords || [] - ).join("\n") + ).join("\n"), ).onChange((value) => { this.source.holidayConfig!.detectionPatterns.keywords = value @@ -1042,14 +1032,14 @@ class IcsSourceModal extends Modal { new Setting(patternsContainer) .setName(t("Categories")) .setDesc( - t("Event categories that indicate holidays (one per line)") + t("Event categories that indicate holidays (one per line)"), ) .addTextArea((text) => { text.setValue( ( this.source.holidayConfig!.detectionPatterns .categories || [] - ).join("\n") + ).join("\n"), ).onChange((value) => { this.source.holidayConfig!.detectionPatterns.categories = value @@ -1064,14 +1054,14 @@ class IcsSourceModal extends Modal { .setName(t("Group Display Format")) .setDesc( t( - "Format for grouped holiday display. Use {title}, {count}, {startDate}, {endDate}" - ) + "Format for grouped holiday display. Use {title}, {count}, {startDate}, {endDate}", + ), ) .setClass("holiday-setting") .addText((text) => { text.setPlaceholder("{title} ({count} days)") .setValue( - this.source.holidayConfig!.groupDisplayFormat || "" + this.source.holidayConfig!.groupDisplayFormat || "", ) .onChange((value) => { this.source.holidayConfig!.groupDisplayFormat = @@ -1083,7 +1073,7 @@ class IcsSourceModal extends Modal { private refreshStatusMappingSettings(container: HTMLElement): void { // Remove existing status mapping settings const existingSettings = container.querySelectorAll( - ".status-mapping-setting" + ".status-mapping-setting", ); existingSettings.forEach((setting) => setting.remove()); @@ -1138,7 +1128,7 @@ class IcsSourceModal extends Modal { .addOption("/", t("Status In Progress")) .addOption("?", t("Status Question")) .setValue( - this.source.statusMapping!.timingRules.currentEvents + this.source.statusMapping!.timingRules.currentEvents, ) .onChange((value) => { this.source.statusMapping!.timingRules.currentEvents = @@ -1158,7 +1148,7 @@ class IcsSourceModal extends Modal { .addOption("/", t("Status In Progress")) .addOption("?", t("Status Question")) .setValue( - this.source.statusMapping!.timingRules.futureEvents + this.source.statusMapping!.timingRules.futureEvents, ) .onChange((value) => { this.source.statusMapping!.timingRules.futureEvents = @@ -1171,7 +1161,7 @@ class IcsSourceModal extends Modal { propertyContainer.createEl("h4", { text: t("Property Rules") }); propertyContainer.createEl("p", { text: t( - "Optional rules based on event properties (higher priority than timing rules)" + "Optional rules based on event properties (higher priority than timing rules)", ), cls: "setting-item-description", }); @@ -1195,7 +1185,7 @@ class IcsSourceModal extends Modal { .addOption("?", t("Status Question")) .setValue( this.source.statusMapping!.propertyRules!.holidayMapping - ?.holidayStatus || "" + ?.holidayStatus || "", ) .onChange((value) => { if ( @@ -1222,8 +1212,8 @@ class IcsSourceModal extends Modal { .setName(t("Category Mapping")) .setDesc( t( - "Map specific categories to statuses (format: category:status, one per line)" - ) + "Map specific categories to statuses (format: category:status, one per line)", + ), ) .addTextArea((text) => { const categoryMapping = @@ -1272,11 +1262,11 @@ class IcsSourceModal extends Modal { // Use WebcalUrlConverter for URL validation const conversionResult = WebcalUrlConverter.convertWebcalUrl( - this.source.url + this.source.url, ); if (!conversionResult.success) { new Notice( - t("Please enter a valid URL") + ": " + conversionResult.error + t("Please enter a valid URL") + ": " + conversionResult.error, ); return false; } @@ -1300,7 +1290,7 @@ class TextReplacementModal extends Modal { constructor( app: App, onSave: (rule: IcsTextReplacement) => void, - existingRule?: IcsTextReplacement + existingRule?: IcsTextReplacement, ) { super(app); this.onSave = onSave; @@ -1385,11 +1375,11 @@ class TextReplacementModal extends Modal { if (this.rule.pattern && input) { const regex = new RegExp( this.rule.pattern, - this.rule.flags || "g" + this.rule.flags || "g", ); const result = input.replace(regex, this.rule.replacement); const resultSpan = testOutput.querySelector( - ".test-result" + ".test-result", ) as HTMLElement; if (resultSpan) { resultSpan.textContent = result; @@ -1398,7 +1388,7 @@ class TextReplacementModal extends Modal { } } else { const resultSpan = testOutput.querySelector( - ".test-result" + ".test-result", ) as HTMLElement; if (resultSpan) { resultSpan.textContent = input || ""; @@ -1407,7 +1397,7 @@ class TextReplacementModal extends Modal { } } catch (error) { const resultSpan = testOutput.querySelector( - ".test-result" + ".test-result", ) as HTMLElement; if (resultSpan) { resultSpan.textContent = "Invalid regex pattern"; @@ -1421,8 +1411,8 @@ class TextReplacementModal extends Modal { .setName(t("Pattern (Regular Expression)")) .setDesc( t( - "Regular expression pattern to match. Use parentheses for capture groups." - ) + "Regular expression pattern to match. Use parentheses for capture groups.", + ), ) .addText((text) => { text.setPlaceholder("^Meeting: ") @@ -1440,8 +1430,8 @@ class TextReplacementModal extends Modal { .setName(t("Replacement")) .setDesc( t( - "Text to replace matches with. Use $1, $2, etc. for capture groups." - ) + "Text to replace matches with. Use $1, $2, etc. for capture groups.", + ), ) .addText((text) => { text.setPlaceholder("") @@ -1459,8 +1449,8 @@ class TextReplacementModal extends Modal { .setName(t("Regex Flags")) .setDesc( t( - "Regular expression flags (e.g., 'g' for global, 'i' for case-insensitive)" - ) + "Regular expression flags (e.g., 'g' for global, 'i' for case-insensitive)", + ), ) .addText((text) => { text.setPlaceholder("g") @@ -1521,7 +1511,7 @@ class TextReplacementModal extends Modal { text.setPlaceholder("Meeting: Weekly Standup").onChange( (value) => { updateTestOutput(value); - } + }, ); }); diff --git a/src/dataflow/sources/IcsSource.ts b/src/dataflow/sources/IcsSource.ts index 392ec902..f436a307 100644 --- a/src/dataflow/sources/IcsSource.ts +++ b/src/dataflow/sources/IcsSource.ts @@ -59,7 +59,7 @@ export class IcsSource { */ private subscribeToIcsUpdates(): void { // Listen for ICS cache updates - this.app.workspace.on("ics-cache-updated" as any, () => { + this.app.workspace.on("task-genius:ics-cache-updated", () => { console.log("[IcsSource] ICS cache updated, reloading events..."); this.loadAndEmitIcsEvents(); }); diff --git a/src/managers/ics-manager.ts b/src/managers/ics-manager.ts index b545f8bb..46114508 100644 --- a/src/managers/ics-manager.ts +++ b/src/managers/ics-manager.ts @@ -15,7 +15,21 @@ import { IcsTextReplacement, IcsEventWithHoliday, } from "../types/ics"; -import { Task, ExtendedMetadata, EnhancedStandardTaskMetadata } from "../types/task"; +import { + AnyCalendarSource, + LegacyIcsSource, + UrlIcsSourceConfig, + isUrlIcsSource, + isGoogleSource, + isOutlookSource, + isAppleSource, + CalendarSource, +} from "../types/calendar-provider"; +import { + Task, + ExtendedMetadata, + EnhancedStandardTaskMetadata, +} from "../types/task"; import { IcsParser } from "../parsers/ics-parser"; import { HolidayDetector } from "../parsers/holiday-detector"; import { StatusMapper } from "../parsers/ics-status-mapper"; @@ -24,6 +38,12 @@ import { TaskProgressBarSettings } from "../common/setting-definition"; import { TimeParsingService } from "../services/time-parsing-service"; import { TimeComponent } from "../types/time-parsing"; import TaskProgressBarPlugin from "src"; +import { CalendarAuthManager } from "./calendar-auth-manager"; +import { + CalendarSourceManager, + WriteResult, + UpdateEventOptions, +} from "../providers/index"; export class IcsManager extends Component { private config: IcsManagerConfig; @@ -33,6 +53,7 @@ export class IcsManager extends Component { private onEventsUpdated?: (sourceId: string, events: IcsEvent[]) => void; private pluginSettings: TaskProgressBarSettings; private timeParsingService?: TimeParsingService; + private calendarSourceManager?: CalendarSourceManager; private plugin?: TaskProgressBarPlugin; @@ -41,12 +62,43 @@ export class IcsManager extends Component { pluginSettings: TaskProgressBarSettings, plugin?: TaskProgressBarPlugin, timeParsingService?: TimeParsingService, + authManager?: CalendarAuthManager, ) { super(); this.config = config; this.pluginSettings = pluginSettings; this.plugin = plugin; this.timeParsingService = timeParsingService; + + // Initialize CalendarSourceManager for OAuth providers + if (authManager) { + this.calendarSourceManager = new CalendarSourceManager(authManager); + } + } + + /** + * Check if a source is a URL-based ICS source (legacy or new format) + * These are the only sources this manager can fetch directly + */ + private isUrlBasedSource( + source: AnyCalendarSource, + ): source is IcsSource | LegacyIcsSource | UrlIcsSourceConfig { + return "url" in source && typeof source.url === "string"; + } + + /** + * Get URL-based sources from configuration + */ + private getUrlBasedSources(): ( + | IcsSource + | LegacyIcsSource + | UrlIcsSourceConfig + )[] { + return this.config.sources.filter((s) => this.isUrlBasedSource(s)) as ( + | IcsSource + | LegacyIcsSource + | UrlIcsSourceConfig + )[]; } /** @@ -157,6 +209,7 @@ export class IcsManager extends Component { console.log("source", source, "sourceId", sourceId); console.log("cacheEntry events count", cacheEntry.events.length); + // Process all enabled sources (URL-based and OAuth-based) if (source?.enabled) { console.log("Source is enabled, applying filters"); // Apply filters if configured @@ -201,6 +254,7 @@ export class IcsManager extends Component { ); console.log("Cache entry events count:", cacheEntry.events.length); + // Process all enabled sources (URL-based and OAuth-based) if (source?.enabled) { // Apply filters first const filteredEvents = this.applyFilters( @@ -210,13 +264,17 @@ export class IcsManager extends Component { console.log("Filtered events count:", filteredEvents.length); - // Apply holiday detection if configured + // Apply holiday detection if configured (only available on URL/legacy sources) let processedEvents: IcsEventWithHoliday[]; - if (source.holidayConfig?.enabled) { + const holidayConfig = + "holidayConfig" in source + ? source.holidayConfig + : undefined; + if (holidayConfig?.enabled) { processedEvents = HolidayDetector.processEventsWithHolidayDetection( filteredEvents, - source.holidayConfig, + holidayConfig, ); } else { // Convert to IcsEventWithHoliday format without holiday detection @@ -323,6 +381,7 @@ export class IcsManager extends Component { const cacheEntry = this.cache.get(sourceId); const source = this.config.sources.find((s) => s.id === sourceId); + // Handle all enabled sources if (!cacheEntry || !source?.enabled) { return []; } @@ -387,7 +446,7 @@ export class IcsManager extends Component { project: event.source.name, context: processedEvent.location, heading: [], - + // Enhanced time components ...enhancedMetadata, }, @@ -397,12 +456,15 @@ export class IcsManager extends Component { description: processedEvent.description, location: processedEvent.location, }, - readonly: true, + // OAuth providers (Google, Outlook, Apple) support two-way sync; URL ICS is read-only + readonly: !this.isOAuthSource(event.source as AnyCalendarSource), badge: event.source.showType === "badge", source: { type: "ics", name: event.source.name, id: event.source.id, + providerType: + (event.source as AnyCalendarSource).type || "url-ics", }, }; @@ -416,7 +478,7 @@ export class IcsManager extends Component { event: IcsEventWithHoliday, ): Task & { icsEvent: IcsEvent; - readonly: true; + readonly: boolean; badge: boolean; source: { type: "ics"; name: string; id: string }; } { @@ -466,7 +528,7 @@ export class IcsManager extends Component { project: event.source.name, context: processedEvent.location, heading: [], - + // Enhanced time components ...enhancedMetadata, } as any, // Use any to allow additional holiday fields @@ -476,12 +538,15 @@ export class IcsManager extends Component { description: processedEvent.description, location: processedEvent.location, }, - readonly: true, + // OAuth providers (Google, Outlook, Apple) support two-way sync; URL ICS is read-only + readonly: !this.isOAuthSource(event.source as AnyCalendarSource), badge: event.source.showType === "badge", source: { type: "ics", name: event.source.name, id: event.source.id, + providerType: + (event.source as AnyCalendarSource).type || "url-ics", }, }; @@ -493,7 +558,7 @@ export class IcsManager extends Component { */ private extractTimeComponentsFromIcsEvent( event: IcsEvent, - processedEvent: IcsEvent + processedEvent: IcsEvent, ): Partial { if (!this.timeParsingService) { return {}; @@ -501,11 +566,14 @@ export class IcsManager extends Component { try { // Create time components from ICS event times - const timeComponents: EnhancedStandardTaskMetadata["timeComponents"] = {}; + const timeComponents: EnhancedStandardTaskMetadata["timeComponents"] = + {}; // Extract time from ICS dtstart (start time) if (event.dtstart && !event.allDay) { - const startTimeComponent = this.createTimeComponentFromDate(event.dtstart); + const startTimeComponent = this.createTimeComponentFromDate( + event.dtstart, + ); if (startTimeComponent) { timeComponents.startTime = startTimeComponent; timeComponents.scheduledTime = startTimeComponent; // ICS events are typically scheduled @@ -514,29 +582,39 @@ export class IcsManager extends Component { // Extract time from ICS dtend (end time) if (event.dtend && !event.allDay) { - const endTimeComponent = this.createTimeComponentFromDate(event.dtend); + const endTimeComponent = this.createTimeComponentFromDate( + event.dtend, + ); if (endTimeComponent) { timeComponents.endTime = endTimeComponent; timeComponents.dueTime = endTimeComponent; // End time can be considered due time - + // Create range relationship if both start and end exist if (timeComponents.startTime) { timeComponents.startTime.isRange = true; - timeComponents.startTime.rangePartner = endTimeComponent; + timeComponents.startTime.rangePartner = + endTimeComponent; endTimeComponent.isRange = true; - endTimeComponent.rangePartner = timeComponents.startTime; + endTimeComponent.rangePartner = + timeComponents.startTime; } } } // Also parse time components from event description and summary if available - let descriptionTimeComponents: EnhancedStandardTaskMetadata["timeComponents"] = {}; - const textToParse = [processedEvent.summary, processedEvent.description, processedEvent.location] + let descriptionTimeComponents: EnhancedStandardTaskMetadata["timeComponents"] = + {}; + const textToParse = [ + processedEvent.summary, + processedEvent.description, + processedEvent.location, + ] .filter(Boolean) - .join(' '); - + .join(" "); + if (textToParse.trim()) { - const { timeComponents: parsedComponents } = this.timeParsingService.parseTimeComponents(textToParse); + const { timeComponents: parsedComponents } = + this.timeParsingService.parseTimeComponents(textToParse); descriptionTimeComponents = parsedComponents; } @@ -548,7 +626,10 @@ export class IcsManager extends Component { }; // Create enhanced datetime objects - const enhancedDates = this.createEnhancedDateTimesFromIcs(event, mergedTimeComponents); + const enhancedDates = this.createEnhancedDateTimesFromIcs( + event, + mergedTimeComponents, + ); const enhancedMetadata: Partial = {}; @@ -562,7 +643,10 @@ export class IcsManager extends Component { return enhancedMetadata; } catch (error) { - console.error(`[IcsManager] Failed to extract time components from ICS event ${event.uid}:`, error); + console.error( + `[IcsManager] Failed to extract time components from ICS event ${event.uid}:`, + error, + ); return {}; } } @@ -576,13 +660,13 @@ export class IcsManager extends Component { } // Format time as HH:MM or HH:MM:SS depending on whether seconds are present - const hours = date.getUTCHours().toString().padStart(2, '0'); - const minutes = date.getUTCMinutes().toString().padStart(2, '0'); + const hours = date.getUTCHours().toString().padStart(2, "0"); + const minutes = date.getUTCMinutes().toString().padStart(2, "0"); const seconds = date.getUTCSeconds(); - + let originalText = `${hours}:${minutes}`; if (seconds > 0) { - originalText += `:${seconds.toString().padStart(2, '0')}`; + originalText += `:${seconds.toString().padStart(2, "0")}`; } return { @@ -599,7 +683,7 @@ export class IcsManager extends Component { */ private createEnhancedDateTimesFromIcs( event: IcsEvent, - timeComponents: EnhancedStandardTaskMetadata["timeComponents"] + timeComponents: EnhancedStandardTaskMetadata["timeComponents"], ): EnhancedStandardTaskMetadata["enhancedDates"] { const enhancedDates: EnhancedStandardTaskMetadata["enhancedDates"] = {}; @@ -618,41 +702,65 @@ export class IcsManager extends Component { // try to combine the date from ICS with the parsed time components if (event.allDay && timeComponents) { const eventDate = new Date(event.dtstart); - + if (timeComponents.startTime) { const startDateTime = new Date(eventDate); - startDateTime.setHours(timeComponents.startTime.hour, timeComponents.startTime.minute, timeComponents.startTime.second || 0); + startDateTime.setHours( + timeComponents.startTime.hour, + timeComponents.startTime.minute, + timeComponents.startTime.second || 0, + ); enhancedDates.startDateTime = startDateTime; enhancedDates.scheduledDateTime = startDateTime; } - + if (timeComponents.endTime) { const endDateTime = new Date(eventDate); - endDateTime.setHours(timeComponents.endTime.hour, timeComponents.endTime.minute, timeComponents.endTime.second || 0); - + endDateTime.setHours( + timeComponents.endTime.hour, + timeComponents.endTime.minute, + timeComponents.endTime.second || 0, + ); + // Handle midnight crossing for time ranges - if (timeComponents.startTime && timeComponents.endTime.hour < timeComponents.startTime.hour) { + if ( + timeComponents.startTime && + timeComponents.endTime.hour < timeComponents.startTime.hour + ) { endDateTime.setDate(endDateTime.getDate() + 1); } - + enhancedDates.endDateTime = endDateTime; enhancedDates.dueDateTime = endDateTime; } - + if (timeComponents.dueTime && !enhancedDates.dueDateTime) { const dueDateTime = new Date(eventDate); - dueDateTime.setHours(timeComponents.dueTime.hour, timeComponents.dueTime.minute, timeComponents.dueTime.second || 0); + dueDateTime.setHours( + timeComponents.dueTime.hour, + timeComponents.dueTime.minute, + timeComponents.dueTime.second || 0, + ); enhancedDates.dueDateTime = dueDateTime; } - - if (timeComponents.scheduledTime && !enhancedDates.scheduledDateTime) { + + if ( + timeComponents.scheduledTime && + !enhancedDates.scheduledDateTime + ) { const scheduledDateTime = new Date(eventDate); - scheduledDateTime.setHours(timeComponents.scheduledTime.hour, timeComponents.scheduledTime.minute, timeComponents.scheduledTime.second || 0); + scheduledDateTime.setHours( + timeComponents.scheduledTime.hour, + timeComponents.scheduledTime.minute, + timeComponents.scheduledTime.second || 0, + ); enhancedDates.scheduledDateTime = scheduledDateTime; } } - return Object.keys(enhancedDates).length > 0 ? enhancedDates : undefined; + return Object.keys(enhancedDates).length > 0 + ? enhancedDates + : undefined; } /** @@ -688,6 +796,17 @@ export class IcsManager extends Component { return undefined; } + /** + * Check if a source is an OAuth-based source (Google, Outlook, Apple) + */ + private isOAuthSource(source: AnyCalendarSource): boolean { + return ( + isGoogleSource(source as CalendarSource) || + isOutlookSource(source as CalendarSource) || + isAppleSource(source as CalendarSource) + ); + } + /** * Manually sync a specific source */ @@ -697,6 +816,25 @@ export class IcsManager extends Component { throw new Error(`Source not found: ${sourceId}`); } + // Handle OAuth sources via CalendarSourceManager + if (this.isOAuthSource(source)) { + if (!this.calendarSourceManager) { + return { + success: false, + error: "OAuth provider manager not initialized. Please configure the calendar authentication first.", + timestamp: Date.now(), + }; + } + return this.syncOAuthSource(source as CalendarSource); + } + + // Only URL-based sources can be synced by this manager directly + if (!this.isUrlBasedSource(source)) { + throw new Error( + `Source ${sourceId} is not a supported calendar source type`, + ); + } + this.updateSyncStatus(sourceId, { status: "syncing" }); try { @@ -784,15 +922,136 @@ export class IcsManager extends Component { const syncPromises = this.config.sources .filter((source) => source.enabled) .map(async (source) => { - const result = await this.syncSource(source.id); - results.set(source.id, result); - return result; + try { + const result = await this.syncSource(source.id); + results.set(source.id, result); + return result; + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : "Unknown error"; + console.warn( + `Failed to sync source ${source.id}:`, + errorMessage, + ); + const failedResult: IcsFetchResult = { + success: false, + error: errorMessage, + timestamp: Date.now(), + }; + results.set(source.id, failedResult); + return failedResult; + } }); await Promise.allSettled(syncPromises); return results; } + /** + * Sync an OAuth-based source (Google, Outlook, Apple CalDAV) + */ + private async syncOAuthSource( + source: CalendarSource, + ): Promise { + if (!this.calendarSourceManager) { + return { + success: false, + error: "Calendar Source Manager not initialized", + timestamp: Date.now(), + }; + } + + this.updateSyncStatus(source.id, { status: "syncing" }); + + try { + const provider = this.calendarSourceManager.getProvider(source); + + // Calculate date range for sync: -30 days to +90 days (120 day window) + const now = new Date(); + const start = new Date(now); + start.setDate(now.getDate() - 30); + const end = new Date(now); + end.setDate(now.getDate() + 90); + + console.log( + `[IcsManager] Syncing OAuth source ${source.id} (${source.type})...`, + ); + + const events = await provider.getEvents({ + range: { start, end }, + }); + + console.log( + `[IcsManager] Fetched ${events.length} events from OAuth source ${source.id}`, + ); + + // Update cache + const timestamp = Date.now(); + const cacheEntry: IcsCacheEntry = { + sourceId: source.id, + events, + timestamp, + expiresAt: timestamp + this.config.maxCacheAge * 60 * 60 * 1000, + }; + this.cache.set(source.id, cacheEntry); + + // Update sync status + this.updateSyncStatus(source.id, { + status: "idle", + lastSync: timestamp, + eventCount: events.length, + }); + + // Notify listeners + this.onEventsUpdated?.(source.id, events); + + // Trigger workspace event so IcsSource can reload + try { + if (this.plugin?.app?.workspace) { + this.plugin.app.workspace.trigger( + "task-genius:ics-cache-updated", + ); + } + } catch (e) { + console.warn( + "[IcsManager] Failed to trigger ics-cache-updated", + e, + ); + } + + return { + success: true, + data: { + events, + errors: [], + metadata: {}, + }, + timestamp, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + + console.warn( + `[IcsManager] OAuth sync failed for source ${source.id}:`, + error, + ); + + this.updateSyncStatus(source.id, { + status: "error", + error: errorMessage, + }); + + return { + success: false, + error: errorMessage, + timestamp: Date.now(), + }; + } + } + /** * Get sync status for a source */ @@ -814,6 +1073,41 @@ export class IcsManager extends Component { this.cache.delete(sourceId); } + /** + * Update cache for a specific source (used by OAuth providers like Google Calendar) + */ + updateCacheForSource(sourceId: string, events: IcsEvent[]): void { + const now = Date.now(); + const cacheEntry: IcsCacheEntry = { + sourceId, + events, + timestamp: now, + expiresAt: now + this.config.maxCacheAge * 60 * 60 * 1000, + }; + this.cache.set(sourceId, cacheEntry); + + // Update sync status + this.updateSyncStatus(sourceId, { + status: "idle", + lastSync: now, + eventCount: events.length, + }); + + // Notify listeners + this.onEventsUpdated?.(sourceId, events); + + // Trigger workspace event so IcsSource can reload + try { + if (this.plugin?.app?.workspace) { + this.plugin.app.workspace.trigger( + "task-genius:ics-cache-updated", + ); + } + } catch (e) { + console.warn("[IcsManager] Failed to trigger ics-cache-updated", e); + } + } + /** * Clear all cache */ @@ -824,7 +1118,9 @@ export class IcsManager extends Component { /** * Fetch ICS data from a source */ - private async fetchIcsData(source: IcsSource): Promise { + private async fetchIcsData( + source: IcsSource | LegacyIcsSource | UrlIcsSourceConfig, + ): Promise { try { // Convert webcal URL if needed const conversionResult = WebcalUrlConverter.convertWebcalUrl( @@ -948,14 +1244,19 @@ export class IcsManager extends Component { /** * Apply filters to events + * @param events - Events to filter + * @param source - Source configuration (any calendar source type) */ - private applyFilters(events: IcsEvent[], source: IcsSource): IcsEvent[] { + private applyFilters( + events: IcsEvent[], + source: AnyCalendarSource, + ): IcsEvent[] { let filteredEvents = [...events]; console.log("applyFilters: initial events count", events.length); console.log("applyFilters: source config", { showAllDayEvents: source.showAllDayEvents, showTimedEvents: source.showTimedEvents, - filters: source.filters, + filters: "filters" in source ? source.filters : undefined, }); // Apply event type filters @@ -974,12 +1275,13 @@ export class IcsManager extends Component { ); } - // Apply custom filters - if (source.filters) { + // Apply custom filters (only for URL-based sources that have filters) + const filters = "filters" in source ? source.filters : undefined; + if (filters) { filteredEvents = filteredEvents.filter((event) => { // Include filters - if (source.filters!.include) { - const include = source.filters!.include; + if (filters.include) { + const include = filters.include; let shouldInclude = true; if (include.summary?.length) { @@ -1018,8 +1320,8 @@ export class IcsManager extends Component { } // Exclude filters - if (source.filters!.exclude) { - const exclude = source.filters!.exclude; + if (filters.exclude) { + const exclude = filters.exclude; if (exclude.summary?.length) { if ( @@ -1300,4 +1602,294 @@ export class IcsManager extends Component { this.stopBackgroundRefresh(); super.onunload(); } + + // ========================================================================= + // Two-Way Sync Methods (for OAuth providers: Google, Outlook, Apple) + // ========================================================================= + + /** + * Check if a source supports write operations (two-way sync) + */ + supportsWrite(sourceId: string): boolean { + const source = this.config.sources.find((s) => s.id === sourceId); + if (!source || !this.calendarSourceManager) { + return false; + } + + // Only OAuth sources support write operations + if (!this.isOAuthSource(source)) { + return false; + } + + try { + const provider = this.calendarSourceManager.getProvider( + source as CalendarSource, + ); + return provider.supportsWrite(); + } catch { + return false; + } + } + + /** + * Check if user can write to a specific calendar + */ + canWriteToCalendar(sourceId: string, calendarId?: string): boolean { + const source = this.config.sources.find((s) => s.id === sourceId); + if (!source || !this.calendarSourceManager) { + return false; + } + + if (!this.isOAuthSource(source)) { + return false; + } + + try { + const provider = this.calendarSourceManager.getProvider( + source as CalendarSource, + ); + return provider.canWriteToCalendar(calendarId); + } catch { + return false; + } + } + + /** + * Create a new event in the calendar + * + * @param sourceId - The calendar source ID + * @param event - The event to create + * @param calendarId - Optional calendar ID (defaults to primary) + * @returns WriteResult with success status and created event + */ + async createEvent( + sourceId: string, + event: IcsEvent, + calendarId?: string, + ): Promise { + const source = this.config.sources.find((s) => s.id === sourceId); + if (!source) { + return { + success: false, + error: `Source not found: ${sourceId}`, + }; + } + + if (!this.calendarSourceManager) { + return { + success: false, + error: "Calendar Source Manager not initialized", + }; + } + + if (!this.isOAuthSource(source)) { + return { + success: false, + error: "Source does not support write operations (ICS URLs are read-only)", + }; + } + + try { + const provider = this.calendarSourceManager.getProvider( + source as CalendarSource, + ); + const result = await provider.createEvent(event, calendarId); + + // If successful, update local cache + if (result.success && result.event) { + this.addEventToCache(sourceId, result.event); + } + + return result; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + console.error( + `[IcsManager] Failed to create event in source ${sourceId}:`, + error, + ); + return { + success: false, + error: errorMessage, + }; + } + } + + /** + * Update an existing event in the calendar + * + * @param sourceId - The calendar source ID + * @param options - Update options including the event and optional original event for conflict detection + * @returns WriteResult with success status and updated event + */ + async updateEvent( + sourceId: string, + options: UpdateEventOptions, + ): Promise { + const source = this.config.sources.find((s) => s.id === sourceId); + if (!source) { + return { + success: false, + error: `Source not found: ${sourceId}`, + }; + } + + if (!this.calendarSourceManager) { + return { + success: false, + error: "Calendar Source Manager not initialized", + }; + } + + if (!this.isOAuthSource(source)) { + return { + success: false, + error: "Source does not support write operations (ICS URLs are read-only)", + }; + } + + try { + const provider = this.calendarSourceManager.getProvider( + source as CalendarSource, + ); + const result = await provider.updateEvent(options); + + // If successful, update local cache + if (result.success && result.event) { + this.updateEventInCache(sourceId, result.event); + } + + return result; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + console.error( + `[IcsManager] Failed to update event in source ${sourceId}:`, + error, + ); + return { + success: false, + error: errorMessage, + }; + } + } + + /** + * Delete an event from the calendar + * + * @param sourceId - The calendar source ID + * @param eventId - The provider event ID (providerEventId field of IcsEvent) + * @param calendarId - Optional calendar ID + * @param etag - Optional ETag for conflict detection + * @returns WriteResult with success status + */ + async deleteEvent( + sourceId: string, + eventId: string, + calendarId?: string, + etag?: string, + ): Promise { + const source = this.config.sources.find((s) => s.id === sourceId); + if (!source) { + return { + success: false, + error: `Source not found: ${sourceId}`, + }; + } + + if (!this.calendarSourceManager) { + return { + success: false, + error: "Calendar Source Manager not initialized", + }; + } + + if (!this.isOAuthSource(source)) { + return { + success: false, + error: "Source does not support write operations (ICS URLs are read-only)", + }; + } + + try { + const provider = this.calendarSourceManager.getProvider( + source as CalendarSource, + ); + const result = await provider.deleteEvent( + eventId, + calendarId, + etag, + ); + + // If successful, remove from local cache + if (result.success) { + this.removeEventFromCache(sourceId, eventId); + } + + return result; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + console.error( + `[IcsManager] Failed to delete event from source ${sourceId}:`, + error, + ); + return { + success: false, + error: errorMessage, + }; + } + } + + /** + * Add an event to the local cache + */ + private addEventToCache(sourceId: string, event: IcsEvent): void { + const cacheEntry = this.cache.get(sourceId); + if (cacheEntry) { + cacheEntry.events.push(event); + cacheEntry.timestamp = Date.now(); + + // Notify listeners + this.onEventsUpdated?.(sourceId, cacheEntry.events); + } + } + + /** + * Update an event in the local cache + */ + private updateEventInCache(sourceId: string, event: IcsEvent): void { + const cacheEntry = this.cache.get(sourceId); + if (cacheEntry) { + const index = cacheEntry.events.findIndex( + (e) => e.providerEventId === event.providerEventId, + ); + if (index !== -1) { + cacheEntry.events[index] = event; + } else { + // Event not found in cache, add it + cacheEntry.events.push(event); + } + cacheEntry.timestamp = Date.now(); + + // Notify listeners + this.onEventsUpdated?.(sourceId, cacheEntry.events); + } + } + + /** + * Remove an event from the local cache + */ + private removeEventFromCache(sourceId: string, eventId: string): void { + const cacheEntry = this.cache.get(sourceId); + if (cacheEntry) { + cacheEntry.events = cacheEntry.events.filter( + (e) => e.providerEventId !== eventId, + ); + cacheEntry.timestamp = Date.now(); + + // Notify listeners + this.onEventsUpdated?.(sourceId, cacheEntry.events); + } + } } diff --git a/src/styles/ics-settings.scss b/src/styles/ics-settings.scss index de0c06e8..87be91c9 100644 --- a/src/styles/ics-settings.scss +++ b/src/styles/ics-settings.scss @@ -38,11 +38,6 @@ /* Global settings */ .ics-global-settings { - margin-bottom: 2rem; - padding: 1.5rem; - border: 1px solid var(--background-modifier-border); - border-radius: 8px; - background: var(--background-secondary); } /* Sources list */ diff --git a/src/types/ics.ts b/src/types/ics.ts index e838ea6c..40280ad2 100644 --- a/src/types/ics.ts +++ b/src/types/ics.ts @@ -1,13 +1,28 @@ /** * ICS (iCalendar) support types and interfaces + * + * This module contains the core ICS types that are used throughout the plugin. + * For the new multi-provider calendar system, see `./calendar-provider.ts`. + * + * The IcsSource interface is maintained for backward compatibility. + * New calendar sources should use CalendarSource from calendar-provider.ts. */ import { Task } from "./task"; +import type { + CalendarProviderType, + AnyCalendarSource, +} from "./calendar-provider"; /** ICS event source configuration */ export interface IcsSource { /** Unique identifier for the ICS source */ id: string; + /** + * Provider type discriminator + * @default 'url-ics' - For backward compatibility with legacy sources + */ + type?: CalendarProviderType; /** Display name for the source */ name: string; /** URL to the ICS file (supports http://, https://, and webcal:// protocols) */ @@ -231,14 +246,55 @@ export interface IcsEvent { customProperties?: Record; /** Source ICS configuration */ source: IcsSource; + + // ========================================================================= + // Sync Metadata (for two-way sync with OAuth providers) + // ========================================================================= + + /** + * Provider-specific event ID (different from ICS uid) + * - Google: event.id + * - Outlook: event.id + * - Apple: derived from URL path + */ + providerEventId?: string; + + /** + * Calendar ID where this event belongs + * - Google: calendarId + * - Outlook: calendar.id + * - Apple: calendar href + */ + providerCalendarId?: string; + + /** + * ETag for conflict detection (optimistic locking) + * Used to detect if remote event was modified since last fetch + */ + etag?: string; + + /** + * Whether this event can be edited (provider supports write + user has permission) + */ + canEdit?: boolean; + + /** + * For recurring events, the ID of the parent recurring event + */ + recurringEventId?: string; + + /** + * Whether this is a single instance of a recurring event + */ + isRecurringInstance?: boolean; } /** ICS event converted to Task format */ export interface IcsTask extends Task { /** Original ICS event data */ icsEvent: IcsEvent; - /** Whether this task is read-only (from ICS) */ - readonly: true; + /** Whether this task is read-only (true for URL ICS, false for writable OAuth providers) */ + readonly: boolean; /** Whether this task is a badge */ badge: boolean; /** Source information */ @@ -246,6 +302,8 @@ export interface IcsTask extends Task { type: "ics"; name: string; id: string; + /** Provider type for determining write capability */ + providerType?: "url-ics" | "google" | "outlook" | "apple-caldav"; }; } @@ -306,8 +364,12 @@ export interface IcsCacheEntry { /** ICS manager configuration */ export interface IcsManagerConfig { - /** List of ICS sources */ - sources: IcsSource[]; + /** + * List of calendar sources (supports all provider types) + * Uses AnyCalendarSource to accept both legacy IcsSource and new CalendarSource formats + * Call normalizeCalendarSources() when reading to ensure consistent format + */ + sources: AnyCalendarSource[]; /** Global refresh interval in minutes */ globalRefreshInterval: number; /** Maximum cache age in hours */