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
This commit is contained in:
Quorafind 2025-12-17 15:45:14 +08:00
parent 7e05356927
commit c456c38201
5 changed files with 824 additions and 185 deletions

View file

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

View file

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

View file

@ -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<ExtendedMetadata> & {
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<EnhancedStandardTaskMetadata> {
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<EnhancedStandardTaskMetadata> = {};
@ -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<IcsFetchResult> {
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<IcsFetchResult> {
private async fetchIcsData(
source: IcsSource | LegacyIcsSource | UrlIcsSourceConfig,
): Promise<IcsFetchResult> {
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<WriteResult> {
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<WriteResult> {
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<WriteResult> {
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);
}
}
}

View file

@ -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 */

View file

@ -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<string, string>;
/** 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 */