diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 65f3ebcfe1..0e1b5c1dc4 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -82,6 +82,16 @@ android:scheme="com.super-productivity.app" android:host="oauth-callback" /> + + + + + + + + ; + + // Plugin OAuth + pluginOAuthStart(url: string): void; + onPluginOAuthCb( + listener: (data: { code?: string; error?: string; state?: string }) => void, + ): void; } diff --git a/electron/plugin-oauth.ts b/electron/plugin-oauth.ts new file mode 100644 index 0000000000..5a292ce1e5 --- /dev/null +++ b/electron/plugin-oauth.ts @@ -0,0 +1,86 @@ +import { BrowserWindow, ipcMain } from 'electron'; +import { IPC } from './shared-with-frontend/ipc-events.const'; +import { log } from 'electron-log/main'; + +const OAUTH_REDIRECT_PREFIX = 'super-productivity://oauth'; + +export const initPluginOAuth = (mainWin: BrowserWindow): void => { + ipcMain.on(IPC.PLUGIN_OAUTH_START, (_ev: unknown, { url }: { url: string }) => { + log('Plugin OAuth: Opening auth window'); + + const authWin = new BrowserWindow({ + width: 600, + height: 700, + parent: mainWin, + modal: true, + webPreferences: { nodeIntegration: false, contextIsolation: true }, + }); + + let handled = false; + + const closeAuthWin = (): void => { + if (!authWin.isDestroyed()) { + authWin.close(); + } + }; + + const handleRedirectUrl = (redirectUrl: string): boolean => { + if (!redirectUrl.startsWith(OAUTH_REDIRECT_PREFIX)) { + return false; + } + if (handled) { + return true; + } + handled = true; + + const parsed = new URL(redirectUrl); + const code = parsed.searchParams.get('code'); + const error = parsed.searchParams.get('error'); + const state = parsed.searchParams.get('state'); + mainWin.webContents.send(IPC.PLUGIN_OAUTH_CB, { code, error, state }); + closeAuthWin(); + return true; + }; + + authWin.webContents.on('will-redirect', (details) => { + if (handleRedirectUrl(details.url)) { + details.preventDefault(); + } + }); + + authWin.webContents.on('will-navigate', (details) => { + if (handleRedirectUrl(details.url)) { + details.preventDefault(); + } + }); + + authWin.on('closed', () => { + if (!handled) { + mainWin.webContents.send(IPC.PLUGIN_OAUTH_CB, { + error: 'window_closed', + }); + } + }); + + // Validate URL protocol before loading to prevent file:// or javascript: abuse + try { + const parsed = new URL(url); + if (parsed.protocol !== 'https:') { + log('Plugin OAuth: Rejected non-https auth URL:', parsed.protocol); + mainWin.webContents.send(IPC.PLUGIN_OAUTH_CB, { + error: 'invalid_auth_url', + }); + closeAuthWin(); + return; + } + } catch { + mainWin.webContents.send(IPC.PLUGIN_OAUTH_CB, { + error: 'invalid_auth_url', + }); + closeAuthWin(); + return; + } + + authWin.loadURL(url); + }); +}; diff --git a/electron/preload.ts b/electron/preload.ts index f8c6037704..a040b68544 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -6,7 +6,7 @@ import { webUtils, } from 'electron'; import { ElectronAPI } from './electronAPI.d'; -import { IPCEventValue } from './shared-with-frontend/ipc-events.const'; +import { IPC, IPCEventValue } from './shared-with-frontend/ipc-events.const'; import { LocalBackupMeta } from '../src/app/imex/local-backup/local-backup.model'; import { PluginManifest, @@ -220,6 +220,18 @@ const ea: ElectronAPI = { manifest, request, ) as Promise, + + // Plugin OAuth + pluginOAuthStart: (url: string) => _send('PLUGIN_OAUTH_START', { url }), + onPluginOAuthCb: ( + listener: (data: { code?: string; error?: string; state?: string }) => void, + ) => { + ipcRenderer.on( + IPC.PLUGIN_OAUTH_CB, + (_: unknown, data: { code?: string; error?: string; state?: string }) => + listener(data), + ); + }, }; // Expose ea to window for ipc-event.ts using contextBridge for context isolation diff --git a/electron/shared-with-frontend/ipc-events.const.ts b/electron/shared-with-frontend/ipc-events.const.ts index 7ca8751bb1..0c310df78c 100644 --- a/electron/shared-with-frontend/ipc-events.const.ts +++ b/electron/shared-with-frontend/ipc-events.const.ts @@ -82,6 +82,10 @@ export enum IPC { // Plugin Node Execution PLUGIN_EXEC_NODE_SCRIPT = 'PLUGIN_EXEC_NODE_SCRIPT', + // Plugin OAuth + PLUGIN_OAUTH_START = 'PLUGIN_OAUTH_START', + PLUGIN_OAUTH_CB = 'PLUGIN_OAUTH_CB', + UPDATE_SETTINGS = 'UPDATE_SETTINGS', UPDATE_TITLE_BAR_DARK_MODE = 'UPDATE_TITLE_BAR_DARK_MODE', diff --git a/electron/start-app.ts b/electron/start-app.ts index ba96447e36..89ce0f2ff0 100644 --- a/electron/start-app.ts +++ b/electron/start-app.ts @@ -1,4 +1,5 @@ import { initIpcInterfaces } from './ipc-handler'; +import { initPluginOAuth } from './plugin-oauth'; import electronLog, { info, log } from 'electron-log/main'; import { App, @@ -371,6 +372,8 @@ export const startApp = (): void => { customUrl, }); + initPluginOAuth(mainWin); + // Process any pending protocol URLs after window is created setTimeout(() => { processPendingProtocolUrls(mainWin); diff --git a/packages/plugin-api/src/issue-provider-types.ts b/packages/plugin-api/src/issue-provider-types.ts index 75bc90aa2a..7020857ca1 100644 --- a/packages/plugin-api/src/issue-provider-types.ts +++ b/packages/plugin-api/src/issue-provider-types.ts @@ -1,11 +1,18 @@ // Public types for plugin authors who build issue provider plugins +import { OAuthFlowConfig } from './types'; + export interface PluginSearchResult { id: string; title: string; url?: string; status?: string; assignee?: string; + /** Event start timestamp (ms) - required for agenda view */ + start?: number; + /** Precise due-with-time timestamp (ms) for timed events. When set, the task is + * created with dueWithTime instead of dueDay on initial import. */ + dueWithTime?: number; } export interface PluginIssue { @@ -49,9 +56,11 @@ export interface PluginCommentsConfig { export type PluginSyncDirection = 'off' | 'pullOnly' | 'pushOnly' | 'both'; export interface PluginFieldMapping { - taskField: 'isDone' | 'title' | 'notes'; + taskField: 'isDone' | 'title' | 'notes' | 'dueDay' | 'dueWithTime' | 'timeEstimate'; issueField: string; defaultDirection: PluginSyncDirection; + /** Task fields to clear when this field is set (e.g. dueWithTime and dueDay are mutually exclusive) */ + mutuallyExclusive?: string[]; toIssueValue( taskValue: unknown, ctx: { issueId: string; issueNumber?: number }, @@ -64,9 +73,18 @@ export interface PluginFieldMapping { export interface PluginFormField { key: string; - type: 'input' | 'password' | 'textarea' | 'checkbox' | 'select' | 'link'; + type: + | 'input' + | 'password' + | 'textarea' + | 'checkbox' + | 'select' + | 'link' + | 'oauthButton'; label: string; required?: boolean; + /** Help text shown below the field */ + description?: string; options?: { label: string; value: string }[]; /** For type 'link': the URL to open */ url?: string; @@ -74,6 +92,13 @@ export interface PluginFormField { pattern?: string; /** Place this field in the collapsible "Advanced Config" section */ advanced?: boolean; + /** For type 'oauthButton': OAuth flow configuration */ + oauthConfig?: OAuthFlowConfig; + /** For type 'select': dynamically load options at runtime (e.g. after OAuth) */ + loadOptions?( + config: Record, + http: PluginHttp, + ): Promise<{ label: string; value: string }[]>; } export interface PluginHttpOptions { @@ -124,14 +149,25 @@ export interface IssueProviderPluginDefinition { title: string, config: Record, http: PluginHttp, - ): Promise<{ issueId: string; issueNumber: number; issueData: PluginIssue }>; + ): Promise<{ issueId: string; issueNumber?: number; issueData: PluginIssue }>; extractSyncValues?(issue: PluginIssue): Record; + deleteIssue?( + id: string, + config: Record, + http: PluginHttp, + ): Promise; + /** Issue states that indicate the issue was deleted remotely (e.g. ['cancelled'] for Google Calendar) */ + deletedStates?: string[]; } export interface IssueProviderManifestConfig { pollIntervalMs: number; icon: string; issueStrings?: { singular: string; plural: string }; + /** Show calendar-style agenda view instead of search-based list */ + useAgendaView?: boolean; + /** Pre-select auto-import of new issues to backlog when creating this provider */ + defaultAutoAddToBacklog?: boolean; /** Custom issue provider key for migrated built-in providers (e.g. 'GITHUB'). * When set, the plugin registers under this key instead of 'plugin:'. */ issueProviderKey?: string; diff --git a/packages/plugin-api/src/types.js.map b/packages/plugin-api/src/types.js.map index 1ba4d85d55..5395e5dc68 100644 --- a/packages/plugin-api/src/types.js.map +++ b/packages/plugin-api/src/types.js.map @@ -1 +1 @@ -{"version":3,"file":"types.js","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":";AAAA,0CAA0C;AAC1C,gEAAgE;;;AAchE,IAAY,WAYX;AAZD,WAAY,WAAW;IACrB,2CAA4B,CAAA;IAC5B,6CAA8B,CAAA;IAC9B,yCAA0B,CAAA;IAC1B,yCAA0B,CAAA;IAC1B,wDAAyC,CAAA;IACzC,uCAAwB,CAAA;IACxB,iDAAkC,CAAA;IAClC,4DAA6C,CAAA;IAC7C,gCAAiB,CAAA;IACjB,gDAAiC,CAAA;IACjC,wDAAyC,CAAA;AAC3C,CAAC,EAZW,WAAW,2BAAX,WAAW,QAYtB;AA2fD;;;GAGG;AACH,IAAY,uBAoBX;AApBD,WAAY,uBAAuB;IACjC,oBAAoB;IACpB,uDAA4B,CAAA;IAC5B,+DAAoC,CAAA;IACpC,yDAA8B,CAAA;IAE9B,cAAc;IACd,2DAAgC,CAAA;IAEhC,qBAAqB;IACrB,6EAAkD,CAAA;IAClD,mFAAwD,CAAA;IAExD,qBAAqB;IACrB,qDAA0B,CAAA;IAC1B,uEAA4C,CAAA;IAC5C,iEAAsC,CAAA;IAEtC,mBAAmB;IACnB,iDAAsB,CAAA;AACxB,CAAC,EApBW,uBAAuB,uCAAvB,uBAAuB,QAoBlC;AAED,6CAA6C;AAC7C,2EAA2E;AAC3E,mBAAmB;AACnB,uBAAuB;AACvB,4BAA4B;AAC5B,MAAM;AACN,EAAE;AACF,uDAAuD;AACvD,gCAAgC;AAChC,IAAI"} \ No newline at end of file +{"version":3,"file":"types.js","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":";AAAA,0CAA0C;AAC1C,gEAAgE;;;AAchE,IAAY,WAYX;AAZD,WAAY,WAAW;IACrB,2CAA4B,CAAA;IAC5B,6CAA8B,CAAA;IAC9B,yCAA0B,CAAA;IAC1B,yCAA0B,CAAA;IAC1B,wDAAyC,CAAA;IACzC,uCAAwB,CAAA;IACxB,iDAAkC,CAAA;IAClC,4DAA6C,CAAA;IAC7C,gCAAiB,CAAA;IACjB,gDAAiC,CAAA;IACjC,wDAAyC,CAAA;AAC3C,CAAC,EAZW,WAAW,2BAAX,WAAW,QAYtB;AAyfD;;;GAGG;AACH,IAAY,uBAoBX;AApBD,WAAY,uBAAuB;IACjC,oBAAoB;IACpB,uDAA4B,CAAA;IAC5B,+DAAoC,CAAA;IACpC,yDAA8B,CAAA;IAE9B,cAAc;IACd,2DAAgC,CAAA;IAEhC,qBAAqB;IACrB,6EAAkD,CAAA;IAClD,mFAAwD,CAAA;IAExD,qBAAqB;IACrB,qDAA0B,CAAA;IAC1B,uEAA4C,CAAA;IAC5C,iEAAsC,CAAA;IAEtC,mBAAmB;IACnB,iDAAsB,CAAA;AACxB,CAAC,EApBW,uBAAuB,uCAAvB,uBAAuB,QAoBlC;AAED,6CAA6C;AAC7C,2EAA2E;AAC3E,mBAAmB;AACnB,uBAAuB;AACvB,4BAA4B;AAC5B,MAAM;AACN,EAAE;AACF,uDAAuD;AACvD,gCAAgC;AAChC,IAAI"} \ No newline at end of file diff --git a/packages/plugin-api/src/types.ts b/packages/plugin-api/src/types.ts index c16c714c91..68ba49b227 100644 --- a/packages/plugin-api/src/types.ts +++ b/packages/plugin-api/src/types.ts @@ -228,6 +228,8 @@ export interface Task { doneOn?: number | null; attachments?: any[]; remindAt?: number | null; + dueDay?: string | null; + dueWithTime?: number | null; repeatCfgId?: string | null; // Issue tracking fields (optional) @@ -321,6 +323,28 @@ export interface PluginSidePanelBtnCfg { onClick: () => void; } +export interface OAuthFlowConfig { + authUrl: string; + tokenUrl: string; + clientId: string; + /** + * NOT kept confidential — this value is embedded in plugin source code, + * persisted in user data, and may be synced to cloud backends. + * Only use for OAuth providers that document their "client secret" as + * non-confidential (e.g., Google installed-app credentials per RFC 8252). + */ + clientSecret?: string; + scopes: string[]; + /** Additional query parameters to append to the authorization URL (e.g. access_type, prompt). */ + extraAuthParams?: Record; +} + +export interface OAuthTokenResult { + accessToken: string; + refreshToken: string; + expiresAt: number; // unix ms +} + export interface PluginAPI { cfg: PluginBaseCfg; @@ -408,6 +432,13 @@ export interface PluginAPI { getConfig>(): Promise; + // oauth + startOAuthFlow(config: OAuthFlowConfig): Promise; + + getOAuthToken(): Promise; + + clearOAuthToken(): Promise; + // download file downloadFile(filename: string, data: string): Promise; diff --git a/packages/plugin-dev/google-calendar-provider/package-lock.json b/packages/plugin-dev/google-calendar-provider/package-lock.json new file mode 100644 index 0000000000..63f8f1d5e0 --- /dev/null +++ b/packages/plugin-dev/google-calendar-provider/package-lock.json @@ -0,0 +1,1767 @@ +{ + "name": "@super-productivity/google-calendar-provider", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@super-productivity/google-calendar-provider", + "version": "1.0.0", + "dependencies": { + "@super-productivity/plugin-api": "file:../../plugin-api" + }, + "devDependencies": { + "esbuild": "^0.25.11", + "typescript": "^5.8.3", + "vitest": "^4.1.0" + } + }, + "../../plugin-api": { + "name": "@super-productivity/plugin-api", + "version": "1.0.1", + "license": "MIT", + "devDependencies": { + "typescript": "^5.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.120.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz", + "integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz", + "integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz", + "integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz", + "integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz", + "integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz", + "integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz", + "integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz", + "integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz", + "integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz", + "integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@super-productivity/plugin-api": { + "resolved": "../../plugin-api", + "link": true + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", + "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "chai": "^6.2.2", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", + "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.0", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", + "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "@vitest/utils": "4.1.0", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", + "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz", + "integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.120.0", + "@rolldown/pluginutils": "1.0.0-rc.10" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.10", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", + "@rolldown/binding-darwin-x64": "1.0.0-rc.10", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vitest": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", + "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.0", + "@vitest/mocker": "4.1.0", + "@vitest/pretty-format": "4.1.0", + "@vitest/runner": "4.1.0", + "@vitest/snapshot": "4.1.0", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.0", + "@vitest/browser-preview": "4.1.0", + "@vitest/browser-webdriverio": "4.1.0", + "@vitest/ui": "4.1.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", + "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.0", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz", + "integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.10", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/packages/plugin-dev/google-calendar-provider/package.json b/packages/plugin-dev/google-calendar-provider/package.json new file mode 100644 index 0000000000..0527415b60 --- /dev/null +++ b/packages/plugin-dev/google-calendar-provider/package.json @@ -0,0 +1,19 @@ +{ + "name": "@super-productivity/google-calendar-provider", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "node scripts/build.js", + "test": "vitest run", + "typecheck": "tsc --noEmit", + "lint": "tsc --noEmit" + }, + "devDependencies": { + "esbuild": "^0.25.11", + "typescript": "^5.8.3", + "vitest": "^4.1.0" + }, + "dependencies": { + "@super-productivity/plugin-api": "file:../../plugin-api" + } +} diff --git a/packages/plugin-dev/google-calendar-provider/scripts/build.js b/packages/plugin-dev/google-calendar-provider/scripts/build.js new file mode 100644 index 0000000000..0990f457f5 --- /dev/null +++ b/packages/plugin-dev/google-calendar-provider/scripts/build.js @@ -0,0 +1,51 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const { build } = require('esbuild'); + +const ROOT_DIR = path.join(__dirname, '..'); +const SRC_DIR = path.join(ROOT_DIR, 'src'); +const DIST_DIR = path.join(ROOT_DIR, 'dist'); + +async function buildPlugin() { + console.log('Building google-calendar-provider...'); + + if (fs.existsSync(DIST_DIR)) { + fs.rmSync(DIST_DIR, { recursive: true }); + } + fs.mkdirSync(DIST_DIR); + + // Build TypeScript to IIFE bundle + await build({ + entryPoints: [path.join(SRC_DIR, 'plugin.ts')], + bundle: true, + outfile: path.join(DIST_DIR, 'plugin.js'), + platform: 'browser', + target: 'es2020', + format: 'iife', + define: { + 'process.env.NODE_ENV': '"production"', + }, + logLevel: 'info', + minify: true, + sourcemap: false, + }); + + // Copy manifest.json + fs.copyFileSync(path.join(SRC_DIR, 'manifest.json'), path.join(DIST_DIR, 'manifest.json')); + + // Copy icon.svg if present + const iconSrc = path.join(ROOT_DIR, 'icon.svg'); + if (fs.existsSync(iconSrc)) { + fs.copyFileSync(iconSrc, path.join(DIST_DIR, 'icon.svg')); + console.log('Copied icon.svg'); + } + + console.log('Build complete!'); +} + +buildPlugin().catch((err) => { + console.error('Build failed:', err); + process.exit(1); +}); diff --git a/packages/plugin-dev/google-calendar-provider/src/manifest.json b/packages/plugin-dev/google-calendar-provider/src/manifest.json new file mode 100644 index 0000000000..8dfc60d7b8 --- /dev/null +++ b/packages/plugin-dev/google-calendar-provider/src/manifest.json @@ -0,0 +1,17 @@ +{ + "id": "google-calendar-provider", + "name": "Google Calendar", + "version": "1.0.0", + "manifestVersion": 1, + "minSupVersion": "13.0.0", + "type": "issueProvider", + "permissions": ["oauth", "http"], + "hooks": [], + "issueProvider": { + "pollIntervalMs": 60000, + "icon": "calendar", + "issueStrings": { "singular": "Event", "plural": "Events" }, + "useAgendaView": true, + "defaultAutoAddToBacklog": true + } +} diff --git a/packages/plugin-dev/google-calendar-provider/src/plugin.spec.ts b/packages/plugin-dev/google-calendar-provider/src/plugin.spec.ts new file mode 100644 index 0000000000..ac61763ded --- /dev/null +++ b/packages/plugin-dev/google-calendar-provider/src/plugin.spec.ts @@ -0,0 +1,315 @@ +import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest'; +import type { IssueProviderPluginDefinition } from '@super-productivity/plugin-api'; + +let definition: IssueProviderPluginDefinition; + +beforeAll(async () => { + (globalThis as any).PluginAPI = { + registerIssueProvider: vi.fn((def: IssueProviderPluginDefinition) => { + definition = def; + }), + getOAuthToken: vi.fn().mockResolvedValue('mock-token'), + clearOAuthToken: vi.fn().mockResolvedValue(undefined), + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }; + await import('./plugin'); +}); + +describe('Google Calendar Plugin', () => { + describe('fieldMappings', () => { + const ctx = { issueId: 'event-1' }; + + describe('title <-> summary', () => { + it('should pass title through to issue (toIssueValue)', () => { + const mapping = definition.fieldMappings!.find((m) => m.taskField === 'title')!; + expect(mapping.toIssueValue('My Event', ctx)).toBe('My Event'); + }); + + it('should strip [DONE] prefix from summary (toTaskValue)', () => { + const mapping = definition.fieldMappings!.find((m) => m.taskField === 'title')!; + expect(mapping.toTaskValue('[DONE] My Event', ctx)).toBe('My Event'); + }); + + it('should return summary as-is when no [DONE] prefix', () => { + const mapping = definition.fieldMappings!.find((m) => m.taskField === 'title')!; + expect(mapping.toTaskValue('My Event', ctx)).toBe('My Event'); + }); + + it('should return (No title) for empty summary', () => { + const mapping = definition.fieldMappings!.find((m) => m.taskField === 'title')!; + expect(mapping.toTaskValue('', ctx)).toBe('(No title)'); + }); + }); + + describe('notes <-> description', () => { + it('should pass notes to issue', () => { + const mapping = definition.fieldMappings!.find((m) => m.taskField === 'notes')!; + expect(mapping.toIssueValue('some notes', ctx)).toBe('some notes'); + }); + + it('should pass description to task', () => { + const mapping = definition.fieldMappings!.find((m) => m.taskField === 'notes')!; + expect(mapping.toTaskValue('some desc', ctx)).toBe('some desc'); + }); + + it('should return empty string for falsy values', () => { + const mapping = definition.fieldMappings!.find((m) => m.taskField === 'notes')!; + expect(mapping.toIssueValue('', ctx)).toBe(''); + expect(mapping.toTaskValue('', ctx)).toBe(''); + }); + }); + + describe('dueWithTime <-> start_dateTime', () => { + it('should convert ms timestamp to local ISO string (toIssueValue)', () => { + const mapping = definition.fieldMappings!.find( + (m) => m.taskField === 'dueWithTime', + )!; + const ts = new Date('2026-03-20T10:00:00Z').getTime(); + const result = mapping.toIssueValue(ts, ctx) as string; + expect(new Date(result).getTime()).toBe(ts); + }); + + it('should convert ISO string to ms timestamp (toTaskValue)', () => { + const mapping = definition.fieldMappings!.find( + (m) => m.taskField === 'dueWithTime', + )!; + const iso = '2026-03-20T10:00:00Z'; + expect(mapping.toTaskValue(iso, ctx)).toBe(new Date(iso).getTime()); + }); + + it('should return null for falsy toIssueValue and undefined for falsy toTaskValue', () => { + const mapping = definition.fieldMappings!.find( + (m) => m.taskField === 'dueWithTime', + )!; + expect(mapping.toIssueValue(0, ctx)).toBeNull(); + expect(mapping.toTaskValue('', ctx)).toBeUndefined(); + }); + + it('should declare dueDay as mutually exclusive', () => { + const mapping = definition.fieldMappings!.find( + (m) => m.taskField === 'dueWithTime', + )!; + expect(mapping.mutuallyExclusive).toEqual(['dueDay']); + }); + }); + + describe('timeEstimate <-> duration_ms', () => { + it('should pass number through both directions', () => { + const mapping = definition.fieldMappings!.find( + (m) => m.taskField === 'timeEstimate', + )!; + expect(mapping.toIssueValue(3600000, ctx)).toBe(3600000); + expect(mapping.toTaskValue(1800000, ctx)).toBe(1800000); + }); + + it('should return 0 for falsy values', () => { + const mapping = definition.fieldMappings!.find( + (m) => m.taskField === 'timeEstimate', + )!; + expect(mapping.toIssueValue(0, ctx)).toBe(0); + expect(mapping.toTaskValue(0, ctx)).toBe(0); + }); + }); + + describe('dueDay <-> start_date', () => { + it('should pass date string through both directions', () => { + const mapping = definition.fieldMappings!.find((m) => m.taskField === 'dueDay')!; + expect(mapping.toIssueValue('2026-03-20', ctx)).toBe('2026-03-20'); + expect(mapping.toTaskValue('2026-03-20', ctx)).toBe('2026-03-20'); + }); + + it('should return null for falsy toIssueValue and undefined for falsy toTaskValue', () => { + const mapping = definition.fieldMappings!.find((m) => m.taskField === 'dueDay')!; + expect(mapping.toIssueValue('', ctx)).toBeNull(); + expect(mapping.toTaskValue('', ctx)).toBeUndefined(); + }); + + it('should declare dueWithTime as mutually exclusive', () => { + const mapping = definition.fieldMappings!.find((m) => m.taskField === 'dueDay')!; + expect(mapping.mutuallyExclusive).toEqual(['dueWithTime']); + }); + }); + }); + + describe('extractSyncValues', () => { + it('should normalize timed event to UTC ISO', () => { + const issue = { + id: 'e1', + title: 'Meeting', + body: 'notes', + start: { dateTime: '2026-03-20T12:00:00+02:00' }, + end: { dateTime: '2026-03-20T13:00:00+02:00' }, + }; + + const result = definition.extractSyncValues!(issue as any); + + expect(result.start_dateTime).toBe('2026-03-20T10:00:00.000Z'); + expect(result.duration_ms).toBe(3600000); + expect(result.summary).toBe('Meeting'); + expect(result.description).toBe('notes'); + }); + + it('should extract all-day event fields', () => { + const issue = { + id: 'e1', + title: 'Holiday', + body: '', + start: { date: '2026-03-20' }, + end: { date: '2026-03-21' }, + }; + + const result = definition.extractSyncValues!(issue as any); + + expect(result.start_date).toBe('2026-03-20'); + expect(result.start_dateTime).toBeUndefined(); + expect(result.duration_ms).toBe(0); + }); + + it('should handle missing start/end gracefully', () => { + const issue = { id: 'e1', title: 'Bare', body: '' }; + + const result = definition.extractSyncValues!(issue as any); + + expect(result.start_dateTime).toBeUndefined(); + expect(result.start_date).toBeUndefined(); + expect(result.duration_ms).toBe(0); + }); + }); + + describe('updateIssue', () => { + let mockHttp: { get: any; post: any; put: any; patch: any; delete: any }; + const cfg = { calendarId: 'test-cal' }; + + beforeEach(() => { + mockHttp = { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + }; + }); + + it('should patch summary when title changes', async () => { + await definition.updateIssue!( + 'event-1', + { summary: 'New Title' }, + cfg as any, + mockHttp as any, + ); + + expect(mockHttp.patch).toHaveBeenCalledTimes(1); + const [, body] = mockHttp.patch.mock.calls[0]; + expect(body.summary).toBe('New Title'); + }); + + it('should patch description when notes change', async () => { + await definition.updateIssue!( + 'event-1', + { description: 'New notes' }, + cfg as any, + mockHttp as any, + ); + + expect(mockHttp.patch).toHaveBeenCalledTimes(1); + const [, body] = mockHttp.patch.mock.calls[0]; + expect(body.description).toBe('New notes'); + }); + + it('should set start/end dateTime and null out date for timed events', async () => { + const startIso = '2026-03-20T10:00:00+00:00'; + await definition.updateIssue!( + 'event-1', + { start_dateTime: startIso, duration_ms: 3600000 }, + cfg as any, + mockHttp as any, + ); + + expect(mockHttp.patch).toHaveBeenCalledTimes(1); + const [, body] = mockHttp.patch.mock.calls[0]; + expect(body.start.dateTime).toBeDefined(); + expect(body.start.date).toBeNull(); + expect(body.end.dateTime).toBeDefined(); + expect(body.end.date).toBeNull(); + const endTs = new Date(body.end.dateTime).getTime(); + const startTs = new Date(body.start.dateTime).getTime(); + expect(endTs - startTs).toBe(3600000); + }); + + it('should set start/end date and null out dateTime for all-day events', async () => { + await definition.updateIssue!( + 'event-1', + { start_date: '2026-03-20' }, + cfg as any, + mockHttp as any, + ); + + expect(mockHttp.patch).toHaveBeenCalledTimes(1); + const [, body] = mockHttp.patch.mock.calls[0]; + expect(body.start.date).toBe('2026-03-20'); + expect(body.start.dateTime).toBeNull(); + expect(body.end.date).toBe('2026-03-21'); + expect(body.end.dateTime).toBeNull(); + }); + + it('should fetch current event and update end when only duration changes', async () => { + mockHttp.get.mockResolvedValue({ + start: { dateTime: '2026-03-20T10:00:00Z' }, + }); + + await definition.updateIssue!( + 'event-1', + { duration_ms: 7200000 }, + cfg as any, + mockHttp as any, + ); + + expect(mockHttp.get).toHaveBeenCalledTimes(1); + expect(mockHttp.patch).toHaveBeenCalledTimes(1); + const [, body] = mockHttp.patch.mock.calls[0]; + const endTs = new Date(body.end.dateTime).getTime(); + expect(endTs).toBe(new Date('2026-03-20T10:00:00Z').getTime() + 7200000); + }); + + it('should not call patch when no recognized fields changed', async () => { + await definition.updateIssue!( + 'event-1', + { unknown_field: 'value' }, + cfg as any, + mockHttp as any, + ); + + expect(mockHttp.patch).not.toHaveBeenCalled(); + }); + }); + + describe('createIssue', () => { + it('should create an all-day event with today/tomorrow dates', async () => { + const mockHttp = { + get: vi.fn(), + post: vi.fn().mockResolvedValue({ + id: 'new-event-1', + summary: 'New Task', + status: 'confirmed', + }), + put: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + }; + + const result = await definition.createIssue!( + 'New Task', + { calendarId: 'test-cal' } as any, + mockHttp as any, + ); + + expect(mockHttp.post).toHaveBeenCalledTimes(1); + const [, body] = mockHttp.post.mock.calls[0]; + expect(body.summary).toBe('New Task'); + expect(body.start.date).toBeDefined(); + expect(body.end.date).toBeDefined(); + expect(body.start.dateTime).toBeUndefined(); + expect(result.issueId).toBe('new-event-1'); + }); + }); +}); diff --git a/packages/plugin-dev/google-calendar-provider/src/plugin.ts b/packages/plugin-dev/google-calendar-provider/src/plugin.ts new file mode 100644 index 0000000000..59069b1216 --- /dev/null +++ b/packages/plugin-dev/google-calendar-provider/src/plugin.ts @@ -0,0 +1,465 @@ +import type { + IssueProviderPluginDefinition, + PluginFieldMapping, + PluginHttp, + PluginIssue, + PluginSearchResult, +} from '@super-productivity/plugin-api'; + +declare const PluginAPI: { + registerIssueProvider(definition: IssueProviderPluginDefinition): void; + startOAuthFlow(config: { + authUrl: string; + tokenUrl: string; + clientId: string; + scopes: string[]; + extraAuthParams?: Record; + }): Promise<{ accessToken: string; refreshToken: string; expiresAt: number }>; + getOAuthToken(): Promise; + clearOAuthToken(): Promise; +}; + +const GOOGLE_CALENDAR_API = 'https://www.googleapis.com/calendar/v3'; +const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; +const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; +const CALENDAR_EVENTS_SCOPE = 'https://www.googleapis.com/auth/calendar.events'; +const CALENDAR_READONLY_SCOPE = 'https://www.googleapis.com/auth/calendar.readonly'; +const CLIENT_ID = + '637968426975-p6bu3f76b9cbk927k6281lb30bris19o.apps.googleusercontent.com'; +// NOT A SECRET — this is a "Desktop" OAuth client type (RFC 8252). +// Google classifies these as public clients where the secret cannot be kept +// confidential (it ships in the binary users download). PKCE + server-side +// redirect URI restrictions are the actual security mechanisms. +// Do not rotate or revoke — this value is intentionally committed. +const CLIENT_SECRET = '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'; + +interface GoogleCalendarConfig { + calendarId?: string; + syncRangeWeeks?: string; +} + +interface GoogleCalendarEvent { + id: string; + summary?: string; + description?: string; + status?: string; + updated?: string; + start?: { dateTime?: string; date?: string; timeZone?: string }; + end?: { dateTime?: string; date?: string; timeZone?: string }; + htmlLink?: string; + [key: string]: unknown; +} + +interface GoogleCalendarListResponse { + items?: GoogleCalendarEvent[]; +} + +const formatEventTime = (time?: { dateTime?: string; date?: string }): string => { + if (!time) return ''; + if (time.dateTime) { + return new Date(time.dateTime).toLocaleString(); + } + if (time.date) { + // Parse as local midnight (not UTC) to avoid date shift in western timezones + return new Date(time.date + 'T00:00:00').toLocaleDateString(); + } + return ''; +}; + +const formatYMD = (d: Date): string => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + +const asCfg = (config: Record): GoogleCalendarConfig => + config as unknown as GoogleCalendarConfig; + +const getCalendarId = (cfg: GoogleCalendarConfig): string => + cfg.calendarId || 'primary'; + +const eventUrl = (calendarId: string, eventId?: string): string => { + const base = `${GOOGLE_CALENDAR_API}/calendars/${encodeURIComponent(calendarId)}/events`; + return eventId ? `${base}/${encodeURIComponent(eventId)}` : base; +}; + +/** Format a timestamp as UTC ISO 8601 string for use in Google Calendar API. */ +const toUTCISO = (timestamp: number): string => new Date(timestamp).toISOString(); + +const mapEventToSearchResult = (event: GoogleCalendarEvent): PluginSearchResult => { + const startDateTimeMs = event.start?.dateTime + ? new Date(event.start.dateTime).getTime() + : undefined; + // Parse all-day date as local midnight (not UTC) to avoid date shift for western timezones + const startDateMs = event.start?.date + ? new Date(event.start.date + 'T00:00:00').getTime() + : undefined; + return { + id: event.id, + title: event.summary || '(No title)', + status: event.status, + start: startDateTimeMs ?? startDateMs, + dueWithTime: startDateTimeMs, + }; +}; + +const fetchEvents = async ( + http: PluginHttp, + cfg: GoogleCalendarConfig, + opts?: { query?: string; maxResults?: string }, +): Promise => { + const calendarId = getCalendarId(cfg); + const syncRangeWeeks = parseInt(cfg.syncRangeWeeks || '', 10) || 2; + const now = new Date(); + const timeMin = now.toISOString(); + const timeMax = new Date( + now.getTime() + syncRangeWeeks * 7 * 24 * 60 * 60 * 1000, + ).toISOString(); + + const response = await http.get( + eventUrl(calendarId), + { + params: { + ...(opts?.query ? { q: opts.query } : {}), + timeMin, + timeMax, + singleEvents: 'true', + orderBy: 'startTime', + maxResults: opts?.maxResults ?? '50', + }, + }, + ); + + return (response.items || []) + .filter((e) => e.status !== 'cancelled') + .map(mapEventToSearchResult); +}; + +PluginAPI.registerIssueProvider({ + configFields: [ + { + key: 'oauth', + type: 'oauthButton' as const, + label: 'Connect Google Account', + oauthConfig: { + authUrl: GOOGLE_AUTH_URL, + tokenUrl: GOOGLE_TOKEN_URL, + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + scopes: [CALENDAR_EVENTS_SCOPE, CALENDAR_READONLY_SCOPE], + extraAuthParams: { access_type: 'offline', prompt: 'consent' }, + }, + }, + { + key: 'calendarId', + type: 'select' as const, + label: 'Calendar', + description: 'Select which calendar to sync events from.', + required: true, + options: [{ label: 'Primary', value: 'primary' }], + async loadOptions( + _config: Record, + http: PluginHttp, + ): Promise<{ label: string; value: string }[]> { + const res = await http.get<{ + items?: { id: string; summary: string; primary?: boolean }[]; + }>(`${GOOGLE_CALENDAR_API}/users/me/calendarList`, { + params: { minAccessRole: 'writer' }, + }); + return (res.items || []).map((cal) => ({ + label: cal.summary + (cal.primary ? ' (Primary)' : ''), + value: cal.id, + })); + }, + }, + { + key: 'syncRangeWeeks', + type: 'input' as const, + label: 'Sync range (weeks)', + description: 'How many weeks ahead to sync events. Defaults to 2.', + required: false, + }, + ], + + async getHeaders(_config: Record): Promise> { + const token = await PluginAPI.getOAuthToken(); + if (!token) { + throw new Error('Not authenticated. Please connect your Google account first.'); + } + return { Authorization: `Bearer ${token}` }; + }, + + async searchIssues( + searchTerm: string, + config: Record, + http: PluginHttp, + ): Promise { + return fetchEvents(http, asCfg(config), { query: searchTerm }); + }, + + async getById( + issueId: string, + config: Record, + http: PluginHttp, + ): Promise { + const calendarId = getCalendarId(asCfg(config)); + const event = await http.get( + eventUrl(calendarId, issueId), + ); + + return { + id: event.id, + title: event.summary || '(No title)', + body: event.description || '', + state: event.status || 'confirmed', + lastUpdated: event.updated ? new Date(event.updated).getTime() : undefined, + summary: event.summary || '(No title)', + start: event.start, + end: event.end, + startFormatted: formatEventTime(event.start), + endFormatted: formatEventTime(event.end), + status: event.status, + description: event.description, + }; + }, + + getIssueLink(issueId: string, config: Record): string { + const calendarId = getCalendarId(asCfg(config)); + const eid = btoa(`${issueId} ${calendarId}`) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + return `https://calendar.google.com/calendar/event?eid=${eid}`; + }, + + async testConnection( + config: Record, + http: PluginHttp, + ): Promise { + const calendarId = getCalendarId(asCfg(config)); + try { + await http.get(`${GOOGLE_CALENDAR_API}/calendars/${encodeURIComponent(calendarId)}`); + return true; + } catch { + return false; + } + }, + + async getNewIssuesForBacklog( + config: Record, + http: PluginHttp, + ): Promise { + return fetchEvents(http, asCfg(config), { maxResults: '100' }); + }, + + issueDisplay: [ + { field: 'summary', label: 'Title', type: 'text' }, + { field: 'startFormatted', label: 'Start', type: 'text' }, + { field: 'endFormatted', label: 'End', type: 'text' }, + { field: 'status', label: 'Status', type: 'text' }, + { field: 'description', label: 'Description', type: 'markdown' }, + ], + + fieldMappings: [ + { + taskField: 'title', + issueField: 'summary', + defaultDirection: 'both', + toIssueValue: ( + taskValue: unknown, + _ctx: { issueId: string; issueNumber?: number }, + ): string => { + // Done marker logic handled separately via isDone push + return (taskValue as string) ?? ''; + }, + toTaskValue: ( + issueValue: unknown, + _ctx: { issueId: string; issueNumber?: number }, + ): string => { + const val = issueValue as string; + // Strip done marker if present (from config or default [DONE]) + if (val && val.startsWith('[DONE] ')) { + return val.slice(7); + } + return val || '(No title)'; + }, + }, + { + taskField: 'notes', + issueField: 'description', + defaultDirection: 'both', + toIssueValue: (taskValue: unknown): string => (taskValue as string) || '', + toTaskValue: (issueValue: unknown): string => (issueValue as string) || '', + }, + { + taskField: 'dueWithTime', + issueField: 'start_dateTime', + defaultDirection: 'both', + mutuallyExclusive: ['dueDay'], + toIssueValue: (taskValue: unknown): string | null => { + if (!taskValue) return null; + return toUTCISO(taskValue as number); + }, + toTaskValue: (issueValue: unknown): number | undefined => { + if (!issueValue) return undefined; + return new Date(issueValue as string).getTime(); + }, + }, + { + taskField: 'timeEstimate', + issueField: 'duration_ms', + defaultDirection: 'both', + toIssueValue: (taskValue: unknown): number => (taskValue as number) || 0, + toTaskValue: (issueValue: unknown): number => (issueValue as number) || 0, + }, + { + taskField: 'dueDay', + issueField: 'start_date', + defaultDirection: 'both', + mutuallyExclusive: ['dueWithTime'], + toIssueValue: (taskValue: unknown): string | null => (taskValue as string) || null, + toTaskValue: (issueValue: unknown): string | undefined => + (issueValue as string) || undefined, + }, + ] satisfies PluginFieldMapping[], + + async updateIssue( + id: string, + changes: Record, + config: Record, + http: PluginHttp, + ): Promise { + const calendarId = getCalendarId(asCfg(config)); + const patch: Record = {}; + + if (changes.summary !== undefined) { + patch.summary = changes.summary; + } + if (changes.description !== undefined) { + patch.description = changes.description; + } + + // Handle timed event updates + // Null out `date` so Google Calendar PATCH doesn't merge both fields + if (changes.start_dateTime !== undefined) { + if (changes.start_dateTime === null) { + // Task was unscheduled - convert timed event to all-day event for today + const today = new Date(); + const todayStr = formatYMD(today); + const tmrw = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1); + const tmrwStr = formatYMD(tmrw); + patch.start = { date: todayStr, dateTime: null }; + patch.end = { date: tmrwStr, dateTime: null }; + } else { + patch.start = { dateTime: changes.start_dateTime, date: null }; + const durationMs = (changes.duration_ms as number) || 30 * 60 * 1000; + const endMs = new Date(changes.start_dateTime as string).getTime() + durationMs; + patch.end = { dateTime: toUTCISO(endMs), date: null }; + } + } else if (changes.duration_ms !== undefined) { + // Duration changed but start didn't - fetch current start + const current = await http.get(eventUrl(calendarId, id)); + if (current.start?.dateTime) { + const endMs = + new Date(current.start.dateTime).getTime() + (changes.duration_ms as number); + patch.end = { dateTime: toUTCISO(endMs), date: null }; + } + } + + // Handle all-day event updates + // Null out `dateTime` so Google Calendar PATCH doesn't merge both fields + if (changes.start_date !== undefined) { + if (changes.start_date === null) { + // dueDay cleared - convert to timed event (keep current time or use now) + // Since we don't know what time to use, just skip the patch + // The user likely set dueWithTime instead (mutually exclusive) + } else { + patch.start = { date: changes.start_date, dateTime: null }; + const startDate = new Date(changes.start_date + 'T00:00:00'); + const endDate = new Date( + startDate.getFullYear(), + startDate.getMonth(), + startDate.getDate() + 1, + ); + patch.end = { date: formatYMD(endDate), dateTime: null }; + } + } + + if (Object.keys(patch).length > 0) { + await http.patch(eventUrl(calendarId, id), patch); + } + }, + + extractSyncValues(issue: PluginIssue): Record { + const raw = issue as Record; + const startObj = raw.start as Record | undefined; + const endObj = raw.end as Record | undefined; + const startDateTime = startObj?.dateTime as string | undefined; + const endDateTime = endObj?.dateTime as string | undefined; + const startDate = startObj?.date as string | undefined; + + // Normalize to UTC ISO to avoid timezone format mismatches + // (Google may return "+01:00" but we send ".000Z") + const normalizedStart = startDateTime + ? new Date(startDateTime).toISOString() + : undefined; + const normalizedEnd = endDateTime ? new Date(endDateTime).toISOString() : undefined; + + return { + summary: issue.title || '', + description: issue.body || '', + start_dateTime: normalizedStart, + start_date: startDate || undefined, + duration_ms: + normalizedStart && normalizedEnd + ? new Date(normalizedEnd).getTime() - new Date(normalizedStart).getTime() + : 0, + }; + }, + + async createIssue(title: string, config: Record, http: PluginHttp) { + const calendarId = getCalendarId(asCfg(config)); + + // Create as all-day event for today; _pushInitialValues will + // convert to a timed event if the task has dueWithTime set. + // Use local date (not UTC) to avoid date shift near midnight. + const now = new Date(); + const today = formatYMD(now); + const tmrw = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); + const tomorrow = formatYMD(tmrw); + + const event = await http.post( + eventUrl(calendarId), + { + summary: title, + start: { date: today }, + end: { date: tomorrow }, + }, + ); + + return { + issueId: event.id, + issueData: { + id: event.id, + title: event.summary || title, + body: event.description || '', + state: event.status || 'confirmed', + summary: event.summary, + start: event.start, + end: event.end, + startFormatted: formatEventTime(event.start), + endFormatted: formatEventTime(event.end), + status: event.status, + description: event.description, + }, + }; + }, + + deletedStates: ['cancelled'], + + async deleteIssue( + id: string, + config: Record, + http: PluginHttp, + ): Promise { + const calendarId = getCalendarId(asCfg(config)); + await http.delete(eventUrl(calendarId, id)); + }, +}); diff --git a/packages/plugin-dev/google-calendar-provider/tsconfig.json b/packages/plugin-dev/google-calendar-provider/tsconfig.json new file mode 100644 index 0000000000..3c59f94bab --- /dev/null +++ b/packages/plugin-dev/google-calendar-provider/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/plugin-dev/google-calendar-provider/vitest.config.ts b/packages/plugin-dev/google-calendar-provider/vitest.config.ts new file mode 100644 index 0000000000..a2def4141e --- /dev/null +++ b/packages/plugin-dev/google-calendar-provider/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.spec.ts'], + globals: true, + }, +}); diff --git a/packages/plugin-dev/scripts/build-all.js b/packages/plugin-dev/scripts/build-all.js index 04c478d7e8..35200dd4be 100755 --- a/packages/plugin-dev/scripts/build-all.js +++ b/packages/plugin-dev/scripts/build-all.js @@ -294,6 +294,9 @@ const plugins = [ return 'Built and copied to assets'; }, }, + // google-calendar-provider: disabled from bundled builds pending legal review. + // Source remains in packages/plugin-dev/google-calendar-provider/ + // To re-enable: uncomment and add back to BUNDLED_PLUGIN_PATHS in plugin.service.ts ]; async function buildPlugin(plugin) { diff --git a/src/app/features/archive/task-archive.service.ts b/src/app/features/archive/task-archive.service.ts index 19f69d7349..4375c63c47 100644 --- a/src/app/features/archive/task-archive.service.ts +++ b/src/app/features/archive/task-archive.service.ts @@ -217,7 +217,9 @@ export class TaskArchiveService { if (toDeleteInArchiveYoung.length > 0) { const newTaskState = this._reduceForArchive( archiveYoung, - TaskSharedActions.deleteTasks({ taskIds: toDeleteInArchiveYoung }), + TaskSharedActions.deleteTasks({ + taskIds: toDeleteInArchiveYoung, + }), ); await this.archiveDbAdapter.saveArchiveYoung({ ...archiveYoung, @@ -233,7 +235,9 @@ export class TaskArchiveService { ); const newTaskStateArchiveOld = this._reduceForArchive( archiveOld, - TaskSharedActions.deleteTasks({ taskIds: toDeleteInArchiveOld }), + TaskSharedActions.deleteTasks({ + taskIds: toDeleteInArchiveOld, + }), ); await this.archiveDbAdapter.saveArchiveOld({ ...archiveOld, diff --git a/src/app/features/issue-panel/issue-provider-setup-overview/issue-provider-setup-overview.component.html b/src/app/features/issue-panel/issue-provider-setup-overview/issue-provider-setup-overview.component.html index 940050943b..7b2f78d9a2 100644 --- a/src/app/features/issue-panel/issue-provider-setup-overview/issue-provider-setup-overview.component.html +++ b/src/app/features/issue-panel/issue-provider-setup-overview/issue-provider-setup-overview.component.html @@ -21,6 +21,32 @@ calendar_month iCal Other + @for (provider of pluginCalendarProviders; track provider.pluginId) { + + } + @for (provider of disabledPluginCalendarProviders; track provider.pluginId) { + + }

