mirror of
https://github.com/taskgenius/taskgenius-plugin.git
synced 2026-07-22 06:40:25 +00:00
feat: support basic ical
This commit is contained in:
parent
5a164d6873
commit
cae4990aa5
15 changed files with 1643 additions and 63 deletions
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
198
src/utils/auth/LocalOAuthServer.ts
Normal file
198
src/utils/auth/LocalOAuthServer.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}/`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
818
src/utils/cloud/iCloudCalendarAdapter.ts
Normal file
818
src/utils/cloud/iCloudCalendarAdapter.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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" : ""
|
||||
}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
35
styles.css
35
styles.css
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue