feat(two-way-sync): add plugin OAuth, two-way field sync, and remote issue deletion

Add OAuth support for plugins with PKCE, token persistence in local-only
IndexedDB, and Electron/web redirect handling. Extend two-way sync with
dueDay, dueWithTime, and timeEstimate field mappings including mutually
exclusive field clearing. Support remote issue deletion on task delete
and remote deletion detection during polling.

Add Google Calendar plugin (disabled from bundled builds pending legal
review) as a development reference for the plugin OAuth and two-way
sync APIs.

Additional fixes:
- Restore accidentally deleted focus-mode translation keys
- Remove full Task[] from deleteTasks action to prevent op-log bloat
- Add dueDay/deadlineDay format validation guards (#6908)
- Fix Android reminder alarm cancel intent matching
- Validate dueDay format to prevent false overdue from corrupted data
- Fix PKCE test expectation after utility consolidation
This commit is contained in:
Johannes Millan 2026-03-21 20:49:31 +01:00
parent 25265c3912
commit 02bc3e88e3
64 changed files with 7231 additions and 185 deletions

View file

@ -82,6 +82,16 @@
android:scheme="com.super-productivity.app"
android:host="oauth-callback" />
</intent-filter>
<!-- OAuth callback handler for plugin authentication -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="com.super-productivity.app"
android:host="plugin-oauth-callback" />
</intent-filter>
</activity>
<activity

View file

@ -207,4 +207,10 @@ export interface ElectronAPI {
manifest: PluginManifest,
request: PluginNodeScriptRequest,
): Promise<PluginNodeScriptResult>;
// Plugin OAuth
pluginOAuthStart(url: string): void;
onPluginOAuthCb(
listener: (data: { code?: string; error?: string; state?: string }) => void,
): void;
}

86
electron/plugin-oauth.ts Normal file
View file

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

View file

@ -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<PluginNodeScriptResult>,
// 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

View file

@ -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',

View file

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

View file

@ -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<string, unknown>,
http: PluginHttp,
): Promise<{ label: string; value: string }[]>;
}
export interface PluginHttpOptions {
@ -124,14 +149,25 @@ export interface IssueProviderPluginDefinition {
title: string,
config: Record<string, unknown>,
http: PluginHttp,
): Promise<{ issueId: string; issueNumber: number; issueData: PluginIssue }>;
): Promise<{ issueId: string; issueNumber?: number; issueData: PluginIssue }>;
extractSyncValues?(issue: PluginIssue): Record<string, unknown>;
deleteIssue?(
id: string,
config: Record<string, unknown>,
http: PluginHttp,
): Promise<void>;
/** 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:<pluginId>'. */
issueProviderKey?: string;

View file

@ -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"}
{"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"}

View file

@ -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<string, string>;
}
export interface OAuthTokenResult {
accessToken: string;
refreshToken: string;
expiresAt: number; // unix ms
}
export interface PluginAPI {
cfg: PluginBaseCfg;
@ -408,6 +432,13 @@ export interface PluginAPI {
getConfig<T = Record<string, unknown>>(): Promise<T | null>;
// oauth
startOAuthFlow(config: OAuthFlowConfig): Promise<OAuthTokenResult>;
getOAuthToken(): Promise<string | null>;
clearOAuthToken(): Promise<void>;
// download file
downloadFile(filename: string, data: string): Promise<void>;

File diff suppressed because it is too large Load diff

View file

@ -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"
}
}

View file

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

View file

@ -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
}
}

View file

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

View file