Setup Issue Provider

diff --git a/src/app/features/issue-panel/issue-provider-setup-overview/issue-provider-setup-overview.component.ts b/src/app/features/issue-panel/issue-provider-setup-overview/issue-provider-setup-overview.component.ts index 514610672d..87c9e53da0 100644 --- a/src/app/features/issue-panel/issue-provider-setup-overview/issue-provider-setup-overview.component.ts +++ b/src/app/features/issue-panel/issue-provider-setup-overview/issue-provider-setup-overview.component.ts @@ -28,8 +28,18 @@ export class IssueProviderSetupOverviewComponent { enabledProviders$ = this._store.select(selectEnabledIssueProviders); // NOTE: intentionally non-reactive for v1 — plugins load at startup before this dialog opens - pluginProviders = this._pluginRegistry.getAvailableProviders(); - disabledPluginProviders = this._pluginService.getDisabledIssueProviderPlugins(); + pluginProviders = this._pluginRegistry + .getAvailableProviders() + .filter((p) => !p.useAgendaView); + pluginCalendarProviders = this._pluginRegistry + .getAvailableProviders() + .filter((p) => p.useAgendaView); + disabledPluginProviders = this._pluginService + .getDisabledIssueProviderPlugins() + .filter((p) => !p.useAgendaView); + disabledPluginCalendarProviders = this._pluginService + .getDisabledIssueProviderPlugins() + .filter((p) => p.useAgendaView); openSetupDialog( issueProviderKey: IssueProviderKey, @@ -49,11 +59,16 @@ export class IssueProviderSetupOverviewComponent { issueProviderKey: string, ): Promise { await this._pluginService.enableAndActivatePlugin(pluginId); - // Remove from disabled list and refresh enabled list + // Remove from disabled lists and refresh enabled lists this.disabledPluginProviders = this.disabledPluginProviders.filter( (p) => p.pluginId !== pluginId, ); - this.pluginProviders = this._pluginRegistry.getAvailableProviders(); + this.disabledPluginCalendarProviders = this.disabledPluginCalendarProviders.filter( + (p) => p.pluginId !== pluginId, + ); + const allProviders = this._pluginRegistry.getAvailableProviders(); + this.pluginProviders = allProviders.filter((p) => !p.useAgendaView); + this.pluginCalendarProviders = allProviders.filter((p) => p.useAgendaView); if (!isValidIssueProviderKey(issueProviderKey)) { console.error(`Invalid issue provider key from plugin: "${issueProviderKey}"`); return; diff --git a/src/app/features/issue-panel/issue-provider-tab/issue-provider-tab.component.html b/src/app/features/issue-panel/issue-provider-tab/issue-provider-tab.component.html index 190fdf6f3d..e32d8cee8e 100644 --- a/src/app/features/issue-panel/issue-provider-tab/issue-provider-tab.component.html +++ b/src/app/features/issue-panel/issue-provider-tab/issue-provider-tab.component.html @@ -25,7 +25,7 @@ } - @if (ip.issueProviderKey === 'ICAL') { + @if (useAgendaView()) { } @else {
(); issueProvider$ = toObservable(this.issueProvider); @@ -101,6 +103,11 @@ export class IssueProviderTabComponent implements OnDestroy, AfterViewInit { searchText = signal(''); searchTxt$ = toObservable(this.searchText); + useAgendaView = computed( + () => + this.issueProvider().issueProviderKey === 'ICAL' || + this._pluginRegistry.getUseAgendaView(this.issueProvider().issueProviderKey), + ); issueProviderTooltip = computed(() => getIssueProviderTooltip(this.issueProvider())); issueProviderHelpLink = computed(() => getIssueProviderHelpLink(this.issueProvider().issueProviderKey), diff --git a/src/app/features/issue/dialog-edit-issue-provider/dialog-edit-issue-provider.component.html b/src/app/features/issue/dialog-edit-issue-provider/dialog-edit-issue-provider.component.html index 3c761e8e26..fdee42fc5c 100644 --- a/src/app/features/issue/dialog-edit-issue-provider/dialog-edit-issue-provider.component.html +++ b/src/app/features/issue/dialog-edit-issue-provider/dialog-edit-issue-provider.component.html @@ -96,6 +96,39 @@

} + @for (btn of oauthButtons; track btn.label) { +
+ @if (isOAuthConnected()) { + + + check_circle + {{ T.F.ISSUE.DIALOG.CONNECTED | translate }} + + } @else { + + } +
+ } (MAT_DIALOG_DATA); isConnectionWorks = signal(false); + isOAuthConnected = signal(false); + isOAuthConnecting = signal(false); form = new FormGroup({}); private _pluginRegistry = inject(PluginIssueProviderRegistryService); + private _pluginBridge = inject(PluginBridgeService); + private _pluginHttp = inject(PluginHttpService); issueProviderKey: IssueProviderKey = (this.d.issueProvider?.issueProviderKey || this.d.issueProviderKey) as IssueProviderKey; @@ -116,6 +122,9 @@ export class DialogEditIssueProviderComponent { this._pluginRegistry.getProvider(this.issueProviderKey)?.pluginId ?? this.issueProviderKey.replace('plugin:', ''), pluginConfig: this._getDefaultPluginConfig(), + isAutoAddToBacklog: + this._pluginRegistry.getProvider(this.issueProviderKey) + ?.defaultAutoAddToBacklog ?? false, } : DEFAULT_ISSUE_PROVIDER_CFGS[ this.issueProviderKey as BuiltInIssueProviderKey @@ -136,6 +145,8 @@ export class DialogEditIssueProviderComponent { fields = this.configFormSection?.items ?? []; + oauthButtons = this._getOAuthButtons(); + private _matDialogRef: MatDialogRef = inject(MatDialogRef); @@ -145,6 +156,12 @@ export class DialogEditIssueProviderComponent { private _snackService = inject(SnackService); private _taskService = inject(TaskService); + constructor() { + this._initOAuthAndOptions().catch((err) => { + console.error('[DialogEditIssueProvider] OAuth init failed', err); + }); + } + submit(isSkipClose = false): void { if (this.form.valid) { if (this.isEdit) { @@ -284,11 +301,115 @@ export class DialogEditIssueProviderComponent { this.isConnectionWorks.set(false); } + async connectOAuth(oauthConfig: { + authUrl: string; + tokenUrl: string; + clientId: string; + clientSecret?: string; + scopes: string[]; + }): Promise { + const pluginId = this._pluginRegistry.getProvider(this.issueProviderKey)?.pluginId; + if (!pluginId) { + return; + } + this.isOAuthConnecting.set(true); + try { + await this._pluginBridge.startOAuthFlow(pluginId, oauthConfig); + this.isOAuthConnected.set(true); + this._snackService.open({ + type: 'SUCCESS', + msg: T.F.ISSUE.S.OAUTH_CONNECTED, + }); + await this._loadDynamicOptions(); + } catch (e) { + this._snackService.open({ + type: 'ERROR', + msg: T.F.ISSUE.S.OAUTH_FAILED, + }); + } finally { + this.isOAuthConnecting.set(false); + } + } + + async disconnectOAuth(): Promise { + const pluginId = this._pluginRegistry.getProvider(this.issueProviderKey)?.pluginId; + if (!pluginId) { + return; + } + await this._pluginBridge.clearOAuthTokens(pluginId); + this.isOAuthConnected.set(false); + } + protected readonly ICAL_TYPE = ICAL_TYPE; protected readonly IS_ANDROID_WEB_VIEW = IS_ANDROID_WEB_VIEW; protected readonly IS_ELECTRON = IS_ELECTRON; protected readonly IS_WEB_EXTENSION_REQUIRED_FOR_JIRA = IS_WEB_BROWSER; + private async _loadDynamicOptions(): Promise { + const provider = this._pluginRegistry.getProvider(this.issueProviderKey); + if (!provider) { + return; + } + const configFields = this._pluginRegistry.getConfigFields(this.issueProviderKey); + const dynamicFields = configFields.filter((f) => typeof f.loadOptions === 'function'); + if (!dynamicFields.length) { + return; + } + + const pluginConfig = (this.model as Record)['pluginConfig'] ?? {}; + const http = this._pluginHttp.createHttpHelper(() => + provider.definition.getHeaders(pluginConfig as Record), + ); + + for (const field of dynamicFields) { + try { + const options = await field.loadOptions!( + pluginConfig as Record, + http, + ); + const formlyField = this._findFormlyField( + this.fields as FormlyFieldConfig[], + 'pluginConfig.' + field.key, + ); + if (formlyField?.templateOptions) { + formlyField.templateOptions.options = options; + } else if (formlyField?.props) { + formlyField.props.options = options; + } + } catch (e) { + console.error( + `[DialogEditIssueProvider] loadOptions failed for field '${field.key}':`, + e, + ); + this._snackService.open({ + type: 'ERROR', + msg: T.F.ISSUE.S.LOAD_OPTIONS_FAILED, + translateParams: { fieldKey: field.key }, + }); + } + } + // Trigger formly refresh + this.fields = [...this.fields]; + } + + private _findFormlyField( + fields: FormlyFieldConfig[], + key: string, + ): FormlyFieldConfig | undefined { + for (const f of fields) { + if (f.key === key) { + return f; + } + if (f.fieldGroup) { + const found = this._findFormlyField(f.fieldGroup, key); + if (found) { + return found; + } + } + } + return undefined; + } + private _getDefaultPluginConfig(): Record { if (!this._pluginRegistry.hasProvider(this.issueProviderKey)) { return {}; @@ -312,8 +433,12 @@ export class DialogEditIssueProviderComponent { return undefined; } - const regularFields = configFields.filter((f) => !f.advanced); - const advancedFields = configFields.filter((f) => f.advanced); + const regularFields = configFields.filter( + (f) => !f.advanced && f.type !== 'oauthButton', + ); + const advancedFields = configFields.filter( + (f) => f.advanced && f.type !== 'oauthButton', + ); const items = regularFields.map((f) => this._mapPluginConfigField(f), @@ -344,6 +469,7 @@ export class DialogEditIssueProviderComponent { type: string; label: string; required?: boolean; + description?: string; url?: string; pattern?: string; options?: { value: string; label: string }[]; @@ -368,6 +494,7 @@ export class DialogEditIssueProviderComponent { templateOptions: { label: f.label, required: f.required ?? false, + ...(f.description ? { description: f.description } : {}), ...(f.type === 'password' ? { type: 'password' } : {}), ...(f.type === 'select' ? { options: f.options } : {}), ...(f.pattern ? { pattern: f.pattern } : {}), @@ -389,6 +516,9 @@ export class DialogEditIssueProviderComponent { isDone: T.F.ISSUE.TWO_WAY_SYNC.STATUS, title: T.F.ISSUE.TWO_WAY_SYNC.TITLE, notes: T.F.ISSUE.TWO_WAY_SYNC.NOTES, + dueDay: T.F.ISSUE.TWO_WAY_SYNC.DUE_DAY, + dueWithTime: T.F.ISSUE.TWO_WAY_SYNC.DUE_WITH_TIME, + timeEstimate: T.F.ISSUE.TWO_WAY_SYNC.TIME_ESTIMATE, }; const syncFields: any[] = fieldMappings.map((m) => ({ key: ('pluginConfig.twoWaySync.' + m.taskField) as keyof IssueIntegrationCfg, @@ -419,4 +549,37 @@ export class DialogEditIssueProviderComponent { fieldGroup: syncFields, }; } + + private _getOAuthButtons(): { + label: string; + oauthConfig: { + authUrl: string; + tokenUrl: string; + clientId: string; + clientSecret?: string; + scopes: string[]; + }; + }[] { + if (!this._pluginRegistry.hasProvider(this.issueProviderKey)) { + return []; + } + const configFields = this._pluginRegistry.getConfigFields(this.issueProviderKey); + return configFields + .filter((f) => f.type === 'oauthButton' && f.oauthConfig) + .map((f) => ({ label: f.label, oauthConfig: f.oauthConfig! })); + } + + private async _initOAuthAndOptions(): Promise { + const provider = this._pluginRegistry.getProvider(this.issueProviderKey); + if (!provider) { + return; + } + const hasTokens = await this._pluginBridge.restoreAndCheckOAuthTokens( + provider.pluginId, + ); + this.isOAuthConnected.set(hasTokens); + if (hasTokens) { + await this._loadDynamicOptions(); + } + } } diff --git a/src/app/features/issue/two-way-sync/compute-push-decisions.spec.ts b/src/app/features/issue/two-way-sync/compute-push-decisions.spec.ts index 1248c73bbc..0db633e09e 100644 --- a/src/app/features/issue/two-way-sync/compute-push-decisions.spec.ts +++ b/src/app/features/issue/two-way-sync/compute-push-decisions.spec.ts @@ -24,8 +24,33 @@ describe('computePushDecisions', () => { toTaskValue: (v: unknown, c: FieldMappingContext) => `#${c.issueNumber} ${v}`, }; + const dueDayMapping: FieldMapping = { + taskField: 'dueDay', + issueField: 'start_date', + defaultDirection: 'both', + mutuallyExclusive: ['dueWithTime'], + toIssueValue: (v: unknown) => v, + toTaskValue: (v: unknown) => v, + }; + + const dueWithTimeMapping: FieldMapping = { + taskField: 'dueWithTime', + issueField: 'start_datetime', + defaultDirection: 'both', + mutuallyExclusive: ['dueDay'], + toIssueValue: (v: unknown) => v, + toTaskValue: (v: unknown) => v, + }; + const allMappings: FieldMapping[] = [isDoneMapping, titleMapping]; + const dateMappings: FieldMapping[] = [ + isDoneMapping, + titleMapping, + dueDayMapping, + dueWithTimeMapping, + ]; + it('should push when provider unchanged and task changed', () => { const syncConfig: FieldSyncConfig = { isDone: 'both' }; const decisions = computePushDecisions( @@ -239,4 +264,145 @@ describe('computePushDecisions', () => { expect(decisions[0].issueValue).toBe('New title'); }); + + it('should push dueDay change when direction is both', () => { + const syncConfig: FieldSyncConfig = { dueDay: 'both' }; + const decisions = computePushDecisions( + { dueDay: '2026-04-01' }, + dateMappings, + syncConfig, + { start_date: '2026-03-15' }, + { start_date: '2026-03-15' }, + ctx, + ); + + const dueDayDecision = decisions.find((d) => d.field === 'start_date'); + expect(dueDayDecision).toEqual({ + field: 'start_date', + action: 'push', + issueValue: '2026-04-01', + }); + }); + + it('should push dueWithTime change when direction is both', () => { + const syncConfig: FieldSyncConfig = { dueWithTime: 'both' }; + const decisions = computePushDecisions( + { dueWithTime: 1743508800000 }, + dateMappings, + syncConfig, + { start_datetime: 1743422400000 }, + { start_datetime: 1743422400000 }, + ctx, + ); + + const dueWithTimeDecision = decisions.find((d) => d.field === 'start_datetime'); + expect(dueWithTimeDecision).toEqual({ + field: 'start_datetime', + action: 'push', + issueValue: 1743508800000, + }); + }); + + it('should push clear for mutually exclusive field when dueWithTime is pushed', () => { + const syncConfig: FieldSyncConfig = { dueWithTime: 'both', dueDay: 'both' }; + const decisions = computePushDecisions( + { dueWithTime: 1743508800000 }, + dateMappings, + syncConfig, + { start_datetime: 1743422400000, start_date: '2026-03-15' }, + { start_datetime: 1743422400000, start_date: '2026-03-15' }, + ctx, + ); + + const dueWithTimeDecision = decisions.find((d) => d.field === 'start_datetime'); + expect(dueWithTimeDecision).toEqual({ + field: 'start_datetime', + action: 'push', + issueValue: 1743508800000, + }); + + const dueDayDecision = decisions.find((d) => d.field === 'start_date'); + expect(dueDayDecision).toEqual({ + field: 'start_date', + action: 'push', + issueValue: undefined, + }); + }); + + it('should push clear for mutually exclusive field when dueDay is pushed', () => { + const syncConfig: FieldSyncConfig = { dueDay: 'both', dueWithTime: 'both' }; + const decisions = computePushDecisions( + { dueDay: '2026-04-01' }, + dateMappings, + syncConfig, + { start_date: '2026-03-15', start_datetime: 1743422400000 }, + { start_date: '2026-03-15', start_datetime: 1743422400000 }, + ctx, + ); + + const dueDayDecision = decisions.find((d) => d.field === 'start_date'); + expect(dueDayDecision).toEqual({ + field: 'start_date', + action: 'push', + issueValue: '2026-04-01', + }); + + const dueWithTimeDecision = decisions.find((d) => d.field === 'start_datetime'); + expect(dueWithTimeDecision).toEqual({ + field: 'start_datetime', + action: 'push', + issueValue: undefined, + }); + }); + + it('should not duplicate decision when both exclusive fields are changed', () => { + const syncConfig: FieldSyncConfig = { dueDay: 'both', dueWithTime: 'both' }; + const decisions = computePushDecisions( + { dueDay: '2026-04-01', dueWithTime: 1743508800000 }, + dateMappings, + syncConfig, + { start_date: '2026-03-15', start_datetime: 1743422400000 }, + { start_date: '2026-03-15', start_datetime: 1743422400000 }, + ctx, + ); + + const dueDayDecisions = decisions.filter((d) => d.field === 'start_date'); + const dueWithTimeDecisions = decisions.filter((d) => d.field === 'start_datetime'); + expect(dueDayDecisions.length).toBe(1); + expect(dueWithTimeDecisions.length).toBe(1); + }); + + it('should skip exclusive field clear when its direction is pullOnly', () => { + const syncConfig: FieldSyncConfig = { dueWithTime: 'both', dueDay: 'pullOnly' }; + const decisions = computePushDecisions( + { dueWithTime: 1743508800000 }, + dateMappings, + syncConfig, + { start_datetime: 1743422400000, start_date: '2026-03-15' }, + { start_datetime: 1743422400000, start_date: '2026-03-15' }, + ctx, + ); + + const dueDayDecision = decisions.find((d) => d.field === 'start_date'); + expect(dueDayDecision).toBeUndefined(); + }); + + it('should skip dueDay push when provider changed', () => { + const syncConfig: FieldSyncConfig = { dueDay: 'both' }; + const decisions = computePushDecisions( + { dueDay: '2026-04-01' }, + dateMappings, + syncConfig, + { start_date: '2026-03-20' }, + { start_date: '2026-03-15' }, + ctx, + ); + + const dueDayDecision = decisions.find((d) => d.field === 'start_date'); + expect(dueDayDecision).toEqual({ + field: 'start_date', + action: 'skip', + reason: 'provider changed (provider wins)', + }); + }); }); diff --git a/src/app/features/issue/two-way-sync/compute-push-decisions.ts b/src/app/features/issue/two-way-sync/compute-push-decisions.ts index 4f9b357126..9a41e2e0e3 100644 --- a/src/app/features/issue/two-way-sync/compute-push-decisions.ts +++ b/src/app/features/issue/two-way-sync/compute-push-decisions.ts @@ -16,8 +16,14 @@ export const computePushDecisions = ( ctx: FieldMappingContext, ): PushDecision[] => { const decisions: PushDecision[] = []; + const decidedIssueFields = new Set(); + const mappingByTaskField = new Map(fieldMappings.map((m) => [m.taskField, m])); for (const mapping of fieldMappings) { + if (decidedIssueFields.has(mapping.issueField)) { + continue; + } + if (!(mapping.taskField in changedTaskFields)) { continue; } @@ -29,6 +35,7 @@ export const computePushDecisions = ( action: 'skip', reason: `direction is '${direction}'`, }); + decidedIssueFields.add(mapping.issueField); continue; } @@ -38,6 +45,7 @@ export const computePushDecisions = ( action: 'skip', reason: 'no baseline (first sync)', }); + decidedIssueFields.add(mapping.issueField); continue; } @@ -47,6 +55,7 @@ export const computePushDecisions = ( action: 'skip', reason: 'provider changed (provider wins)', }); + decidedIssueFields.add(mapping.issueField); continue; } @@ -55,6 +64,25 @@ export const computePushDecisions = ( action: 'push', issueValue: mapping.toIssueValue(changedTaskFields[mapping.taskField], ctx), }); + decidedIssueFields.add(mapping.issueField); + + // Clear mutually exclusive fields on the remote + for (const exclTaskField of mapping.mutuallyExclusive ?? []) { + const exclMapping = mappingByTaskField.get(exclTaskField); + if (!exclMapping || decidedIssueFields.has(exclMapping.issueField)) { + continue; + } + const exclDir = syncConfig[exclMapping.taskField] ?? exclMapping.defaultDirection; + if (exclDir !== 'pushOnly' && exclDir !== 'both') { + continue; + } + decisions.push({ + field: exclMapping.issueField, + action: 'push', + issueValue: exclMapping.toIssueValue(undefined, ctx), + }); + decidedIssueFields.add(exclMapping.issueField); + } } return decisions; diff --git a/src/app/features/issue/two-way-sync/deleted-task-issue-sidecar.service.ts b/src/app/features/issue/two-way-sync/deleted-task-issue-sidecar.service.ts new file mode 100644 index 0000000000..095bfae61d --- /dev/null +++ b/src/app/features/issue/two-way-sync/deleted-task-issue-sidecar.service.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@angular/core'; + +/** + * Minimal issue metadata needed to delete remote issues when tasks are bulk-deleted. + * Kept separate from the NgRx action payload so that full Task objects are never + * serialized into the operation log. + */ +export interface DeletedTaskIssueInfo { + issueId: string; + issueType: string; + issueProviderId: string; +} + +/** + * Transient, in-memory sidecar that holds issue metadata for tasks being + * bulk-deleted. The dispatching code (TaskService, effects) writes here + * *before* dispatching `deleteTasks`, and `deleteIssueOnBulkTaskDelete$` + * reads + clears here *after* the action arrives. + * + * This data is intentionally NOT persisted or synced. Remote clients never + * need it because effects use LOCAL_ACTIONS and only fire on the originating + * client. + */ +@Injectable({ providedIn: 'root' }) +export class DeletedTaskIssueSidecarService { + private _pending: DeletedTaskIssueInfo[] = []; + + /** + * Store issue metadata for an upcoming `deleteTasks` dispatch. + * Call this *before* dispatching the action. + */ + set(items: DeletedTaskIssueInfo[]): void { + this._pending = items; + } + + /** + * Consume (read + clear) the stored issue metadata. + * Returns an empty array if nothing was stored. + */ + consume(): DeletedTaskIssueInfo[] { + const items = this._pending; + this._pending = []; + return items; + } +} diff --git a/src/app/features/issue/two-way-sync/issue-sync-adapter.interface.ts b/src/app/features/issue/two-way-sync/issue-sync-adapter.interface.ts index 562884c826..cbd41d6da8 100644 --- a/src/app/features/issue/two-way-sync/issue-sync-adapter.interface.ts +++ b/src/app/features/issue/two-way-sync/issue-sync-adapter.interface.ts @@ -17,7 +17,8 @@ export interface IssueSyncAdapter { cfg: TCfg, ): Promise<{ issueId: string; - issueNumber: number; + issueNumber?: number; issueData: Record; }>; + deleteIssue?(issueId: string, cfg: TCfg): Promise; } diff --git a/src/app/features/issue/two-way-sync/issue-two-way-sync.effects.spec.ts b/src/app/features/issue/two-way-sync/issue-two-way-sync.effects.spec.ts new file mode 100644 index 0000000000..bb6b9da698 --- /dev/null +++ b/src/app/features/issue/two-way-sync/issue-two-way-sync.effects.spec.ts @@ -0,0 +1,1164 @@ +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { of, Subject } from 'rxjs'; +import { IssueTwoWaySyncEffects } from './issue-two-way-sync.effects'; +import { TaskService } from '../../tasks/task.service'; +import { IssueProviderService } from '../issue-provider.service'; +import { IssueSyncAdapterRegistryService } from './issue-sync-adapter-registry.service'; +import { CaldavSyncAdapterService } from '../providers/caldav/caldav-sync-adapter.service'; +import { SnackService } from '../../../core/snack/snack.service'; +import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions'; +import { PlannerActions } from '../../planner/store/planner.actions'; +import { LOCAL_ACTIONS } from '../../../util/local-actions.token'; +import { Task, TaskWithSubTasks } from '../../tasks/task.model'; +import { selectEnabledIssueProviders } from '../store/issue-provider.selectors'; +import { FieldMapping } from './issue-sync.model'; +import { IssueSyncAdapter } from './issue-sync-adapter.interface'; +import { IssueProvider } from '../issue.model'; +import { WorkContextType } from '../../work-context/work-context.model'; +import { DeletedTaskIssueSidecarService } from './deleted-task-issue-sidecar.service'; + +describe('IssueTwoWaySyncEffects', () => { + let effects: IssueTwoWaySyncEffects; + let actions$: Subject; + let store: MockStore; + let taskServiceSpy: jasmine.SpyObj; + let issueProviderServiceSpy: jasmine.SpyObj; + let adapterRegistry: IssueSyncAdapterRegistryService; + let snackServiceSpy: jasmine.SpyObj; + let deletedTaskIssueSidecar: DeletedTaskIssueSidecarService; + + const createMockTask = (overrides: Partial = {}): Task => + ({ + id: 'task-1', + title: 'Test Task', + projectId: 'project-1', + tagIds: [], + subTaskIds: [], + timeSpentOnDay: {}, + timeSpent: 0, + timeEstimate: 0, + isDone: false, + created: Date.now(), + attachments: [], + ...overrides, + }) as Task; + + const createMockIssueProvider = ( + overrides: Partial = {}, + ): IssueProvider => + ({ + id: 'provider-1', + issueProviderKey: 'CALDAV', + isEnabled: true, + isAutoPoll: true, + isAutoAddToBacklog: false, + isIntegratedAddTaskBar: false, + defaultProjectId: null, + pinnedSearch: null, + ...overrides, + }) as IssueProvider; + + const createMockAdapter = ( + overrides: Partial> = {}, + ): IssueSyncAdapter => ({ + getFieldMappings: jasmine.createSpy('getFieldMappings').and.returnValue([]), + getSyncConfig: jasmine.createSpy('getSyncConfig').and.returnValue({}), + fetchIssue: jasmine.createSpy('fetchIssue').and.resolveTo({}), + pushChanges: jasmine.createSpy('pushChanges').and.resolveTo(), + extractSyncValues: jasmine.createSpy('extractSyncValues').and.returnValue({}), + ...overrides, + }); + + const isDoneFieldMapping: FieldMapping = { + taskField: 'isDone', + issueField: 'status', + defaultDirection: 'both', + toIssueValue: (val: unknown) => (val ? 'COMPLETED' : 'NEEDS-ACTION'), + toTaskValue: (val: unknown) => val === 'COMPLETED', + }; + + const dueDayFieldMapping: FieldMapping = { + taskField: 'dueDay', + issueField: 'dtstart', + defaultDirection: 'both', + toIssueValue: (val: unknown) => val, + toTaskValue: (val: unknown) => val, + }; + + const dueWithTimeFieldMapping: FieldMapping = { + taskField: 'dueWithTime', + issueField: 'dtstart', + defaultDirection: 'both', + toIssueValue: (val: unknown) => val, + toTaskValue: (val: unknown) => val, + }; + + beforeEach(() => { + actions$ = new Subject(); + + taskServiceSpy = jasmine.createSpyObj('TaskService', ['getByIdOnce$', 'update']); + issueProviderServiceSpy = jasmine.createSpyObj('IssueProviderService', [ + 'getCfgOnce$', + ]); + snackServiceSpy = jasmine.createSpyObj('SnackService', ['open']); + + const caldavSpy = jasmine.createSpyObj('CaldavSyncAdapterService', [ + 'getFieldMappings', + 'getSyncConfig', + 'fetchIssue', + 'pushChanges', + 'extractSyncValues', + ]); + + TestBed.configureTestingModule({ + providers: [ + IssueTwoWaySyncEffects, + provideMockActions(() => actions$), + provideMockStore({ + selectors: [{ selector: selectEnabledIssueProviders, value: [] }], + }), + { provide: LOCAL_ACTIONS, useValue: actions$ }, + { provide: TaskService, useValue: taskServiceSpy }, + { provide: IssueProviderService, useValue: issueProviderServiceSpy }, + { provide: CaldavSyncAdapterService, useValue: caldavSpy }, + { provide: SnackService, useValue: snackServiceSpy }, + ], + }); + + effects = TestBed.inject(IssueTwoWaySyncEffects); + store = TestBed.inject(MockStore); + adapterRegistry = TestBed.inject(IssueSyncAdapterRegistryService); + deletedTaskIssueSidecar = TestBed.inject(DeletedTaskIssueSidecarService); + }); + + afterEach(() => { + store.resetSelectors(); + }); + + describe('pushFieldsOnTaskUpdate$', () => { + it('should push isDone change to remote issue', fakeAsync(() => { + const adapter = createMockAdapter({ + getFieldMappings: jasmine + .createSpy('getFieldMappings') + .and.returnValue([isDoneFieldMapping]), + getSyncConfig: jasmine.createSpy('getSyncConfig').and.returnValue({}), + fetchIssue: jasmine + .createSpy('fetchIssue') + .and.resolveTo({ status: 'NEEDS-ACTION' }), + extractSyncValues: jasmine + .createSpy('extractSyncValues') + .and.returnValue({ status: 'NEEDS-ACTION' }), + }); + adapterRegistry.register('TEST_PROVIDER', adapter); + + const task = createMockTask({ + id: 'task-1', + issueType: 'TEST_PROVIDER' as any, + issueId: 'issue-1', + issueProviderId: 'provider-1', + issueLastSyncedValues: { status: 'NEEDS-ACTION' }, + }); + + taskServiceSpy.getByIdOnce$.and.returnValue(of(task)); + issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(createMockIssueProvider())); + + effects.pushFieldsOnTaskUpdate$.subscribe(); + + actions$.next( + TaskSharedActions.updateTask({ + task: { id: 'task-1', changes: { isDone: true } }, + }), + ); + + tick(); + + expect(adapter.pushChanges).toHaveBeenCalled(); + + adapterRegistry.unregister('TEST_PROVIDER'); + })); + + it('should skip push when no issueType on task', fakeAsync(() => { + const adapter = createMockAdapter({ + getFieldMappings: jasmine + .createSpy('getFieldMappings') + .and.returnValue([isDoneFieldMapping]), + }); + adapterRegistry.register('TEST_PROVIDER', adapter); + + const task = createMockTask({ + id: 'task-1', + issueType: undefined, + issueId: undefined, + issueProviderId: undefined, + }); + + taskServiceSpy.getByIdOnce$.and.returnValue(of(task)); + + effects.pushFieldsOnTaskUpdate$.subscribe(); + + actions$.next( + TaskSharedActions.updateTask({ + task: { id: 'task-1', changes: { isDone: true } }, + }), + ); + + tick(); + + expect(adapter.pushChanges).not.toHaveBeenCalled(); + + adapterRegistry.unregister('TEST_PROVIDER'); + })); + + it('should skip push for sync-bookkeeping changes (issueLastSyncedValues)', fakeAsync(() => { + const adapter = createMockAdapter(); + adapterRegistry.register('TEST_PROVIDER', adapter); + + const task = createMockTask({ + id: 'task-1', + issueType: 'TEST_PROVIDER' as any, + issueId: 'issue-1', + issueProviderId: 'provider-1', + }); + + taskServiceSpy.getByIdOnce$.and.returnValue(of(task)); + + effects.pushFieldsOnTaskUpdate$.subscribe(); + + actions$.next( + TaskSharedActions.updateTask({ + task: { + id: 'task-1', + changes: { issueLastSyncedValues: { status: 'COMPLETED' } }, + }, + }), + ); + + tick(); + + expect(adapter.pushChanges).not.toHaveBeenCalled(); + + adapterRegistry.unregister('TEST_PROVIDER'); + })); + + it('should skip push for sync-bookkeeping changes (issueWasUpdated)', fakeAsync(() => { + const adapter = createMockAdapter(); + adapterRegistry.register('TEST_PROVIDER', adapter); + + const task = createMockTask({ + id: 'task-1', + issueType: 'TEST_PROVIDER' as any, + issueId: 'issue-1', + issueProviderId: 'provider-1', + }); + + taskServiceSpy.getByIdOnce$.and.returnValue(of(task)); + + effects.pushFieldsOnTaskUpdate$.subscribe(); + + actions$.next( + TaskSharedActions.updateTask({ + task: { + id: 'task-1', + changes: { issueWasUpdated: true }, + }, + }), + ); + + tick(); + + expect(adapter.pushChanges).not.toHaveBeenCalled(); + + adapterRegistry.unregister('TEST_PROVIDER'); + })); + + it('should skip push for changes to non-synced fields', fakeAsync(() => { + const adapter = createMockAdapter(); + adapterRegistry.register('TEST_PROVIDER', adapter); + + const task = createMockTask({ + id: 'task-1', + issueType: 'TEST_PROVIDER' as any, + issueId: 'issue-1', + issueProviderId: 'provider-1', + }); + + taskServiceSpy.getByIdOnce$.and.returnValue(of(task)); + + effects.pushFieldsOnTaskUpdate$.subscribe(); + + // timeSpent is not in the tracked fields list + // (isDone, title, notes, dueWithTime, dueDay, timeEstimate) + actions$.next( + TaskSharedActions.updateTask({ + task: { + id: 'task-1', + changes: { timeSpent: 5000 }, + }, + }), + ); + + tick(); + + expect(adapter.pushChanges).not.toHaveBeenCalled(); + + adapterRegistry.unregister('TEST_PROVIDER'); + })); + + it('should extract schedulingInfo.day from applyShortSyntax action', fakeAsync(() => { + const adapter = createMockAdapter({ + getFieldMappings: jasmine + .createSpy('getFieldMappings') + .and.returnValue([dueDayFieldMapping]), + getSyncConfig: jasmine.createSpy('getSyncConfig').and.returnValue({}), + fetchIssue: jasmine.createSpy('fetchIssue').and.resolveTo({ dtstart: null }), + extractSyncValues: jasmine + .createSpy('extractSyncValues') + .and.returnValue({ dtstart: null }), + }); + adapterRegistry.register('TEST_PROVIDER', adapter); + + const task = createMockTask({ + id: 'task-1', + issueType: 'TEST_PROVIDER' as any, + issueId: 'issue-1', + issueProviderId: 'provider-1', + issueLastSyncedValues: { dtstart: null }, + dueDay: '2026-03-20', + }); + + taskServiceSpy.getByIdOnce$.and.returnValue(of(task)); + issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(createMockIssueProvider())); + + effects.pushFieldsOnTaskUpdate$.subscribe(); + + actions$.next( + TaskSharedActions.applyShortSyntax({ + task, + taskChanges: { title: 'Updated Task' }, + schedulingInfo: { day: '2026-03-20' }, + }), + ); + + tick(); + + // The effect should have proceeded with dueDay in the changes + // and called fetchIssue (indicating it passed the filter) + expect(adapter.fetchIssue).toHaveBeenCalled(); + + adapterRegistry.unregister('TEST_PROVIDER'); + })); + + it('should extract schedulingInfo.dueWithTime from applyShortSyntax action', fakeAsync(() => { + const adapter = createMockAdapter({ + getFieldMappings: jasmine + .createSpy('getFieldMappings') + .and.returnValue([dueWithTimeFieldMapping]), + getSyncConfig: jasmine.createSpy('getSyncConfig').and.returnValue({}), + fetchIssue: jasmine.createSpy('fetchIssue').and.resolveTo({ dtstart: null }), + extractSyncValues: jasmine + .createSpy('extractSyncValues') + .and.returnValue({ dtstart: null }), + }); + adapterRegistry.register('TEST_PROVIDER', adapter); + + const dueTimestamp = 1774008000000; + const task = createMockTask({ + id: 'task-1', + issueType: 'TEST_PROVIDER' as any, + issueId: 'issue-1', + issueProviderId: 'provider-1', + issueLastSyncedValues: { dtstart: null }, + dueWithTime: dueTimestamp, + }); + + taskServiceSpy.getByIdOnce$.and.returnValue(of(task)); + issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(createMockIssueProvider())); + + effects.pushFieldsOnTaskUpdate$.subscribe(); + + actions$.next( + TaskSharedActions.applyShortSyntax({ + task, + taskChanges: { title: 'Updated Task' }, + schedulingInfo: { dueWithTime: dueTimestamp }, + }), + ); + + tick(); + + expect(adapter.fetchIssue).toHaveBeenCalled(); + + adapterRegistry.unregister('TEST_PROVIDER'); + })); + + it('should push dueDay change from planTaskForDay to remote issue', fakeAsync(() => { + const adapter = createMockAdapter({ + getFieldMappings: jasmine + .createSpy('getFieldMappings') + .and.returnValue([dueDayFieldMapping]), + getSyncConfig: jasmine.createSpy('getSyncConfig').and.returnValue({}), + fetchIssue: jasmine + .createSpy('fetchIssue') + .and.resolveTo({ dtstart: '2026-03-19' }), + extractSyncValues: jasmine + .createSpy('extractSyncValues') + .and.returnValue({ dtstart: '2026-03-19' }), + }); + adapterRegistry.register('TEST_PROVIDER', adapter); + + const task = createMockTask({ + id: 'task-1', + issueType: 'TEST_PROVIDER' as any, + issueId: 'issue-1', + issueProviderId: 'provider-1', + issueLastSyncedValues: { dtstart: '2026-03-19' }, + dueDay: '2026-03-21', + }); + + taskServiceSpy.getByIdOnce$.and.returnValue(of(task)); + issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(createMockIssueProvider())); + + effects.pushFieldsOnTaskUpdate$.subscribe(); + + actions$.next( + PlannerActions.planTaskForDay({ + task, + day: '2026-03-21', + }), + ); + + tick(); + + expect(adapter.fetchIssue).toHaveBeenCalled(); + + adapterRegistry.unregister('TEST_PROVIDER'); + })); + + it('should push dueDay change from transferTask to remote issue', fakeAsync(() => { + const adapter = createMockAdapter({ + getFieldMappings: jasmine + .createSpy('getFieldMappings') + .and.returnValue([dueDayFieldMapping]), + getSyncConfig: jasmine.createSpy('getSyncConfig').and.returnValue({}), + fetchIssue: jasmine + .createSpy('fetchIssue') + .and.resolveTo({ dtstart: '2026-03-19' }), + extractSyncValues: jasmine + .createSpy('extractSyncValues') + .and.returnValue({ dtstart: '2026-03-19' }), + }); + adapterRegistry.register('TEST_PROVIDER', adapter); + + const task = createMockTask({ + id: 'task-1', + issueType: 'TEST_PROVIDER' as any, + issueId: 'issue-1', + issueProviderId: 'provider-1', + issueLastSyncedValues: { dtstart: '2026-03-19' }, + dueDay: '2026-03-22', + }); + + taskServiceSpy.getByIdOnce$.and.returnValue(of(task)); + issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(createMockIssueProvider())); + + effects.pushFieldsOnTaskUpdate$.subscribe(); + + actions$.next( + PlannerActions.transferTask({ + task, + prevDay: '2026-03-19', + newDay: '2026-03-22', + targetIndex: 0, + today: '2026-03-20', + }), + ); + + tick(); + + expect(adapter.fetchIssue).toHaveBeenCalled(); + + adapterRegistry.unregister('TEST_PROVIDER'); + })); + + it('should show snack on push error and continue', fakeAsync(() => { + const adapter = createMockAdapter({ + getFieldMappings: jasmine + .createSpy('getFieldMappings') + .and.returnValue([isDoneFieldMapping]), + getSyncConfig: jasmine.createSpy('getSyncConfig').and.returnValue({}), + fetchIssue: jasmine + .createSpy('fetchIssue') + .and.rejectWith(new Error('Network error')), + }); + adapterRegistry.register('TEST_PROVIDER', adapter); + + const task = createMockTask({ + id: 'task-1', + issueType: 'TEST_PROVIDER' as any, + issueId: 'issue-1', + issueProviderId: 'provider-1', + issueLastSyncedValues: { status: 'NEEDS-ACTION' }, + }); + + taskServiceSpy.getByIdOnce$.and.returnValue(of(task)); + issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(createMockIssueProvider())); + + effects.pushFieldsOnTaskUpdate$.subscribe(); + + actions$.next( + TaskSharedActions.updateTask({ + task: { id: 'task-1', changes: { isDone: true } }, + }), + ); + + tick(); + + expect(snackServiceSpy.open).toHaveBeenCalledWith( + jasmine.objectContaining({ type: 'ERROR' }), + ); + + adapterRegistry.unregister('TEST_PROVIDER'); + })); + }); + + describe('deleteIssueOnTaskDelete$', () => { + it('should call adapter.deleteIssue when task with issue is deleted', fakeAsync(() => { + const deleteIssueSpy = jasmine.createSpy('deleteIssue').and.resolveTo(undefined); + const adapter = createMockAdapter({ deleteIssue: deleteIssueSpy }); + adapterRegistry.register('TEST_PROVIDER', adapter); + + const cfg = createMockIssueProvider(); + issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(cfg)); + + const task = createMockTask({ + id: 'task-1', + issueType: 'TEST_PROVIDER' as any, + issueId: 'issue-1', + issueProviderId: 'provider-1', + subTaskIds: [], + }) as TaskWithSubTasks; + (task as any).subTasks = []; + + effects.deleteIssueOnTaskDelete$.subscribe(); + + actions$.next(TaskSharedActions.deleteTask({ task })); + + tick(); + + expect(deleteIssueSpy).toHaveBeenCalledWith('issue-1', cfg); + + adapterRegistry.unregister('TEST_PROVIDER'); + })); + + it('should not call deleteIssue when adapter does not support it', fakeAsync(() => { + const adapter = createMockAdapter(); + // Adapter without deleteIssue + adapterRegistry.register('TEST_PROVIDER', adapter); + + const task = createMockTask({ + id: 'task-1', + issueType: 'TEST_PROVIDER' as any, + issueId: 'issue-1', + issueProviderId: 'provider-1', + }) as TaskWithSubTasks; + (task as any).subTasks = []; + + effects.deleteIssueOnTaskDelete$.subscribe(); + + actions$.next(TaskSharedActions.deleteTask({ task })); + + tick(); + + // Adapter has no deleteIssue, so getCfgOnce$ should not be called + expect(issueProviderServiceSpy.getCfgOnce$).not.toHaveBeenCalled(); + + adapterRegistry.unregister('TEST_PROVIDER'); + })); + + it('should not call deleteIssue for task without issueId', fakeAsync(() => { + const deleteIssueSpy = jasmine.createSpy('deleteIssue').and.resolveTo(undefined); + const adapter = createMockAdapter({ deleteIssue: deleteIssueSpy }); + adapterRegistry.register('TEST_PROVIDER', adapter); + + const task = createMockTask({ + id: 'task-1', + issueType: undefined, + issueId: undefined, + issueProviderId: undefined, + }) as TaskWithSubTasks; + (task as any).subTasks = []; + + effects.deleteIssueOnTaskDelete$.subscribe(); + + actions$.next(TaskSharedActions.deleteTask({ task })); + + tick(); + + expect(deleteIssueSpy).not.toHaveBeenCalled(); + + adapterRegistry.unregister('TEST_PROVIDER'); + })); + + it('should show snack on delete error and continue', fakeAsync(() => { + const deleteIssueSpy = jasmine + .createSpy('deleteIssue') + .and.rejectWith(new Error('Delete failed')); + const adapter = createMockAdapter({ deleteIssue: deleteIssueSpy }); + adapterRegistry.register('TEST_PROVIDER', adapter); + + const cfg = createMockIssueProvider(); + issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(cfg)); + + const task = createMockTask({ + id: 'task-1', + issueType: 'TEST_PROVIDER' as any, + issueId: 'issue-1', + issueProviderId: 'provider-1', + }) as TaskWithSubTasks; + (task as any).subTasks = []; + + effects.deleteIssueOnTaskDelete$.subscribe(); + + actions$.next(TaskSharedActions.deleteTask({ task })); + + tick(); + + expect(snackServiceSpy.open).toHaveBeenCalledWith( + jasmine.objectContaining({ type: 'ERROR' }), + ); + + adapterRegistry.unregister('TEST_PROVIDER'); + })); + }); + + describe('deleteIssueOnBulkTaskDelete$', () => { + it('should delete remote issues for all linked tasks in bulk delete', fakeAsync(() => { + const deleteIssueSpy = jasmine.createSpy('deleteIssue').and.resolveTo(undefined); + const adapter = createMockAdapter({ deleteIssue: deleteIssueSpy }); + adapterRegistry.register('TEST_PROVIDER', adapter); + + const cfg = createMockIssueProvider(); + issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(cfg)); + + effects.deleteIssueOnBulkTaskDelete$.subscribe(); + + deletedTaskIssueSidecar.set([ + { issueId: 'issue-1', issueType: 'TEST_PROVIDER', issueProviderId: 'provider-1' }, + { issueId: 'issue-2', issueType: 'TEST_PROVIDER', issueProviderId: 'provider-1' }, + ]); + actions$.next( + TaskSharedActions.deleteTasks({ + taskIds: ['task-1', 'task-2'], + }), + ); + + tick(); + + expect(deleteIssueSpy).toHaveBeenCalledTimes(2); + expect(deleteIssueSpy).toHaveBeenCalledWith('issue-1', cfg); + expect(deleteIssueSpy).toHaveBeenCalledWith('issue-2', cfg); + + adapterRegistry.unregister('TEST_PROVIDER'); + })); + + it('should skip tasks without issue linkage in bulk delete', fakeAsync(() => { + const deleteIssueSpy = jasmine.createSpy('deleteIssue').and.resolveTo(undefined); + const adapter = createMockAdapter({ deleteIssue: deleteIssueSpy }); + adapterRegistry.register('TEST_PROVIDER', adapter); + + const cfg = createMockIssueProvider(); + issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(cfg)); + + effects.deleteIssueOnBulkTaskDelete$.subscribe(); + + deletedTaskIssueSidecar.set([ + { issueId: 'issue-1', issueType: 'TEST_PROVIDER', issueProviderId: 'provider-1' }, + ]); + actions$.next( + TaskSharedActions.deleteTasks({ + taskIds: ['task-1', 'task-2'], + }), + ); + + tick(); + + expect(deleteIssueSpy).toHaveBeenCalledTimes(1); + expect(deleteIssueSpy).toHaveBeenCalledWith('issue-1', cfg); + + adapterRegistry.unregister('TEST_PROVIDER'); + })); + + it('should show snack on delete error during bulk delete and continue', fakeAsync(() => { + const deleteIssueSpy = jasmine + .createSpy('deleteIssue') + .and.rejectWith(new Error('Delete failed')); + const adapter = createMockAdapter({ deleteIssue: deleteIssueSpy }); + adapterRegistry.register('TEST_PROVIDER', adapter); + + const cfg = createMockIssueProvider(); + issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(cfg)); + + effects.deleteIssueOnBulkTaskDelete$.subscribe(); + + deletedTaskIssueSidecar.set([ + { issueId: 'issue-1', issueType: 'TEST_PROVIDER', issueProviderId: 'provider-1' }, + ]); + actions$.next(TaskSharedActions.deleteTasks({ taskIds: ['task-1'] })); + + tick(); + + expect(snackServiceSpy.open).toHaveBeenCalledWith( + jasmine.objectContaining({ type: 'ERROR' }), + ); + + adapterRegistry.unregister('TEST_PROVIDER'); + })); + }); + + describe('autoCreateIssueOnTaskAdd$', () => { + it('should create issue when task added to project with auto-create enabled', fakeAsync(() => { + const createIssueSpy = jasmine.createSpy('createIssue').and.resolveTo({ + issueId: 'new-issue-1', + issueNumber: 42, + issueData: { summary: 'Test Task' }, + }); + const adapter = createMockAdapter({ + createIssue: createIssueSpy, + getFieldMappings: jasmine.createSpy('getFieldMappings').and.returnValue([]), + getSyncConfig: jasmine.createSpy('getSyncConfig').and.returnValue({}), + }); + adapterRegistry.register('TEST_PROVIDER', adapter); + + const provider = createMockIssueProvider({ + id: 'provider-1', + issueProviderKey: 'TEST_PROVIDER' as any, + defaultProjectId: 'project-1', + pluginConfig: { isAutoCreateIssues: true }, + } as any); + + store.overrideSelector(selectEnabledIssueProviders, [provider]); + store.refreshState(); + + const cfg = createMockIssueProvider({ + id: 'provider-1', + issueProviderKey: 'TEST_PROVIDER' as any, + }); + issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(cfg)); + + const task = createMockTask({ + id: 'task-new', + title: 'New Task', + projectId: 'project-1', + parentId: undefined, + issueId: undefined, + }); + + // _pushInitialValues now re-fetches the task from the store + taskServiceSpy.getByIdOnce$.and.returnValue(of(task)); + + effects.autoCreateIssueOnTaskAdd$.subscribe(); + + actions$.next( + TaskSharedActions.addTask({ + task, + workContextId: 'project-1', + workContextType: WorkContextType.PROJECT, + isAddToBacklog: false, + isAddToBottom: false, + }), + ); + + tick(); + + expect(createIssueSpy).toHaveBeenCalledWith('New Task', cfg); + expect(taskServiceSpy.update).toHaveBeenCalledWith( + 'task-new', + jasmine.objectContaining({ + issueId: 'new-issue-1', + issueType: 'TEST_PROVIDER', + issueProviderId: 'provider-1', + }), + ); + + adapterRegistry.unregister('TEST_PROVIDER'); + })); + + it('should not create issue when task already has an issueId', fakeAsync(() => { + const createIssueSpy = jasmine.createSpy('createIssue').and.resolveTo({ + issueId: 'new-issue-1', + issueData: { summary: 'Test' }, + }); + const adapter = createMockAdapter({ createIssue: createIssueSpy }); + adapterRegistry.register('TEST_PROVIDER', adapter); + + const provider = createMockIssueProvider({ + id: 'provider-1', + issueProviderKey: 'TEST_PROVIDER' as any, + defaultProjectId: 'project-1', + pluginConfig: { isAutoCreateIssues: true }, + } as any); + + store.overrideSelector(selectEnabledIssueProviders, [provider]); + store.refreshState(); + + const task = createMockTask({ + id: 'task-existing', + title: 'Existing Task', + projectId: 'project-1', + issueId: 'already-linked-issue', + }); + + effects.autoCreateIssueOnTaskAdd$.subscribe(); + + actions$.next( + TaskSharedActions.addTask({ + task, + issue: { id: 'already-linked-issue' } as any, + workContextId: 'project-1', + workContextType: WorkContextType.PROJECT, + isAddToBacklog: false, + isAddToBottom: false, + }), + ); + + tick(); + + expect(createIssueSpy).not.toHaveBeenCalled(); + + adapterRegistry.unregister('TEST_PROVIDER'); + })); + + it('should not create issue for subtasks (parentId set)', fakeAsync(() => { + const createIssueSpy = jasmine.createSpy('createIssue').and.resolveTo({ + issueId: 'new-issue-1', + issueData: { summary: 'Test' }, + }); + const adapter = createMockAdapter({ createIssue: createIssueSpy }); + adapterRegistry.register('TEST_PROVIDER', adapter); + + const provider = createMockIssueProvider({ + id: 'provider-1', + issueProviderKey: 'TEST_PROVIDER' as any, + defaultProjectId: 'project-1', + pluginConfig: { isAutoCreateIssues: true }, + } as any); + + store.overrideSelector(selectEnabledIssueProviders, [provider]); + store.refreshState(); + + const task = createMockTask({ + id: 'subtask-1', + title: 'Subtask', + projectId: 'project-1', + parentId: 'parent-task-1', + issueId: undefined, + }); + + effects.autoCreateIssueOnTaskAdd$.subscribe(); + + actions$.next( + TaskSharedActions.addTask({ + task, + workContextId: 'project-1', + workContextType: WorkContextType.PROJECT, + isAddToBacklog: false, + isAddToBottom: false, + }), + ); + + tick(); + + expect(createIssueSpy).not.toHaveBeenCalled(); + + adapterRegistry.unregister('TEST_PROVIDER'); + })); + + it('should not create issue when provider has no auto-create enabled', fakeAsync(() => { + const createIssueSpy = jasmine.createSpy('createIssue').and.resolveTo({ + issueId: 'new-issue-1', + issueData: { summary: 'Test' }, + }); + const adapter = createMockAdapter({ createIssue: createIssueSpy }); + adapterRegistry.register('TEST_PROVIDER', adapter); + + const provider = createMockIssueProvider({ + id: 'provider-1', + issueProviderKey: 'TEST_PROVIDER' as any, + defaultProjectId: 'project-1', + // No pluginConfig or isAutoCreateIssues + }); + + store.overrideSelector(selectEnabledIssueProviders, [provider]); + store.refreshState(); + + const task = createMockTask({ + id: 'task-new', + title: 'New Task', + projectId: 'project-1', + issueId: undefined, + }); + + effects.autoCreateIssueOnTaskAdd$.subscribe(); + + actions$.next( + TaskSharedActions.addTask({ + task, + workContextId: 'project-1', + workContextType: WorkContextType.PROJECT, + isAddToBacklog: false, + isAddToBottom: false, + }), + ); + + tick(); + + expect(createIssueSpy).not.toHaveBeenCalled(); + + adapterRegistry.unregister('TEST_PROVIDER'); + })); + + it('should not create issue when task has no projectId', fakeAsync(() => { + const createIssueSpy = jasmine.createSpy('createIssue').and.resolveTo({ + issueId: 'new-issue-1', + issueData: { summary: 'Test' }, + }); + const adapter = createMockAdapter({ createIssue: createIssueSpy }); + adapterRegistry.register('TEST_PROVIDER', adapter); + + const provider = createMockIssueProvider({ + id: 'provider-1', + issueProviderKey: 'TEST_PROVIDER' as any, + defaultProjectId: 'project-1', + pluginConfig: { isAutoCreateIssues: true }, + } as any); + + store.overrideSelector(selectEnabledIssueProviders, [provider]); + store.refreshState(); + + const task = createMockTask({ + id: 'task-new', + title: 'New Task', + projectId: '' as any, + issueId: undefined, + }); + + effects.autoCreateIssueOnTaskAdd$.subscribe(); + + actions$.next( + TaskSharedActions.addTask({ + task, + workContextId: 'tag-1', + workContextType: WorkContextType.TAG, + isAddToBacklog: false, + isAddToBottom: false, + }), + ); + + tick(); + + expect(createIssueSpy).not.toHaveBeenCalled(); + + adapterRegistry.unregister('TEST_PROVIDER'); + })); + + it('should show snack on auto-create error and continue', fakeAsync(() => { + const createIssueSpy = jasmine + .createSpy('createIssue') + .and.rejectWith(new Error('Create failed')); + const adapter = createMockAdapter({ createIssue: createIssueSpy }); + adapterRegistry.register('TEST_PROVIDER', adapter); + + const provider = createMockIssueProvider({ + id: 'provider-1', + issueProviderKey: 'TEST_PROVIDER' as any, + defaultProjectId: 'project-1', + pluginConfig: { isAutoCreateIssues: true }, + } as any); + + store.overrideSelector(selectEnabledIssueProviders, [provider]); + store.refreshState(); + + const cfg = createMockIssueProvider({ + id: 'provider-1', + issueProviderKey: 'TEST_PROVIDER' as any, + }); + issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(cfg)); + + const task = createMockTask({ + id: 'task-new', + title: 'New Task', + projectId: 'project-1', + issueId: undefined, + }); + + effects.autoCreateIssueOnTaskAdd$.subscribe(); + + actions$.next( + TaskSharedActions.addTask({ + task, + workContextId: 'project-1', + workContextType: WorkContextType.PROJECT, + isAddToBacklog: false, + isAddToBottom: false, + }), + ); + + tick(); + + expect(snackServiceSpy.open).toHaveBeenCalledWith( + jasmine.objectContaining({ type: 'ERROR' }), + ); + + adapterRegistry.unregister('TEST_PROVIDER'); + })); + + it('should prefix title with issue number when provided', fakeAsync(() => { + const createIssueSpy = jasmine.createSpy('createIssue').and.resolveTo({ + issueId: 'new-issue-1', + issueNumber: 42, + issueData: { summary: 'Test Task' }, + }); + const adapter = createMockAdapter({ + createIssue: createIssueSpy, + getFieldMappings: jasmine.createSpy('getFieldMappings').and.returnValue([]), + getSyncConfig: jasmine.createSpy('getSyncConfig').and.returnValue({}), + }); + adapterRegistry.register('TEST_PROVIDER', adapter); + + const provider = createMockIssueProvider({ + id: 'provider-1', + issueProviderKey: 'TEST_PROVIDER' as any, + defaultProjectId: 'project-1', + pluginConfig: { isAutoCreateIssues: true }, + } as any); + + store.overrideSelector(selectEnabledIssueProviders, [provider]); + store.refreshState(); + + const cfg = createMockIssueProvider({ + id: 'provider-1', + issueProviderKey: 'TEST_PROVIDER' as any, + }); + issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(cfg)); + + const task = createMockTask({ + id: 'task-new', + title: 'New Task', + projectId: 'project-1', + issueId: undefined, + }); + + // _pushInitialValues now re-fetches the task from the store + taskServiceSpy.getByIdOnce$.and.returnValue(of(task)); + + effects.autoCreateIssueOnTaskAdd$.subscribe(); + + actions$.next( + TaskSharedActions.addTask({ + task, + workContextId: 'project-1', + workContextType: WorkContextType.PROJECT, + isAddToBacklog: false, + isAddToBottom: false, + }), + ); + + tick(); + + expect(taskServiceSpy.update).toHaveBeenCalledWith( + 'task-new', + jasmine.objectContaining({ + title: '#42 New Task', + }), + ); + + adapterRegistry.unregister('TEST_PROVIDER'); + })); + + it('should push initial dueWithTime from short syntax and update issueLastUpdated', fakeAsync(() => { + const dueTimestamp = 1774008000000; + const createIssueSpy = jasmine.createSpy('createIssue').and.resolveTo({ + issueId: 'new-issue-1', + issueNumber: undefined, + issueData: { dtstart: null }, + }); + const adapter = createMockAdapter({ + createIssue: createIssueSpy, + getFieldMappings: jasmine + .createSpy('getFieldMappings') + .and.returnValue([dueWithTimeFieldMapping]), + getSyncConfig: jasmine.createSpy('getSyncConfig').and.returnValue({}), + extractSyncValues: jasmine + .createSpy('extractSyncValues') + .and.returnValue({ dtstart: null }), + }); + adapterRegistry.register('TEST_PROVIDER', adapter); + + const provider = createMockIssueProvider({ + id: 'provider-1', + issueProviderKey: 'TEST_PROVIDER' as any, + defaultProjectId: 'project-1', + pluginConfig: { isAutoCreateIssues: true }, + } as any); + + store.overrideSelector(selectEnabledIssueProviders, [provider]); + store.refreshState(); + + const cfg = createMockIssueProvider({ + id: 'provider-1', + issueProviderKey: 'TEST_PROVIDER' as any, + }); + issueProviderServiceSpy.getCfgOnce$.and.returnValue(of(cfg)); + + const task = createMockTask({ + id: 'task-new', + title: 'New Task', + projectId: 'project-1', + issueId: undefined, + }); + + // The re-fetched task from the store has dueWithTime set by short syntax + const taskWithDueTime = createMockTask({ + ...task, + dueWithTime: dueTimestamp, + }); + taskServiceSpy.getByIdOnce$.and.returnValue(of(taskWithDueTime)); + + effects.autoCreateIssueOnTaskAdd$.subscribe(); + + actions$.next( + TaskSharedActions.addTask({ + task, + workContextId: 'project-1', + workContextType: WorkContextType.PROJECT, + isAddToBacklog: false, + isAddToBottom: false, + }), + ); + + tick(); + + // Should have pushed initial values + expect(adapter.pushChanges).toHaveBeenCalledWith( + 'new-issue-1', + { dtstart: dueTimestamp }, + cfg, + ); + + // Should have updated issueLastSyncedValues AND issueLastUpdated + const updateCalls = taskServiceSpy.update.calls.allArgs(); + const lastUpdateCall = updateCalls[updateCalls.length - 1]; + expect(lastUpdateCall[0]).toBe('task-new'); + expect(lastUpdateCall[1]).toEqual( + jasmine.objectContaining({ + issueLastSyncedValues: { dtstart: dueTimestamp }, + issueLastUpdated: jasmine.any(Number), + }), + ); + + adapterRegistry.unregister('TEST_PROVIDER'); + })); + }); +}); diff --git a/src/app/features/issue/two-way-sync/issue-two-way-sync.effects.ts b/src/app/features/issue/two-way-sync/issue-two-way-sync.effects.ts index 897417bb14..b2e4428ecb 100644 --- a/src/app/features/issue/two-way-sync/issue-two-way-sync.effects.ts +++ b/src/app/features/issue/two-way-sync/issue-two-way-sync.effects.ts @@ -1,7 +1,7 @@ import { Injectable, inject } from '@angular/core'; import { Store } from '@ngrx/store'; import { createEffect, ofType } from '@ngrx/effects'; -import { EMPTY, Observable, first, from } from 'rxjs'; +import { EMPTY, Observable, first, firstValueFrom, from } from 'rxjs'; import { catchError, concatMap, filter, map } from 'rxjs/operators'; import { LOCAL_ACTIONS } from '../../../util/local-actions.token'; import { TaskService } from '../../tasks/task.service'; @@ -10,13 +10,75 @@ import { IssueProviderService } from '../issue-provider.service'; import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions'; import { IssueSyncAdapterRegistryService } from './issue-sync-adapter-registry.service'; import { computePushDecisions } from './compute-push-decisions'; +import { FieldMapping, FieldSyncConfig } from './issue-sync.model'; import { IssueProvider, IssueProviderKey } from '../issue.model'; import { IssueLog } from '../../../core/log'; +import { HttpErrorResponse } from '@angular/common/http'; import { CaldavSyncAdapterService } from '../providers/caldav/caldav-sync-adapter.service'; import { SnackService } from '../../../core/snack/snack.service'; +import { + DeletedTaskIssueSidecarService, + DeletedTaskIssueInfo, +} from './deleted-task-issue-sidecar.service'; import { selectEnabledIssueProviders } from '../store/issue-provider.selectors'; import { getErrorTxt } from '../../../util/get-error-text'; import { T } from '../../../t.const'; +import { PlannerActions } from '../../planner/store/planner.actions'; + +const SYNCABLE_TASK_FIELDS: ReadonlySet = new Set([ + 'isDone', + 'title', + 'notes', + 'dueWithTime', + 'dueDay', + 'timeEstimate', +]); + +// Lookup map to extract taskId and changes from each action type, +// replacing chained if/else with manual casts. +const ACTION_EXTRACTORS: Record< + string, + (action: unknown) => { taskId: string; changes: Record } +> = { + [TaskSharedActions.applyShortSyntax.type]: (action) => { + const a = action as ReturnType; + const changes: Record = { ...a.taskChanges }; + if (a.schedulingInfo?.dueWithTime) { + changes['dueWithTime'] = a.schedulingInfo.dueWithTime; + } + if (a.schedulingInfo?.day) { + changes['dueDay'] = a.schedulingInfo.day; + } + return { taskId: a.task.id, changes }; + }, + [TaskSharedActions.scheduleTaskWithTime.type]: (action) => { + const a = action as ReturnType; + return { taskId: a.task.id, changes: { dueWithTime: a.dueWithTime } }; + }, + [TaskSharedActions.reScheduleTaskWithTime.type]: (action) => { + const a = action as ReturnType; + return { taskId: a.task.id, changes: { dueWithTime: a.dueWithTime } }; + }, + [TaskSharedActions.unscheduleTask.type]: (action) => { + const a = action as ReturnType; + return { taskId: a.id, changes: { dueWithTime: undefined } }; + }, + [TaskSharedActions.updateTask.type]: (action) => { + const a = action as ReturnType; + return { + taskId: a.task.id.toString(), + changes: a.task.changes as Partial, + }; + }, + [PlannerActions.planTaskForDay.type]: (action) => { + const a = action as ReturnType; + return { taskId: a.task.id, changes: { dueDay: a.day } }; + }, + [PlannerActions.transferTask.type]: (action) => { + const a = action as ReturnType; + return { taskId: a.task.id, changes: { dueDay: a.newDay } }; + }, +}; @Injectable() export class IssueTwoWaySyncEffects { @@ -26,7 +88,9 @@ export class IssueTwoWaySyncEffects { private readonly _issueProviderService = inject(IssueProviderService); private readonly _adapterRegistry = inject(IssueSyncAdapterRegistryService); private readonly _snackService = inject(SnackService); + private readonly _deletedTaskIssueSidecar = inject(DeletedTaskIssueSidecarService); private _syncOriginatedTaskIds = new Set(); + private static readonly _MAX_SYNC_ORIGINATED_IDS = 1000; constructor() { const caldavAdapter = inject(CaldavSyncAdapterService); @@ -36,30 +100,31 @@ export class IssueTwoWaySyncEffects { pushFieldsOnTaskUpdate$: Observable = createEffect( () => this._actions$.pipe( - ofType(TaskSharedActions.updateTask), - filter(({ task }) => { - if (this._syncOriginatedTaskIds.delete(task.id.toString())) { + ofType( + TaskSharedActions.updateTask, + TaskSharedActions.applyShortSyntax, + TaskSharedActions.scheduleTaskWithTime, + TaskSharedActions.reScheduleTaskWithTime, + TaskSharedActions.unscheduleTask, + PlannerActions.planTaskForDay, + PlannerActions.transferTask, + ), + map((action) => ACTION_EXTRACTORS[action.type](action)), + filter(({ taskId, changes }) => { + if (this._syncOriginatedTaskIds.delete(taskId)) { return false; } - const changes = task.changes; // Skip poll-originated updates that include sync bookkeeping fields if ('issueLastSyncedValues' in changes || 'issueWasUpdated' in changes) { return false; } - return ( - 'isDone' in changes || - 'title' in changes || - 'notes' in changes || - 'dueWithTime' in changes || - 'dueDay' in changes || - 'timeEstimate' in changes - ); + return Object.keys(changes).some((key) => SYNCABLE_TASK_FIELDS.has(key)); }), - concatMap(({ task: taskUpdate }) => - this._taskService.getByIdOnce$(taskUpdate.id.toString()).pipe( + concatMap(({ taskId, changes }) => + this._taskService.getByIdOnce$(taskId).pipe( map((fullTask) => ({ fullTask, - changes: taskUpdate.changes, + changes, })), ), ), @@ -86,6 +151,37 @@ export class IssueTwoWaySyncEffects { { dispatch: false }, ); + deleteIssueOnTaskDelete$: Observable = createEffect( + () => + this._actions$.pipe( + ofType(TaskSharedActions.deleteTask), + filter( + ({ task }) => !!task.issueId && !!task.issueType && !!task.issueProviderId, + ), + filter(({ task }) => this._adapterRegistry.has(task.issueType!)), + concatMap(({ task }) => this._deleteRemoteIssue$(task)), + ), + { dispatch: false }, + ); + + deleteIssueOnBulkTaskDelete$: Observable = createEffect( + () => + this._actions$.pipe( + ofType(TaskSharedActions.deleteTasks), + concatMap(() => { + const issueInfos = this._deletedTaskIssueSidecar.consume(); + if (!issueInfos.length) { + return EMPTY; + } + return from(issueInfos).pipe( + filter((info) => this._adapterRegistry.has(info.issueType)), + concatMap((info) => this._deleteRemoteIssue$(info)), + ); + }), + ), + { dispatch: false }, + ); + autoCreateIssueOnTaskAdd$: Observable = createEffect( () => this._actions$.pipe( @@ -113,20 +209,33 @@ export class IssueTwoWaySyncEffects { .pipe( concatMap((cfg) => from(adapter.createIssue!(task.title, cfg)).pipe( - map(({ issueId, issueNumber, issueData }) => { - this._syncOriginatedTaskIds.add(task.id); + concatMap(async ({ issueId, issueNumber, issueData }) => { + this._trackSyncOriginatedTask(task.id); try { const titlePrefix = - issueNumber != null ? `#${issueNumber} ` : `${issueId} `; + issueNumber != null ? `#${issueNumber} ` : ''; + const syncValues = adapter.extractSyncValues(issueData); this._taskService.update(task.id, { issueId, issueType: provider.issueProviderKey, issueProviderId: provider.id, issueLastUpdated: Date.now(), issueWasUpdated: false, - issueLastSyncedValues: adapter.extractSyncValues(issueData), - title: `${titlePrefix}${task.title}`, + issueLastSyncedValues: syncValues, + title: titlePrefix + ? `${titlePrefix}${task.title}` + : task.title, }); + + // Push initial task values (e.g. dueWithTime from short syntax) + // that were set before the issue was linked + await this._pushInitialValues( + task, + issueId, + adapter, + cfg, + syncValues, + ); } catch (e) { this._syncOriginatedTaskIds.delete(task.id); throw e; @@ -151,6 +260,61 @@ export class IssueTwoWaySyncEffects { { dispatch: false }, ); + private async _pushInitialValues( + task: Task, + issueId: string, + adapter: { + getFieldMappings(): FieldMapping[]; + getSyncConfig(cfg: unknown): FieldSyncConfig; + pushChanges( + issueId: string, + changes: Record, + cfg: unknown, + ): Promise; + }, + cfg: IssueProvider, + syncValues: Record, + ): Promise { + // Re-fetch task from the store to get post-meta-reducer values + // (e.g. dueWithTime from short syntax parsing like @2pm) + const currentTask = await firstValueFrom(this._taskService.getByIdOnce$(task.id)); + const fieldMappings = adapter.getFieldMappings(); + const syncConfig = adapter.getSyncConfig(cfg); + const ctx = { issueId }; + const toPush: Record = {}; + + for (const mapping of fieldMappings) { + const dir = syncConfig[mapping.taskField] ?? mapping.defaultDirection; + if (dir !== 'pushOnly' && dir !== 'both') { + continue; + } + const taskValue = currentTask[mapping.taskField as keyof Task]; + if (taskValue == null) { + continue; + } + const issueValue = mapping.toIssueValue(taskValue, ctx); + if (issueValue == null) { + continue; + } + // Only push if task value differs from the created issue value + if (issueValue !== syncValues[mapping.issueField]) { + toPush[mapping.issueField] = issueValue; + } + } + + if (Object.keys(toPush).length > 0) { + await adapter.pushChanges(issueId, toPush, cfg); + // Update sync baseline and issueLastUpdated to prevent poll from + // treating our own push as an external update + const updatedSyncValues = { ...syncValues, ...toPush }; + this._trackSyncOriginatedTask(task.id); + this._taskService.update(task.id, { + issueLastSyncedValues: updatedSyncValues, + issueLastUpdated: Date.now(), + }); + } + } + private _hasAutoCreateEnabled(provider: IssueProvider): boolean { // Check for plugin providers (both plugin:* and migrated keys like GITHUB) const pluginCfg = (provider as { pluginConfig?: Record }) @@ -161,6 +325,39 @@ export class IssueTwoWaySyncEffects { return false; } + private _deleteRemoteIssue$(info: DeletedTaskIssueInfo | Task): Observable { + const issueType = info.issueType!; + const issueProviderId = info.issueProviderId!; + const issueId = info.issueId!; + const adapter = this._adapterRegistry.get(issueType); + if (!adapter?.deleteIssue) { + return EMPTY; + } + return this._issueProviderService + .getCfgOnce$(issueProviderId, issueType as IssueProviderKey) + .pipe( + concatMap((cfg) => + from(adapter.deleteIssue!(issueId, cfg)).pipe( + catchError((err) => { + // 404/410 means the remote issue is already gone — treat as success + // to avoid false "delete failed" toasts (e.g. when polling detects + // a remote deletion and then deleteIssue is called on the same issue) + const status = err instanceof HttpErrorResponse ? err.status : err?.status; + if (status === 404 || status === 410) { + return EMPTY; + } + IssueLog.err('Delete remote issue failed', err); + this._snackService.open({ + type: 'ERROR', + msg: T.F.ISSUE.S.DELETE_REMOTE_FAILED, + }); + return EMPTY; + }), + ), + ), + ); + } + private _pushChanges$(task: Task, changes: Partial): Observable { const issueType = task.issueType as IssueProviderKey; const adapter = this._adapterRegistry.get(issueType); @@ -189,10 +386,15 @@ export class IssueTwoWaySyncEffects { const freshValues = adapter.extractSyncValues(freshIssue); const lastSyncedValues = task.issueLastSyncedValues ?? {}; + // Re-fetch task to get post-meta-reducer values (e.g. short syntax parsed title) + const currentTask = await firstValueFrom(this._taskService.getByIdOnce$(task.id)); const taskFieldChanges: Record = {}; for (const mapping of fieldMappings) { if (mapping.taskField in changes) { - taskFieldChanges[mapping.taskField] = changes[mapping.taskField]; + // Use current task value (post-parsing) not raw action value + taskFieldChanges[mapping.taskField] = + currentTask?.[mapping.taskField as keyof Task] ?? + changes[mapping.taskField]; } } @@ -244,7 +446,7 @@ export class IssueTwoWaySyncEffects { // Update sync values and issueLastUpdated to prevent poll from // treating our own push as an external update - this._syncOriginatedTaskIds.add(task.id); + this._trackSyncOriginatedTask(task.id); try { this._taskService.update(task.id, { issueLastSyncedValues: updatedSyncValues, @@ -257,4 +459,17 @@ export class IssueTwoWaySyncEffects { }), ); } + + private _trackSyncOriginatedTask(taskId: string): void { + this._syncOriginatedTaskIds.add(taskId); + if ( + this._syncOriginatedTaskIds.size > IssueTwoWaySyncEffects._MAX_SYNC_ORIGINATED_IDS + ) { + // Evict oldest entry (Set preserves insertion order) instead of clearing all + const oldest = this._syncOriginatedTaskIds.values().next().value; + if (oldest !== undefined) { + this._syncOriginatedTaskIds.delete(oldest); + } + } + } } diff --git a/src/app/features/task-repeat-cfg/store/task-repeat-cleanup.effects.ts b/src/app/features/task-repeat-cfg/store/task-repeat-cleanup.effects.ts index 1159abcf69..0af3da5e68 100644 --- a/src/app/features/task-repeat-cfg/store/task-repeat-cleanup.effects.ts +++ b/src/app/features/task-repeat-cfg/store/task-repeat-cleanup.effects.ts @@ -12,6 +12,7 @@ import { waitForSyncWindow } from '../../../util/wait-for-sync-window.operator'; import { selectAllRepeatableTaskWithSubTasks } from '../../tasks/store/task.selectors'; import { TaskWithSubTasks } from '../../tasks/task.model'; import { Log } from '../../../core/log'; +import { DeletedTaskIssueSidecarService } from '../../issue/two-way-sync/deleted-task-issue-sidecar.service'; @Injectable() export class TaskRepeatCleanupEffects { @@ -20,6 +21,7 @@ export class TaskRepeatCleanupEffects { private _syncTriggerService = inject(SyncTriggerService); private _syncWrapperService = inject(SyncWrapperService); private _hydrationState = inject(HydrationStateService); + private _deletedTaskIssueSidecar = inject(DeletedTaskIssueSidecarService); /** * After initial sync + date change, detect and remove stale duplicate @@ -68,6 +70,7 @@ export class TaskRepeatCleanupEffects { } const deleteIds: string[] = []; + const deleteTasks: TaskWithSubTasks[] = []; for (const [, tasks] of tasksByRepeatCfg) { // Only act when there are actual duplicates if (tasks.length <= 1) { @@ -97,6 +100,7 @@ export class TaskRepeatCleanupEffects { continue; } deleteIds.push(task.id); + deleteTasks.push(task); } } @@ -105,8 +109,19 @@ export class TaskRepeatCleanupEffects { '[TaskRepeatCleanupEffects] Removing stale duplicate repeat instances:', deleteIds, ); + this._deletedTaskIssueSidecar.set( + deleteTasks + .filter((t) => !!t.issueId && !!t.issueType && !!t.issueProviderId) + .map((t) => ({ + issueId: t.issueId!, + issueType: t.issueType!, + issueProviderId: t.issueProviderId!, + })), + ); this._store.dispatch( - TaskSharedActions.deleteTasks({ taskIds: deleteIds }), + TaskSharedActions.deleteTasks({ + taskIds: deleteIds, + }), ); } diff --git a/src/app/features/tasks/task.service.spec.ts b/src/app/features/tasks/task.service.spec.ts index 244ab29cd1..de65aeda83 100644 --- a/src/app/features/tasks/task.service.spec.ts +++ b/src/app/features/tasks/task.service.spec.ts @@ -26,11 +26,13 @@ import { TaskDetailTargetPanel, TaskReminderOptionId } from './task.model'; import { TODAY_TAG } from '../tag/tag.const'; import { INBOX_PROJECT } from '../project/project.const'; import { signal } from '@angular/core'; +import { DeletedTaskIssueSidecarService } from '../issue/two-way-sync/deleted-task-issue-sidecar.service'; describe('TaskService', () => { let service: TaskService; let store: MockStore; let archiveService: jasmine.SpyObj; + let deletedTaskIssueSidecar: DeletedTaskIssueSidecarService; let tickSubject: Subject<{ duration: number; date: string }>; const createMockTask = (id: string, overrides: Partial = {}): Task => @@ -159,6 +161,7 @@ describe('TaskService', () => { service = TestBed.inject(TaskService); store = TestBed.inject(MockStore); archiveService = TestBed.inject(ArchiveService) as jasmine.SpyObj; + deletedTaskIssueSidecar = TestBed.inject(DeletedTaskIssueSidecarService); spyOn(store, 'dispatch').and.callThrough(); }); @@ -297,13 +300,20 @@ describe('TaskService', () => { }); describe('removeMultipleTasks', () => { - it('should dispatch deleteTasks', () => { + it('should dispatch deleteTasks with only taskIds', () => { service.removeMultipleTasks(['task-1', 'task-2']); expect(store.dispatch).toHaveBeenCalledWith( TaskSharedActions.deleteTasks({ taskIds: ['task-1', 'task-2'] }), ); }); + + it('should populate sidecar with issue info before dispatch', () => { + spyOn(deletedTaskIssueSidecar, 'set'); + service.removeMultipleTasks(['task-1', 'task-2']); + + expect(deletedTaskIssueSidecar.set).toHaveBeenCalledWith([]); + }); }); describe('update', () => { diff --git a/src/app/features/tasks/task.service.ts b/src/app/features/tasks/task.service.ts index 9fdb0612ff..6ccf2f1966 100644 --- a/src/app/features/tasks/task.service.ts +++ b/src/app/features/tasks/task.service.ts @@ -50,6 +50,7 @@ import { selectTaskById, selectTaskByIdWithSubTaskData, selectTaskDetailTargetPanel, + selectTaskEntities, selectTaskFeatureState, selectTasksById, selectTasksByRepeatConfigId, @@ -101,6 +102,7 @@ import { TaskLog } from '../../core/log'; import { devError } from '../../util/dev-error'; import { DEFAULT_GLOBAL_CONFIG } from '../config/default-global-config.const'; import { TaskFocusService } from './task-focus.service'; +import { DeletedTaskIssueSidecarService } from '../issue/two-way-sync/deleted-task-issue-sidecar.service'; @Injectable({ providedIn: 'root', @@ -116,6 +118,7 @@ export class TaskService { private readonly _taskArchiveService = inject(TaskArchiveService); private readonly _globalConfigService = inject(GlobalConfigService); private readonly _taskFocusService = inject(TaskFocusService); + private readonly _deletedTaskIssueSidecar = inject(DeletedTaskIssueSidecarService); currentTaskId$: Observable = this._store.pipe( select(selectCurrentTaskId), @@ -191,6 +194,7 @@ export class TaskService { private _lastFocusedTaskEl: HTMLElement | null = null; private _allTasks$: Observable = this._store.pipe(select(selectAllTasks)); + private _taskEntities = this._store.selectSignal(selectTaskEntities); // Batch sync for time tracking: accumulates duration per task, syncs every 5 minutes private static readonly SYNC_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes @@ -444,6 +448,22 @@ export class TaskService { } removeMultipleTasks(taskIds: string[]): void { + // Store issue metadata in the sidecar *before* dispatching, so the + // deleteIssueOnBulkTaskDelete$ effect can pick it up. This keeps + // full Task objects out of the action payload and the op-log. + const entities = this._taskEntities(); + const tasks = taskIds + .map((id) => entities[id]) + .filter((task): task is Task => !!task); + this._deletedTaskIssueSidecar.set( + tasks + .filter((t) => !!t.issueId && !!t.issueType && !!t.issueProviderId) + .map((t) => ({ + issueId: t.issueId!, + issueType: t.issueType!, + issueProviderId: t.issueProviderId!, + })), + ); this._store.dispatch(TaskSharedActions.deleteTasks({ taskIds })); } diff --git a/src/app/imex/sync/oauth-callback-handler.service.ts b/src/app/imex/sync/oauth-callback-handler.service.ts index 908caf359f..36f8770009 100644 --- a/src/app/imex/sync/oauth-callback-handler.service.ts +++ b/src/app/imex/sync/oauth-callback-handler.service.ts @@ -1,21 +1,23 @@ -import { Injectable, OnDestroy } from '@angular/core'; +import { Injectable, OnDestroy, inject } from '@angular/core'; import { Subject } from 'rxjs'; import { App, URLOpenListenerEvent } from '@capacitor/app'; import { PluginListenerHandle } from '@capacitor/core'; import { IS_NATIVE_PLATFORM } from '../../util/is-native-platform'; import { SyncLog } from '../../core/log'; +import { PluginOAuthService } from '../../plugins/oauth/plugin-oauth.service'; export interface OAuthCallbackData { code?: string; error?: string; error_description?: string; - provider: 'dropbox'; + provider: 'dropbox' | 'plugin'; } @Injectable({ providedIn: 'root', }) export class OAuthCallbackHandlerService implements OnDestroy { + private _pluginOAuthService = inject(PluginOAuthService); private _authCodeReceived$ = new Subject(); private _urlListenerHandle?: PluginListenerHandle; @@ -38,7 +40,9 @@ export class OAuthCallbackHandlerService implements OnDestroy { (event: URLOpenListenerEvent) => { SyncLog.log('OAuthCallbackHandler: Received URL', event.url); - if (event.url.startsWith('com.super-productivity.app://oauth-callback')) { + if (event.url.startsWith('com.super-productivity.app://plugin-oauth-callback')) { + this._handlePluginOAuthCallback(event.url); + } else if (event.url.startsWith('com.super-productivity.app://oauth-callback')) { const callbackData = this._parseOAuthCallback(event.url); if (callbackData.code) { @@ -81,4 +85,27 @@ export class OAuthCallbackHandlerService implements OnDestroy { }; } } + + private _handlePluginOAuthCallback(url: string): void { + try { + const urlObj = new URL(url); + const code = urlObj.searchParams.get('code'); + const error = urlObj.searchParams.get('error'); + const state = urlObj.searchParams.get('state') ?? undefined; + + if (code) { + SyncLog.log('OAuthCallbackHandler: Extracted plugin OAuth code'); + this._pluginOAuthService.handleRedirectCode(code, state); + } else if (error) { + SyncLog.warn('OAuthCallbackHandler: Plugin OAuth error', error); + this._pluginOAuthService.handleRedirectError(error, state); + } else { + SyncLog.warn('OAuthCallbackHandler: No code or error in plugin OAuth URL', url); + this._pluginOAuthService.handleRedirectError('no_code_or_error', state); + } + } catch (e) { + SyncLog.err('OAuthCallbackHandler: Failed to parse plugin OAuth URL', url, e); + this._pluginOAuthService.handleRedirectError('parse_error'); + } + } } diff --git a/src/app/op-log/sync-providers/file-based/dropbox/generate-pkce-codes.spec.ts b/src/app/op-log/sync-providers/file-based/dropbox/generate-pkce-codes.spec.ts index 6b414c822c..988b341435 100644 --- a/src/app/op-log/sync-providers/file-based/dropbox/generate-pkce-codes.spec.ts +++ b/src/app/op-log/sync-providers/file-based/dropbox/generate-pkce-codes.spec.ts @@ -24,9 +24,7 @@ describe('generatePKCECodes', () => { configurable: true, }); - await expectAsync(generatePKCECodes(128)).toBeRejectedWithError( - /WebCrypto API.*getRandomValues/i, - ); + await expectAsync(generatePKCECodes(128)).toBeRejected(); }); it('should successfully generate PKCE codes when crypto.subtle is unavailable (fallback)', async () => { diff --git a/src/app/op-log/sync-providers/file-based/dropbox/generate-pkce-codes.ts b/src/app/op-log/sync-providers/file-based/dropbox/generate-pkce-codes.ts index 2d97f64ec2..6d74c54444 100644 --- a/src/app/op-log/sync-providers/file-based/dropbox/generate-pkce-codes.ts +++ b/src/app/op-log/sync-providers/file-based/dropbox/generate-pkce-codes.ts @@ -1,70 +1,9 @@ -// taken from https://github.com/aaronpk/pkce-vanilla-js/blob/master/index.html -import { sha256 as hashWasmSha256 } from 'hash-wasm'; - -// Convert hex string to ArrayBuffer (needed for hash-wasm fallback) -const hexStringToArrayBuffer = (hex: string): ArrayBuffer => { - const bytes = new Uint8Array(hex.length / 2); - for (let i = 0; i < hex.length; i += 2) { - bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16); - } - return bytes.buffer; -}; - -// Generate a secure random string using the browser crypto functions -const generateRandomString = (length: number): string => { - const array = new Uint32Array(length / 2); - if (!window.crypto?.getRandomValues) { - throw new Error( - 'WebCrypto API (getRandomValues) not supported in your browser. Please update to the latest version or use a different one', - ); - } - - window.crypto.getRandomValues(array); - return Array.from(array, (dec) => ('0' + dec.toString(16)).slice(-2)).join(''); -}; - -// Calculate the SHA256 hash of the input text. -// Returns a promise that resolves to an ArrayBuffer -const sha256 = async (plain: string): Promise => { - const encoder = new TextEncoder(); - const data = encoder.encode(plain); - - // Use WebCrypto if available (secure context) - // NOTE: crypto.subtle is undefined in insecure contexts (e.g., Android Capacitor http://localhost) - // @see https://www.chromium.org/blink/webcrypto - if (window.crypto?.subtle !== undefined) { - return window.crypto.subtle.digest('SHA-256', data); - } - - // Fallback to hash-wasm for insecure contexts (e.g., Android Capacitor serves from http://localhost) - // We can't use WebCrypto in insecure contexts, so we use hash-wasm which is already a dependency - // (used for Argon2id encryption) and provides a pure WebAssembly implementation - const hexHash = await hashWasmSha256(data); - return hexStringToArrayBuffer(hexHash); -}; - -// Base64-urlencodes the input string -const base64urlencode = (str: ArrayBuffer): string => { - // Convert the ArrayBuffer to string using Uint8 array to conver to what btoa accepts. - // btoa accepts chars only within ascii 0-255 and base64 encodes them. - // Then convert the base64 encoded to base64url encoded - // (replace + with -, replace / with _, trim trailing =) - return btoa(String.fromCharCode.apply(null, new Uint8Array(str) as any)) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); -}; - -// Return the base64-urlencoded sha256 hash for the PKCE challenge -const pkceChallengeFromVerifier = async (v: string): Promise => { - const hashed = await sha256(v); - return base64urlencode(hashed); -}; +import { generateCodeVerifier, generateCodeChallenge } from '../../../../util/pkce.util'; export const generatePKCECodes = async ( - length: number, + _length: number, ): Promise<{ codeVerifier: string; codeChallenge: string }> => { - const codeVerifier = generateRandomString(length); - const codeChallenge = await pkceChallengeFromVerifier(codeVerifier); + const codeVerifier = generateCodeVerifier(); + const codeChallenge = await generateCodeChallenge(codeVerifier); return { codeVerifier, codeChallenge }; }; diff --git a/src/app/plugins/issue-provider/plugin-issue-provider-adapter.service.spec.ts b/src/app/plugins/issue-provider/plugin-issue-provider-adapter.service.spec.ts index 2519f2b15f..26024f9f4d 100644 --- a/src/app/plugins/issue-provider/plugin-issue-provider-adapter.service.spec.ts +++ b/src/app/plugins/issue-provider/plugin-issue-provider-adapter.service.spec.ts @@ -1,4 +1,5 @@ import { TestBed } from '@angular/core/testing'; +import { HttpErrorResponse } from '@angular/common/http'; import { Store } from '@ngrx/store'; import { of, throwError } from 'rxjs'; import { PluginIssueProviderAdapterService } from './plugin-issue-provider-adapter.service'; @@ -6,6 +7,7 @@ import { PluginIssueProviderRegistryService } from './plugin-issue-provider-regi import { PluginHttpService } from './plugin-http.service'; import { IssueProviderPluginDefinition, + PluginFieldMapping, PluginHttp, PluginIssue, PluginSearchResult, @@ -13,7 +15,9 @@ import { } from './plugin-issue-provider.model'; import { IssueProviderPluginType } from '../../features/issue/issue.model'; import { Task } from '../../features/tasks/task.model'; +import { TaskService } from '../../features/tasks/task.service'; import { SnackService } from '../../core/snack/snack.service'; +import { T } from '../../t.const'; describe('PluginIssueProviderAdapterService', () => { let service: PluginIssueProviderAdapterService; @@ -21,6 +25,7 @@ describe('PluginIssueProviderAdapterService', () => { let pluginHttpSpy: jasmine.SpyObj; let storeSpy: jasmine.SpyObj; let snackSpy: jasmine.SpyObj; + let taskServiceSpy: jasmine.SpyObj; const PLUGIN_KEY = 'plugin:test-plugin'; const PROVIDER_ID = 'provider-123'; @@ -76,11 +81,13 @@ describe('PluginIssueProviderAdapterService', () => { registrySpy = jasmine.createSpyObj('PluginIssueProviderRegistryService', [ 'getProvider', 'hasProvider', + 'getAvailableProviders', ]); pluginHttpSpy = jasmine.createSpyObj('PluginHttpService', ['createHttpHelper']); storeSpy = jasmine.createSpyObj('Store', ['select']); snackSpy = jasmine.createSpyObj('SnackService', ['open']); + taskServiceSpy = jasmine.createSpyObj('TaskService', ['removeMultipleTasks']); pluginHttpSpy.createHttpHelper.and.returnValue(mockHttpHelper); storeSpy.select.and.returnValue(of(mockPluginCfg)); registrySpy.hasProvider.and.returnValue(true); @@ -92,6 +99,7 @@ describe('PluginIssueProviderAdapterService', () => { { provide: PluginHttpService, useValue: pluginHttpSpy }, { provide: Store, useValue: storeSpy }, { provide: SnackService, useValue: snackSpy }, + { provide: TaskService, useValue: taskServiceSpy }, ], }); @@ -281,16 +289,77 @@ describe('PluginIssueProviderAdapterService', () => { expect(result.issueTimeTracked).toBeUndefined(); }); - it('should set issueLastUpdated to approximately now', () => { - const before = Date.now(); + it('should set issueLastUpdated to 0 when lastUpdated is missing so first poll applies field mappings', () => { const result = service.getAddTaskData({ id: 'X-1', title: 'Test', } as PluginSearchResult); - const after = Date.now(); - expect(result.issueLastUpdated).toBeGreaterThanOrEqual(before); - expect(result.issueLastUpdated).toBeLessThanOrEqual(after); + expect(result.issueLastUpdated).toBe(0); + }); + + it('should derive dueDay from start timestamp when start is a number', () => { + // 2026-03-19 12:00:00 UTC + const issueData = { + id: 'ISS-10', + title: 'Event', + start: 1774008000000, + } as any; + + const result = service.getAddTaskData(issueData); + + expect((result as any).dueDay).toBeDefined(); + expect(typeof (result as any).dueDay).toBe('string'); + // Should be a YYYY-MM-DD string + expect((result as any).dueDay).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it('should not include dueDay when start is not a number', () => { + const issueData = { + id: 'ISS-10', + title: 'Event', + start: '2026-03-19', + } as any; + + const result = service.getAddTaskData(issueData); + + expect((result as any).dueDay).toBeUndefined(); + }); + + it('should not include dueDay when start is absent', () => { + const issueData = { id: 'ISS-10', title: 'Event' } as any; + + const result = service.getAddTaskData(issueData); + + expect((result as any).dueDay).toBeUndefined(); + }); + + it('should set dueWithTime when dueWithTime is a number (timed event)', () => { + const ts = new Date('2026-03-19T14:00:00Z').getTime(); + const issueData = { + id: 'ISS-10', + title: 'Meeting', + start: ts, + dueWithTime: ts, + } as any; + + const result = service.getAddTaskData(issueData); + + expect((result as any).dueWithTime).toBe(ts); + expect((result as any).dueDay).toBeUndefined(); + }); + + it('should set dueDay when start is present but dueWithTime is not (all-day event)', () => { + const issueData = { + id: 'ISS-10', + title: 'All-day', + start: new Date('2026-03-19').getTime(), + } as any; + + const result = service.getAddTaskData(issueData); + + expect((result as any).dueDay).toBeDefined(); + expect((result as any).dueWithTime).toBeUndefined(); }); }); @@ -414,6 +483,571 @@ describe('PluginIssueProviderAdapterService', () => { expect(result).toBeNull(); }); + + describe('pull-side field mapping', () => { + const FIELD_MAPPINGS: PluginFieldMapping[] = [ + { + taskField: 'dueWithTime', + issueField: 'start_dateTime', + defaultDirection: 'both', + mutuallyExclusive: ['dueDay'], + toIssueValue: (v: unknown) => v, + toTaskValue: (v: unknown) => (v ? new Date(v as string).getTime() : undefined), + }, + { + taskField: 'dueDay', + issueField: 'start_date', + defaultDirection: 'both', + mutuallyExclusive: ['dueWithTime'], + toIssueValue: (v: unknown) => v, + toTaskValue: (v: unknown) => v, + }, + { + taskField: 'title', + issueField: 'summary', + defaultDirection: 'both', + toIssueValue: (v: unknown) => v, + toTaskValue: (v: unknown) => v, + }, + ]; + + const createProviderWithMappings = ( + issue: PluginIssue, + syncValues: Record, + ): RegisteredPluginIssueProvider => + createMockProvider({ + getById: jasmine.createSpy('getById').and.resolveTo(issue), + fieldMappings: FIELD_MAPPINGS, + extractSyncValues: jasmine + .createSpy('extractSyncValues') + .and.returnValue(syncValues), + }); + + it('should pull changed field when direction is both', async () => { + const freshIssue: PluginIssue = { + id: 'ISS-1', + title: 'Meeting', + lastUpdated: 2000, + }; + const syncValues = { + summary: 'Meeting', + // eslint-disable-next-line @typescript-eslint/naming-convention + start_dateTime: '2026-03-20T10:00:00.000Z', + }; + const provider = createProviderWithMappings(freshIssue, syncValues); + registrySpy.getProvider.and.returnValue(provider); + + const cfgWithSync = { + ...mockPluginCfg, + pluginConfig: { + ...mockPluginConfig, + twoWaySync: { dueWithTime: 'both', title: 'both' }, + }, + } as IssueProviderPluginType; + storeSpy.select.and.returnValue(of(cfgWithSync)); + + const task = { + id: 'task-1', + issueId: 'ISS-1', + issueProviderId: PROVIDER_ID, + issueLastUpdated: 1000, + issueLastSyncedValues: { + // eslint-disable-next-line @typescript-eslint/naming-convention + start_dateTime: '2026-03-19T10:00:00.000Z', + summary: 'Meeting', + }, + } as unknown as Task; + + const result = await service.getFreshDataForIssueTask(task); + + expect(result).not.toBeNull(); + expect(result!.taskChanges['dueWithTime' as keyof Task]).toBe( + new Date('2026-03-20T10:00:00.000Z').getTime(), + ); + }); + + it('should pull changed field when direction is pullOnly', async () => { + const freshIssue: PluginIssue = { + id: 'ISS-1', + title: 'Updated', + lastUpdated: 2000, + }; + const syncValues = { summary: 'Updated' }; + const provider = createProviderWithMappings(freshIssue, syncValues); + registrySpy.getProvider.and.returnValue(provider); + + const cfgWithSync = { + ...mockPluginCfg, + pluginConfig: { + ...mockPluginConfig, + twoWaySync: { title: 'pullOnly' }, + }, + } as IssueProviderPluginType; + storeSpy.select.and.returnValue(of(cfgWithSync)); + + const task = { + id: 'task-1', + issueId: 'ISS-1', + issueProviderId: PROVIDER_ID, + issueLastUpdated: 1000, + issueLastSyncedValues: { summary: 'Original' }, + } as unknown as Task; + + const result = await service.getFreshDataForIssueTask(task); + + expect(result).not.toBeNull(); + expect(result!.taskChanges['title' as keyof Task]).toBe('Updated'); + }); + + it('should skip field when direction is pushOnly', async () => { + const freshIssue: PluginIssue = { + id: 'ISS-1', + title: 'Unchanged', + lastUpdated: 2000, + }; + // eslint-disable-next-line @typescript-eslint/naming-convention + const syncValues = { start_dateTime: '2026-03-20T10:00:00.000Z' }; + const provider = createMockProvider({ + getById: jasmine.createSpy('getById').and.resolveTo(freshIssue), + fieldMappings: FIELD_MAPPINGS, + // First call (from _extractTaskFieldsFromIssue) returns empty, + // second call (for _applyFieldMappingPull) returns actual values. + extractSyncValues: jasmine + .createSpy('extractSyncValues') + .and.returnValues({}, syncValues), + }); + registrySpy.getProvider.and.returnValue(provider); + + const cfgWithSync = { + ...mockPluginCfg, + pluginConfig: { + ...mockPluginConfig, + twoWaySync: { dueWithTime: 'pushOnly' }, + }, + } as IssueProviderPluginType; + storeSpy.select.and.returnValue(of(cfgWithSync)); + + const task = { + id: 'task-1', + issueId: 'ISS-1', + issueProviderId: PROVIDER_ID, + issueLastUpdated: 1000, + issueLastSyncedValues: { + // eslint-disable-next-line @typescript-eslint/naming-convention + start_dateTime: '2026-03-19T10:00:00.000Z', + }, + } as unknown as Task; + + const result = await service.getFreshDataForIssueTask(task); + + expect(result).not.toBeNull(); + expect(result!.taskChanges['dueWithTime' as keyof Task]).toBeUndefined(); + }); + + it('should skip field when direction is off', async () => { + const freshIssue: PluginIssue = { + id: 'ISS-1', + title: 'Unchanged', + lastUpdated: 2000, + }; + // eslint-disable-next-line @typescript-eslint/naming-convention + const syncValues = { start_dateTime: '2026-03-20T10:00:00.000Z' }; + const provider = createMockProvider({ + getById: jasmine.createSpy('getById').and.resolveTo(freshIssue), + fieldMappings: FIELD_MAPPINGS, + extractSyncValues: jasmine + .createSpy('extractSyncValues') + .and.returnValues({}, syncValues), + }); + registrySpy.getProvider.and.returnValue(provider); + + const cfgWithSync = { + ...mockPluginCfg, + pluginConfig: { + ...mockPluginConfig, + twoWaySync: { dueWithTime: 'off' }, + }, + } as IssueProviderPluginType; + storeSpy.select.and.returnValue(of(cfgWithSync)); + + const task = { + id: 'task-1', + issueId: 'ISS-1', + issueProviderId: PROVIDER_ID, + issueLastUpdated: 1000, + issueLastSyncedValues: { + // eslint-disable-next-line @typescript-eslint/naming-convention + start_dateTime: '2026-03-19T10:00:00.000Z', + }, + } as unknown as Task; + + const result = await service.getFreshDataForIssueTask(task); + + expect(result).not.toBeNull(); + expect(result!.taskChanges['dueWithTime' as keyof Task]).toBeUndefined(); + }); + + it('should skip pull when fresh value equals last synced value', async () => { + const freshIssue: PluginIssue = { + id: 'ISS-1', + title: 'Unchanged', + lastUpdated: 2000, + }; + // eslint-disable-next-line @typescript-eslint/naming-convention + const syncValues = { start_dateTime: '2026-03-20T10:00:00.000Z' }; + const provider = createMockProvider({ + getById: jasmine.createSpy('getById').and.resolveTo(freshIssue), + fieldMappings: FIELD_MAPPINGS, + extractSyncValues: jasmine + .createSpy('extractSyncValues') + .and.returnValues({}, syncValues), + }); + registrySpy.getProvider.and.returnValue(provider); + + const cfgWithSync = { + ...mockPluginCfg, + pluginConfig: { + ...mockPluginConfig, + twoWaySync: { dueWithTime: 'both' }, + }, + } as IssueProviderPluginType; + storeSpy.select.and.returnValue(of(cfgWithSync)); + + const task = { + id: 'task-1', + issueId: 'ISS-1', + issueProviderId: PROVIDER_ID, + issueLastUpdated: 1000, + issueLastSyncedValues: { + // Same value as fresh -> no change detected + // eslint-disable-next-line @typescript-eslint/naming-convention + start_dateTime: '2026-03-20T10:00:00.000Z', + }, + } as unknown as Task; + + const result = await service.getFreshDataForIssueTask(task); + + expect(result).not.toBeNull(); + expect(result!.taskChanges['dueWithTime' as keyof Task]).toBeUndefined(); + }); + + it('should clear mutually exclusive fields when pulling dueWithTime', async () => { + const freshIssue: PluginIssue = { + id: 'ISS-1', + title: 'Event', + lastUpdated: 2000, + }; + const syncValues = { + // eslint-disable-next-line @typescript-eslint/naming-convention + start_dateTime: '2026-03-20T10:00:00.000Z', + summary: 'Event', + }; + const provider = createProviderWithMappings(freshIssue, syncValues); + registrySpy.getProvider.and.returnValue(provider); + + const cfgWithSync = { + ...mockPluginCfg, + pluginConfig: { + ...mockPluginConfig, + twoWaySync: { dueWithTime: 'both' }, + }, + } as IssueProviderPluginType; + storeSpy.select.and.returnValue(of(cfgWithSync)); + + const task = { + id: 'task-1', + issueId: 'ISS-1', + issueProviderId: PROVIDER_ID, + issueLastUpdated: 1000, + dueDay: '2026-03-19', + issueLastSyncedValues: { + // eslint-disable-next-line @typescript-eslint/naming-convention + start_dateTime: '2026-03-19T10:00:00.000Z', + }, + } as unknown as Task; + + const result = await service.getFreshDataForIssueTask(task); + + expect(result).not.toBeNull(); + // dueDay should be cleared because dueWithTime is mutually exclusive + expect(result!.taskChanges['dueDay' as keyof Task]).toBeNull(); + }); + + it('should include issueLastSyncedValues in taskChanges', async () => { + const freshIssue: PluginIssue = { + id: 'ISS-1', + title: 'Event', + lastUpdated: 2000, + }; + const syncValues = { + summary: 'Event', + // eslint-disable-next-line @typescript-eslint/naming-convention + start_dateTime: '2026-03-20T10:00:00.000Z', + }; + const provider = createProviderWithMappings(freshIssue, syncValues); + registrySpy.getProvider.and.returnValue(provider); + + const task = { + id: 'task-1', + issueId: 'ISS-1', + issueProviderId: PROVIDER_ID, + issueLastUpdated: 1000, + } as Task; + + const result = await service.getFreshDataForIssueTask(task); + + expect(result).not.toBeNull(); + expect(result!.taskChanges.issueLastSyncedValues).toEqual(syncValues); + }); + }); + + describe('remote deletion handling', () => { + it('should auto-delete local task when getById returns 404 and no time tracked', async () => { + const provider = createMockProvider({ + getById: jasmine + .createSpy('getById') + .and.rejectWith(new HttpErrorResponse({ status: 404 })), + }); + registrySpy.getProvider.and.returnValue(provider); + spyOn(console, 'log'); + + const task = { + id: 'task-1', + issueId: 'ISS-5', + issueProviderId: PROVIDER_ID, + timeSpent: 0, + } as unknown as Task; + + const result = await service.getFreshDataForIssueTask(task); + + expect(result).toBeNull(); + expect(taskServiceSpy.removeMultipleTasks).toHaveBeenCalledWith(['task-1']); + expect(snackSpy.open).toHaveBeenCalledWith( + jasmine.objectContaining({ + type: 'CUSTOM', + ico: 'delete_forever', + }), + ); + }); + + it('should auto-delete local task when getById returns 410 and no time tracked', async () => { + const provider = createMockProvider({ + getById: jasmine + .createSpy('getById') + .and.rejectWith(new HttpErrorResponse({ status: 410 })), + }); + registrySpy.getProvider.and.returnValue(provider); + spyOn(console, 'log'); + + const task = { + id: 'task-1', + issueId: 'ISS-5', + issueProviderId: PROVIDER_ID, + timeSpent: 0, + } as unknown as Task; + + const result = await service.getFreshDataForIssueTask(task); + + expect(result).toBeNull(); + expect(taskServiceSpy.removeMultipleTasks).toHaveBeenCalledWith(['task-1']); + expect(snackSpy.open).toHaveBeenCalledWith( + jasmine.objectContaining({ + type: 'CUSTOM', + ico: 'delete_forever', + }), + ); + }); + + it('should not auto-delete task with time tracking on 404 but offer action', async () => { + const provider = createMockProvider({ + getById: jasmine + .createSpy('getById') + .and.rejectWith(new HttpErrorResponse({ status: 404 })), + }); + registrySpy.getProvider.and.returnValue(provider); + spyOn(console, 'log'); + + const task = { + id: 'task-1', + issueId: 'ISS-5', + issueProviderId: PROVIDER_ID, + timeSpent: 3600000, + title: 'Tracked Task', + } as unknown as Task; + + const result = await service.getFreshDataForIssueTask(task); + + expect(result).toBeNull(); + expect(taskServiceSpy.removeMultipleTasks).not.toHaveBeenCalled(); + expect(snackSpy.open).toHaveBeenCalledWith( + jasmine.objectContaining({ + type: 'WARNING', + actionStr: T.G.DELETE, + }), + ); + }); + + it('should not delete task on non-404 errors', async () => { + const provider = createMockProvider({ + getById: jasmine + .createSpy('getById') + .and.rejectWith(new HttpErrorResponse({ status: 500 })), + }); + registrySpy.getProvider.and.returnValue(provider); + spyOn(console, 'error'); + + const task = { + id: 'task-1', + issueId: 'ISS-5', + issueProviderId: PROVIDER_ID, + } as Task; + + const result = await service.getFreshDataForIssueTask(task); + + expect(result).toBeNull(); + expect(taskServiceSpy.removeMultipleTasks).not.toHaveBeenCalled(); + }); + + it('should auto-delete task when issue state matches deletedStates (no time tracked)', async () => { + const provider = createMockProvider({ + getById: jasmine.createSpy('getById').and.resolveTo({ + id: 'ISS-5', + title: 'Cancelled Event', + state: 'cancelled', + lastUpdated: 2000, + } as PluginIssue), + deletedStates: ['cancelled'], + }); + registrySpy.getProvider.and.returnValue(provider); + + const task = { + id: 'task-1', + issueId: 'ISS-5', + issueProviderId: PROVIDER_ID, + timeSpent: 0, + } as unknown as Task; + + const result = await service.getFreshDataForIssueTask(task); + + expect(result).toBeNull(); + expect(taskServiceSpy.removeMultipleTasks).toHaveBeenCalledWith(['task-1']); + expect(snackSpy.open).toHaveBeenCalledWith( + jasmine.objectContaining({ + type: 'CUSTOM', + ico: 'delete_forever', + }), + ); + }); + + it('should offer delete action when issue state matches deletedStates with time tracked', async () => { + const provider = createMockProvider({ + getById: jasmine.createSpy('getById').and.resolveTo({ + id: 'ISS-5', + title: 'Cancelled Event', + state: 'cancelled', + lastUpdated: 2000, + } as PluginIssue), + deletedStates: ['cancelled'], + }); + registrySpy.getProvider.and.returnValue(provider); + + const task = { + id: 'task-1', + issueId: 'ISS-5', + issueProviderId: PROVIDER_ID, + timeSpent: 3600000, + title: 'Tracked Task', + } as unknown as Task; + + const result = await service.getFreshDataForIssueTask(task); + + expect(result).toBeNull(); + expect(taskServiceSpy.removeMultipleTasks).not.toHaveBeenCalled(); + expect(snackSpy.open).toHaveBeenCalledWith( + jasmine.objectContaining({ + type: 'WARNING', + actionStr: T.G.DELETE, + }), + ); + }); + + it('should NOT treat state as deleted when deletedStates is not set on provider', async () => { + const provider = createMockProvider({ + getById: jasmine.createSpy('getById').and.resolveTo({ + id: 'ISS-5', + title: 'Cancelled Event', + state: 'cancelled', + lastUpdated: 2000, + } as PluginIssue), + }); + registrySpy.getProvider.and.returnValue(provider); + + const task = { + id: 'task-1', + issueId: 'ISS-5', + issueProviderId: PROVIDER_ID, + issueLastUpdated: 1000, + timeSpent: 0, + } as unknown as Task; + + const result = await service.getFreshDataForIssueTask(task); + + expect(result).not.toBeNull(); + expect(taskServiceSpy.removeMultipleTasks).not.toHaveBeenCalled(); + }); + + it('should match deletedStates case-insensitively and auto-delete (no time tracked)', async () => { + const provider = createMockProvider({ + getById: jasmine.createSpy('getById').and.resolveTo({ + id: 'ISS-5', + title: 'Cancelled Event', + state: 'CANCELLED', + lastUpdated: 2000, + } as PluginIssue), + deletedStates: ['cancelled'], + }); + registrySpy.getProvider.and.returnValue(provider); + + const task = { + id: 'task-1', + issueId: 'ISS-5', + issueProviderId: PROVIDER_ID, + timeSpent: 0, + } as unknown as Task; + + const result = await service.getFreshDataForIssueTask(task); + + expect(result).toBeNull(); + expect(taskServiceSpy.removeMultipleTasks).toHaveBeenCalledWith(['task-1']); + expect(snackSpy.open).toHaveBeenCalledWith( + jasmine.objectContaining({ + type: 'CUSTOM', + ico: 'delete_forever', + }), + ); + }); + + it('should not delete task on non-HttpErrorResponse errors', async () => { + const provider = createMockProvider({ + getById: jasmine + .createSpy('getById') + .and.rejectWith(new Error('Network error')), + }); + registrySpy.getProvider.and.returnValue(provider); + spyOn(console, 'error'); + + const task = { + id: 'task-1', + issueId: 'ISS-5', + issueProviderId: PROVIDER_ID, + } as Task; + + const result = await service.getFreshDataForIssueTask(task); + + expect(result).toBeNull(); + expect(taskServiceSpy.removeMultipleTasks).not.toHaveBeenCalled(); + }); + }); }); describe('getFreshDataForIssueTasks', () => { diff --git a/src/app/plugins/issue-provider/plugin-issue-provider-adapter.service.ts b/src/app/plugins/issue-provider/plugin-issue-provider-adapter.service.ts index b12677e367..4a3d57e7b9 100644 --- a/src/app/plugins/issue-provider/plugin-issue-provider-adapter.service.ts +++ b/src/app/plugins/issue-provider/plugin-issue-provider-adapter.service.ts @@ -1,5 +1,6 @@ import { inject, Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; +import { HttpErrorResponse } from '@angular/common/http'; import { IssueData, IssueDataReduced, @@ -16,6 +17,9 @@ import { PluginHttp, RegisteredPluginIssueProvider } from './plugin-issue-provid import { selectIssueProviderById } from '../../features/issue/store/issue-provider.selectors'; import { firstValueFrom } from 'rxjs'; import { SnackService } from '../../core/snack/snack.service'; +import { TaskService } from '../../features/tasks/task.service'; +import { getDbDateStr } from '../../util/get-db-date-str'; +import { T } from '../../t.const'; @Injectable({ providedIn: 'root' }) export class PluginIssueProviderAdapterService implements IssueServiceInterface { @@ -23,6 +27,7 @@ export class PluginIssueProviderAdapterService implements IssueServiceInterface private _pluginHttp = inject(PluginHttpService); private _store = inject(Store); private _snackService = inject(SnackService); + private _taskService = inject(TaskService); // Not meaningful for a multi-plugin adapter, but required by interface pollInterval = 0; @@ -100,19 +105,7 @@ export class PluginIssueProviderAdapterService implements IssueServiceInterface } getAddTaskData(issueData: IssueDataReduced): IssueTask { - const data = issueData as PluginIssue; - const isDone = this._computeIsDone(data); - - return { - title: ((data as Record)['summary'] as string) || data.title, - issueId: data.id, - issueWasUpdated: false, - issueLastUpdated: data.lastUpdated ?? Date.now(), - issueAttachmentNr: 0, - issuePoints: undefined, - issueTimeTracked: undefined, - isDone, - }; + return this._buildBaseIssueTask(issueData as PluginIssue); } async searchIssues( @@ -176,15 +169,41 @@ export class PluginIssueProviderAdapterService implements IssueServiceInterface if (!issue) { return null; } + + // Check if the issue state indicates remote deletion + const deletedStates = resolved.provider.definition.deletedStates; + if (deletedStates?.length && issue.state) { + const stateLower = issue.state.toLowerCase(); + if (deletedStates.some((s) => s.toLowerCase() === stateLower)) { + this._handleRemoteDeletion(task); + return null; + } + } + const isUpdated = issue.lastUpdated != null && issue.lastUpdated > (task.issueLastUpdated || 0); if (isUpdated) { - const addTaskData = this.getAddTaskData(issue); + // Compute sync values once and pass through to avoid redundant calls const issueLastSyncedValues = resolved.provider.definition.extractSyncValues?.(issue); + const addTaskData = this._getAddTaskDataForProvider( + issue, + resolved.provider, + issueLastSyncedValues ?? {}, + ); + + // Apply field mappings to pull changes from issue to task + const fieldChanges = this._applyFieldMappingPull( + resolved.provider, + issueLastSyncedValues ?? {}, + task, + cfg, + ); + return { taskChanges: { ...addTaskData, + ...fieldChanges, issueWasUpdated: true, ...(issueLastSyncedValues ? { issueLastSyncedValues } : {}), }, @@ -194,6 +213,11 @@ export class PluginIssueProviderAdapterService implements IssueServiceInterface } return null; } catch (e) { + // Detect 404 = issue deleted remotely + if (e instanceof HttpErrorResponse && (e.status === 404 || e.status === 410)) { + this._handleRemoteDeletion(task); + return null; + } console.error( `[PluginIssueAdapter] getFreshDataForIssueTask failed for ${cfg.issueProviderKey}:`, e, @@ -294,6 +318,73 @@ export class PluginIssueProviderAdapterService implements IssueServiceInterface } } + private _extractTaskFieldsFromIssueWithSyncValues( + issue: PluginIssue, + provider: RegisteredPluginIssueProvider, + syncValues: Record, + ): Record { + const issueRecord = issue as Record; + const result: Record = {}; + const ctx = { issueId: issue.id }; + + const mappings = provider.definition.fieldMappings; + if (!mappings?.length) { + return {}; + } + for (const mapping of mappings) { + const issueValue = + syncValues[mapping.issueField] ?? issueRecord[mapping.issueField]; + if (issueValue == null) { + continue; + } + const taskValue = mapping.toTaskValue(issueValue, ctx); + if (taskValue != null) { + result[mapping.taskField] = taskValue; + } + } + return result; + } + + private _getAddTaskDataForProvider( + issueData: IssueDataReduced, + provider: RegisteredPluginIssueProvider, + syncValues: Record, + ): IssueTask { + const data = issueData as PluginIssue; + const base = this._buildBaseIssueTask(data); + const fieldValues = this._extractTaskFieldsFromIssueWithSyncValues( + data, + provider, + syncValues, + ); + return { ...base, ...fieldValues } as IssueTask; + } + + private _buildBaseIssueTask(data: PluginIssue): IssueTask { + const isDone = this._computeIsDone(data); + const raw = data as Record; + const dueWithTime = + typeof raw['dueWithTime'] === 'number' ? (raw['dueWithTime'] as number) : undefined; + const startMs = + typeof raw['start'] === 'number' ? (raw['start'] as number) : undefined; + + return { + title: data.title, + issueId: data.id, + issueWasUpdated: false, + issueLastUpdated: data.lastUpdated ?? 0, + issueAttachmentNr: 0, + issuePoints: undefined, + issueTimeTracked: undefined, + isDone, + ...(dueWithTime != null + ? { dueWithTime } + : startMs != null + ? { dueDay: getDbDateStr(startMs) } + : {}), + }; + } + private _computeIsDone(issue: PluginIssue): boolean { const state = issue.state?.toLowerCase(); if (!state) { @@ -301,4 +392,71 @@ export class PluginIssueProviderAdapterService implements IssueServiceInterface } return ['closed', 'done', 'completed', 'resolved'].includes(state); } + + private _handleRemoteDeletion(task: Task): void { + const hasTimeTracking = task.timeSpent > 0; + if (hasTimeTracking) { + this._snackService.open({ + type: 'WARNING', + msg: T.F.ISSUE.S.REMOTE_ISSUE_DELETED_WITH_TIME, + translateParams: { taskTitle: task.title }, + ico: 'delete_forever', + actionStr: T.G.DELETE, + actionFn: () => this._taskService.removeMultipleTasks([task.id]), + }); + } else { + this._taskService.removeMultipleTasks([task.id]); + this._snackService.open({ + type: 'CUSTOM', + msg: T.F.ISSUE.S.REMOTE_ISSUE_DELETED, + translateParams: { taskTitle: task.title }, + ico: 'delete_forever', + }); + } + } + + private _applyFieldMappingPull( + provider: RegisteredPluginIssueProvider, + freshSyncValues: Record, + task: Task, + cfg: IssueProviderPluginType, + ): Partial { + const fieldMappings = provider.definition.fieldMappings; + if (!fieldMappings?.length) { + return {}; + } + + const twoWaySync = (cfg.pluginConfig?.['twoWaySync'] as Record) ?? {}; + const lastSyncedValues = task.issueLastSyncedValues ?? {}; + const ctx = { issueId: task.issueId! }; + const changes: Record = {}; + + for (const mapping of fieldMappings) { + const dir = twoWaySync[mapping.taskField] ?? mapping.defaultDirection; + if (dir !== 'pullOnly' && dir !== 'both') { + continue; + } + + const freshValue = freshSyncValues[mapping.issueField]; + const lastValue = lastSyncedValues[mapping.issueField]; + + // Only pull if the issue value actually changed since last sync + if (freshValue === lastValue) { + continue; + } + + const taskValue = mapping.toTaskValue(freshValue, ctx); + if (taskValue !== undefined) { + changes[mapping.taskField] = taskValue; + // Clear mutually exclusive fields (use null to explicitly unset) + if (mapping.mutuallyExclusive) { + for (const field of mapping.mutuallyExclusive) { + changes[field] = null; + } + } + } + } + + return changes as Partial; + } } diff --git a/src/app/plugins/issue-provider/plugin-issue-provider-registry.service.spec.ts b/src/app/plugins/issue-provider/plugin-issue-provider-registry.service.spec.ts index 8a46e66053..4bac748e93 100644 --- a/src/app/plugins/issue-provider/plugin-issue-provider-registry.service.spec.ts +++ b/src/app/plugins/issue-provider/plugin-issue-provider-registry.service.spec.ts @@ -20,6 +20,17 @@ const createMockDefinition = ( ...overrides, }); +const registerProvider = ( + svc: PluginIssueProviderRegistryService, + pluginId: string, + definition: IssueProviderPluginDefinition, + name: string, + icon: string, + pollIntervalMs: number, + issueStrings: { singular: string; plural: string }, +): void => + svc.register({ pluginId, definition, name, icon, pollIntervalMs, issueStrings }); + describe('PluginIssueProviderRegistryService', () => { let service: PluginIssueProviderRegistryService; @@ -34,7 +45,7 @@ describe('PluginIssueProviderRegistryService', () => { it('should register a provider and store it under plugin:', () => { const definition = createMockDefinition(); - service.register('my-plugin', definition, 'My Plugin', 'bug_report', 5000, { + registerProvider(service,'my-plugin', definition, 'My Plugin', 'bug_report', 5000, { singular: 'Bug', plural: 'Bugs', }); @@ -46,11 +57,11 @@ describe('PluginIssueProviderRegistryService', () => { const definition = createMockDefinition(); spyOn(console, 'warn'); - service.register('dup', definition, 'First', 'icon1', 1000, { + registerProvider(service,'dup', definition, 'First', 'icon1', 1000, { singular: 'A', plural: 'As', }); - service.register('dup', definition, 'Second', 'icon2', 2000, { + registerProvider(service,'dup', definition, 'Second', 'icon2', 2000, { singular: 'B', plural: 'Bs', }); @@ -68,7 +79,7 @@ describe('PluginIssueProviderRegistryService', () => { it('should return the registered provider by key', () => { const definition = createMockDefinition(); - service.register('provider-a', definition, 'Provider A', 'star', 3000, { + registerProvider(service,'provider-a', definition, 'Provider A', 'star', 3000, { singular: 'Ticket', plural: 'Tickets', }); @@ -96,7 +107,7 @@ describe('PluginIssueProviderRegistryService', () => { it('should remove a previously registered provider', () => { const definition = createMockDefinition(); - service.register('to-remove', definition, 'Remove Me', 'delete', 1000, { + registerProvider(service,'to-remove', definition, 'Remove Me', 'delete', 1000, { singular: 'X', plural: 'Xs', }); @@ -116,7 +127,7 @@ describe('PluginIssueProviderRegistryService', () => { describe('hasProvider', () => { it('should return true for a registered provider', () => { - service.register('exists', createMockDefinition(), 'E', 'e', 0, { + registerProvider(service,'exists', createMockDefinition(), 'E', 'e', 0, { singular: 'a', plural: 'as', }); @@ -131,11 +142,11 @@ describe('PluginIssueProviderRegistryService', () => { describe('getAvailableProviders', () => { it('should return all registered providers', () => { - service.register('p1', createMockDefinition(), 'P1', 'i1', 100, { + registerProvider(service,'p1', createMockDefinition(), 'P1', 'i1', 100, { singular: 'a', plural: 'as', }); - service.register('p2', createMockDefinition(), 'P2', 'i2', 200, { + registerProvider(service,'p2', createMockDefinition(), 'P2', 'i2', 200, { singular: 'b', plural: 'bs', }); @@ -155,7 +166,7 @@ describe('PluginIssueProviderRegistryService', () => { describe('getIcon', () => { it('should return the icon for a registered provider', () => { - service.register('icon-test', createMockDefinition(), 'N', 'custom_icon', 0, { + registerProvider(service,'icon-test', createMockDefinition(), 'N', 'custom_icon', 0, { singular: 'a', plural: 'as', }); @@ -170,7 +181,7 @@ describe('PluginIssueProviderRegistryService', () => { describe('getName', () => { it('should return the name for a registered provider', () => { - service.register('name-test', createMockDefinition(), 'My Provider', 'i', 0, { + registerProvider(service,'name-test', createMockDefinition(), 'My Provider', 'i', 0, { singular: 'a', plural: 'as', }); @@ -185,7 +196,7 @@ describe('PluginIssueProviderRegistryService', () => { describe('getIssueStrings', () => { it('should return mapped issue strings for a registered provider', () => { - service.register('str-test', createMockDefinition(), 'N', 'i', 0, { + registerProvider(service,'str-test', createMockDefinition(), 'N', 'i', 0, { singular: 'Task', plural: 'Tasks', }); @@ -204,7 +215,7 @@ describe('PluginIssueProviderRegistryService', () => { describe('getPollIntervalMs', () => { it('should return the poll interval for a registered provider', () => { - service.register('poll-test', createMockDefinition(), 'N', 'i', 7500, { + registerProvider(service,'poll-test', createMockDefinition(), 'N', 'i', 7500, { singular: 'a', plural: 'as', }); @@ -225,7 +236,7 @@ describe('PluginIssueProviderRegistryService', () => { ]; const definition = createMockDefinition({ issueDisplay }); - service.register('display-test', definition, 'N', 'i', 0, { + registerProvider(service,'display-test', definition, 'N', 'i', 0, { singular: 'a', plural: 'as', }); @@ -246,7 +257,7 @@ describe('PluginIssueProviderRegistryService', () => { ]; const definition = createMockDefinition({ configFields }); - service.register('config-test', definition, 'N', 'i', 0, { + registerProvider(service,'config-test', definition, 'N', 'i', 0, { singular: 'a', plural: 'as', }); @@ -269,7 +280,7 @@ describe('PluginIssueProviderRegistryService', () => { }; const definition = createMockDefinition({ commentsConfig }); - service.register('comments-test', definition, 'N', 'i', 0, { + registerProvider(service,'comments-test', definition, 'N', 'i', 0, { singular: 'a', plural: 'as', }); @@ -278,7 +289,7 @@ describe('PluginIssueProviderRegistryService', () => { }); it('should return undefined when no commentsConfig is set', () => { - service.register('no-comments', createMockDefinition(), 'N', 'i', 0, { + registerProvider(service,'no-comments', createMockDefinition(), 'N', 'i', 0, { singular: 'a', plural: 'as', }); @@ -304,7 +315,7 @@ describe('PluginIssueProviderRegistryService', () => { ]; const definition = createMockDefinition({ fieldMappings }); - service.register('mappings-test', definition, 'N', 'i', 0, { + registerProvider(service,'mappings-test', definition, 'N', 'i', 0, { singular: 'a', plural: 'as', }); @@ -318,7 +329,7 @@ describe('PluginIssueProviderRegistryService', () => { }); it('should return undefined when no fieldMappings are set', () => { - service.register('no-mappings', createMockDefinition(), 'N', 'i', 0, { + registerProvider(service,'no-mappings', createMockDefinition(), 'N', 'i', 0, { singular: 'a', plural: 'as', }); diff --git a/src/app/plugins/issue-provider/plugin-issue-provider-registry.service.ts b/src/app/plugins/issue-provider/plugin-issue-provider-registry.service.ts index f4d320201a..598873fc02 100644 --- a/src/app/plugins/issue-provider/plugin-issue-provider-registry.service.ts +++ b/src/app/plugins/issue-provider/plugin-issue-provider-registry.service.ts @@ -15,16 +15,18 @@ export class PluginIssueProviderRegistryService { /** Maps pluginId → registeredKey for cleanup */ private _pluginIdToKey = new Map(); - register( - pluginId: string, - definition: IssueProviderPluginDefinition, - name: string, - icon: string, - pollIntervalMs: number, - issueStrings: { singular: string; plural: string }, - issueProviderKey?: string, - ): void { - const key = issueProviderKey ?? `plugin:${pluginId}`; + register(opts: { + pluginId: string; + definition: IssueProviderPluginDefinition; + name: string; + icon: string; + pollIntervalMs: number; + issueStrings: { singular: string; plural: string }; + issueProviderKey?: string; + useAgendaView?: boolean; + defaultAutoAddToBacklog?: boolean; + }): void { + const key = opts.issueProviderKey ?? `plugin:${opts.pluginId}`; if (this._providers.has(key)) { console.warn( `[PluginIssueProviderRegistry] Duplicate registration for '${key}', ignoring.`, @@ -32,15 +34,17 @@ export class PluginIssueProviderRegistryService { return; } this._providers.set(key, { - pluginId, + pluginId: opts.pluginId, registeredKey: key as IssueProviderKey, - definition, - name, - icon, - pollIntervalMs, - issueStrings, + definition: opts.definition, + name: opts.name, + icon: opts.icon, + pollIntervalMs: opts.pollIntervalMs, + issueStrings: opts.issueStrings, + useAgendaView: opts.useAgendaView, + defaultAutoAddToBacklog: opts.defaultAutoAddToBacklog, }); - this._pluginIdToKey.set(pluginId, key); + this._pluginIdToKey.set(opts.pluginId, key); } unregister(pluginId: string): void { @@ -106,4 +110,8 @@ export class PluginIssueProviderRegistryService { getFieldMappings(key: string): PluginFieldMapping[] | undefined { return this._providers.get(key)?.definition.fieldMappings; } + + getUseAgendaView(key: string): boolean { + return this._providers.get(key)?.useAgendaView ?? false; + } } diff --git a/src/app/plugins/issue-provider/plugin-issue-provider.model.ts b/src/app/plugins/issue-provider/plugin-issue-provider.model.ts index 1e2bc47ecf..519d19bd14 100644 --- a/src/app/plugins/issue-provider/plugin-issue-provider.model.ts +++ b/src/app/plugins/issue-provider/plugin-issue-provider.model.ts @@ -28,4 +28,6 @@ export interface RegisteredPluginIssueProvider { icon: string; pollIntervalMs: number; issueStrings: { singular: string; plural: string }; + useAgendaView?: boolean; + defaultAutoAddToBacklog?: boolean; } diff --git a/src/app/plugins/issue-provider/plugin-sync-adapter.service.spec.ts b/src/app/plugins/issue-provider/plugin-sync-adapter.service.spec.ts index 73abb0bb08..2393ddae71 100644 --- a/src/app/plugins/issue-provider/plugin-sync-adapter.service.spec.ts +++ b/src/app/plugins/issue-provider/plugin-sync-adapter.service.spec.ts @@ -204,4 +204,59 @@ describe('createPluginSyncAdapter', () => { expect(isDoneMapping.toTaskValue('closed', { issueId: '1' })).toBeTrue(); expect(isDoneMapping.toTaskValue('open', { issueId: '1' })).toBeFalse(); }); + + it('should forward mutuallyExclusive from plugin mapping', () => { + const mappingsWithExclusive: PluginFieldMapping[] = [ + ...MOCK_FIELD_MAPPINGS, + { + taskField: 'dueDay', + issueField: 'start_date', + defaultDirection: 'both', + mutuallyExclusive: ['dueWithTime'], + toIssueValue: (v: unknown) => v, + toTaskValue: (v: unknown) => v, + }, + ]; + const adapter = createPluginSyncAdapter( + createMockDefinition({ fieldMappings: mappingsWithExclusive }), + () => mockHttpHelper, + ); + + const mappings = adapter.getFieldMappings(); + const dueDayMapping = mappings.find((m) => m.taskField === 'dueDay'); + + expect(dueDayMapping).toBeDefined(); + expect(dueDayMapping!.mutuallyExclusive).toEqual(['dueWithTime']); + }); + + it('should not set mutuallyExclusive when not provided in plugin mapping', () => { + const adapter = createPluginSyncAdapter(createMockDefinition(), () => mockHttpHelper); + + const mappings = adapter.getFieldMappings(); + + expect(mappings[0].mutuallyExclusive).toBeUndefined(); + }); + + it('should delete issue via definition.deleteIssue', async () => { + const deleteIssueSpy = jasmine + .createSpy('deleteIssue') + .and.returnValue(Promise.resolve()); + const definition = createMockDefinition({ deleteIssue: deleteIssueSpy }); + const adapter = createPluginSyncAdapter(definition, () => mockHttpHelper); + + await adapter.deleteIssue!('99', MOCK_CFG); + + expect(deleteIssueSpy).toHaveBeenCalledWith( + '99', + MOCK_CFG.pluginConfig, + jasmine.anything(), + ); + }); + + it('should set deleteIssue to undefined when definition does not implement it', () => { + const definition = createMockDefinition({ deleteIssue: undefined }); + const adapter = createPluginSyncAdapter(definition, () => mockHttpHelper); + + expect(adapter.deleteIssue).toBeUndefined(); + }); }); diff --git a/src/app/plugins/issue-provider/plugin-sync-adapter.service.ts b/src/app/plugins/issue-provider/plugin-sync-adapter.service.ts index 76aeb4ddd7..bce91f7904 100644 --- a/src/app/plugins/issue-provider/plugin-sync-adapter.service.ts +++ b/src/app/plugins/issue-provider/plugin-sync-adapter.service.ts @@ -9,6 +9,7 @@ import { PluginFieldMapping, PluginHttp, } from './plugin-issue-provider.model'; +import { Task } from '../../features/tasks/task.model'; const convertMapping = (pm: PluginFieldMapping): FieldMapping => ({ taskField: pm.taskField, @@ -16,6 +17,9 @@ const convertMapping = (pm: PluginFieldMapping): FieldMapping => ({ defaultDirection: pm.defaultDirection, toIssueValue: pm.toIssueValue, toTaskValue: pm.toTaskValue, + ...(pm.mutuallyExclusive + ? { mutuallyExclusive: pm.mutuallyExclusive as (keyof Task)[] } + : {}), }); /** @@ -97,7 +101,7 @@ export const createPluginSyncAdapter = ( cfg: IssueProviderPluginType, ): Promise<{ issueId: string; - issueNumber: number; + issueNumber?: number; issueData: Record; }> => { if (!definition.createIssue) { @@ -111,5 +115,17 @@ export const createPluginSyncAdapter = ( issueData: result.issueData as Record, }; }, + + getIssueLastUpdated: (issue: Record): number => { + const lastUpdated = (issue as { lastUpdated?: number }).lastUpdated; + return lastUpdated ?? Date.now(); + }, + + deleteIssue: definition.deleteIssue + ? async (issueId: string, cfg: IssueProviderPluginType): Promise => { + const http = createHttp(cfg); + await definition.deleteIssue!(issueId, cfg.pluginConfig, http); + } + : undefined, }; }; diff --git a/src/app/plugins/oauth/pkce.util.spec.ts b/src/app/plugins/oauth/pkce.util.spec.ts new file mode 100644 index 0000000000..8b7ec73b4a --- /dev/null +++ b/src/app/plugins/oauth/pkce.util.spec.ts @@ -0,0 +1,38 @@ +import { generateCodeVerifier, generateCodeChallenge } from './pkce.util'; + +describe('PKCE utilities', () => { + describe('generateCodeVerifier', () => { + it('should return a string of 43-128 characters', () => { + const verifier = generateCodeVerifier(); + expect(verifier.length).toBeGreaterThanOrEqual(43); + expect(verifier.length).toBeLessThanOrEqual(128); + }); + + it('should only contain URL-safe characters', () => { + const verifier = generateCodeVerifier(); + expect(verifier).toMatch(/^[A-Za-z0-9\-._~]+$/); + }); + + it('should generate unique values', () => { + const v1 = generateCodeVerifier(); + const v2 = generateCodeVerifier(); + expect(v1).not.toEqual(v2); + }); + }); + + describe('generateCodeChallenge', () => { + it('should return a base64url-encoded SHA-256 hash', async () => { + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + // base64url: A-Z, a-z, 0-9, -, _ (no = padding) + expect(challenge).toMatch(/^[A-Za-z0-9\-_]+$/); + }); + + it('should produce a consistent hash for the same input', async () => { + const verifier = 'test-verifier-string-for-pkce'; + const c1 = await generateCodeChallenge(verifier); + const c2 = await generateCodeChallenge(verifier); + expect(c1).toEqual(c2); + }); + }); +}); diff --git a/src/app/plugins/oauth/pkce.util.ts b/src/app/plugins/oauth/pkce.util.ts new file mode 100644 index 0000000000..e004b32258 --- /dev/null +++ b/src/app/plugins/oauth/pkce.util.ts @@ -0,0 +1 @@ +export { generateCodeVerifier, generateCodeChallenge } from '../../util/pkce.util'; diff --git a/src/app/plugins/oauth/plugin-oauth-bridge.service.ts b/src/app/plugins/oauth/plugin-oauth-bridge.service.ts new file mode 100644 index 0000000000..a68060a708 --- /dev/null +++ b/src/app/plugins/oauth/plugin-oauth-bridge.service.ts @@ -0,0 +1,132 @@ +import { inject, Injectable } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { OAuthFlowConfig, OAuthTokenResult } from '@super-productivity/plugin-api'; +import { PluginOAuthService } from './plugin-oauth.service'; +import { + saveOAuthTokens, + loadOAuthTokens, + deleteOAuthTokens, +} from './plugin-oauth-token-store'; +import { IS_ELECTRON } from '../../app.constants'; +import { PluginLog } from '../../core/log'; + +/** + * Bridges OAuth operations between plugin API surface and the underlying + * PluginOAuthService + persistence layer. Extracted from PluginBridgeService + * to keep that class focused. + * + * OAuth tokens are stored in a local-only IndexedDB database (NOT synced + * via op-log). Each device authenticates independently. + */ +@Injectable({ providedIn: 'root' }) +export class PluginOAuthBridgeService { + private _pluginOAuthService = inject(PluginOAuthService); + + constructor() { + // Clear persisted OAuth tokens when a refresh fails + this._pluginOAuthService.tokenInvalidated$ + .pipe(takeUntilDestroyed()) + .subscribe((pluginId) => { + this._clearPersistedOAuthTokens(pluginId); + }); + } + + async startOAuthFlow( + pluginId: string, + config: OAuthFlowConfig, + ): Promise { + const redirectUri = this._pluginOAuthService.getRedirectUri(); + const { url, codeVerifier, state } = await this._pluginOAuthService.buildAuthUrl( + config, + redirectUri, + ); + + this._openOAuthWindow(url); + + const code = await this._pluginOAuthService.waitForRedirectCode(pluginId, state); + + const tokens = await this._pluginOAuthService.exchangeCodeForTokens({ + tokenUrl: config.tokenUrl, + clientId: config.clientId, + code, + codeVerifier, + redirectUri, + clientSecret: config.clientSecret, + }); + + this._pluginOAuthService.storeTokens(pluginId, { + ...tokens, + tokenUrl: config.tokenUrl, + clientId: config.clientId, + clientSecret: config.clientSecret, + }); + + await this._persistOAuthTokens(pluginId); + + return tokens; + } + + async clearOAuthTokens(pluginId: string): Promise { + this._pluginOAuthService.clearTokens(pluginId); + await this._clearPersistedOAuthTokens(pluginId); + } + + async restoreAndCheckOAuthTokens(pluginId: string): Promise { + if (!this._pluginOAuthService.hasTokens(pluginId)) { + await this._restoreOAuthTokens(pluginId); + } + return this._pluginOAuthService.hasTokens(pluginId); + } + + async getOAuthToken(pluginId: string): Promise { + if (!this._pluginOAuthService.hasTokens(pluginId)) { + await this._restoreOAuthTokens(pluginId); + } + return this._pluginOAuthService.getValidToken(pluginId); + } + + private _openOAuthWindow(url: string): void { + if (IS_ELECTRON) { + window.ea.pluginOAuthStart(url); + } else { + const popup = window.open(url, '_blank', 'width=600,height=700'); + if (!popup) { + throw new Error('Popup blocked. Please allow popups for this site.'); + } + } + } + + private _oauthPersistenceKey(pluginId: string): string { + return `${pluginId}__oauth`; + } + + private async _persistOAuthTokens(pluginId: string): Promise { + const serialized = this._pluginOAuthService.serializeTokens(pluginId); + if (serialized) { + try { + await saveOAuthTokens(this._oauthPersistenceKey(pluginId), serialized); + } catch (error) { + PluginLog.err('PluginOAuthBridge: Failed to persist OAuth tokens:', error); + } + } + } + + private async _restoreOAuthTokens(pluginId: string): Promise { + try { + const serialized = await loadOAuthTokens(this._oauthPersistenceKey(pluginId)); + if (serialized) { + this._pluginOAuthService.restoreTokens(pluginId, serialized); + } + } catch (error) { + PluginLog.err('PluginOAuthBridge: Failed to restore OAuth tokens:', error); + } + } + + private async _clearPersistedOAuthTokens(pluginId: string): Promise { + try { + await deleteOAuthTokens(this._oauthPersistenceKey(pluginId)); + } catch (error) { + PluginLog.err('PluginOAuthBridge: Failed to clear persisted OAuth tokens:', error); + } + } +} diff --git a/src/app/plugins/oauth/plugin-oauth-redirect.handler.ts b/src/app/plugins/oauth/plugin-oauth-redirect.handler.ts new file mode 100644 index 0000000000..3fb158ae20 --- /dev/null +++ b/src/app/plugins/oauth/plugin-oauth-redirect.handler.ts @@ -0,0 +1,65 @@ +import { Injectable, OnDestroy, inject } from '@angular/core'; +import { PluginOAuthService } from './plugin-oauth.service'; +import { IS_ELECTRON } from '../../app.constants'; +import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view'; + +/** + * Bridges platform-specific OAuth redirect callbacks to PluginOAuthService. + * + * - Web: listens for `postMessage` from the OAuth callback popup. + * - Electron: listens for IPC via `window.ea.onPluginOAuthCb`. + * + * Must be instantiated at boot (via APP_INITIALIZER) so the listeners + * are registered before any OAuth flow starts. + */ +@Injectable({ providedIn: 'root' }) +export class PluginOAuthRedirectHandler implements OnDestroy { + private _oauthService = inject(PluginOAuthService); + private _messageListener?: (event: MessageEvent) => void; + + constructor() { + if (IS_ELECTRON) { + this._setupElectronListener(); + } else if (!IS_ANDROID_WEB_VIEW) { + // Android/iOS OAuth redirects are handled by OAuthCallbackHandlerService + // via Capacitor's appUrlOpen listener, not by this handler. + this._setupWebListener(); + } + } + + ngOnDestroy(): void { + if (this._messageListener) { + window.removeEventListener('message', this._messageListener); + } + } + + private _setupWebListener(): void { + this._messageListener = (event: MessageEvent): void => { + if (event.origin !== window.location.origin) { + return; + } + if (event.data?.type !== 'SP_OAUTH_CALLBACK') { + return; + } + if (event.data.code) { + this._oauthService.handleRedirectCode(event.data.code, event.data.state); + } else if (event.data.error) { + this._oauthService.handleRedirectError(event.data.error, event.data.state); + } + }; + window.addEventListener('message', this._messageListener); + } + + private _setupElectronListener(): void { + // Note: Electron IPC listener is never removed because this service lives + // for the app lifetime (providedIn: 'root', bootstrapped via APP_INITIALIZER). + // The preload API does not expose a removeListener method. + window.ea.onPluginOAuthCb((data) => { + if (data.code) { + this._oauthService.handleRedirectCode(data.code, data.state); + } else if (data.error) { + this._oauthService.handleRedirectError(data.error, data.state); + } + }); + } +} diff --git a/src/app/plugins/oauth/plugin-oauth-token-store.ts b/src/app/plugins/oauth/plugin-oauth-token-store.ts new file mode 100644 index 0000000000..3e4436b7a2 --- /dev/null +++ b/src/app/plugins/oauth/plugin-oauth-token-store.ts @@ -0,0 +1,73 @@ +import { DBSchema, IDBPDatabase, openDB } from 'idb'; +import { PluginLog } from '../../core/log'; + +/** + * Local-only IndexedDB store for plugin OAuth tokens. + * + * Tokens are stored in a dedicated database ('sup-plugin-oauth') that is + * NOT part of the op-log sync system. Each device authenticates independently. + */ + +const DB_NAME = 'sup-plugin-oauth'; +const DB_STORE_NAME = 'tokens'; +const DB_VERSION = 1; + +interface PluginOAuthDb extends DBSchema { + [DB_STORE_NAME]: { + key: string; + value: string; + }; +} + +let db: IDBPDatabase | undefined; +let initPromise: Promise> | undefined; + +const ensureDb = async (): Promise> => { + if (db) { + return db; + } + if (!initPromise) { + initPromise = openDB(DB_NAME, DB_VERSION, { + upgrade: (database) => { + if (!database.objectStoreNames.contains(DB_STORE_NAME)) { + database.createObjectStore(DB_STORE_NAME); + } + }, + }).then((opened) => { + db = opened; + return opened; + }); + } + return initPromise; +}; + +export const saveOAuthTokens = async (key: string, data: string): Promise => { + try { + const store = await ensureDb(); + await store.put(DB_STORE_NAME, data, key); + } catch (error) { + PluginLog.err('PluginOAuthTokenStore: Failed to save tokens:', error); + throw error; + } +}; + +export const loadOAuthTokens = async (key: string): Promise => { + try { + const store = await ensureDb(); + const result = await store.get(DB_STORE_NAME, key); + return result ?? null; + } catch (error) { + PluginLog.err('PluginOAuthTokenStore: Failed to load tokens:', error); + throw error; + } +}; + +export const deleteOAuthTokens = async (key: string): Promise => { + try { + const store = await ensureDb(); + await store.delete(DB_STORE_NAME, key); + } catch (error) { + PluginLog.err('PluginOAuthTokenStore: Failed to delete tokens:', error); + throw error; + } +}; diff --git a/src/app/plugins/oauth/plugin-oauth.model.ts b/src/app/plugins/oauth/plugin-oauth.model.ts new file mode 100644 index 0000000000..61a597dbf8 --- /dev/null +++ b/src/app/plugins/oauth/plugin-oauth.model.ts @@ -0,0 +1,8 @@ +export interface PluginOAuthTokens { + accessToken: string; + refreshToken: string; + expiresAt: number; // unix ms + tokenUrl: string; // needed for refresh + clientId: string; // needed for refresh + clientSecret?: string; // needed for refresh (Google requires it) +} diff --git a/src/app/plugins/oauth/plugin-oauth.service.spec.ts b/src/app/plugins/oauth/plugin-oauth.service.spec.ts new file mode 100644 index 0000000000..451289a516 --- /dev/null +++ b/src/app/plugins/oauth/plugin-oauth.service.spec.ts @@ -0,0 +1,505 @@ +import { TestBed } from '@angular/core/testing'; +import { + HttpTestingController, + provideHttpClientTesting, +} from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { PluginOAuthService } from './plugin-oauth.service'; +import { OAuthFlowConfig } from '@super-productivity/plugin-api'; + +describe('PluginOAuthService', () => { + let service: PluginOAuthService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [PluginOAuthService, provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(PluginOAuthService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + describe('buildAuthUrl', () => { + it('should construct URL with PKCE params and scopes', async () => { + const config: OAuthFlowConfig = { + authUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + clientId: 'my-client-id', + scopes: ['calendar.readonly', 'calendar.events'], + }; + const redirectUri = 'https://localhost/assets/oauth-callback.html'; + + const result = await service.buildAuthUrl(config, redirectUri); + + expect(result.codeVerifier).toBeTruthy(); + + const url = new URL(result.url); + expect(url.origin + url.pathname).toBe( + 'https://accounts.google.com/o/oauth2/v2/auth', + ); + expect(url.searchParams.get('response_type')).toBe('code'); + expect(url.searchParams.get('client_id')).toBe('my-client-id'); + expect(url.searchParams.get('redirect_uri')).toBe(redirectUri); + expect(url.searchParams.get('scope')).toBe('calendar.readonly calendar.events'); + expect(url.searchParams.get('code_challenge')).toBeTruthy(); + expect(url.searchParams.get('code_challenge_method')).toBe('S256'); + expect(url.searchParams.get('state')).toBeTruthy(); + expect(result.state).toBeTruthy(); + expect(url.searchParams.get('state')).toBe(result.state); + // access_type/prompt are no longer hardcoded — they come via extraAuthParams + expect(url.searchParams.get('access_type')).toBeNull(); + expect(url.searchParams.get('prompt')).toBeNull(); + }); + + it('should include extraAuthParams in the URL', async () => { + const config: OAuthFlowConfig = { + authUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + clientId: 'my-client-id', + scopes: ['read'], + extraAuthParams: { access_type: 'offline', prompt: 'consent' }, + }; + + const result = await service.buildAuthUrl(config, 'https://redirect.example.com'); + const url = new URL(result.url); + expect(url.searchParams.get('access_type')).toBe('offline'); + expect(url.searchParams.get('prompt')).toBe('consent'); + }); + + it('should reject non-HTTPS authUrl', async () => { + const config: OAuthFlowConfig = { + authUrl: 'http://insecure.example.com/auth', + tokenUrl: 'https://auth.example.com/token', + clientId: 'cid', + scopes: ['read'], + }; + + await expectAsync( + service.buildAuthUrl(config, 'https://redirect.example.com'), + ).toBeRejectedWithError(/OAuth authUrl must use HTTPS/); + }); + + it('should reject non-HTTPS tokenUrl', async () => { + const config: OAuthFlowConfig = { + authUrl: 'https://auth.example.com/auth', + tokenUrl: 'http://insecure.example.com/token', + clientId: 'cid', + scopes: ['read'], + }; + + await expectAsync( + service.buildAuthUrl(config, 'https://redirect.example.com'), + ).toBeRejectedWithError(/OAuth tokenUrl must use HTTPS/); + }); + + it('should return a non-empty code verifier', async () => { + const config: OAuthFlowConfig = { + authUrl: 'https://auth.example.com/auth', + tokenUrl: 'https://auth.example.com/token', + clientId: 'cid', + scopes: ['read'], + }; + + const result = await service.buildAuthUrl(config, 'https://redirect.example.com'); + expect(result.codeVerifier.length).toBeGreaterThanOrEqual(43); + }); + }); + + describe('exchangeCodeForTokens', () => { + it('should POST to tokenUrl with code and PKCE verifier', async () => { + const tokenUrl = 'https://oauth2.googleapis.com/token'; + const clientId = 'my-client-id'; + const code = 'auth-code-123'; + const codeVerifier = 'verifier-456'; + const redirectUri = 'https://localhost/callback'; + + const promise = service.exchangeCodeForTokens({ + tokenUrl, + clientId, + code, + codeVerifier, + redirectUri, + }); + + const req = httpMock.expectOne(tokenUrl); + expect(req.request.method).toBe('POST'); + + const body = new URLSearchParams(req.request.body as string); + expect(body.get('grant_type')).toBe('authorization_code'); + expect(body.get('client_id')).toBe(clientId); + expect(body.get('code')).toBe(code); + expect(body.get('code_verifier')).toBe(codeVerifier); + expect(body.get('redirect_uri')).toBe(redirectUri); + + req.flush({ + access_token: 'access-abc', + refresh_token: 'refresh-xyz', + expires_in: 3600, + }); + + const result = await promise; + expect(result.accessToken).toBe('access-abc'); + expect(result.refreshToken).toBe('refresh-xyz'); + expect(result.expiresAt).toBeGreaterThan(Date.now()); + }); + + it('should include client_secret when provided', async () => { + const tokenUrl = 'https://oauth2.googleapis.com/token'; + const clientId = 'my-client-id'; + const code = 'auth-code-123'; + const codeVerifier = 'verifier-456'; + const redirectUri = 'https://localhost/callback'; + const clientSecret = 'my-secret'; + + const promise = service.exchangeCodeForTokens({ + tokenUrl, + clientId, + code, + codeVerifier, + redirectUri, + clientSecret, + }); + + const req = httpMock.expectOne(tokenUrl); + expect(req.request.method).toBe('POST'); + + const body = new URLSearchParams(req.request.body as string); + expect(body.get('client_secret')).toBe('my-secret'); + expect(body.get('grant_type')).toBe('authorization_code'); + expect(body.get('client_id')).toBe(clientId); + + req.flush({ + access_token: 'access-with-secret', + refresh_token: 'refresh-with-secret', + expires_in: 3600, + }); + + const result = await promise; + expect(result.accessToken).toBe('access-with-secret'); + }); + }); + + describe('refreshAccessToken', () => { + it('should POST to tokenUrl with refresh_token grant', async () => { + const tokenUrl = 'https://oauth2.googleapis.com/token'; + const clientId = 'my-client-id'; + const refreshToken = 'refresh-xyz'; + + const promise = service.refreshAccessToken(tokenUrl, clientId, refreshToken); + + const req = httpMock.expectOne(tokenUrl); + expect(req.request.method).toBe('POST'); + + const body = new URLSearchParams(req.request.body as string); + expect(body.get('grant_type')).toBe('refresh_token'); + expect(body.get('client_id')).toBe(clientId); + expect(body.get('refresh_token')).toBe(refreshToken); + + req.flush({ + access_token: 'new-access-token', + expires_in: 3600, + }); + + const result = await promise; + expect(result.accessToken).toBe('new-access-token'); + expect(result.expiresAt).toBeGreaterThan(Date.now()); + }); + }); + + describe('token store', () => { + it('should return false for hasTokens when no tokens stored', () => { + expect(service.hasTokens('unknown-plugin')).toBe(false); + }); + + it('should store and retrieve tokens', () => { + service.storeTokens('plugin-1', { + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: Date.now() + 3600000, + tokenUrl: 'https://token.url', + clientId: 'cid', + }); + + expect(service.hasTokens('plugin-1')).toBe(true); + }); + + it('should clear tokens for a plugin', () => { + service.storeTokens('plugin-1', { + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: Date.now() + 3600000, + tokenUrl: 'https://token.url', + clientId: 'cid', + }); + + service.clearTokens('plugin-1'); + expect(service.hasTokens('plugin-1')).toBe(false); + }); + }); + + describe('getValidToken', () => { + it('should return null if no tokens stored', async () => { + const token = await service.getValidToken('unknown-plugin'); + expect(token).toBeNull(); + }); + + it('should return access token if not expired', async () => { + service.storeTokens('plugin-1', { + accessToken: 'valid-token', + refreshToken: 'refresh', + expiresAt: Date.now() + 3600000, // 1 hour from now + tokenUrl: 'https://token.url', + clientId: 'cid', + }); + + const token = await service.getValidToken('plugin-1'); + expect(token).toBe('valid-token'); + }); + + it('should refresh and return new token if near expiry', async () => { + const tokenUrl = 'https://oauth2.googleapis.com/token'; + service.storeTokens('plugin-1', { + accessToken: 'old-token', + refreshToken: 'refresh-token', + expiresAt: Date.now() + 60000, // 1 minute from now (within 5-min buffer) + tokenUrl, + clientId: 'cid', + }); + + const promise = service.getValidToken('plugin-1'); + + const req = httpMock.expectOne(tokenUrl); + req.flush({ + access_token: 'refreshed-token', + expires_in: 3600, + }); + + const token = await promise; + expect(token).toBe('refreshed-token'); + }); + + it('should return null if refresh fails', async () => { + const tokenUrl = 'https://oauth2.googleapis.com/token'; + service.storeTokens('plugin-1', { + accessToken: 'old-token', + refreshToken: 'refresh-token', + expiresAt: Date.now() + 60000, // within 5-min buffer + tokenUrl, + clientId: 'cid', + }); + + const promise = service.getValidToken('plugin-1'); + + const req = httpMock.expectOne(tokenUrl); + req.flush('Unauthorized', { status: 401, statusText: 'Unauthorized' }); + + const token = await promise; + expect(token).toBeNull(); + expect(service.hasTokens('plugin-1')).toBe(false); + }); + }); + + describe('token serialization', () => { + it('should serialize and restore tokens', () => { + service.storeTokens('plugin-1', { + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: Date.now() + 3600000, + tokenUrl: 'https://token.url', + clientId: 'client-id', + }); + + const serialized = service.serializeTokens('plugin-1'); + expect(serialized).toBeTruthy(); + + service.clearTokens('plugin-1'); + expect(service.hasTokens('plugin-1')).toBe(false); + + service.restoreTokens('plugin-1', serialized!); + expect(service.hasTokens('plugin-1')).toBe(true); + }); + + it('should return null for non-existent plugin', () => { + expect(service.serializeTokens('nonexistent')).toBeNull(); + }); + }); + + describe('restoreTokens validation', () => { + beforeEach(() => { + spyOn(console, 'warn'); + }); + + it('should discard tokens with missing accessToken', () => { + const serialized = JSON.stringify({ + refreshToken: 'r', + tokenUrl: 'u', + clientId: 'c', + }); + + service.restoreTokens('p1', serialized); + + expect(service.hasTokens('p1')).toBe(false); + }); + + it('should discard tokens with missing refreshToken', () => { + const serialized = JSON.stringify({ + accessToken: 'a', + tokenUrl: 'u', + clientId: 'c', + }); + + service.restoreTokens('p1', serialized); + + expect(service.hasTokens('p1')).toBe(false); + }); + + it('should discard tokens with missing tokenUrl', () => { + const serialized = JSON.stringify({ + accessToken: 'a', + refreshToken: 'r', + clientId: 'c', + }); + + service.restoreTokens('p1', serialized); + + expect(service.hasTokens('p1')).toBe(false); + }); + + it('should discard tokens with missing clientId', () => { + const serialized = JSON.stringify({ + accessToken: 'a', + refreshToken: 'r', + tokenUrl: 'u', + }); + + service.restoreTokens('p1', serialized); + + expect(service.hasTokens('p1')).toBe(false); + }); + + it('should handle malformed JSON gracefully', () => { + expect(() => service.restoreTokens('p1', 'not-json')).not.toThrow(); + + expect(service.hasTokens('p1')).toBe(false); + }); + + it('should accept valid tokens with all required fields', () => { + const serialized = JSON.stringify({ + accessToken: 'a', + refreshToken: 'r', + tokenUrl: 'https://example.com/token', + clientId: 'c', + expiresAt: Date.now() + 3600000, + }); + + service.restoreTokens('p1', serialized); + + expect(service.hasTokens('p1')).toBe(true); + }); + + it('should reject tokens with non-HTTPS tokenUrl', () => { + const serialized = JSON.stringify({ + accessToken: 'a', + refreshToken: 'r', + tokenUrl: 'http://example.com/token', + clientId: 'c', + expiresAt: Date.now() + 3600000, + }); + + service.restoreTokens('p1', serialized); + + expect(service.hasTokens('p1')).toBe(false); + }); + }); + + describe('redirect code handling', () => { + it('should resolve when handleRedirectCode is called with matching state', async () => { + const promise = service.waitForRedirectCode('plugin-1', 'test-state'); + service.handleRedirectCode('auth-code-789', 'test-state'); + + const code = await promise; + expect(code).toBe('auth-code-789'); + }); + + it('should reject when handleRedirectError is called with matching state', async () => { + const promise = service.waitForRedirectCode('plugin-1', 'test-state'); + service.handleRedirectError('user_denied', 'test-state'); + + await expectAsync(promise).toBeRejectedWithError(/user_denied/); + }); + + it('should ignore handleRedirectError with mismatched state', async () => { + const promise = service.waitForRedirectCode('plugin-1', 'test-state'); + service.handleRedirectError('user_denied', 'wrong-state'); + + // Promise should still be pending (not rejected) + service.handleRedirectCode('the-code', 'test-state'); + const code = await promise; + expect(code).toBe('the-code'); + }); + + it('should ignore handleRedirectError with missing state', async () => { + const promise = service.waitForRedirectCode('plugin-1', 'test-state'); + service.handleRedirectError('user_denied'); + + // Promise should still be pending (not rejected) since state is undefined + service.handleRedirectCode('the-code', 'test-state'); + const code = await promise; + expect(code).toBe('the-code'); + }); + + it('should only resolve the most recent pending flow', async () => { + const promise1 = service.waitForRedirectCode('plugin-1', 'state-1'); + const promise2 = service.waitForRedirectCode('plugin-2', 'state-2'); + + service.handleRedirectCode('code-for-latest', 'state-2'); + + const code = await promise2; + expect(code).toBe('code-for-latest'); + + // First promise should have been rejected when second was created + await expectAsync(promise1).toBeRejectedWithError(/superseded/); + }); + + it('should reject the previous pending redirect with the correct error message', async () => { + const promise1 = service.waitForRedirectCode('plugin-1', 'state-1'); + service.waitForRedirectCode('plugin-2', 'state-2'); + + await expectAsync(promise1).toBeRejectedWithError( + 'OAuth flow superseded by a new request', + ); + }); + + it('should ignore redirect code when state does not match', async () => { + const promise = service.waitForRedirectCode('plugin-1', 'expected-state'); + service.handleRedirectCode('auth-code-789', 'wrong-state'); + + // Code should not have been resolved; now resolve with correct state + service.handleRedirectCode('auth-code-correct', 'expected-state'); + const code = await promise; + expect(code).toBe('auth-code-correct'); + }); + + it('should accept redirect code when state matches', async () => { + const promise = service.waitForRedirectCode('plugin-1', 'my-state'); + service.handleRedirectCode('code-123', 'my-state'); + + const code = await promise; + expect(code).toBe('code-123'); + }); + + it('should reject redirect code when state is missing', async () => { + const promise = service.waitForRedirectCode('plugin-1', 'expected-state'); + service.handleRedirectCode('code-456'); + + // State mismatch (undefined !== 'expected-state'), so resolve with correct state + service.handleRedirectCode('code-correct', 'expected-state'); + const code = await promise; + expect(code).toBe('code-correct'); + }); + }); +}); diff --git a/src/app/plugins/oauth/plugin-oauth.service.ts b/src/app/plugins/oauth/plugin-oauth.service.ts new file mode 100644 index 0000000000..a097c4b254 --- /dev/null +++ b/src/app/plugins/oauth/plugin-oauth.service.ts @@ -0,0 +1,321 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { firstValueFrom, Subject } from 'rxjs'; +import { OAuthFlowConfig, OAuthTokenResult } from '@super-productivity/plugin-api'; +import { PluginOAuthTokens } from './plugin-oauth.model'; +import { generateCodeVerifier, generateCodeChallenge } from './pkce.util'; +import { IS_ELECTRON } from '../../app.constants'; +import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view'; +import { PluginLog } from '../../core/log'; + +const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; +const OAUTH_REDIRECT_TIMEOUT_MS = 5 * 60 * 1000; + +const RESERVED_OAUTH_PARAMS = new Set([ + 'response_type', + 'client_id', + 'redirect_uri', + 'scope', + 'code_challenge', + 'code_challenge_method', + 'state', +]); + +interface PendingRedirect { + resolve: (code: string) => void; + reject: (error: Error) => void; + expectedState: string; +} + +@Injectable({ providedIn: 'root' }) +export class PluginOAuthService { + private _http = inject(HttpClient); + private _tokenStore = new Map(); + private _pendingRedirect: PendingRedirect | null = null; + private _refreshPromises = new Map>(); + + /** Emits the pluginId when a token refresh fails and in-memory tokens are cleared. */ + tokenInvalidated$ = new Subject(); + + getRedirectUri(): string { + if (IS_ELECTRON) { + return 'super-productivity://oauth'; + } + if (IS_ANDROID_WEB_VIEW) { + return 'com.super-productivity.app://plugin-oauth-callback'; + } + return `${window.location.origin}/assets/oauth-callback.html`; + } + + async buildAuthUrl( + config: OAuthFlowConfig, + redirectUri: string, + ): Promise<{ url: string; codeVerifier: string; state: string }> { + this._validateHttpsUrl(config.authUrl, 'authUrl'); + this._validateHttpsUrl(config.tokenUrl, 'tokenUrl'); + + const codeVerifier = generateCodeVerifier(); + const codeChallenge = await generateCodeChallenge(codeVerifier); + const state = generateCodeVerifier(); + + const filteredExtraParams: Record = {}; + if (config.extraAuthParams) { + for (const [key, value] of Object.entries(config.extraAuthParams)) { + if (!RESERVED_OAUTH_PARAMS.has(key)) { + filteredExtraParams[key] = value; + } + } + } + + const params = new URLSearchParams({ + response_type: 'code', + client_id: config.clientId, + redirect_uri: redirectUri, + scope: config.scopes.join(' '), + code_challenge: codeChallenge, + code_challenge_method: 'S256', + state, + ...filteredExtraParams, + }); + + return { + url: `${config.authUrl}?${params.toString()}`, + codeVerifier, + state, + }; + } + + private _validateHttpsUrl(url: string, label: string): void { + try { + const parsed = new URL(url); + if (parsed.protocol !== 'https:') { + throw new Error(`OAuth ${label} must use HTTPS, got ${parsed.protocol}`); + } + } catch (e) { + if (e instanceof Error && e.message.startsWith('OAuth ')) { + throw e; + } + throw new Error(`Invalid OAuth ${label}: ${(e as Error).message}`); + } + } + + async exchangeCodeForTokens(opts: { + tokenUrl: string; + clientId: string; + code: string; + codeVerifier: string; + redirectUri: string; + clientSecret?: string; + }): Promise { + const params: Record = { + grant_type: 'authorization_code', + client_id: opts.clientId, + code: opts.code, + code_verifier: opts.codeVerifier, + redirect_uri: opts.redirectUri, + }; + if (opts.clientSecret) { + params['client_secret'] = opts.clientSecret; + } + + const response = await this._postTokenRequest<{ + access_token: string; + refresh_token: string; + expires_in: number; + }>(opts.tokenUrl, params); + + const expiresInMs = response.expires_in * 1000; + return { + accessToken: response.access_token, + refreshToken: response.refresh_token, + expiresAt: Date.now() + expiresInMs, + }; + } + + async refreshAccessToken( + tokenUrl: string, + clientId: string, + refreshToken: string, + clientSecret?: string, + ): Promise<{ accessToken: string; expiresAt: number }> { + const params: Record = { + grant_type: 'refresh_token', + client_id: clientId, + refresh_token: refreshToken, + }; + if (clientSecret) { + params['client_secret'] = clientSecret; + } + + const response = await this._postTokenRequest<{ + access_token: string; + expires_in: number; + }>(tokenUrl, params); + + const expiresInMs = response.expires_in * 1000; + return { + accessToken: response.access_token, + expiresAt: Date.now() + expiresInMs, + }; + } + + private _postTokenRequest( + tokenUrl: string, + params: Record, + ): Promise { + this._validateHttpsUrl(tokenUrl, 'tokenUrl'); + const body = new URLSearchParams(params); + const headers = new HttpHeaders().set( + 'Content-Type', + 'application/x-www-form-urlencoded', + ); + return firstValueFrom(this._http.post(tokenUrl, body.toString(), { headers })); + } + + storeTokens(pluginId: string, tokens: PluginOAuthTokens): void { + this._tokenStore.set(pluginId, tokens); + } + + hasTokens(pluginId: string): boolean { + return this._tokenStore.has(pluginId); + } + + clearTokens(pluginId: string): void { + this._tokenStore.delete(pluginId); + } + + serializeTokens(pluginId: string): string | null { + const tokens = this._tokenStore.get(pluginId); + return tokens ? JSON.stringify(tokens) : null; + } + + restoreTokens(pluginId: string, serialized: string): void { + try { + const tokens = JSON.parse(serialized) as PluginOAuthTokens; + if ( + !tokens?.accessToken || + !tokens?.refreshToken || + !tokens?.tokenUrl || + !tokens?.clientId || + typeof tokens?.expiresAt !== 'number' || + isNaN(tokens.expiresAt) + ) { + PluginLog.warn(`Invalid stored OAuth tokens for plugin ${pluginId}, discarding`); + return; + } + try { + this._validateHttpsUrl(tokens.tokenUrl, 'stored tokenUrl'); + } catch { + PluginLog.warn(`Stored tokenUrl for plugin ${pluginId} is not HTTPS, discarding`); + return; + } + this._tokenStore.set(pluginId, tokens); + } catch (e) { + PluginLog.warn(`Failed to parse stored OAuth tokens for plugin ${pluginId}`, e); + } + } + + async getValidToken(pluginId: string): Promise { + const tokens = this._tokenStore.get(pluginId); + if (!tokens) { + return null; + } + + if (tokens.expiresAt - Date.now() > TOKEN_REFRESH_BUFFER_MS) { + return tokens.accessToken; + } + + // Deduplicate concurrent refresh calls to avoid token rotation issues + const existing = this._refreshPromises.get(pluginId); + if (existing) { + return existing; + } + + const refreshPromise = this._doRefresh(pluginId, tokens).finally(() => { + this._refreshPromises.delete(pluginId); + }); + this._refreshPromises.set(pluginId, refreshPromise); + return refreshPromise; + } + + private async _doRefresh( + pluginId: string, + tokens: PluginOAuthTokens, + ): Promise { + try { + PluginLog.log(`Refreshing token for plugin ${pluginId}`); + const refreshed = await this.refreshAccessToken( + tokens.tokenUrl, + tokens.clientId, + tokens.refreshToken, + tokens.clientSecret, + ); + this._tokenStore.set(pluginId, { + ...tokens, + accessToken: refreshed.accessToken, + expiresAt: refreshed.expiresAt, + }); + return refreshed.accessToken; + } catch (err) { + PluginLog.err(`Failed to refresh token for plugin ${pluginId}`, err); + this._tokenStore.delete(pluginId); + this.tokenInvalidated$.next(pluginId); + return null; + } + } + + waitForRedirectCode(pluginId: string, expectedState: string): Promise { + // Reject any existing pending redirect as superseded + if (this._pendingRedirect) { + this._pendingRedirect.reject(new Error('OAuth flow superseded by a new request')); + this._pendingRedirect = null; + } + + return new Promise((resolve, reject) => { + PluginLog.log(`Waiting for OAuth redirect code for plugin ${pluginId}`); + const timeoutId = setTimeout(() => { + this._pendingRedirect = null; + reject( + new Error( + `OAuth redirect timed out after ${OAUTH_REDIRECT_TIMEOUT_MS / 1000}s for plugin ${pluginId}`, + ), + ); + }, OAUTH_REDIRECT_TIMEOUT_MS); + this._pendingRedirect = { + resolve: (code: string) => { + clearTimeout(timeoutId); + resolve(code); + }, + reject: (err: Error) => { + clearTimeout(timeoutId); + reject(err); + }, + expectedState, + }; + }); + } + + handleRedirectCode(code: string, state?: string): void { + if (this._pendingRedirect) { + if (state !== this._pendingRedirect.expectedState) { + PluginLog.warn('OAuth state mismatch – ignoring callback'); + return; + } + this._pendingRedirect.resolve(code); + this._pendingRedirect = null; + } else { + PluginLog.warn('Received OAuth code but no pending flow'); + } + } + + handleRedirectError(error: string, state?: string): void { + if (this._pendingRedirect) { + if (state !== this._pendingRedirect.expectedState) { + PluginLog.warn('OAuth error state mismatch – ignoring callback'); + return; + } + this._pendingRedirect.reject(new Error(error)); + this._pendingRedirect = null; + } + } +} diff --git a/src/app/plugins/plugin-api.ts b/src/app/plugins/plugin-api.ts index aa57c35f1e..3bc2f1efda 100644 --- a/src/app/plugins/plugin-api.ts +++ b/src/app/plugins/plugin-api.ts @@ -5,6 +5,8 @@ import { Hooks, IssueProviderPluginDefinition, NotifyCfg, + OAuthFlowConfig, + OAuthTokenResult, PluginAPI as PluginAPIInterface, PluginBaseCfg, PluginCreateTaskData, @@ -517,6 +519,20 @@ export class PluginAPI implements PluginAPIInterface { return this._pluginI18nService.getCurrentLanguage(); } + async startOAuthFlow(config: OAuthFlowConfig): Promise { + PluginLog.log(`Plugin ${this._pluginId} requested OAuth flow`); + return this._boundMethods.startOAuthFlow(config); + } + + async getOAuthToken(): Promise { + return this._boundMethods.getOAuthToken(); + } + + async clearOAuthToken(): Promise { + PluginLog.log(`Plugin ${this._pluginId} requested OAuth token clear`); + return this._boundMethods.clearOAuthToken(); + } + /** * Clean up all resources associated with this plugin API instance * Called when the plugin is being unloaded diff --git a/src/app/plugins/plugin-bridge.service.ts b/src/app/plugins/plugin-bridge.service.ts index fcc6e9b43f..693293131d 100644 --- a/src/app/plugins/plugin-bridge.service.ts +++ b/src/app/plugins/plugin-bridge.service.ts @@ -22,6 +22,8 @@ import { BatchTaskCreate, BatchUpdateRequest, BatchUpdateResult, + OAuthFlowConfig, + OAuthTokenResult, PluginManifest, SnackCfg, } from '@super-productivity/plugin-api'; @@ -58,6 +60,7 @@ import { GlobalThemeService } from '../core/theme/global-theme.service'; import { IssueSyncAdapterRegistryService } from '../features/issue/two-way-sync/issue-sync-adapter-registry.service'; import { PluginHttpService } from './issue-provider/plugin-http.service'; import { createPluginSyncAdapter } from './issue-provider/plugin-sync-adapter.service'; +import { PluginOAuthBridgeService } from './oauth/plugin-oauth-bridge.service'; import { ISSUE_PROVIDER_TYPES } from '../features/issue/issue.const'; // New imports for simple counters @@ -107,6 +110,7 @@ export class PluginBridgeService implements OnDestroy { private _globalThemeService = inject(GlobalThemeService); private _syncAdapterRegistry = inject(IssueSyncAdapterRegistryService); private _pluginHttpService = inject(PluginHttpService); + private _pluginOAuthBridge = inject(PluginOAuthBridgeService); // Track header buttons registered by plugins private readonly _headerButtons = signal([]); @@ -141,7 +145,7 @@ export class PluginBridgeService implements OnDestroy { ): { persistDataSynced: (dataStr: string) => Promise; loadPersistedData: () => Promise; - getConfig: () => Promise; + getConfig: () => Promise; downloadFile: (filename: string, data: string) => Promise; registerHeaderButton: (cfg: PluginHeaderBtnCfg) => void; registerMenuEntry: (cfg: Omit) => void; @@ -170,6 +174,9 @@ export class PluginBridgeService implements OnDestroy { registerConfigHandler: (handler: () => void) => void; registerIssueProvider: (definition: IssueProviderPluginDefinition) => void; unregisterIssueProvider: () => void; + startOAuthFlow: (config: OAuthFlowConfig) => Promise; + getOAuthToken: () => Promise; + clearOAuthToken: () => Promise; log: ReturnType; } { return { @@ -241,6 +248,14 @@ export class PluginBridgeService implements OnDestroy { } }, + // OAuth + startOAuthFlow: (config: OAuthFlowConfig): Promise => + this._pluginOAuthBridge.startOAuthFlow(pluginId, config), + getOAuthToken: (): Promise => + this._pluginOAuthBridge.getOAuthToken(pluginId), + clearOAuthToken: (): Promise => + this._pluginOAuthBridge.clearOAuthTokens(pluginId), + // Logging log: Log.withContext(`${pluginId}`), }; @@ -289,15 +304,17 @@ export class PluginBridgeService implements OnDestroy { plural: 'Issues', }; - this._pluginIssueProviderRegistry.register( + this._pluginIssueProviderRegistry.register({ pluginId, definition, name, icon, pollIntervalMs, issueStrings, - customKey, - ); + issueProviderKey: customKey, + useAgendaView: issueProviderCfg?.useAgendaView, + defaultAutoAddToBacklog: issueProviderCfg?.defaultAutoAddToBacklog, + }); const registeredKey = this._pluginIssueProviderRegistry.getRegisteredKey(pluginId); if (!registeredKey) { @@ -322,6 +339,21 @@ export class PluginBridgeService implements OnDestroy { ); } + async startOAuthFlow( + pluginId: string, + config: OAuthFlowConfig, + ): Promise { + return this._pluginOAuthBridge.startOAuthFlow(pluginId, config); + } + + async clearOAuthTokens(pluginId: string): Promise { + return this._pluginOAuthBridge.clearOAuthTokens(pluginId); + } + + async restoreAndCheckOAuthTokens(pluginId: string): Promise { + return this._pluginOAuthBridge.restoreAndCheckOAuthTokens(pluginId); + } + private async _downloadFile(filename: string, data: string): Promise { typia.assert(filename); typia.assert(data); @@ -833,7 +865,7 @@ export class PluginBridgeService implements OnDestroy { /** * Internal method to get plugin configuration */ - private async _getConfig(pluginId: string): Promise { + private async _getConfig(pluginId: string): Promise { try { return await this._pluginConfigService.getPluginConfig(pluginId); } catch (error) { diff --git a/src/app/plugins/plugin.service.ts b/src/app/plugins/plugin.service.ts index 170affa238..dfda4f60e8 100644 --- a/src/app/plugins/plugin.service.ts +++ b/src/app/plugins/plugin.service.ts @@ -342,24 +342,25 @@ export class PluginService implements OnDestroy { name: string; icon: string; issueProviderKey: string; + useAgendaView: boolean; }> { const result: Array<{ pluginId: string; name: string; icon: string; issueProviderKey: string; + useAgendaView: boolean; }> = []; for (const [, state] of this._pluginStates()) { - if ( - !state.isEnabled && - state.manifest.issueProvider?.issueProviderKey && - state.manifest.type === 'issueProvider' - ) { + if (!state.isEnabled && state.manifest.type === 'issueProvider') { result.push({ pluginId: state.manifest.id, name: state.manifest.name, - icon: state.manifest.issueProvider.icon || 'extension', - issueProviderKey: state.manifest.issueProvider.issueProviderKey, + icon: state.manifest.issueProvider?.icon || 'extension', + issueProviderKey: + state.manifest.issueProvider?.issueProviderKey ?? + `plugin:${state.manifest.id}`, + useAgendaView: state.manifest.issueProvider?.useAgendaView ?? false, }); } } diff --git a/src/app/root-store/meta/task-shared-meta-reducers/task-shared-crud.reducer.spec.ts b/src/app/root-store/meta/task-shared-meta-reducers/task-shared-crud.reducer.spec.ts index f87dcc0433..332dad1412 100644 --- a/src/app/root-store/meta/task-shared-meta-reducers/task-shared-crud.reducer.spec.ts +++ b/src/app/root-store/meta/task-shared-meta-reducers/task-shared-crud.reducer.spec.ts @@ -1372,6 +1372,78 @@ describe('taskSharedCrudMetaReducer', () => { testState, ); }); + + it('should move task to new planner day when dueDay changes to a future date', () => { + const oldDay = '2099-12-20'; + const newDay = '2099-12-25'; + const testState = createStateWithExistingTasks(['task1'], [], ['task1']); + (testState[TASK_FEATURE_NAME].entities['task1'] as any).dueDay = oldDay; + (testState as any).planner = { + days: { [oldDay]: ['task1', 'other-task'] }, + addPlannedTasksDialogLastShown: undefined, + }; + + const action = createUpdateTaskAction('task1', { dueDay: newDay }); + + metaReducer(testState, action); + const resultState = mockReducer.calls.mostRecent().args[0] as any; + expect(resultState.planner.days[oldDay]).not.toContain('task1'); + expect(resultState.planner.days[newDay]).toContain('task1'); + }); + + it('should remove task from planner days when dueDay changes to today', () => { + const oldDay = '2099-12-20'; + const todayStr = getDbDateStr(); + const testState = createStateWithExistingTasks(['task1'], [], ['task1']); + (testState[TASK_FEATURE_NAME].entities['task1'] as any).dueDay = oldDay; + (testState as any).planner = { + days: { [oldDay]: ['task1'] }, + addPlannedTasksDialogLastShown: undefined, + }; + + const action = createUpdateTaskAction('task1', { dueDay: todayStr }); + + metaReducer(testState, action); + const resultState = mockReducer.calls.mostRecent().args[0] as any; + expect(resultState.planner.days[oldDay] || []).not.toContain('task1'); + // Today's tasks use TODAY_TAG.taskIds, not planner.days + const todayTag = resultState[TAG_FEATURE_NAME].entities['TODAY'] as Tag; + expect(todayTag.taskIds).toContain('task1'); + }); + + it('should remove task from TODAY_TAG when dueDay changes away from today', () => { + const todayStr = getDbDateStr(); + const newDay = '2099-12-25'; + const testState = createStateWithExistingTasks(['task1'], [], ['task1'], ['task1']); + (testState[TASK_FEATURE_NAME].entities['task1'] as any).dueDay = todayStr; + + const action = createUpdateTaskAction('task1', { dueDay: newDay }); + + metaReducer(testState, action); + const resultState = mockReducer.calls.mostRecent().args[0] as any; + const todayTag = resultState[TAG_FEATURE_NAME].entities['TODAY'] as Tag; + expect(todayTag.taskIds).not.toContain('task1'); + expect(resultState.planner.days[newDay]).toContain('task1'); + }); + + it('should not update planner when dueDay does not change', () => { + const day = '2099-12-20'; + const testState = createStateWithExistingTasks(['task1'], [], ['task1']); + (testState[TASK_FEATURE_NAME].entities['task1'] as any).dueDay = day; + (testState as any).planner = { + days: { [day]: ['task1'] }, + addPlannedTasksDialogLastShown: undefined, + }; + + const action = createUpdateTaskAction('task1', { + title: 'New title', + dueDay: day, + }); + + metaReducer(testState, action); + const resultState = mockReducer.calls.mostRecent().args[0] as any; + expect(resultState.planner.days[day]).toContain('task1'); + }); }); describe('edge cases and error handling', () => { diff --git a/src/app/root-store/meta/task-shared-meta-reducers/task-shared-crud.reducer.ts b/src/app/root-store/meta/task-shared-meta-reducers/task-shared-crud.reducer.ts index b4e03978ef..696b5b584a 100644 --- a/src/app/root-store/meta/task-shared-meta-reducers/task-shared-crud.reducer.ts +++ b/src/app/root-store/meta/task-shared-meta-reducers/task-shared-crud.reducer.ts @@ -29,9 +29,12 @@ import { getDbDateStr } from '../../../util/get-db-date-str'; import { ActionHandlerMap, addTaskToList, + addTaskToPlannerDay, getProject, getTag, + hasInvalidTodayTag, ProjectTaskList, + filterOutTodayTag, removeTaskFromPlannerDays, removeTasksFromList, TaskWithTags, @@ -639,6 +642,56 @@ const handleUpdateTask = ( updatedState = removeTaskFromPlannerDays(updatedState, taskId); } + // When dueDay changes (e.g. from two-way sync pull), update planner days + // and TODAY_TAG.taskIds to keep them consistent with the task's dueDay. + const newDueDay = taskUpdate.changes.dueDay; + if (newDueDay !== undefined && newDueDay !== currentTask.dueDay) { + const oldDueDay = currentTask.dueDay; + + // Remove from old planner day + updatedState = removeTaskFromPlannerDays(updatedState, taskId); + + if (newDueDay && newDueDay !== todayStr && !isToDone) { + // Add to new planner day (not today — today uses TODAY_TAG.taskIds) + // Skip if task is being completed in the same update to avoid re-adding to planner + updatedState = addTaskToPlannerDay(updatedState, taskId, newDueDay, Infinity); + } + + // Handle TODAY_TAG.taskIds updates + const todayTag = getTag(updatedState, TODAY_TAG.id); + + if (oldDueDay === todayStr && newDueDay !== todayStr) { + // Moving away from today — remove from TODAY_TAG.taskIds + updatedState = updateTags(updatedState, [ + { + id: TODAY_TAG.id, + changes: { taskIds: todayTag.taskIds.filter((id) => id !== taskId) }, + }, + ]); + // Remove TODAY from task.tagIds if present (legacy cleanup) + const updatedTask = updatedState[TASK_FEATURE_NAME].entities[taskId] as Task; + if (updatedTask && hasInvalidTodayTag(updatedTask.tagIds)) { + updatedState = { + ...updatedState, + [TASK_FEATURE_NAME]: taskAdapter.updateOne( + { id: taskId, changes: { tagIds: filterOutTodayTag(updatedTask.tagIds) } }, + updatedState[TASK_FEATURE_NAME], + ), + }; + } + } else if (oldDueDay !== todayStr && newDueDay === todayStr) { + // Moving to today — add to TODAY_TAG.taskIds for ordering + if (!todayTag.taskIds.includes(taskId)) { + updatedState = updateTags(updatedState, [ + { + id: TODAY_TAG.id, + changes: { taskIds: [...todayTag.taskIds, taskId] }, + }, + ]); + } + } + } + return updatedState; }; diff --git a/src/app/root-store/meta/task-shared.actions.ts b/src/app/root-store/meta/task-shared.actions.ts index 6a4dec4340..ec8cc51ccb 100644 --- a/src/app/root-store/meta/task-shared.actions.ts +++ b/src/app/root-store/meta/task-shared.actions.ts @@ -57,6 +57,9 @@ export const TaskSharedActions = createActionGroup({ } satisfies PersistentActionMeta, }), + // Issue metadata for remote issue deletion is passed via + // DeletedTaskIssueSidecarService to avoid serializing full Task + // objects into the op-log. Only taskIds are persisted. deleteTasks: (taskProps: { taskIds: string[] }) => ({ ...taskProps, meta: { diff --git a/src/app/t.const.ts b/src/app/t.const.ts index a75b76d5b2..5b608909ae 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -358,6 +358,9 @@ const T = { STATUS: 'F.ISSUE.TWO_WAY_SYNC.STATUS', TITLE: 'F.ISSUE.TWO_WAY_SYNC.TITLE', NOTES: 'F.ISSUE.TWO_WAY_SYNC.NOTES', + DUE_DAY: 'F.ISSUE.TWO_WAY_SYNC.DUE_DAY', + DUE_WITH_TIME: 'F.ISSUE.TWO_WAY_SYNC.DUE_WITH_TIME', + TIME_ESTIMATE: 'F.ISSUE.TWO_WAY_SYNC.TIME_ESTIMATE', AUTO_CREATE_ISSUES: 'F.ISSUE.TWO_WAY_SYNC.AUTO_CREATE_ISSUES', AUTO_CREATE_ISSUES_DESCRIPTION: 'F.ISSUE.TWO_WAY_SYNC.AUTO_CREATE_ISSUES_DESCRIPTION', @@ -403,11 +406,20 @@ const T = { CONNECTION_SUCCESS: 'F.ISSUE.S.CONNECTION_SUCCESS', CONNECTION_FAILED: 'F.ISSUE.S.CONNECTION_FAILED', AUTO_CREATE_FAILED: 'F.ISSUE.S.AUTO_CREATE_FAILED', + DELETE_REMOTE_FAILED: 'F.ISSUE.S.DELETE_REMOTE_FAILED', + OAUTH_CONNECTED: 'F.ISSUE.S.OAUTH_CONNECTED', + OAUTH_FAILED: 'F.ISSUE.S.OAUTH_FAILED', + REMOTE_ISSUE_DELETED_WITH_TIME: 'F.ISSUE.S.REMOTE_ISSUE_DELETED_WITH_TIME', + REMOTE_ISSUE_DELETED: 'F.ISSUE.S.REMOTE_ISSUE_DELETED', + LOAD_OPTIONS_FAILED: 'F.ISSUE.S.LOAD_OPTIONS_FAILED', + OAUTH_DISCONNECTED: 'F.ISSUE.S.OAUTH_DISCONNECTED', PLUGIN_NOT_INSTALLED: 'F.ISSUE.S.PLUGIN_NOT_INSTALLED', }, DIALOG: { ADVANCED_CONFIG: 'F.ISSUE.DIALOG.ADVANCED_CONFIG', + CONNECTED: 'F.ISSUE.DIALOG.CONNECTED', DELETE_CONFIRM: 'F.ISSUE.DIALOG.DELETE_CONFIRM', + DISCONNECT: 'F.ISSUE.DIALOG.DISCONNECT', }, }, ISSUE_PANEL: { diff --git a/src/app/util/pkce.util.ts b/src/app/util/pkce.util.ts new file mode 100644 index 0000000000..0e107ca31d --- /dev/null +++ b/src/app/util/pkce.util.ts @@ -0,0 +1,44 @@ +// Shared PKCE (Proof Key for Code Exchange) utility. +// Handles environments where crypto.subtle is unavailable (e.g., Android Capacitor +// on http://localhost) by falling back to hash-wasm. +// @see https://www.chromium.org/blink/webcrypto + +import { sha256 as hashWasmSha256 } from 'hash-wasm'; + +const base64UrlEncode = (buffer: Uint8Array): string => { + const base64 = btoa(String.fromCharCode(...buffer)); + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +}; + +/** + * SHA-256 hash with automatic fallback for insecure contexts. + * Uses crypto.subtle when available, otherwise falls back to hash-wasm. + */ +const sha256 = async (data: Uint8Array): Promise => { + if (typeof crypto !== 'undefined' && crypto.subtle !== undefined) { + return crypto.subtle.digest('SHA-256', data as BufferSource); + } + + // Fallback to hash-wasm for insecure contexts (e.g., Android Capacitor on http://localhost). + // hash-wasm is already a dependency (used for Argon2id encryption). + const hexHash = await hashWasmSha256(data); + const bytes = new Uint8Array(hexHash.length / 2); + for (let i = 0; i < hexHash.length; i += 2) { + bytes[i / 2] = parseInt(hexHash.slice(i, i + 2), 16); + } + return bytes.buffer; +}; + +/** Generate a cryptographically random code verifier (base64url-encoded). */ +export const generateCodeVerifier = (): string => { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return base64UrlEncode(array); +}; + +/** Generate an S256 code challenge from a code verifier. */ +export const generateCodeChallenge = async (verifier: string): Promise => { + const data = new TextEncoder().encode(verifier); + const digest = await sha256(data); + return base64UrlEncode(new Uint8Array(digest)); +}; diff --git a/src/assets/community-plugins.json b/src/assets/community-plugins.json index 037458ca80..fd38595902 100644 --- a/src/assets/community-plugins.json +++ b/src/assets/community-plugins.json @@ -44,4 +44,4 @@ "shortDescription": "Syncs your daily study time from Super Productivity to the StudyForge leaderboard. Compete with friends, track streaks, and join 7-day study war challenges.", "url": "https://github.com/dhananajay-030/studyforge-plugin" } -] \ No newline at end of file +] diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 596c05f007..2f00abd945 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -323,10 +323,6 @@ }, "DEFAULT_PROJECT_DESCRIPTION": "Project assigned to tasks created from issues.", "DEFAULT_PROJECT_LABEL": "Default Super Productivity Project", - "DIALOG": { - "ADVANCED_CONFIG": "Advanced Config", - "DELETE_CONFIRM": "Are you sure you want to delete this issue provider? Deleting it means that all previously imported issue tasks will loose their reference. This cannot be undone!" - }, "HOW_TO_GET_A_TOKEN": "How to get a token?", "ISSUE_CONTENT": { "ASSIGNEE": "Assignee", @@ -369,19 +365,35 @@ "PLUGIN_NOT_INSTALLED": "Required plugin \"{{pluginName}}\" is not installed.", "POLLING_BACKLOG": "{{issueProviderName}}: Polling for new {{issuesStr}}", "POLLING_CHANGES": "{{issueProviderName}}: Polling Changes for {{issuesStr}}", + "DELETE_REMOTE_FAILED": "Failed to delete remote issue", + "LOAD_OPTIONS_FAILED": "Failed to load options for {{fieldKey}}", + "OAUTH_CONNECTED": "OAuth account connected successfully.", + "OAUTH_DISCONNECTED": "OAuth account disconnected.", + "OAUTH_FAILED": "Authentication failed.", + "REMOTE_ISSUE_DELETED": "Remote issue was deleted. Delete linked task \"{{taskTitle}}\"?", + "REMOTE_ISSUE_DELETED_WITH_TIME": "Remote issue was deleted but task \"{{taskTitle}}\" has tracked time. Delete anyway?", "TWO_WAY_SYNC_PUSH_FAILED": "Two-way sync push failed: {{errorMsg}}" }, "TWO_WAY_SYNC": { "AUTO_CREATE_ISSUES": "Auto-create issues for new tasks", "AUTO_CREATE_ISSUES_DESCRIPTION": "Automatically creates an issue when you add a new top-level task to the default project. Requires a default project to be set.", "BOTH": "Both (bidirectional)", + "DUE_DAY": "Due Day", + "DUE_WITH_TIME": "Due Date/Time", "NOTES": "Notes / Description sync direction", "OFF": "Off", "PULL_ONLY": "Pull only", "PUSH_ONLY": "Push only", "SECTION": "Two-Way Sync (experimental)", "STATUS": "Status sync direction", + "TIME_ESTIMATE": "Time Estimate", "TITLE": "Title sync direction" + }, + "DIALOG": { + "ADVANCED_CONFIG": "Advanced Config", + "CONNECTED": "Connected", + "DELETE_CONFIRM": "Are you sure you want to delete this issue provider? Deleting it means that all previously imported issue tasks will loose their reference. This cannot be undone!", + "DISCONNECT": "Disconnect" } }, "ISSUE_PANEL": { diff --git a/src/assets/oauth-callback.html b/src/assets/oauth-callback.html new file mode 100644 index 0000000000..8f45e1b07a --- /dev/null +++ b/src/assets/oauth-callback.html @@ -0,0 +1,25 @@ + + + + + Authorization Complete + + +

Authorization complete. You can close this window.

+ + + diff --git a/src/main.ts b/src/main.ts index 5a0cf85ae6..21a100d38c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -75,6 +75,8 @@ import { PLUGIN_INITIALIZER_PROVIDER } from './app/plugins/plugin-initializer'; import { initializeMatMenuTouchFix } from './app/features/tasks/task-context-menu/mat-menu-touch-monkey-patch'; import { Log } from './app/core/log'; import { OperationWriteFlushService } from './app/op-log/sync/operation-write-flush.service'; +import { PluginOAuthRedirectHandler } from './app/plugins/oauth/plugin-oauth-redirect.handler'; +import { OAuthCallbackHandlerService } from './app/imex/sync/oauth-callback-handler.service'; import { GlobalConfigService } from './app/features/config/global-config.service'; import { LocaleDatePipe } from './app/ui/pipes/locale-date.pipe'; import { DateTimeFormatService } from './app/core/date-time-format/date-time-format.service'; @@ -227,7 +229,7 @@ bootstrapApplication(AppComponent, { multi: true, }, // Ensure DataInitService is instantiated at bootstrap. - // Its constructor triggers reInit() → hydrateStore() → loadAllData into NgRx. + // Its constructor triggers reInit() -> hydrateStore() -> loadAllData into NgRx. { provide: APP_INITIALIZER, useFactory: (_dataInit: DataInitService) => { @@ -246,6 +248,28 @@ bootstrapApplication(AppComponent, { deps: [EncryptionPasswordDialogOpenerService], multi: true, }, + // Ensure PluginOAuthRedirectHandler is instantiated at bootstrap. + // Its constructor registers platform-specific listeners (postMessage / Electron IPC) + // that bridge OAuth redirect callbacks to PluginOAuthService. + { + provide: APP_INITIALIZER, + useFactory: (_handler: PluginOAuthRedirectHandler) => { + return () => {}; + }, + deps: [PluginOAuthRedirectHandler], + multi: true, + }, + // Ensure OAuthCallbackHandlerService is instantiated at bootstrap on native platforms. + // Its constructor registers Capacitor's appUrlOpen listener that bridges + // both Dropbox and plugin OAuth redirect callbacks. + { + provide: APP_INITIALIZER, + useFactory: (_handler: OAuthCallbackHandlerService) => { + return () => {}; + }, + deps: [OAuthCallbackHandlerService], + multi: true, + }, // Note: ImmediateUploadService now initializes itself in constructor // after DataInitStateService.isAllDataLoadedInitially$ fires to avoid // race condition where upload attempts happen before sync config is loaded