feat: support basic ical

This commit is contained in:
Quorafind 2025-07-03 18:09:29 +08:00
parent 5a164d6873
commit cae4990aa5
15 changed files with 1643 additions and 63 deletions

View file

@ -1870,7 +1870,10 @@ class GoogleCalendarAuthModal extends Modal {
),
});
instructionsList.createEl("li", {
text: t("You will be redirected back to Obsidian automatically"),
text: t("Copy the authorization code shown on the page"),
});
instructionsList.createEl("li", {
text: t("Return to Obsidian and paste the code when prompted"),
});
// Authentication status
@ -1992,6 +1995,45 @@ class iCloudCalendarAuthModal extends Modal {
text: t("Enter your Apple ID and the generated password below"),
});
// Helper button to open Apple ID management page
const helperContainer = contentEl.createDiv("auth-helper-buttons");
const appleIdButton = helperContainer.createEl("button", {
text: t("Open Apple ID Management"),
cls: "apple-id-link-button",
});
appleIdButton.onclick = () => {
const cloudManager = this.plugin.getCloudCalendarManager();
if (cloudManager) {
const icloudProvider = cloudManager.getOAuth2Provider("icloud");
if (
icloudProvider &&
typeof (icloudProvider as any).getAppSpecificPasswordUrl ===
"function"
) {
const url = (
icloudProvider as any
).getAppSpecificPasswordUrl();
window.open(url, "_blank");
} else {
// Fallback URL
window.open(
"https://appleid.apple.com/account/manage#security",
"_blank"
);
}
} else {
// Fallback URL
window.open(
"https://appleid.apple.com/account/manage#security",
"_blank"
);
}
};
helperContainer.createEl("div", {
text: t("This will open Apple ID management page in your browser"),
cls: "apple-id-link-description",
});
// Username input
new Setting(contentEl)
.setName(t("Apple ID"))

View file

@ -12,6 +12,7 @@ import { TagsBasesView } from "./TagsBasesView";
import TaskProgressBarPlugin from "../index";
import "../styles/base-view.css";
import { requireApiVersion } from "obsidian";
import { BasesPlugin } from "../types/bases";
export class ViewManager extends Component {
private app: App;

View file

@ -628,6 +628,41 @@
color: var(--text-muted);
}
/* Apple ID management button styles */
.auth-helper-buttons {
margin: 16px 0;
padding: 12px;
background: var(--background-modifier-form-field);
border-radius: 6px;
border: 1px solid var(--background-modifier-border);
text-align: center;
}
.apple-id-link-button {
background: var(--color-orange);
color: white;
border: none;
padding: 0.6rem 1.2rem;
border-radius: 6px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.9em;
margin-bottom: 0.5rem;
}
.apple-id-link-button:hover {
background: var(--color-orange);
opacity: 0.8;
transform: translateY(-1px);
}
.apple-id-link-description {
font-size: 0.8em;
color: var(--text-muted);
margin-top: 0.5rem;
}
.auth-status {
margin: 16px 0;
padding: 12px;
@ -640,17 +675,14 @@
}
.auth-status-pending {
background: var(--background-modifier-info);
color: var(--text-info);
}
.auth-status-success {
background: var(--background-modifier-success);
color: var(--text-success);
}
.auth-status-error {
background: var(--background-modifier-error);
color: var(--text-error);
}

View file

@ -778,6 +778,10 @@ const translations = {
"Choose which calendars to sync from this source",
"Cloud calendar source updated": "Cloud calendar source updated",
"Failed to save configuration: ": "Failed to save configuration: ",
"Copy the authorization code shown on the page":
"Copy the authorization code shown on the page",
"Return to Obsidian and paste the code when prompted":
"Return to Obsidian and paste the code when prompted",
"Type of authentication required": "Type of authentication required",
"ICS Auth None": "None",
"Basic Auth": "Basic Auth",
@ -2117,6 +2121,10 @@ const translations = {
"Comma-separated list of task IDs this task depends on":
"Comma-separated list of task IDs this task depends on",
"Unique identifier for this task": "Unique identifier for this task",
"Open Apple ID Management": "Open Apple ID Management",
"This will open Apple ID management page in your browser":
"This will open Apple ID management page in your browser",
"Create App-Specific Password": "Create App-Specific Password",
};
export default translations;

View file

@ -8,6 +8,7 @@ import { Component, Notice } from "obsidian";
import { OAuth2Manager } from "./OAuth2Manager";
import { ObsidianURIHandler } from "./ObsidianURIHandler";
import { OAuth2Config, OAuth2Tokens } from "../../types/cloud-calendar";
import { LocalOAuthServer } from "./LocalOAuthServer";
import TaskProgressBarPlugin from "../../index";
export class BrowserAuthFlow extends Component {
@ -50,7 +51,12 @@ export class BrowserAuthFlow extends Component {
this.activeFlows.set(flowId, context);
// Update redirect URI to use Obsidian protocol
// For Google, use OOB flow
if (provider === "google") {
return await this.startGoogleOOBFlow(config, context);
}
// Update redirect URI to use Obsidian protocol for other providers
const updatedConfig = {
...config,
redirectUri: this.uriHandler.getOAuthRedirectUri(),
@ -267,6 +273,175 @@ export class BrowserAuthFlow extends Component {
}
}
/**
* Start Google OAuth OOB (Out-of-Band) flow
*/
private async startGoogleOOBFlow(
config: OAuth2Config,
context: AuthFlowContext
): Promise<OAuth2Tokens> {
const oauthProvider = this.oauth2Manager.getProvider("google");
if (!oauthProvider) {
throw new Error("Google OAuth provider not found");
}
// Update config to use OOB redirect URI
const oobConfig = {
...config,
redirectUri: "urn:ietf:wg:oauth:2.0:oob",
};
// Build auth URL
const authUrl = oauthProvider.buildAuthUrl(oobConfig);
// Show instructions to user
this.showFlowNotification(
"Opening Google authentication page. After completing authentication, you'll see an authorization code. Copy it and return to Obsidian.",
"info",
15000
);
// Open browser
try {
window.open(authUrl, "_blank");
context.status = "browser_opened";
// Wait for user to enter the authorization code
const authCode = await this.promptForAuthCode();
context.status = "code_received";
// Exchange code for tokens
const tokens = await oauthProvider.exchangeCodeForTokens(
authCode,
oobConfig
);
context.status = "completed";
context.tokens = tokens;
this.showFlowNotification(
"Google authentication successful!",
"success"
);
return tokens;
} catch (error) {
context.status = "error";
context.error =
error instanceof Error ? error.message : "Unknown error";
throw error;
}
}
/**
* Prompt user for authorization code
*/
private async promptForAuthCode(): Promise<string> {
return new Promise((resolve, reject) => {
// Create a modal for code input
const modal = document.createElement("div");
modal.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 8px;
padding: 20px;
z-index: 1000;
min-width: 400px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
`;
modal.innerHTML = `
<h3 style="margin: 0 0 15px 0; color: var(--text-normal);">Enter Authorization Code</h3>
<p style="margin: 0 0 15px 0; color: var(--text-muted);">
Please paste the authorization code from Google:
</p>
<input
type="text"
id="auth-code-input"
style="
width: 100%;
padding: 8px;
margin: 10px 0;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
background: var(--background-primary);
color: var(--text-normal);
"
placeholder="Paste authorization code here"
>
<div style="text-align: right; margin-top: 15px;">
<button
id="auth-code-cancel"
style="
margin-right: 10px;
padding: 8px 16px;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
background: var(--background-primary);
color: var(--text-normal);
cursor: pointer;
"
>Cancel</button>
<button
id="auth-code-submit"
style="
padding: 8px 16px;
border: none;
border-radius: 4px;
background: var(--interactive-accent);
color: var(--text-on-accent);
cursor: pointer;
"
>Submit</button>
</div>
`;
document.body.appendChild(modal);
const input = modal.querySelector(
"#auth-code-input"
) as HTMLInputElement;
const submitBtn = modal.querySelector(
"#auth-code-submit"
) as HTMLButtonElement;
const cancelBtn = modal.querySelector(
"#auth-code-cancel"
) as HTMLButtonElement;
const cleanup = () => {
document.body.removeChild(modal);
};
submitBtn.onclick = () => {
const code = input.value.trim();
if (code) {
cleanup();
resolve(code);
} else {
new Notice("Please enter the authorization code");
}
};
cancelBtn.onclick = () => {
cleanup();
reject(new Error("Authentication cancelled"));
};
// Handle Enter key
input.onkeydown = (e) => {
if (e.key === "Enter") {
submitBtn.click();
}
};
input.focus();
});
}
/**
* Open browser for manual OAuth flow
*/
@ -435,6 +610,7 @@ export interface AuthFlowStatus {
status:
| "starting"
| "browser_opened"
| "code_received"
| "validating"
| "completed"
| "error"

View file

@ -5,6 +5,7 @@
import { OAuth2Provider, OAuth2Error } from "./OAuth2Provider";
import { OAuth2Config, OAuth2Tokens } from "../../types/cloud-calendar";
import { LocalOAuthServer } from "./LocalOAuthServer";
export class GoogleOAuth2Provider extends OAuth2Provider {
readonly name = "google";
@ -24,9 +25,14 @@ export class GoogleOAuth2Provider extends OAuth2Provider {
buildAuthUrl(config: OAuth2Config): string {
this.validateConfig(config);
// Use out-of-band (OOB) redirect URI for Google OAuth compliance
const redirectUri = config.redirectUri.startsWith("obsidian://")
? "urn:ietf:wg:oauth:2.0:oob"
: config.redirectUri;
const params = {
client_id: config.clientId,
redirect_uri: config.redirectUri,
redirect_uri: redirectUri,
scope: config.scopes.join(" "),
response_type: "code",
access_type: "offline", // Required for refresh token
@ -47,12 +53,17 @@ export class GoogleOAuth2Provider extends OAuth2Provider {
): Promise<OAuth2Tokens> {
this.validateConfig(config);
// Use the appropriate redirect URI based on the flow
const redirectUri = config.redirectUri.startsWith("obsidian://")
? "urn:ietf:wg:oauth:2.0:oob"
: config.redirectUri;
const requestBody = {
client_id: config.clientId,
client_secret: config.clientSecret || "",
code: code,
grant_type: "authorization_code",
redirect_uri: config.redirectUri,
redirect_uri: redirectUri,
};
const tokenData = await this.makeRequest({

View file

@ -0,0 +1,198 @@
/**
* Local OAuth Server
* Creates a temporary local HTTP server to handle OAuth callbacks
* This is required for Google OAuth compliance
*/
import { Notice } from "obsidian";
export class LocalOAuthServer {
private server: any = null;
private port: number = 8080;
private isRunning: boolean = false;
private onCallback: ((params: Record<string, string>) => void) | null =
null;
/**
* Start the local OAuth server
*/
async start(
onCallback: (params: Record<string, string>) => void
): Promise<string> {
if (this.isRunning) {
throw new Error("OAuth server is already running");
}
this.onCallback = onCallback;
try {
// For Obsidian desktop, we need to use a different approach
// Since we can't import Node.js modules directly, we'll use a workaround
if (this.isDesktop()) {
return this.startDesktopServer();
} else {
// For mobile, we'll use a different approach
return this.startMobileServer();
}
} catch (error) {
console.error("Failed to start OAuth server:", error);
throw new Error("Failed to start local OAuth server");
}
}
/**
* Stop the OAuth server
*/
async stop(): Promise<void> {
if (!this.isRunning || !this.server) {
return;
}
try {
if (this.server.close) {
this.server.close();
}
this.server = null;
this.isRunning = false;
this.onCallback = null;
console.log("OAuth server stopped");
} catch (error) {
console.error("Error stopping OAuth server:", error);
}
}
/**
* Get the callback URL for OAuth
*/
getCallbackUrl(): string {
return `http://localhost:${this.port}/oauth/callback`;
}
/**
* Start server for desktop Obsidian
*/
private async startDesktopServer(): Promise<string> {
try {
// Use Electron's built-in capabilities
const { ipcRenderer } = require("electron");
// Request the main process to start a local server
const serverUrl = await ipcRenderer.invoke("start-oauth-server", {
port: this.port,
callback: this.handleOAuthCallback.bind(this),
});
this.isRunning = true;
return serverUrl;
} catch (error) {
// Fallback: Use a simple approach with window.open and manual code entry
return this.startFallbackServer();
}
}
/**
* Start server for mobile Obsidian
*/
private async startMobileServer(): Promise<string> {
// Mobile doesn't support local servers, use fallback
return this.startFallbackServer();
}
/**
* Fallback server implementation
*/
private async startFallbackServer(): Promise<string> {
// Create a simple polling mechanism
this.isRunning = true;
// Show instructions to user
new Notice(
"After completing Google authentication, you'll be redirected to a page showing an authorization code. Copy that code and paste it in the next dialog.",
10000
);
// Use a different redirect URI that shows the code
return "urn:ietf:wg:oauth:2.0:oob";
}
/**
* Handle OAuth callback
*/
private handleOAuthCallback(params: Record<string, string>): void {
if (this.onCallback) {
this.onCallback(params);
}
}
/**
* Check if running on desktop
*/
private isDesktop(): boolean {
return (window as any).require !== undefined;
}
/**
* Manual code entry for fallback
*/
async promptForAuthCode(): Promise<string> {
return new Promise((resolve, reject) => {
// Create a simple input dialog
const modal = document.createElement("div");
modal.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 8px;
padding: 20px;
z-index: 1000;
min-width: 400px;
`;
modal.innerHTML = `
<h3>Enter Authorization Code</h3>
<p>Please paste the authorization code from Google:</p>
<input type="text" id="auth-code-input" style="width: 100%; padding: 8px; margin: 10px 0;" placeholder="Paste authorization code here">
<div style="text-align: right; margin-top: 15px;">
<button id="auth-code-cancel" style="margin-right: 10px;">Cancel</button>
<button id="auth-code-submit">Submit</button>
</div>
`;
document.body.appendChild(modal);
const input = modal.querySelector(
"#auth-code-input"
) as HTMLInputElement;
const submitBtn = modal.querySelector(
"#auth-code-submit"
) as HTMLButtonElement;
const cancelBtn = modal.querySelector(
"#auth-code-cancel"
) as HTMLButtonElement;
const cleanup = () => {
document.body.removeChild(modal);
};
submitBtn.onclick = () => {
const code = input.value.trim();
if (code) {
cleanup();
resolve(code);
} else {
new Notice("Please enter the authorization code");
}
};
cancelBtn.onclick = () => {
cleanup();
reject(new Error("Authentication cancelled"));
};
input.focus();
});
}
}

View file

@ -11,7 +11,7 @@ import {
OAuth2AuthResponse,
} from "../../types/cloud-calendar";
import { OAuth2Provider } from "./OAuth2Provider";
import { TaskProgressBarPlugin } from "../../index";
import TaskProgressBarPlugin from "../../index";
export class OAuth2Manager extends Component {
private plugin: TaskProgressBarPlugin;

View file

@ -7,7 +7,7 @@
import { Plugin, Notice } from "obsidian";
import { OAuth2AuthResponse } from "../../types/cloud-calendar";
import { OAuth2Manager } from "./OAuth2Manager";
import { TaskProgressBarPlugin } from "../../index";
import TaskProgressBarPlugin from "../../index";
export class ObsidianURIHandler {
private plugin: TaskProgressBarPlugin;

View file

@ -6,6 +6,7 @@
import { OAuth2Provider, OAuth2Error } from "./OAuth2Provider";
import { OAuth2Config, OAuth2Tokens } from "../../types/cloud-calendar";
import { requestUrl } from "obsidian";
export class iCloudOAuth2Provider extends OAuth2Provider {
readonly name = "icloud";
@ -52,17 +53,43 @@ export class iCloudOAuth2Provider extends OAuth2Provider {
);
}
// Validate and normalize username
const validatedUsername = this.validateAndNormalizeUsername(username);
const validatedPassword = this.validateAppSpecificPassword(appPassword);
// Validate credentials by making a test CalDAV request
const isValid = await this.validateCredentials(username, appPassword);
if (!isValid) {
throw new OAuth2Error(
"invalid_grant",
"Invalid username or app-specific password"
try {
const isValid = await this.validateCredentials(
validatedUsername,
validatedPassword
);
if (!isValid) {
throw new OAuth2Error(
"invalid_grant",
"Invalid username or app-specific password. Please check your Apple ID and ensure you're using an app-specific password (not your regular Apple ID password)."
);
}
} catch (error) {
if (error instanceof OAuth2Error) {
// Re-throw OAuth2Error with potentially more specific message
if (error.code === "network_error") {
throw new OAuth2Error(
"network_error",
"Unable to connect to iCloud servers. Please check your internet connection and try again."
);
}
throw error;
} else {
// Handle unexpected errors
throw new OAuth2Error(
"unknown_error",
"An unexpected error occurred during authentication. Please try again."
);
}
}
// Create a pseudo-token using base64 encoded credentials
const credentials = btoa(`${username}:${appPassword}`);
const credentials = btoa(`${validatedUsername}:${validatedPassword}`);
return {
accessToken: credentials,
@ -139,8 +166,18 @@ export class iCloudOAuth2Provider extends OAuth2Provider {
password: string
): Promise<boolean> {
try {
// Normalize username - remove @icloud.com if present
const normalizedUsername = username.replace(/@icloud\.com$/, "");
// Try the principal URL first for better compatibility
const principalUrl = `${this.caldavUrl}/${normalizedUsername}/principal/`;
console.log(
`Attempting iCloud CalDAV authentication for user: ${normalizedUsername}`
);
const response = await this.makeCalDAVRequest(
`${this.caldavUrl}/${username}/calendars/`,
principalUrl,
"PROPFIND",
{
Authorization: `Basic ${btoa(`${username}:${password}`)}`,
@ -150,15 +187,109 @@ export class iCloudOAuth2Provider extends OAuth2Provider {
'<?xml version="1.0" encoding="utf-8" ?><propfind xmlns="DAV:"><prop><displayname/></prop></propfind>'
);
return response.status === 207; // Multi-Status response indicates success
// Accept both 207 (Multi-Status) and 200 (OK) as success
const isSuccess =
response.status === 207 || response.status === 200;
if (!isSuccess) {
console.warn(
`iCloud CalDAV principal URL failed with status ${response.status}`
);
// Check for specific authentication errors
if (response.status === 401) {
console.error(
"Authentication failed - invalid credentials"
);
return false;
}
if (response.status === 403) {
console.error(
"Access forbidden - check if 2FA is enabled and app-specific password is correct"
);
return false;
}
// Try alternative URL format if principal fails
console.log("Trying alternative CalDAV URL format...");
const alternativeUrl = `${this.caldavUrl}/${normalizedUsername}/calendars/`;
try {
const altResponse = await this.makeCalDAVRequest(
alternativeUrl,
"PROPFIND",
{
Authorization: `Basic ${btoa(
`${username}:${password}`
)}`,
"Content-Type": "application/xml; charset=utf-8",
Depth: "0",
},
'<?xml version="1.0" encoding="utf-8" ?><propfind xmlns="DAV:"><prop><displayname/></prop></propfind>'
);
const altSuccess =
altResponse.status === 207 ||
altResponse.status === 200;
if (altSuccess) {
console.log("Alternative CalDAV URL succeeded");
} else {
console.warn(
`Alternative CalDAV URL also failed with status ${altResponse.status}`
);
// Provide specific error information
if (altResponse.status === 401) {
console.error(
"Authentication failed on alternative URL - credentials are invalid"
);
} else if (altResponse.status === 403) {
console.error(
"Access forbidden on alternative URL - check permissions"
);
}
}
return altSuccess;
} catch (altError) {
console.error(
"Alternative CalDAV URL request failed:",
altError
);
return false;
}
}
console.log("iCloud CalDAV authentication successful");
return isSuccess;
} catch (error) {
console.error("iCloud credential validation failed:", error);
return false;
if (error instanceof OAuth2Error) {
console.error(
"iCloud credential validation failed:",
error.message
);
// Re-throw OAuth2Error to preserve error details
throw error;
} else {
console.error(
"iCloud credential validation failed with unexpected error:",
error
);
// Convert unexpected errors to OAuth2Error
throw new OAuth2Error(
"network_error",
error instanceof Error
? error.message
: "Unknown network error occurred"
);
}
}
}
/**
* Make CalDAV request to iCloud
* Make CalDAV request to iCloud using Obsidian's requestUrl API
*/
private async makeCalDAVRequest(
url: string,
@ -167,13 +298,31 @@ export class iCloudOAuth2Provider extends OAuth2Provider {
body?: string
): Promise<Response> {
try {
const response = await fetch(url, {
const response = await requestUrl({
url,
method,
headers,
body,
throw: false, // Don't throw on HTTP errors, handle them manually
});
return response;
// Convert Obsidian's response format to standard Response-like object
return {
status: response.status,
statusText: response.status.toString(),
ok: response.status >= 200 && response.status < 300,
headers: new Headers(response.headers || {}),
text: async () => response.text || "",
json: async () => {
try {
return (
response.json || JSON.parse(response.text || "{}")
);
} catch {
return {};
}
},
} as Response;
} catch (error) {
throw new OAuth2Error(
"network_error",
@ -210,6 +359,13 @@ export class iCloudOAuth2Provider extends OAuth2Provider {
}
}
/**
* Get the direct URL to Apple ID App-Specific Password creation page
*/
getAppSpecificPasswordUrl(): string {
return `${this.authUrl}#security`;
}
/**
* Get iCloud-specific provider capabilities
*/
@ -279,11 +435,82 @@ export class iCloudOAuth2Provider extends OAuth2Provider {
}
}
/**
* Validate and normalize Apple ID username
*/
private validateAndNormalizeUsername(username: string): string {
if (!username || typeof username !== "string") {
throw new OAuth2Error("invalid_request", "Username is required");
}
const trimmed = username.trim();
if (!trimmed) {
throw new OAuth2Error(
"invalid_request",
"Username cannot be empty"
);
}
// Basic email format validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(trimmed)) {
throw new OAuth2Error(
"invalid_request",
"Please enter a valid Apple ID email address"
);
}
// Normalize to lowercase
return trimmed.toLowerCase();
}
/**
* Validate app-specific password format
*/
private validateAppSpecificPassword(password: string): string {
if (!password || typeof password !== "string") {
throw new OAuth2Error(
"invalid_request",
"App-specific password is required"
);
}
const trimmed = password.trim();
if (!trimmed) {
throw new OAuth2Error(
"invalid_request",
"App-specific password cannot be empty"
);
}
// Remove any spaces or hyphens that might be in the password
const cleaned = trimmed.replace(/[\s-]/g, "");
// App-specific passwords are typically 16 characters long
if (cleaned.length < 12) {
throw new OAuth2Error(
"invalid_request",
"App-specific password appears to be too short. Please ensure you're using the complete app-specific password."
);
}
return cleaned;
}
/**
* Get CalDAV principal URL for the user
*/
async getPrincipalUrl(username: string, password: string): Promise<string> {
const isValid = await this.validateCredentials(username, password);
const validatedUsername = this.validateAndNormalizeUsername(username);
const normalizedUsername = validatedUsername.replace(
/@icloud\.com$/,
""
);
const isValid = await this.validateCredentials(
validatedUsername,
password
);
if (!isValid) {
throw new OAuth2Error(
"invalid_credentials",
@ -291,7 +518,7 @@ export class iCloudOAuth2Provider extends OAuth2Provider {
);
}
return `${this.caldavUrl}/${username}/`;
return `${this.caldavUrl}/${normalizedUsername}/`;
}
/**

View file

@ -5,14 +5,16 @@
*/
import { Component, Notice } from "obsidian";
import { TaskProgressBarPlugin } from "../../index";
import TaskProgressBarPlugin from "../../index";
import { OAuth2Manager } from "../auth/OAuth2Manager";
import { BrowserAuthFlow } from "../auth/BrowserAuthFlow";
import { ObsidianURIHandler } from "../auth/ObsidianURIHandler";
import { GoogleOAuth2Provider } from "../auth/GoogleOAuth2Provider";
import { iCloudOAuth2Provider } from "../auth/iCloudOAuth2Provider";
import { OAuth2Provider } from "../auth/OAuth2Provider";
import { CloudCalendarAdapter } from "./CloudCalendarAdapter";
import { GoogleCalendarAdapter } from "./GoogleCalendarAdapter";
import { iCloudCalendarAdapter } from "./iCloudCalendarAdapter";
import {
CloudCalendarConfig,
CloudSyncResult,
@ -67,6 +69,7 @@ export class CloudCalendarManager extends Component {
// Register cloud adapters
this.adapters.set("google", new GoogleCalendarAdapter());
this.adapters.set("icloud", new iCloudCalendarAdapter());
// Initialize URI handler
this.uriHandler.initialize();
@ -369,6 +372,13 @@ export class CloudCalendarManager extends Component {
return Array.from(this.configurations.values());
}
/**
* Get OAuth2 provider by name
*/
getOAuth2Provider(providerName: string): OAuth2Provider | undefined {
return this.oauth2Manager.getProvider(providerName);
}
/**
* Get configuration by ID
*/

View file

@ -0,0 +1,818 @@
/**
* iCloud Calendar CalDAV Adapter
* Implements iCloud Calendar integration using CalDAV protocol
* Uses App-Specific Passwords for authentication
*/
import {
CloudCalendarAdapter,
CloudCalendarError,
CloudUserInfo,
} from "./CloudCalendarAdapter";
import {
CloudCalendarSource,
EventFetchOptions,
EventFetchResult,
CalendarListResult,
CloudProviderCapabilities,
} from "../../types/cloud-calendar";
import { IcsEvent, IcsSource } from "../../types/ics";
import { requestUrl } from "obsidian";
export class iCloudCalendarAdapter extends CloudCalendarAdapter {
readonly name = "icloud";
readonly apiBaseUrl = "https://caldav.icloud.com";
readonly principalUrl = "https://caldav.icloud.com/";
/**
* Get iCloud Calendar provider capabilities
*/
getCapabilities(): CloudProviderCapabilities {
return {
name: this.name,
supportsRead: true,
supportsWrite: true,
supportsIncrementalSync: false, // CalDAV doesn't support incremental sync like Google
supportsWebhooks: false,
supportsRecurringEvents: true,
maxEventsPerRequest: 1000,
rateLimit: {
requestsPerSecond: 5, // More conservative for CalDAV
requestsPerDay: 10000,
},
};
}
/**
* Get list of calendars for the authenticated user
*/
async getCalendarList(accessToken: string): Promise<CalendarListResult> {
this.validateAccessToken(accessToken);
try {
// For iCloud, accessToken contains both username and app password
const { username, appPassword } =
this.parseAccessToken(accessToken);
// First, discover the principal URL
const principalPath = await this.discoverPrincipal(
username,
appPassword
);
// Get calendar home set
const calendarHomePath = await this.getCalendarHomeSet(
username,
appPassword,
principalPath
);
// Get list of calendars
const calendars = await this.getCalendarCollection(
username,
appPassword,
calendarHomePath
);
return {
calendars,
nextPageToken: undefined, // CalDAV doesn't use pagination
syncToken: undefined, // CalDAV doesn't support sync tokens
};
} catch (error) {
throw this.handleCalDAVError(
error,
"Failed to fetch calendar list"
);
}
}
/**
* Get events from a specific calendar
*/
async getEvents(
accessToken: string,
calendarId: string,
options: EventFetchOptions
): Promise<EventFetchResult> {
this.validateAccessToken(accessToken);
this.validateCalendarId(calendarId);
try {
const { username, appPassword } =
this.parseAccessToken(accessToken);
// Build CalDAV REPORT query
const reportXml = this.buildCalendarQuery(options);
const response = await this.makeCalDAVRequest(
username,
appPassword,
{
url: calendarId, // calendarId is the full CalDAV URL
method: "REPORT",
headers: {
"Content-Type": "application/xml; charset=utf-8",
Depth: "1",
},
body: reportXml,
}
);
const events = this.parseCalendarDataResponse(response, calendarId);
return {
events,
nextPageToken: undefined,
nextSyncToken: undefined,
hasMore: false,
};
} catch (error) {
throw this.handleCalDAVError(
error,
`Failed to fetch events from calendar ${calendarId}`
);
}
}
/**
* Test CalDAV connection and credentials
*/
async testConnection(accessToken: string): Promise<boolean> {
try {
const { username, appPassword } =
this.parseAccessToken(accessToken);
// Test with a simple OPTIONS request
await this.makeCalDAVRequest(username, appPassword, {
url: this.principalUrl,
method: "OPTIONS",
});
return true;
} catch (error) {
console.warn("iCloud CalDAV connection test failed:", error);
return false;
}
}
/**
* Get user information (limited for CalDAV)
*/
async getUserInfo(accessToken: string): Promise<CloudUserInfo> {
this.validateAccessToken(accessToken);
try {
const { username } = this.parseAccessToken(accessToken);
return {
id: username,
email: username, // iCloud username is usually email
name: username.split("@")[0], // Extract name from email
picture: undefined,
provider: this.name,
};
} catch (error) {
throw this.handleCalDAVError(
error,
"Failed to get user information"
);
}
}
/**
* Create a new event (CalDAV PUT)
*/
async createEvent(
accessToken: string,
calendarId: string,
event: IcsEvent
): Promise<IcsEvent> {
this.validateAccessToken(accessToken);
this.validateCalendarId(calendarId);
try {
const { username, appPassword } =
this.parseAccessToken(accessToken);
// Generate unique event UID
const eventUid = event.uid || this.generateEventUid();
const eventUrl = `${calendarId}${eventUid}.ics`;
// Convert to iCalendar format
const icalData = this.convertIcsEventToiCal(event);
await this.makeCalDAVRequest(username, appPassword, {
url: eventUrl,
method: "PUT",
headers: {
"Content-Type": "text/calendar; charset=utf-8",
"If-None-Match": "*", // Ensure we're creating, not updating
},
body: icalData,
});
// Return the created event with updated UID
return {
...event,
uid: eventUid,
};
} catch (error) {
throw this.handleCalDAVError(error, "Failed to create event");
}
}
/**
* Update an existing event
*/
async updateEvent(
accessToken: string,
calendarId: string,
eventId: string,
event: IcsEvent
): Promise<IcsEvent> {
this.validateAccessToken(accessToken);
this.validateCalendarId(calendarId);
try {
const { username, appPassword } =
this.parseAccessToken(accessToken);
const eventUrl = `${calendarId}${eventId}.ics`;
// Convert to iCalendar format
const icalData = this.convertIcsEventToiCal(event);
await this.makeCalDAVRequest(username, appPassword, {
url: eventUrl,
method: "PUT",
headers: {
"Content-Type": "text/calendar; charset=utf-8",
},
body: icalData,
});
return event;
} catch (error) {
throw this.handleCalDAVError(error, "Failed to update event");
}
}
/**
* Delete an event
*/
async deleteEvent(
accessToken: string,
calendarId: string,
eventId: string
): Promise<void> {
this.validateAccessToken(accessToken);
this.validateCalendarId(calendarId);
try {
const { username, appPassword } =
this.parseAccessToken(accessToken);
const eventUrl = `${calendarId}${eventId}.ics`;
await this.makeCalDAVRequest(username, appPassword, {
url: eventUrl,
method: "DELETE",
});
} catch (error) {
throw this.handleCalDAVError(error, "Failed to delete event");
}
}
/**
* Parse access token to extract username and app password
*/
private parseAccessToken(accessToken: string): {
username: string;
appPassword: string;
} {
try {
// Access token is base64 encoded "username:appPassword"
const decoded = atob(accessToken);
const [username, appPassword] = decoded.split(":");
if (!username || !appPassword) {
throw new Error("Invalid access token format");
}
return { username, appPassword };
} catch (error) {
throw new CloudCalendarError(
"invalid_token",
"Invalid iCloud access token format"
);
}
}
/**
* Make authenticated CalDAV request
*/
private async makeCalDAVRequest(
username: string,
appPassword: string,
options: {
url: string;
method: string;
headers?: Record<string, string>;
body?: string;
}
): Promise<any> {
const auth = btoa(`${username}:${appPassword}`);
const headers = {
Authorization: `Basic ${auth}`,
"User-Agent": "Obsidian Task Genius CalDAV Client",
...options.headers,
};
try {
const response = await requestUrl({
url: options.url,
method: options.method as any,
headers,
body: options.body,
});
return response.text || response.json;
} catch (error) {
throw error;
}
}
/**
* Discover principal URL for the user
*/
private async discoverPrincipal(
username: string,
appPassword: string
): Promise<string> {
const propfindXml = `<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:current-user-principal/>
</d:prop>
</d:propfind>`;
const response = await this.makeCalDAVRequest(username, appPassword, {
url: this.principalUrl,
method: "PROPFIND",
headers: {
"Content-Type": "application/xml; charset=utf-8",
Depth: "0",
},
body: propfindXml,
});
// Parse XML response to extract principal path
const principalMatch = response.match(/<d:href>([^<]+)<\/d:href>/);
if (!principalMatch) {
throw new Error("Could not discover principal URL");
}
return principalMatch[1];
}
/**
* Get calendar home set for the principal
*/
private async getCalendarHomeSet(
username: string,
appPassword: string,
principalPath: string
): Promise<string> {
const propfindXml = `<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<c:calendar-home-set/>
</d:prop>
</d:propfind>`;
const response = await this.makeCalDAVRequest(username, appPassword, {
url: `${this.apiBaseUrl}${principalPath}`,
method: "PROPFIND",
headers: {
"Content-Type": "application/xml; charset=utf-8",
Depth: "0",
},
body: propfindXml,
});
// Parse XML response to extract calendar home set
const homeSetMatch = response.match(/<d:href>([^<]+)<\/d:href>/);
if (!homeSetMatch) {
throw new Error("Could not find calendar home set");
}
return homeSetMatch[1];
}
/**
* Get calendar collection from home set
*/
private async getCalendarCollection(
username: string,
appPassword: string,
calendarHomePath: string
): Promise<CloudCalendarSource[]> {
const propfindXml = `<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/">
<d:prop>
<d:displayname/>
<d:resourcetype/>
<c:calendar-description/>
<cs:getctag/>
<c:supported-calendar-component-set/>
</d:prop>
</d:propfind>`;
const response = await this.makeCalDAVRequest(username, appPassword, {
url: `${this.apiBaseUrl}${calendarHomePath}`,
method: "PROPFIND",
headers: {
"Content-Type": "application/xml; charset=utf-8",
Depth: "1",
},
body: propfindXml,
});
return this.parseCalendarCollectionResponse(response);
}
/**
* Parse calendar collection response
*/
private parseCalendarCollectionResponse(
xmlResponse: string
): CloudCalendarSource[] {
const calendars: CloudCalendarSource[] = [];
// Simple XML parsing - in production, use a proper XML parser
const responseMatches = xmlResponse.match(
/<d:response>[\s\S]*?<\/d:response>/g
);
if (!responseMatches) {
return calendars;
}
for (const responseXml of responseMatches) {
// Check if this is a calendar resource
if (!responseXml.includes("<c:calendar/>")) {
continue;
}
const hrefMatch = responseXml.match(/<d:href>([^<]+)<\/d:href>/);
const displayNameMatch = responseXml.match(
/<d:displayname>([^<]*)<\/d:displayname>/
);
const descriptionMatch = responseXml.match(
/<c:calendar-description>([^<]*)<\/c:calendar-description>/
);
if (hrefMatch) {
const calendarUrl = hrefMatch[1];
const displayName = displayNameMatch
? displayNameMatch[1]
: "Unnamed Calendar";
const description = descriptionMatch
? descriptionMatch[1]
: undefined;
calendars.push({
id: `${this.apiBaseUrl}${calendarUrl}`,
name: displayName,
description,
color: undefined, // CalDAV doesn't provide color info
enabled: true,
showType: "event",
isPrimary: displayName.toLowerCase().includes("calendar"), // Heuristic
accessRole: "owner", // Assume owner for user's own calendars
timeZone: undefined,
});
}
}
return calendars;
}
/**
* Build CalDAV calendar query for events
*/
private buildCalendarQuery(options: EventFetchOptions): string {
const startDate =
options.startDate.toISOString().replace(/[-:]/g, "").split(".")[0] +
"Z";
const endDate =
options.endDate.toISOString().replace(/[-:]/g, "").split(".")[0] +
"Z";
return `<?xml version="1.0" encoding="UTF-8"?>
<c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:d="DAV:">
<d:prop>
<d:getetag/>
<c:calendar-data/>
</d:prop>
<c:filter>
<c:comp-filter name="VCALENDAR">
<c:comp-filter name="VEVENT">
<c:time-range start="${startDate}" end="${endDate}"/>
</c:comp-filter>
</c:comp-filter>
</c:filter>
</c:calendar-query>`;
}
/**
* Parse calendar data response from CalDAV
*/
private parseCalendarDataResponse(
xmlResponse: string,
calendarId: string
): IcsEvent[] {
const events: IcsEvent[] = [];
// Extract calendar data from XML response
const calendarDataMatches = xmlResponse.match(
/<c:calendar-data>[\s\S]*?<\/c:calendar-data>/g
);
if (!calendarDataMatches) {
return events;
}
for (const calendarDataXml of calendarDataMatches) {
// Extract iCalendar data (CDATA section)
const icalDataMatch = calendarDataXml.match(
/<!\[CDATA\[([\s\S]*?)\]\]>/
);
let icalData = icalDataMatch
? icalDataMatch[1]
: calendarDataXml.replace(/<\/?c:calendar-data>/g, "");
// Parse iCalendar data
const event = this.parseICalendarEvent(icalData, calendarId);
if (event) {
events.push(event);
}
}
return events;
}
/**
* Parse iCalendar event data
*/
private parseICalendarEvent(
icalData: string,
calendarId: string
): IcsEvent | null {
try {
const lines = icalData.split(/\r?\n/).filter((line) => line.trim());
const event: Partial<IcsEvent> = {};
// Create source
const source: IcsSource = {
id: `icloud-${calendarId}`,
name: "iCloud Calendar",
url: calendarId,
enabled: true,
showType: "event",
refreshInterval: 60,
showAllDayEvents: true,
showTimedEvents: true,
};
for (const line of lines) {
const [key, ...valueParts] = line.split(":");
const value = valueParts.join(":");
switch (key) {
case "UID":
event.uid = value;
break;
case "SUMMARY":
event.summary = value;
break;
case "DESCRIPTION":
event.description = value;
break;
case "LOCATION":
event.location = value;
break;
case "DTSTART":
event.dtstart = this.parseICalDateTime(value);
event.allDay = !value.includes("T");
break;
case "DTEND":
event.dtend = this.parseICalDateTime(value);
break;
case "STATUS":
event.status = value;
break;
case "CREATED":
event.created = this.parseICalDateTime(value);
break;
case "LAST-MODIFIED":
event.lastModified = this.parseICalDateTime(value);
break;
}
}
if (!event.uid || !event.summary || !event.dtstart) {
return null;
}
return {
uid: event.uid,
summary: event.summary,
description: event.description,
dtstart: event.dtstart,
dtend: event.dtend,
allDay: event.allDay || false,
location: event.location,
categories: [],
status: event.status,
created: event.created,
lastModified: event.lastModified,
source,
attendees: [],
} as IcsEvent;
} catch (error) {
console.warn("Failed to parse iCalendar event:", error);
return null;
}
}
/**
* Parse iCalendar date/time format
*/
private parseICalDateTime(value: string): Date {
// Handle different iCalendar date formats
if (value.includes("T")) {
// DateTime format: YYYYMMDDTHHMMSSZ
const dateTimeStr = value.replace(/[TZ]/g, "");
const year = parseInt(dateTimeStr.substr(0, 4));
const month = parseInt(dateTimeStr.substr(4, 2)) - 1;
const day = parseInt(dateTimeStr.substr(6, 2));
const hour = parseInt(dateTimeStr.substr(8, 2)) || 0;
const minute = parseInt(dateTimeStr.substr(10, 2)) || 0;
const second = parseInt(dateTimeStr.substr(12, 2)) || 0;
return new Date(Date.UTC(year, month, day, hour, minute, second));
} else {
// Date only format: YYYYMMDD
const year = parseInt(value.substr(0, 4));
const month = parseInt(value.substr(4, 2)) - 1;
const day = parseInt(value.substr(6, 2));
return new Date(year, month, day);
}
}
/**
* Convert IcsEvent to iCalendar format
*/
private convertIcsEventToiCal(event: IcsEvent): string {
const lines: string[] = [];
lines.push("BEGIN:VCALENDAR");
lines.push("VERSION:2.0");
lines.push("PRODID:-//Obsidian Task Genius//CalDAV Client//EN");
lines.push("BEGIN:VEVENT");
lines.push(`UID:${event.uid}`);
lines.push(`SUMMARY:${event.summary}`);
if (event.description) {
lines.push(`DESCRIPTION:${event.description}`);
}
if (event.location) {
lines.push(`LOCATION:${event.location}`);
}
// Format dates
if (event.allDay) {
lines.push(
`DTSTART;VALUE=DATE:${this.formatICalDate(event.dtstart)}`
);
if (event.dtend) {
lines.push(
`DTEND;VALUE=DATE:${this.formatICalDate(event.dtend)}`
);
}
} else {
lines.push(`DTSTART:${this.formatICalDateTime(event.dtstart)}`);
if (event.dtend) {
lines.push(`DTEND:${this.formatICalDateTime(event.dtend)}`);
}
}
if (event.status) {
lines.push(`STATUS:${event.status}`);
}
const now = new Date();
lines.push(`DTSTAMP:${this.formatICalDateTime(now)}`);
lines.push(`CREATED:${this.formatICalDateTime(event.created || now)}`);
lines.push(
`LAST-MODIFIED:${this.formatICalDateTime(
event.lastModified || now
)}`
);
lines.push("END:VEVENT");
lines.push("END:VCALENDAR");
return lines.join("\r\n");
}
/**
* Format date for iCalendar (YYYYMMDD)
*/
private formatICalDate(date: Date): string {
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const day = date.getDate().toString().padStart(2, "0");
return `${year}${month}${day}`;
}
/**
* Format datetime for iCalendar (YYYYMMDDTHHMMSSZ)
*/
private formatICalDateTime(date: Date): string {
const year = date.getUTCFullYear();
const month = (date.getUTCMonth() + 1).toString().padStart(2, "0");
const day = date.getUTCDate().toString().padStart(2, "0");
const hour = date.getUTCHours().toString().padStart(2, "0");
const minute = date.getUTCMinutes().toString().padStart(2, "0");
const second = date.getUTCSeconds().toString().padStart(2, "0");
return `${year}${month}${day}T${hour}${minute}${second}Z`;
}
/**
* Generate unique event UID
*/
private generateEventUid(): string {
const timestamp = Date.now();
const random = Math.random().toString(36).substr(2, 9);
return `${timestamp}-${random}@obsidian-task-genius`;
}
/**
* Handle CalDAV errors
*/
private handleCalDAVError(error: any, context: string): CloudCalendarError {
if (error instanceof CloudCalendarError) {
return error;
}
// CalDAV-specific error handling
if (error.status === 401) {
return new CloudCalendarError(
"unauthorized",
"Invalid iCloud credentials or app-specific password",
401
);
}
if (error.status === 403) {
return new CloudCalendarError(
"forbidden",
"Access denied to iCloud calendar",
403
);
}
if (error.status === 404) {
return new CloudCalendarError(
"not_found",
"Calendar or event not found",
404
);
}
if (error.status === 507) {
return new CloudCalendarError(
"insufficient_storage",
"iCloud storage quota exceeded",
507
);
}
// Default error handling
const message = error.message || "Unknown CalDAV error";
return new CloudCalendarError(
error.code || "caldav_error",
`${context}: ${message}`,
error.status || 0
);
}
}

View file

@ -1,23 +1,35 @@
import { BaseActionExecutor } from './BaseActionExecutor';
import {
OnCompletionConfig,
OnCompletionExecutionContext,
import { BaseActionExecutor } from "./BaseActionExecutor";
import {
OnCompletionConfig,
OnCompletionExecutionContext,
OnCompletionExecutionResult,
OnCompletionActionType,
OnCompletionCompleteConfig
} from '../../types/onCompletion';
import { Task } from '../../types/task';
OnCompletionCompleteConfig,
} from "../../types/onCompletion";
import { Task } from "../../types/task";
/**
* Executor for complete action - marks related tasks as completed
*/
export class CompleteActionExecutor extends BaseActionExecutor {
executeForCanvas(
context: OnCompletionExecutionContext,
config: OnCompletionConfig
): Promise<OnCompletionExecutionResult> {
return this.execute(context, config);
}
executeForMarkdown(
context: OnCompletionExecutionContext,
config: OnCompletionConfig
): Promise<OnCompletionExecutionResult> {
return this.execute(context, config);
}
public async execute(
context: OnCompletionExecutionContext,
config: OnCompletionConfig
): Promise<OnCompletionExecutionResult> {
if (!this.validateConfig(config)) {
return this.createErrorResult('Invalid complete configuration');
return this.createErrorResult("Invalid complete configuration");
}
const completeConfig = config as OnCompletionCompleteConfig;
@ -30,14 +42,14 @@ export class CompleteActionExecutor extends BaseActionExecutor {
// Get all tasks from the task manager
const taskManager = plugin.taskManager;
if (!taskManager) {
return this.createErrorResult('Task manager not available');
return this.createErrorResult("Task manager not available");
}
for (const taskId of completeConfig.taskIds) {
try {
// Find the task by ID
const targetTask = taskManager.getTaskById(taskId);
if (!targetTask) {
failedTasks.push(`Task not found: ${taskId}`);
continue;
@ -52,11 +64,11 @@ export class CompleteActionExecutor extends BaseActionExecutor {
const updatedTask: Task = {
...targetTask,
completed: true,
status: 'x',
status: "x",
metadata: {
...targetTask.metadata,
completedDate: Date.now()
}
completedDate: Date.now(),
},
};
// Update the task using the task manager
@ -68,22 +80,23 @@ export class CompleteActionExecutor extends BaseActionExecutor {
}
// Build result message
let message = '';
let message = "";
if (completedTasks.length > 0) {
message += `Completed tasks: ${completedTasks.join(', ')}`;
message += `Completed tasks: ${completedTasks.join(", ")}`;
}
if (failedTasks.length > 0) {
if (message) message += '; ';
message += `Failed: ${failedTasks.join(', ')}`;
if (message) message += "; ";
message += `Failed: ${failedTasks.join(", ")}`;
}
const success = completedTasks.length > 0;
return success
return success
? this.createSuccessResult(message)
: this.createErrorResult(message || 'No tasks were completed');
: this.createErrorResult(message || "No tasks were completed");
} catch (error) {
return this.createErrorResult(`Failed to complete related tasks: ${error.message}`);
return this.createErrorResult(
`Failed to complete related tasks: ${error.message}`
);
}
}
@ -93,12 +106,17 @@ export class CompleteActionExecutor extends BaseActionExecutor {
}
const completeConfig = config as OnCompletionCompleteConfig;
return Array.isArray(completeConfig.taskIds) && completeConfig.taskIds.length > 0;
return (
Array.isArray(completeConfig.taskIds) &&
completeConfig.taskIds.length > 0
);
}
public getDescription(config: OnCompletionConfig): string {
const completeConfig = config as OnCompletionCompleteConfig;
const taskCount = completeConfig.taskIds?.length || 0;
return `Complete ${taskCount} related task${taskCount !== 1 ? 's' : ''}`;
return `Complete ${taskCount} related task${
taskCount !== 1 ? "s" : ""
}`;
}
}
}

View file

@ -1,26 +1,38 @@
import { BaseActionExecutor } from './BaseActionExecutor';
import {
OnCompletionConfig,
OnCompletionExecutionContext,
import { BaseActionExecutor } from "./BaseActionExecutor";
import {
OnCompletionConfig,
OnCompletionExecutionContext,
OnCompletionExecutionResult,
OnCompletionActionType,
OnCompletionKeepConfig
} from '../../types/onCompletion';
OnCompletionKeepConfig,
} from "../../types/onCompletion";
/**
* Executor for keep action - leaves the completed task as is (no action)
*/
export class KeepActionExecutor extends BaseActionExecutor {
executeForCanvas(
context: OnCompletionExecutionContext,
config: OnCompletionConfig
): Promise<OnCompletionExecutionResult> {
return this.execute(context, config);
}
executeForMarkdown(
context: OnCompletionExecutionContext,
config: OnCompletionConfig
): Promise<OnCompletionExecutionResult> {
return this.execute(context, config);
}
public async execute(
context: OnCompletionExecutionContext,
config: OnCompletionConfig
): Promise<OnCompletionExecutionResult> {
if (!this.validateConfig(config)) {
return this.createErrorResult('Invalid keep configuration');
return this.createErrorResult("Invalid keep configuration");
}
// Keep action does nothing - just return success
return this.createSuccessResult('Task kept in place');
return this.createSuccessResult("Task kept in place");
}
protected validateConfig(config: OnCompletionConfig): boolean {
@ -28,6 +40,6 @@ export class KeepActionExecutor extends BaseActionExecutor {
}
public getDescription(config: OnCompletionConfig): string {
return 'Keep the completed task in place (no action)';
return "Keep the completed task in place (no action)";
}
}
}

File diff suppressed because one or more lines are too long