@ -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<string, string>;
}): Promise<{ accessToken: string; refreshToken: string; expiresAt: number }>;
getOAuthToken(): Promise<string | null>;
clearOAuthToken(): Promise<void>;
};
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<string, unknown>): 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<PluginSearchResult[]> => {
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<GoogleCalendarListResponse>(
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<string, unknown>,
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<string, unknown>): Promise<Record<string, string>> {
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<string, unknown>,
http: PluginHttp,
): Promise<PluginSearchResult[]> {
return fetchEvents(http, asCfg(config), { query: searchTerm });
},
async getById(
issueId: string,
config: Record<string, unknown>,
http: PluginHttp,
): Promise<PluginIssue> {
const calendarId = getCalendarId(asCfg(config));
const event = await http.get<GoogleCalendarEvent>(
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, unknown>): 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<string, unknown>,
http: PluginHttp,
): Promise<boolean> {
const calendarId = getCalendarId(asCfg(config));
try {
await http.get(`${GOOGLE_CALENDAR_API}/calendars/${encodeURIComponent(calendarId)}`);
return true;
} catch {
return false;
}
},
async getNewIssuesForBacklog(
config: Record<string, unknown>,
http: PluginHttp,
): Promise<PluginSearchResult[]> {
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<string, unknown>,
config: Record<string, unknown>,
http: PluginHttp,
): Promise<void> {
const calendarId = getCalendarId(asCfg(config));
const patch: Record<string, unknown> = {};
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<GoogleCalendarEvent>(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<string, unknown> {
const raw = issue as Record<string, unknown>;
const startObj = raw.start as Record<string, unknown> | undefined;
const endObj = raw.end as Record<string, unknown> | 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<string, unknown>, 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<GoogleCalendarEvent>(
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<string, unknown>,
http: PluginHttp,
): Promise<void> {
const calendarId = getCalendarId(asCfg(config));
await http.delete(eventUrl(calendarId, id));
},
});

View file

@ -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"]
}

View file

@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.spec.ts'],
globals: true,
},
});

View file

@ -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) {

View file

@ -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,

View file

@ -21,6 +21,32 @@
<mat-icon>calendar_month</mat-icon>
<span>iCal Other</span>
</button>
@for (provider of pluginCalendarProviders; track provider.pluginId) {
<button
mat-raised-button
(click)="openSetupDialog(provider.registeredKey)"
>
@if (provider.icon && provider.icon !== 'extension') {
<mat-icon [svgIcon]="provider.icon"></mat-icon>
} @else {
<mat-icon>calendar_month</mat-icon>
}
<span>{{ provider.name }}</span>
</button>
}
@for (provider of disabledPluginCalendarProviders; track provider.pluginId) {
<button
mat-raised-button
(click)="enablePluginAndOpenSetup(provider.pluginId, provider.issueProviderKey)"
>
@if (provider.icon && provider.icon !== 'extension') {
<mat-icon [svgIcon]="provider.icon"></mat-icon>
} @else {
<mat-icon>calendar_month</mat-icon>
}
<span>{{ provider.name }}</span>
</button>
}
</div>
<h4>Setup Issue Provider</h4>

View file

@ -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<void> {
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;

View file

@ -25,7 +25,7 @@
</div>
}
<!---->
@if (ip.issueProviderKey === 'ICAL') {
@if (useAgendaView()) {
<issue-panel-calendar-agenda [issueProvider]="ip"></issue-panel-calendar-agenda>
} @else {
<form

View file

@ -50,6 +50,7 @@ import { IS_MOUSE_PRIMARY } from '../../../util/is-mouse-primary';
import { getIssueProviderHelpLink } from '../../issue/mapping-helper/get-issue-provider-help-link';
import { ISSUE_PROVIDER_HUMANIZED } from '../../issue/issue.const';
import { IssuePanelCalendarAgendaComponent } from '../issue-panel-calendar-agenda/issue-panel-calendar-agenda.component';
import { PluginIssueProviderRegistryService } from '../../../plugins/issue-provider/plugin-issue-provider-registry.service';
import { standardListAnimation } from '../../../ui/animations/standard-list.ani';
import { MatIconButton } from '@angular/material/button';
import { TranslatePipe } from '@ngx-translate/core';
@ -94,6 +95,7 @@ export class IssueProviderTabComponent implements OnDestroy, AfterViewInit {
private _issueService = inject(IssueService);
private _matDialog = inject(MatDialog);
private _store = inject(Store);
private _pluginRegistry = inject(PluginIssueProviderRegistryService);
issueProvider = input.required<IssueProvider>();
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),

View file

@ -96,6 +96,39 @@
<p [innerHTML]="T.G.EXTENSION_INFO | translate"></p>
</div>
}
@for (btn of oauthButtons; track btn.label) {
<div style="margin-bottom: 16px">
@if (isOAuthConnected()) {
<button
mat-stroked-button
type="button"
color="primary"
(click)="disconnectOAuth()"
>
<mat-icon>link_off</mat-icon>
{{ T.F.ISSUE.DIALOG.DISCONNECT | translate }}
</button>
<span style="margin-left: 8px; color: var(--palette-accent)">
<mat-icon
style="vertical-align: middle; font-size: 18px; width: 18px; height: 18px"
>check_circle</mat-icon
>
{{ T.F.ISSUE.DIALOG.CONNECTED | translate }}
</span>
} @else {
<button
mat-stroked-button
type="button"
color="primary"
[disabled]="isOAuthConnecting()"
(click)="connectOAuth(btn.oauthConfig)"
>
<mat-icon>login</mat-icon>
{{ btn.label }}
</button>
}
</div>
}
<formly-form
[model]="model"
(modelChange)="formlyModelChange($event)"

View file

@ -43,13 +43,15 @@ import { JiraAdditionalCfgComponent } from '../providers/jira/jira-view-componen
import { HelpSectionComponent } from '../../../ui/help-section/help-section.component';
import { TranslatePipe } from '@ngx-translate/core';
import { MatSlideToggle } from '@angular/material/slide-toggle';
import { FormlyModule } from '@ngx-formly/core';
import { FormlyFieldConfig, FormlyModule } from '@ngx-formly/core';
import { MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
import { devError } from '../../../util/dev-error';
import { IssueLog } from '../../../core/log';
import { PluginIssueProviderRegistryService } from '../../../plugins/issue-provider/plugin-issue-provider-registry.service';
import { PluginBridgeService } from '../../../plugins/plugin-bridge.service';
import { PluginHttpService } from '../../../plugins/issue-provider/plugin-http.service';
import { TrelloAdditionalCfgComponent } from '../providers/trello/trello-view-components/trello_cfg/trello_additional_cfg.component';
// ClickUp is now a plugin — no built-in config component needed
import { NextcloudDeckAdditionalCfgComponent } from '../providers/nextcloud-deck/nextcloud-deck-additional-cfg.component';
@ -95,9 +97,13 @@ export class DialogEditIssueProviderComponent {
}>(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<DialogEditIssueProviderComponent> =
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<void> {
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<void> {
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<void> {
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<string, unknown>)['pluginConfig'] ?? {};
const http = this._pluginHttp.createHttpHelper(() =>
provider.definition.getHeaders(pluginConfig as Record<string, unknown>),
);
for (const field of dynamicFields) {
try {
const options = await field.loadOptions!(
pluginConfig as Record<string, unknown>,
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<string, unknown> {
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<void> {
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();
}
}
}

View file

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

View file

@ -16,8 +16,14 @@ export const computePushDecisions = (
ctx: FieldMappingContext,
): PushDecision[] => {
const decisions: PushDecision[] = [];
const decidedIssueFields = new Set<string>();
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;

View file

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

View file

@ -17,7 +17,8 @@ export interface IssueSyncAdapter<TCfg> {
cfg: TCfg,
): Promise<{
issueId: string;
issueNumber: number;
issueNumber?: number;
issueData: Record<string, unknown>;
}>;
deleteIssue?(issueId: string, cfg: TCfg): Promise<void>;
}

File diff suppressed because it is too large Load diff

View file

@ -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<string> = 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<string, unknown> }
> = {
[TaskSharedActions.applyShortSyntax.type]: (action) => {
const a = action as ReturnType<typeof TaskSharedActions.applyShortSyntax>;
const changes: Record<string, unknown> = { ...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<typeof TaskSharedActions.scheduleTaskWithTime>;
return { taskId: a.task.id, changes: { dueWithTime: a.dueWithTime } };
},
[TaskSharedActions.reScheduleTaskWithTime.type]: (action) => {
const a = action as ReturnType<typeof TaskSharedActions.reScheduleTaskWithTime>;
return { taskId: a.task.id, changes: { dueWithTime: a.dueWithTime } };
},
[TaskSharedActions.unscheduleTask.type]: (action) => {
const a = action as ReturnType<typeof TaskSharedActions.unscheduleTask>;
return { taskId: a.id, changes: { dueWithTime: undefined } };
},
[TaskSharedActions.updateTask.type]: (action) => {
const a = action as ReturnType<typeof TaskSharedActions.updateTask>;
return {
taskId: a.task.id.toString(),
changes: a.task.changes as Partial<Task>,
};
},
[PlannerActions.planTaskForDay.type]: (action) => {
const a = action as ReturnType<typeof PlannerActions.planTaskForDay>;
return { taskId: a.task.id, changes: { dueDay: a.day } };
},
[PlannerActions.transferTask.type]: (action) => {
const a = action as ReturnType<typeof PlannerActions.transferTask>;
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<string>();
private static readonly _MAX_SYNC_ORIGINATED_IDS = 1000;
constructor() {
const caldavAdapter = inject(CaldavSyncAdapterService);
@ -36,30 +100,31 @@ export class IssueTwoWaySyncEffects {
pushFieldsOnTaskUpdate$: Observable<unknown> = 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<unknown> = 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<unknown> = 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<unknown> = 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<string, unknown>,
cfg: unknown,
): Promise<void>;
},
cfg: IssueProvider,
syncValues: Record<string, unknown>,
): Promise<void> {
// 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<string, unknown> = {};
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<string, unknown> })
@ -161,6 +325,39 @@ export class IssueTwoWaySyncEffects {
return false;
}
private _deleteRemoteIssue$(info: DeletedTaskIssueInfo | Task): Observable<unknown> {
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<Task>): Observable<unknown> {
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<string, unknown> = {};
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);
}
}
}
}

View file

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

View file

@ -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<ArchiveService>;
let deletedTaskIssueSidecar: DeletedTaskIssueSidecarService;
let tickSubject: Subject<{ duration: number; date: string }>;
const createMockTask = (id: string, overrides: Partial<Task> = {}): Task =>
@ -159,6 +161,7 @@ describe('TaskService', () => {
service = TestBed.inject(TaskService);
store = TestBed.inject(MockStore);
archiveService = TestBed.inject(ArchiveService) as jasmine.SpyObj<ArchiveService>;
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', () => {

View file

@ -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<string | null> = this._store.pipe(
select(selectCurrentTaskId),
@ -191,6 +194,7 @@ export class TaskService {
private _lastFocusedTaskEl: HTMLElement | null = null;
private _allTasks$: Observable<Task[]> = 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 }));
}

View file

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

View file

@ -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 () => {

View file

@ -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<ArrayBuffer> => {
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<string> => {
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 };
};

View file

@ -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<PluginHttpService>;
let storeSpy: jasmine.SpyObj<Store>;
let snackSpy: jasmine.SpyObj<SnackService>;
let taskServiceSpy: jasmine.SpyObj<TaskService>;
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<string, unknown>,
): 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', () => {

View file

@ -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<string, unknown>)['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<string, unknown>,
): Record<string, unknown> {
const issueRecord = issue as Record<string, unknown>;
const result: Record<string, unknown> = {};
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<string, unknown>,
): 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<string, unknown>;
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<string, unknown>,
task: Task,
cfg: IssueProviderPluginType,
): Partial<Task> {
const fieldMappings = provider.definition.fieldMappings;
if (!fieldMappings?.length) {
return {};
}
const twoWaySync = (cfg.pluginConfig?.['twoWaySync'] as Record<string, string>) ?? {};
const lastSyncedValues = task.issueLastSyncedValues ?? {};
const ctx = { issueId: task.issueId! };
const changes: Record<string, unknown> = {};
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<Task>;
}
}

View file

@ -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:<pluginId>', () => {
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',
});

View file

@ -15,16 +15,18 @@ export class PluginIssueProviderRegistryService {
/** Maps pluginId → registeredKey for cleanup */
private _pluginIdToKey = new Map<string, string>();
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;
}
}

View file

@ -28,4 +28,6 @@ export interface RegisteredPluginIssueProvider {
icon: string;
pollIntervalMs: number;
issueStrings: { singular: string; plural: string };
useAgendaView?: boolean;
defaultAutoAddToBacklog?: boolean;
}

View file

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

View file

@ -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<string, unknown>;
}> => {
if (!definition.createIssue) {
@ -111,5 +115,17 @@ export const createPluginSyncAdapter = (
issueData: result.issueData as Record<string, unknown>,
};
},
getIssueLastUpdated: (issue: Record<string, unknown>): number => {
const lastUpdated = (issue as { lastUpdated?: number }).lastUpdated;
return lastUpdated ?? Date.now();
},
deleteIssue: definition.deleteIssue
? async (issueId: string, cfg: IssueProviderPluginType): Promise<void> => {
const http = createHttp(cfg);
await definition.deleteIssue!(issueId, cfg.pluginConfig, http);
}
: undefined,
};
};

View file

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

View file

@ -0,0 +1 @@
export { generateCodeVerifier, generateCodeChallenge } from '../../util/pkce.util';

View file

@ -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<OAuthTokenResult> {
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<void> {
this._pluginOAuthService.clearTokens(pluginId);
await this._clearPersistedOAuthTokens(pluginId);
}
async restoreAndCheckOAuthTokens(pluginId: string): Promise<boolean> {
if (!this._pluginOAuthService.hasTokens(pluginId)) {
await this._restoreOAuthTokens(pluginId);
}
return this._pluginOAuthService.hasTokens(pluginId);
}
async getOAuthToken(pluginId: string): Promise<string | null> {
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<void> {
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<void> {
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<void> {
try {
await deleteOAuthTokens(this._oauthPersistenceKey(pluginId));
} catch (error) {
PluginLog.err('PluginOAuthBridge: Failed to clear persisted OAuth tokens:', error);
}
}
}

View file

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

View file

@ -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<PluginOAuthDb> | undefined;
let initPromise: Promise<IDBPDatabase<PluginOAuthDb>> | undefined;
const ensureDb = async (): Promise<IDBPDatabase<PluginOAuthDb>> => {
if (db) {
return db;
}
if (!initPromise) {
initPromise = openDB<PluginOAuthDb>(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<void> => {
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<string | null> => {
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<void> => {
try {
const store = await ensureDb();
await store.delete(DB_STORE_NAME, key);
} catch (error) {
PluginLog.err('PluginOAuthTokenStore: Failed to delete tokens:', error);
throw error;
}
};

View file

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

View file

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

View file

@ -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<string, PluginOAuthTokens>();
private _pendingRedirect: PendingRedirect | null = null;
private _refreshPromises = new Map<string, Promise<string | null>>();
/** Emits the pluginId when a token refresh fails and in-memory tokens are cleared. */
tokenInvalidated$ = new Subject<string>();
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<string, string> = {};
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<OAuthTokenResult> {
const params: Record<string, string> = {
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<string, string> = {
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<T>(
tokenUrl: string,
params: Record<string, string>,
): Promise<T> {
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<T>(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<string | null> {
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<string | null> {
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<string> {
// 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<string>((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;
}
}
}

View file

@ -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<OAuthTokenResult> {
PluginLog.log(`Plugin ${this._pluginId} requested OAuth flow`);
return this._boundMethods.startOAuthFlow(config);
}
async getOAuthToken(): Promise<string | null> {
return this._boundMethods.getOAuthToken();
}
async clearOAuthToken(): Promise<void> {
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

View file

@ -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<PluginHeaderBtnCfg[]>([]);
@ -141,7 +145,7 @@ export class PluginBridgeService implements OnDestroy {
): {
persistDataSynced: (dataStr: string) => Promise<void>;
loadPersistedData: () => Promise<string | null>;
getConfig: () => Promise<any>;
getConfig: () => Promise<unknown>;
downloadFile: (filename: string, data: string) => Promise<void>;
registerHeaderButton: (cfg: PluginHeaderBtnCfg) => void;
registerMenuEntry: (cfg: Omit<PluginMenuEntryCfg, 'pluginId'>) => void;
@ -170,6 +174,9 @@ export class PluginBridgeService implements OnDestroy {
registerConfigHandler: (handler: () => void) => void;
registerIssueProvider: (definition: IssueProviderPluginDefinition) => void;
unregisterIssueProvider: () => void;
startOAuthFlow: (config: OAuthFlowConfig) => Promise<OAuthTokenResult>;
getOAuthToken: () => Promise<string | null>;
clearOAuthToken: () => Promise<void>;
log: ReturnType<typeof Log.withContext>;
} {
return {
@ -241,6 +248,14 @@ export class PluginBridgeService implements OnDestroy {
}
},
// OAuth
startOAuthFlow: (config: OAuthFlowConfig): Promise<OAuthTokenResult> =>
this._pluginOAuthBridge.startOAuthFlow(pluginId, config),
getOAuthToken: (): Promise<string | null> =>
this._pluginOAuthBridge.getOAuthToken(pluginId),
clearOAuthToken: (): Promise<void> =>
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<OAuthTokenResult> {
return this._pluginOAuthBridge.startOAuthFlow(pluginId, config);
}
async clearOAuthTokens(pluginId: string): Promise<void> {
return this._pluginOAuthBridge.clearOAuthTokens(pluginId);
}
async restoreAndCheckOAuthTokens(pluginId: string): Promise<boolean> {
return this._pluginOAuthBridge.restoreAndCheckOAuthTokens(pluginId);
}
private async _downloadFile(filename: string, data: string): Promise<void> {
typia.assert<string>(filename);
typia.assert<string>(data);
@ -833,7 +865,7 @@ export class PluginBridgeService implements OnDestroy {
/**
* Internal method to get plugin configuration
*/
private async _getConfig(pluginId: string): Promise<any> {
private async _getConfig(pluginId: string): Promise<unknown> {
try {
return await this._pluginConfigService.getPluginConfig(pluginId);
} catch (error) {

View file

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

View file

@ -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', () => {

View file

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

View file

@ -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: {

View file

@ -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: {

44
src/app/util/pkce.util.ts Normal file
View file

@ -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<ArrayBuffer> => {
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<string> => {
const data = new TextEncoder().encode(verifier);
const digest = await sha256(data);
return base64UrlEncode(new Uint8Array(digest));
};

View file

@ -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"
}
]
]

View file

@ -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 <strong>all previously imported issue tasks will loose their reference</strong>. 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 <strong>all previously imported issue tasks will loose their reference</strong>. This cannot be undone!",
"DISCONNECT": "Disconnect"
}
},
"ISSUE_PANEL": {

View file

@ -0,0 +1,25 @@
<!doctype html>
<html>
<head>
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'">
<title>Authorization Complete</title>
</head>
<body>
<p>Authorization complete. You can close this window.</p>
<script>
(function () {
var params = new URLSearchParams(window.location.search);
var code = params.get('code');
var error = params.get('error');
var state = params.get('state');
if (window.opener) {
window.opener.postMessage(
{ type: 'SP_OAUTH_CALLBACK', code: code, error: error, state: state },
window.location.origin,
);
}
window.close();
})();
</script>
</body>
</html>

View file

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