super-productivity/src/app/plugins/plugin-bridge.service.ts
Meh-S-Eze 3db96fd8a3
fix(plugins): expose focused task API to iframe plugins (#8291)
Co-authored-by: Shem Freeze <whatamehs@Shems-MacBook-Air.local>
2026-06-15 14:57:34 +02:00

2126 lines
72 KiB
TypeScript

import { computed, inject, Injectable, Injector, OnDestroy, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatDialog } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { SnackService } from '../core/snack/snack.service';
import { NotifyService } from '../core/notify/notify.service';
import {
DialogCfg,
DialogResult,
Hooks,
NotifyCfg,
PluginCreateTaskData,
PluginHeaderBtnCfg,
PluginHookHandler,
PluginMenuEntryCfg,
PluginNodeScriptRequest,
PluginNodeScriptResult,
PluginShortcutCfg,
PluginSidePanelBtnCfg,
PluginWorkContextHeaderBtnCfg,
ActiveWorkContext,
Task,
} from './plugin-api.model';
import { toActiveWorkContext } from './util/active-work-context.util';
import {
assertPluginPersistenceKey,
composeId,
} from './util/plugin-persistence-key.util';
import {
BatchTaskCreate,
BatchUpdateRequest,
BatchUpdateResult,
OAuthFlowConfig,
OAuthTokenResult,
PluginAppState,
PluginManifest,
PluginNote,
PluginSimpleCounterFull,
PluginTaskRepeatCfg,
SnackCfg,
} from '@super-productivity/plugin-api';
import { snackCfgToSnackParams } from './plugin-api-mapper';
import { PluginHooksService } from './plugin-hooks';
import { TaskService } from '../features/tasks/task.service';
import { TaskFocusService } from '../features/tasks/task-focus.service';
import { addSubTask } from '../features/tasks/store/task.actions';
import { selectTaskFeatureState } from '../features/tasks/store/task.selectors';
import { parseTimeSpentChanges } from '../features/tasks/short-syntax';
import { GlobalConfigService } from '../features/config/global-config.service';
import { DEFAULT_GLOBAL_CONFIG } from '../features/config/default-global-config.const';
import { selectConfigFeatureState } from '../features/config/store/global-config.reducer';
import { TaskSharedActions } from '../root-store/meta/task-shared.actions';
import { nanoid } from 'nanoid';
import { WorkContextService } from '../features/work-context/work-context.service';
import { ProjectService } from '../features/project/project.service';
import { TagService } from '../features/tag/tag.service';
import typia from 'typia';
import { distinctUntilChanged, first, map, take, timeout } from 'rxjs/operators';
import { firstValueFrom } from 'rxjs';
import { selectProjectFeatureState } from '../features/project/store/project.selectors';
import { selectNoteFeatureState } from '../features/note/store/note.reducer';
import { selectTagFeatureState } from '../features/tag/store/tag.reducer';
import { selectTaskByIdWithSubTaskData } from '../features/tasks/store/task.selectors';
import { PluginUserPersistenceService } from './plugin-user-persistence.service';
import { PluginConfigService } from './plugin-config.service';
import { TaskArchiveService } from '../features/archive/task-archive.service';
import { Router } from '@angular/router';
import { PluginDialogComponent } from './ui/plugin-dialog/plugin-dialog.component';
import { IS_ELECTRON } from '../app.constants';
import { isAllowedPluginAction } from './allowed-plugin-actions.const';
import { TranslateService } from '@ngx-translate/core';
import { T } from '../t.const';
import { SyncWrapperService } from '../imex/sync/sync-wrapper.service';
import { Log, PluginLog } from '../core/log';
import { TaskCopy } from '../features/tasks/task.model';
import { ProjectCopy } from '../features/project/project.model';
import { TagCopy } from '../features/tag/tag.model';
import { MAX_BATCH_OPERATIONS_SIZE } from '../op-log/core/operation-log.const';
import { PluginIssueProviderRegistryService } from './issue-provider/plugin-issue-provider-registry.service';
import { IssueProviderPluginDefinition } from './issue-provider/plugin-issue-provider.model';
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';
import { PluginService } from './plugin.service';
import { PluginI18nService } from './plugin-i18n.service';
import { formatDateForPlugin } from './plugin-i18n-date.util';
const toPluginTaskCopy = (
task: (TaskCopy & { subTasks?: unknown }) | null | undefined,
): TaskCopy | null => {
if (!task) {
return null;
}
const taskCopy = { ...task };
delete taskCopy.subTasks;
return taskCopy;
};
// New imports for simple counters
import { selectAllSimpleCounters } from '../features/simple-counter/store/simple-counter.reducer';
import { selectTaskRepeatCfgFeatureState } from '../features/task-repeat-cfg/store/task-repeat-cfg.selectors';
import {
SimpleCounter,
SimpleCounterType,
} from '../features/simple-counter/simple-counter.model';
import { EMPTY_SIMPLE_COUNTER } from '../features/simple-counter/simple-counter.const';
import {
deleteSimpleCounter,
toggleSimpleCounterCounter,
updateSimpleCounter,
upsertSimpleCounter,
} from '../features/simple-counter/store/simple-counter.actions';
import { getDbDateStr } from '../util/get-db-date-str';
import { DataInitService } from '../core/data-init/data-init.service';
interface PluginNodeExecutionElectronApi {
requestGrant(pluginId: string): Promise<{ token: string } | null>;
executeScript(
pluginId: string,
grantToken: string,
request: PluginNodeScriptRequest,
): Promise<PluginNodeScriptResult>;
revokeGrant(pluginId: string, grantToken: string): Promise<void>;
}
type PluginDateFormat = 'short' | 'medium' | 'long' | 'time' | 'datetime';
/**
* PluginBridge acts as an intermediary layer between plugins and the main application services.
* This provides:
* - Clean separation of concerns
* - Controlled access to app functionality
* - Easy testing and mocking
* - Centralized plugin-to-app communication
*/
@Injectable({
providedIn: 'root',
})
export class PluginBridgeService implements OnDestroy {
private _snackService = inject(SnackService);
private _notifyService = inject(NotifyService);
private _dialog = inject(MatDialog);
private _store = inject(Store);
private _pluginHooksService = inject(PluginHooksService);
private _taskService = inject(TaskService);
private _taskFocusService = inject(TaskFocusService);
private _workContextService = inject(WorkContextService);
private _projectService = inject(ProjectService);
private _tagService = inject(TagService);
private _pluginUserPersistenceService = inject(PluginUserPersistenceService);
private _pluginConfigService = inject(PluginConfigService);
private _taskArchiveService = inject(TaskArchiveService);
private _router = inject(Router);
private _injector = inject(Injector);
private _translateService = inject(TranslateService);
private _syncWrapperService = inject(SyncWrapperService);
private _pluginIssueProviderRegistry = inject(PluginIssueProviderRegistryService);
private _globalThemeService = inject(GlobalThemeService);
private _syncAdapterRegistry = inject(IssueSyncAdapterRegistryService);
private _pluginHttpService = inject(PluginHttpService);
private _pluginOAuthBridge = inject(PluginOAuthBridgeService);
private _dataInitService = inject(DataInitService);
private _globalConfigService = inject(GlobalConfigService);
readonly #nodeExecutionGrantTokens = new Map<string, string>();
readonly #nodeExecutionApi = this._consumeNodeExecutionApi();
// Track header buttons registered by plugins
private readonly _headerButtons = signal<PluginHeaderBtnCfg[]>([]);
public readonly headerButtons = this._headerButtons.asReadonly();
// Track menu entries registered by plugins
private readonly _menuEntries = signal<PluginMenuEntryCfg[]>([]);
public readonly menuEntries = this._menuEntries.asReadonly();
// Track shortcuts registered by plugins
readonly shortcuts = signal<PluginShortcutCfg[]>([]);
// Track side panel buttons registered by plugins
private readonly _sidePanelButtons = signal<PluginSidePanelBtnCfg[]>([]);
public readonly sidePanelButtons = this._sidePanelButtons.asReadonly();
// Track work-context-scoped header buttons registered by plugins. These are
// filtered against the active work context (project or TODAY tag) before
// rendering — see workContextHeaderButtons below.
private readonly _workContextHeaderButtons = signal<PluginWorkContextHeaderBtnCfg[]>(
[],
);
// Snapshot of the active work context, used to filter context-scoped
// buttons. Distinct on (id, type) so it does not churn when task/tag/
// project data changes within the same context (e.g. a task is added).
private readonly _activeWorkContextSig = toSignal(
this._workContextService.activeWorkContext$.pipe(
distinctUntilChanged((a, b) => a?.id === b?.id && a?.type === b?.type),
),
{ initialValue: null },
);
public readonly workContextHeaderButtons = computed(() => {
const ctx = this._activeWorkContextSig();
const buttons = this._workContextHeaderButtons();
if (!ctx) return [] as PluginWorkContextHeaderBtnCfg[];
let key: 'PROJECT' | 'TAG' | 'TODAY';
if (ctx.type === 'PROJECT') {
key = 'PROJECT';
} else if (ctx.id === 'TODAY') {
key = 'TODAY';
} else {
key = 'TAG';
}
return buttons.filter((b) => b.showFor.includes(key));
});
// Holds the pluginId currently embedded in the work-view body, or null when
// the normal task list should render. Set via showInWorkContext().
private readonly _workContextEmbedPluginId = signal<string | null>(null);
public readonly workContextEmbedPluginId = this._workContextEmbedPluginId.asReadonly();
// Track config handlers registered by plugins (for settings button on plugin card)
private readonly _configHandlers = new Map<string, () => void>();
constructor() {
// Initialize window focus tracking
this._initWindowFocusTracking();
}
/**
* Create bound methods for a specific plugin
* This ensures each plugin has its own set of methods with the pluginId already bound
*/
createBoundMethods(
pluginId: string,
manifest?: PluginManifest,
): {
persistDataSynced: (dataStr: string, key?: string) => Promise<void>;
loadPersistedData: (key?: string) => Promise<string | null>;
getConfig: () => Promise<unknown>;
downloadFile: (filename: string, data: string) => Promise<void>;
registerHeaderButton: (cfg: PluginHeaderBtnCfg) => void;
registerMenuEntry: (cfg: Omit<PluginMenuEntryCfg, 'pluginId'>) => void;
registerSidePanelButton: (cfg: Omit<PluginSidePanelBtnCfg, 'pluginId'>) => void;
registerWorkContextHeaderButton: (
cfg: Omit<PluginWorkContextHeaderBtnCfg, 'pluginId'>,
) => void;
registerShortcut: (cfg: PluginShortcutCfg) => void;
showIndexHtmlAsView: () => void;
showInWorkContext: () => void;
closeWorkContextView: () => void;
getActiveWorkContext: () => Promise<ActiveWorkContext | null>;
getSelectedTask: () => Promise<TaskCopy | null>;
getFocusedTask: () => Promise<TaskCopy | null>;
triggerSync: () => Promise<void>;
dispatchAction: (action: { type: string; [key: string]: unknown }) => void;
executeNodeScript: (
request: PluginNodeScriptRequest,
) => Promise<PluginNodeScriptResult>;
getAllCounters: () => Promise<{ [key: string]: number }>;
getCounter: (id: string) => Promise<number | null>;
setCounter: (id: string, value: number) => Promise<void>;
incrementCounter: (id: string, incrementBy?: number) => Promise<number>;
decrementCounter: (id: string, decrementBy?: number) => Promise<number>;
deleteCounter: (id: string) => Promise<void>;
getAllSimpleCounters: () => Promise<SimpleCounter[]>;
getSimpleCounter: (id: string) => Promise<SimpleCounter | undefined>;
updateSimpleCounter: (id: string, updates: Partial<SimpleCounter>) => Promise<void>;
toggleSimpleCounter: (id: string) => Promise<void>;
setSimpleCounterEnabled: (id: string, isEnabled: boolean) => Promise<void>;
deleteSimpleCounter: (id: string) => Promise<void>;
setSimpleCounterToday: (id: string, value: number) => Promise<void>;
setSimpleCounterDate: (id: string, date: string, value: number) => Promise<void>;
registerConfigHandler: (handler: () => void) => void;
registerIssueProvider: (definition: IssueProviderPluginDefinition) => void;
unregisterIssueProvider: () => void;
startOAuthFlow: (config: OAuthFlowConfig) => Promise<OAuthTokenResult>;
getOAuthToken: () => Promise<string | null>;
clearOAuthToken: () => Promise<void>;
translate: (key: string, params?: Record<string, string | number>) => string;
formatDate: (date: Date | string | number, format: PluginDateFormat) => string;
getCurrentLanguage: () => string;
log: ReturnType<typeof Log.withContext>;
} {
return {
// Data persistence
persistDataSynced: (dataStr: string, key?: string) =>
this._persistDataSynced(pluginId, dataStr, key),
loadPersistedData: (key?: string) => this._loadPersistedData(pluginId, key),
getConfig: () => this._getConfig(pluginId),
downloadFile: (filename: string, data: string) =>
this._downloadFile(filename, data),
// UI registration
registerHeaderButton: (cfg: PluginHeaderBtnCfg) =>
this._registerHeaderButton(pluginId, cfg),
registerMenuEntry: (cfg: Omit<PluginMenuEntryCfg, 'pluginId'>) =>
this._registerMenuEntry(pluginId, cfg),
registerSidePanelButton: (cfg: Omit<PluginSidePanelBtnCfg, 'pluginId'>) =>
this._registerSidePanelButton(pluginId, cfg),
registerWorkContextHeaderButton: (
cfg: Omit<PluginWorkContextHeaderBtnCfg, 'pluginId'>,
) => this._registerWorkContextHeaderButton(pluginId, cfg),
registerShortcut: (cfg: PluginShortcutCfg) => this._registerShortcut(pluginId, cfg),
registerConfigHandler: (handler: () => void) =>
this._configHandlers.set(pluginId, handler),
// Navigation
showIndexHtmlAsView: () => this._showIndexHtmlAsView(pluginId),
showInWorkContext: () => this._showInWorkContext(pluginId),
closeWorkContextView: () => this._closeWorkContextView(pluginId),
getActiveWorkContext: () => this.getActiveWorkContext(),
getSelectedTask: () => this.getSelectedTask(),
getFocusedTask: () => this.getFocusedTask(),
// Sync
triggerSync: () => this._triggerSync(pluginId),
// Actions
dispatchAction: (action: { type: string; [key: string]: unknown }) =>
this._dispatchAction(pluginId, action),
// Node execution
executeNodeScript: (request: PluginNodeScriptRequest) =>
this._executeNodeScript(pluginId, manifest || null, request),
// Basic counter methods (existing)
getAllCounters: () => this.getAllCounters(),
getCounter: (id: string) => this.getCounter(id),
setCounter: (id: string, value: number) => this.setCounter(id, value),
incrementCounter: (id: string, incrementBy = 1) =>
this.incrementCounter(id, incrementBy),
decrementCounter: (id: string, decrementBy = 1) =>
this.decrementCounter(id, decrementBy),
deleteCounter: (id: string) => this.deleteSimpleCounter(id),
// Full SimpleCounter methods (new)
getAllSimpleCounters: () => this.getAllSimpleCounters(),
getSimpleCounter: (id: string) => this.getSimpleCounter(id),
updateSimpleCounter: (id: string, updates: Partial<SimpleCounter>) =>
this.updateSimpleCounter(id, updates),
toggleSimpleCounter: (id: string) => this.toggleSimpleCounter(id),
setSimpleCounterEnabled: (id: string, isEnabled: boolean) =>
this.setSimpleCounterEnabled(id, isEnabled),
deleteSimpleCounter: (id: string) => this.deleteSimpleCounter(id),
setSimpleCounterToday: (id: string, value: number) =>
this.setSimpleCounterToday(id, value),
setSimpleCounterDate: (id: string, date: string, value: number) =>
this.setSimpleCounterDate(id, date, value),
// Issue provider registration
registerIssueProvider: (definition: IssueProviderPluginDefinition) =>
this._registerIssueProvider(pluginId, manifest, definition),
unregisterIssueProvider: () => {
const registeredKey =
this._pluginIssueProviderRegistry.getRegisteredKey(pluginId);
this._pluginIssueProviderRegistry.unregister(pluginId);
if (registeredKey) {
this._syncAdapterRegistry.unregister(registeredKey);
}
},
// OAuth
startOAuthFlow: (config: OAuthFlowConfig): Promise<OAuthTokenResult> =>
this._pluginOAuthBridge.startOAuthFlow(pluginId, config),
getOAuthToken: (): Promise<string | null> =>
this._pluginOAuthBridge.getOAuthToken(
pluginId,
this._getOAuthConfigForPlugin(pluginId),
),
clearOAuthToken: (): Promise<void> =>
this._pluginOAuthBridge.clearOAuthTokens(pluginId),
// i18n
translate: (key: string, params?: Record<string, string | number>): string =>
this._injector.get(PluginI18nService).translate(pluginId, key, params),
formatDate: (date: Date | string | number, format: PluginDateFormat): string =>
formatDateForPlugin(
date,
format,
this._injector.get(PluginI18nService).getCurrentLanguage(),
),
getCurrentLanguage: (): string =>
this._injector.get(PluginI18nService).getCurrentLanguage(),
// Logging
log: Log.withContext(`${pluginId}`),
};
}
private _isPluginBundled(pluginId: string): boolean {
const pluginService = this._injector.get(PluginService);
const path = pluginService.getPluginPath(pluginId);
return !!path && path.startsWith('assets/bundled-plugins/');
}
/**
* Register an issue provider plugin.
*/
private _registerIssueProvider(
pluginId: string,
manifest: PluginManifest | undefined,
definition: IssueProviderPluginDefinition,
): void {
if (typeof definition?.getHeaders !== 'function') {
throw new Error('IssueProviderPluginDefinition.getHeaders must be a function');
}
if (typeof definition?.searchIssues !== 'function') {
throw new Error('IssueProviderPluginDefinition.searchIssues must be a function');
}
if (typeof definition?.getById !== 'function') {
throw new Error('IssueProviderPluginDefinition.getById must be a function');
}
if (typeof definition?.getIssueLink !== 'function') {
throw new Error('IssueProviderPluginDefinition.getIssueLink must be a function');
}
if (!Array.isArray(definition?.issueDisplay)) {
throw new Error('IssueProviderPluginDefinition.issueDisplay must be an array');
}
if (!Array.isArray(definition?.configFields)) {
throw new Error('IssueProviderPluginDefinition.configFields must be an array');
}
const issueProviderCfg = manifest?.issueProvider;
const customKey = issueProviderCfg?.issueProviderKey;
if (customKey && (ISSUE_PROVIDER_TYPES as readonly string[]).includes(customKey)) {
throw new Error(`Plugin cannot register under built-in key "${customKey}"`);
}
const name = manifest?.name ?? pluginId;
const humanReadableName = issueProviderCfg?.humanReadableName ?? name;
const customIconName = `plugin-${pluginId}-icon`;
const icon = this._globalThemeService.hasPluginIcon(customIconName)
? customIconName
: (issueProviderCfg?.icon ?? 'extension');
const pollIntervalMs = issueProviderCfg?.pollIntervalMs ?? 0;
const issueStrings = issueProviderCfg?.issueStrings ?? {
singular: 'Issue',
plural: 'Issues',
};
this._pluginIssueProviderRegistry.register({
pluginId,
definition,
name,
humanReadableName,
icon,
pollIntervalMs,
issueStrings,
issueProviderKey: customKey,
useAgendaView: issueProviderCfg?.useAgendaView,
defaultAutoAddToBacklog: issueProviderCfg?.defaultAutoAddToBacklog,
allowPrivateNetwork:
issueProviderCfg?.allowPrivateNetwork && this._isPluginBundled(pluginId),
});
const registeredKey = this._pluginIssueProviderRegistry.getRegisteredKey(pluginId);
if (!registeredKey) {
PluginLog.warn(`Plugin ${pluginId} registration failed (duplicate?), skipping`);
return;
}
// Register adapter when plugin supports any issue side effects. Push support
// still requires updateIssue; create/delete can work without it.
if (
definition.createIssue ||
definition.deleteIssue ||
(definition.fieldMappings?.length && definition.updateIssue)
) {
const registered = this._pluginIssueProviderRegistry.getProvider(registeredKey);
const httpOpts = { allowPrivateNetwork: registered?.allowPrivateNetwork };
const adapter = createPluginSyncAdapter(
definition,
(getHeaders) => this._pluginHttpService.createHttpHelper(getHeaders, httpOpts),
this._tagService,
);
this._syncAdapterRegistry.register(registeredKey, adapter);
PluginLog.log(
`Plugin ${pluginId} registered sync adapter under '${registeredKey}'`,
);
}
PluginLog.log(
`Plugin ${pluginId} registered issue provider under '${registeredKey}'`,
);
}
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,
this._getOAuthConfigForPlugin(pluginId),
);
}
private _getOAuthConfigForPlugin(pluginId: string): OAuthFlowConfig | undefined {
const registeredKey = this._pluginIssueProviderRegistry.getRegisteredKey(pluginId);
if (!registeredKey) {
return undefined;
}
return this._pluginIssueProviderRegistry
.getConfigFields(registeredKey)
.find((f) => f.type === 'oauthButton' && f.oauthConfig)?.oauthConfig;
}
private async _downloadFile(filename: string, data: string): Promise<void> {
typia.assert<string>(filename);
typia.assert<string>(data);
const { download } = await import('../util/download');
await download(filename, data);
}
/**
* Show a snack message to the user
*/
showSnack(snackCfg: SnackCfg): void {
typia.assert<SnackCfg>(snackCfg);
const snackParams = snackCfgToSnackParams(snackCfg);
this._snackService.open(snackParams);
}
/**
* Show a notification to the user
*/
async notify(notifyCfg: NotifyCfg): Promise<void> {
typia.assert<NotifyCfg>(notifyCfg);
// Use the app's NotifyService for better integration
await this._notifyService.notify({
title: notifyCfg.title,
body: notifyCfg.body,
icon: 'assets/icons/icon-128x128.png',
duration: 5000, // 5 seconds default duration
});
// No notifyCfg — title/body are user content; log history is exportable (rule #9).
PluginLog.log('PluginBridge: Notification sent successfully');
}
/**
* Open a dialog using Angular Material
*/
async openDialog(dialogCfg: DialogCfg): Promise<DialogResult> {
typia.assert<DialogCfg>(dialogCfg);
return new Promise<DialogResult>((resolve, reject) => {
try {
const dialogRef = this._dialog.open(PluginDialogComponent, {
data: dialogCfg,
// TODO make configurable
// width: '500px',
// maxWidth: '90vw',
// maxHeight: '80vh',
autoFocus: true,
restoreFocus: true,
disableClose: false,
closeOnNavigation: false,
});
dialogRef.afterClosed().subscribe((result) => {
PluginLog.log('PluginBridge: Dialog closed');
resolve(result);
});
} catch (error) {
PluginLog.err('PluginBridge: Failed to open dialog:', error);
reject(error);
}
});
}
/**
* Internal method to show plugin index.html as view
*/
private _showIndexHtmlAsView(pluginId: string): void {
PluginLog.log('PluginBridge: Navigating to plugin index view', {
pluginId,
});
// Navigate to the plugin index route
this._router.navigate(['/plugins', pluginId, 'index']);
}
/**
* Mount this plugin's index.html inside the work-view body for the active
* work context. Work-view renders it in place of the task list.
*/
private _showInWorkContext(pluginId: string): void {
this._workContextEmbedPluginId.set(pluginId);
}
/**
* Clear the work-view embed slot if this plugin currently owns it.
*/
private _closeWorkContextView(pluginId: string): void {
if (this._workContextEmbedPluginId() === pluginId) {
this._workContextEmbedPluginId.set(null);
}
}
/**
* Snapshot of the active work context for plugins. A context is always
* active once the app has loaded — it stays set even on non-work-view
* routes — so this normally resolves to that context. It resolves to
* null only if the initial data load has not completed within the
* timeout (e.g. a plugin calling this very early from its init hook),
* which is preferable to a Promise that never resolves.
*/
async getActiveWorkContext(): Promise<ActiveWorkContext | null> {
try {
const ctx = await firstValueFrom(
this._workContextService.activeWorkContext$.pipe(
// 10s is well past normal data-load time but still bounded so
// a plugin can fall back to other behaviour instead of hanging.
timeout({ first: 10_000 }),
),
);
return toActiveWorkContext(ctx);
} catch {
return null;
}
}
/**
* Get all tasks
*/
async getTasks(): Promise<TaskCopy[]> {
const tasks = await this._taskService.allTasks$.pipe(first()).toPromise();
return tasks || [];
}
/**
* Get archived tasks
*/
async getArchivedTasks(): Promise<TaskCopy[]> {
try {
const taskArchive = await this._taskArchiveService.load();
// Convert the archive format to TaskCopy array
const archivedTasks: TaskCopy[] = taskArchive.ids.map((id) => {
const task = taskArchive.entities[id];
if (!task) {
throw new Error(`Archived task with id ${id} not found in entities`);
}
return task as TaskCopy;
});
PluginLog.log('PluginBridge: Retrieved archived tasks', {
count: archivedTasks.length,
});
return archivedTasks;
} catch (error) {
PluginLog.err('PluginBridge: Failed to load archived tasks:', error);
return [];
}
}
/**
* Get current context tasks
*/
async getCurrentContextTasks(): Promise<TaskCopy[]> {
const contextTasks = await this._workContextService.mainListTasks$
.pipe(first())
.toPromise();
return contextTasks || [];
}
/**
* Returns a read-only snapshot of the application state for plugins.
*
* Credential surfaces are stripped before returning:
* - `globalConfig.sync` (WebDAV/Nextcloud passwords, SuperSync access
* tokens, encryption keys)
* - `globalConfig.misc.unsplashApiKey`
* - per-project `issueIntegrationCfgs` (Jira/CalDAV passwords,
* GitLab/Redmine tokens, OpenProject/Trello/Linear keys)
*/
async getAppState(): Promise<PluginAppState> {
const [
taskState,
projectState,
tagState,
noteState,
taskRepeatCfgState,
allSimpleCounters,
globalConfig,
] = await Promise.all([
firstValueFrom(this._store.select(selectTaskFeatureState)),
firstValueFrom(this._store.select(selectProjectFeatureState)),
firstValueFrom(this._store.select(selectTagFeatureState)),
firstValueFrom(this._store.select(selectNoteFeatureState)),
firstValueFrom(this._store.select(selectTaskRepeatCfgFeatureState)),
firstValueFrom(this._store.select(selectAllSimpleCounters)),
firstValueFrom(this._store.select(selectConfigFeatureState)),
]);
const simpleCounters: Record<string, PluginSimpleCounterFull> = {};
(allSimpleCounters ?? []).forEach((counter) => {
simpleCounters[counter.id] = {
id: counter.id,
title: counter.title,
type: String(counter.type),
isEnabled: counter.isEnabled,
isOn: (counter as { isOn?: boolean }).isOn,
countOnDay: counter.countOnDay ?? {},
};
});
const projects: Record<string, ProjectCopy> = {};
const rawProjects = (projectState?.entities ?? {}) as Record<
string,
ProjectCopy | undefined
>;
for (const id of Object.keys(rawProjects)) {
const p = rawProjects[id];
if (!p) continue;
const safe = { ...p };
delete (safe as { issueIntegrationCfgs?: unknown }).issueIntegrationCfgs;
projects[id] = safe as ProjectCopy;
}
const safeGlobalConfig: Record<string, unknown> = { ...(globalConfig ?? {}) };
delete safeGlobalConfig['sync'];
if (safeGlobalConfig['misc']) {
const safeMisc = { ...(safeGlobalConfig['misc'] as Record<string, unknown>) };
delete safeMisc['unsplashApiKey'];
safeGlobalConfig['misc'] = safeMisc;
}
return {
tasks: (taskState?.entities ?? {}) as Record<string, Task>,
projects,
tags: (tagState?.entities ?? {}) as Record<string, TagCopy>,
notes: (noteState?.entities ?? {}) as Record<string, PluginNote>,
taskRepeatCfgs: (taskRepeatCfgState?.entities ?? {}) as Record<
string,
PluginTaskRepeatCfg
>,
simpleCounters,
globalConfig: safeGlobalConfig,
};
}
async reInitData(): Promise<void> {
PluginLog.log('PluginBridge: Re-initializing app data');
await this._dataInitService.reInit();
}
/**
* Update a task
*/
async updateTask(taskId: string, updates: Partial<TaskCopy>): Promise<void> {
typia.assert<string>(taskId);
typia.assert<Partial<TaskCopy>>(updates);
// Validate that referenced project, tags and parent task exist if they are being updated
await this._validateTaskReferences(
updates.projectId,
updates.tagIds,
updates.parentId,
);
const { projectId, ...otherUpdates } = updates;
if (projectId !== undefined) {
const taskWithSubTasks = await firstValueFrom(
this._store.select((state) =>
selectTaskByIdWithSubTaskData(state, { id: taskId }),
),
);
if (!taskWithSubTasks?.id || taskWithSubTasks.id !== taskId) {
throw new Error(
this._translateService.instant(T.PLUGINS.TASK_NOT_FOUND, { taskId }),
);
}
if (taskWithSubTasks.parentId) {
throw new Error(
'Subtasks cannot be moved directly. Move the parent task instead.',
);
}
if (taskWithSubTasks.projectId === projectId) {
PluginLog.log('PluginBridge: Task already in target project', {
taskId,
projectId,
});
} else {
this._taskService.moveToProject(taskWithSubTasks, projectId);
PluginLog.log('PluginBridge: Task moved to project successfully', {
taskId,
projectId,
});
}
}
if (Object.keys(otherUpdates).length > 0) {
this._taskService.update(taskId, otherUpdates);
}
PluginLog.log('PluginBridge: Task updated successfully', { taskId });
}
/**
* Add a new task
*/
async addTask(taskData: PluginCreateTaskData): Promise<string> {
typia.assert<PluginCreateTaskData>(taskData);
// Validate that referenced project, tags, and parent task exist
await this._validateTaskReferences(
taskData.projectId,
taskData.tagIds,
taskData.parentId,
);
let createdTask: Task;
if (taskData.parentId) {
// For subtasks, we need to use the addSubTask action to properly update parent.
// Short-syntax (e.g. "15m") is normally applied by ShortSyntaxEffects, but that
// effect only listens to `addTask`/`updateTask` — not `addSubTask`. So the
// bridge has to parse subtask titles itself, mirroring MarkdownPasteService.
// Tags/projects are intentionally not parsed: subtasks always inherit them
// from the parent (see addSubTask reducer).
const subTaskTitleProps = this._parseSubTaskTitleTimeProps(taskData.title);
const newTask = this._taskService.createNewTaskWithDefaults({
title: subTaskTitleProps.title,
additional: {
notes: taskData.notes || '',
timeEstimate: taskData.timeEstimate || 0,
isDone: (taskData as { isDone?: boolean }).isDone || false,
tagIds: [], // Subtasks don't have tags
projectId: taskData.projectId || undefined,
...subTaskTitleProps.timeProps,
},
});
// Dispatch the addSubTask action which properly updates parent's subTaskIds
this._store.dispatch(
addSubTask({
task: newTask,
parentId: taskData.parentId,
}),
);
createdTask = newTask;
PluginLog.log('PluginBridge: Subtask added successfully', {
taskId: createdTask.id,
});
return createdTask.id;
} else {
// For main tasks, use the regular add method
const additional: Partial<TaskCopy> = {
projectId: taskData.projectId || undefined,
tagIds: taskData.tagIds || [],
notes: taskData.notes || '',
timeEstimate: taskData.timeEstimate || 0,
isDone: (taskData as { isDone?: boolean }).isDone || false,
dueDay: taskData.dueDay ?? undefined,
};
// Add the task using TaskService
const taskId = this._taskService.add(
taskData.title,
false, // isAddToBacklog
additional,
false, // isAddToBottom
);
PluginLog.log('PluginBridge: Task added successfully', { taskId });
return taskId;
}
}
/**
* Delete a task
*/
async deleteTask(taskId: string): Promise<void> {
typia.assert<string>(taskId);
try {
// Get the task with its subtasks
const taskWithSubTasks = await this._store
.select(selectTaskByIdWithSubTaskData, { id: taskId })
.pipe(first())
.toPromise();
if (!taskWithSubTasks) {
throw new Error(
this._translateService.instant(T.PLUGINS.TASK_NOT_FOUND, { taskId }),
);
}
// Use the TaskService remove method which handles deletion properly
this._taskService.remove(taskWithSubTasks);
PluginLog.log('PluginBridge: Task deleted successfully', {
taskId,
hadSubTasks: taskWithSubTasks.subTasks.length > 0,
});
} catch (error) {
PluginLog.err('PluginBridge: Failed to delete task:', error);
throw error;
}
}
/**
* Get all projects
*/
async getAllProjects(): Promise<ProjectCopy[]> {
const projects = await this._projectService.list$.pipe(first()).toPromise();
return projects || [];
}
/**
* Add a new project
*/
async addProject(projectData: Partial<ProjectCopy>): Promise<string> {
typia.assert<Partial<ProjectCopy>>(projectData);
PluginLog.log('PluginBridge: Project add');
return this._projectService.add(projectData);
}
/**
* Update a project
*/
async updateProject(projectId: string, updates: Partial<ProjectCopy>): Promise<void> {
typia.assert<string>(projectId);
typia.assert<Partial<ProjectCopy>>(updates);
// Update the project using ProjectService (ProjectCopy is compatible with Project)
this._projectService.update(projectId, updates);
PluginLog.log('PluginBridge: Project updated successfully', { projectId });
}
/**
* Get all tags
*/
async getAllTags(): Promise<TagCopy[]> {
const tags = await this._tagService.tags$.pipe(first()).toPromise();
return tags || [];
}
/**
* Add a new tag
*/
async addTag(tagData: Partial<TagCopy>): Promise<string> {
typia.assert<Partial<TagCopy>>(tagData);
// Add the tag using TagService (TagCopy is compatible with Tag)
const tagId = this._tagService.addTag(tagData);
PluginLog.log('PluginBridge: Tag added successfully', { tagId });
return tagId;
}
/**
* Update a tag
*/
async updateTag(tagId: string, updates: Partial<TagCopy>): Promise<void> {
typia.assert<string>(tagId);
typia.assert<Partial<TagCopy>>(updates);
// Update the tag using TagService (TagCopy is compatible with Tag)
this._tagService.updateTag(tagId, updates);
PluginLog.log('PluginBridge: Tag updated successfully', { tagId });
}
/**
* Reorder tasks in a project or parent task
* @param taskIds - Array of task IDs in the new order
* @param contextId - Project ID or parent task ID
* @param contextType - 'project' or 'task' to indicate if contextId is a project or parent task
*/
async reorderTasks(
taskIds: string[],
contextId: string,
contextType: 'project' | 'task',
): Promise<void> {
typia.assert<string[]>(taskIds);
typia.assert<string>(contextId);
typia.assert<'project' | 'task'>(contextType);
if (contextType === 'project') {
// Update project's taskIds to reflect new order
const project = await this._projectService
.getByIdOnce$(contextId)
.pipe(first())
.toPromise();
if (!project) {
throw new Error(
this._translateService.instant(T.PLUGINS.PROJECT_NOT_FOUND, { contextId }),
);
}
// Validate all taskIds belong to the project
const allProjectTaskIds = [
...(project.taskIds || []),
...(project.backlogTaskIds || []),
];
// Also check if tasks actually belong to this project by their projectId
const allTasks = await this._taskService.allTasks$.pipe(first()).toPromise();
const tasksInProject =
allTasks?.filter((t) => t.projectId === contextId && !t.parentId) || [];
const taskIdsInProject = tasksInProject.map((t) => t.id);
PluginLog.log('PluginBridge: Validating task reorder', {
requestedTaskIds: taskIds,
projectTaskIds: allProjectTaskIds,
actualTasksInProject: taskIdsInProject,
projectId: contextId,
});
// Use a more lenient validation - check if tasks have the correct projectId
const invalidTaskIds = taskIds.filter((id) => {
const task = allTasks?.find((t) => t.id === id);
return !task || task.projectId !== contextId || task.parentId;
});
if (invalidTaskIds.length > 0) {
throw new Error(
this._translateService.instant(T.PLUGINS.TASKS_NOT_IN_PROJECT, {
taskIds: invalidTaskIds.join(', '),
contextId,
}),
);
}
// Update the project with new task order
// Note: This assumes all tasks are in the regular list, not backlog
this._projectService.update(contextId, { taskIds });
PluginLog.log('PluginBridge: Project tasks reordered successfully', {
projectId: contextId,
newOrder: taskIds,
});
} else {
// Update parent task's subTaskIds to reflect new order
const parentTask = await this._taskService
.getByIdOnce$(contextId)
.pipe(first())
.toPromise();
if (!parentTask) {
throw new Error(
this._translateService.instant(T.PLUGINS.PARENT_TASK_NOT_FOUND, { contextId }),
);
}
// Validate all taskIds are subtasks of the parent
const invalidSubTaskIds = taskIds.filter(
(id) => !parentTask.subTaskIds.includes(id),
);
if (invalidSubTaskIds.length > 0) {
throw new Error(
this._translateService.instant(T.PLUGINS.TASKS_NOT_SUBTASKS, {
taskIds: invalidSubTaskIds.join(', '),
contextId,
}),
);
}
// Update the task with new subtask order
this._taskService.update(contextId, { subTaskIds: taskIds });
PluginLog.log('PluginBridge: Subtasks reordered successfully', {
parentTaskId: contextId,
newOrder: taskIds,
});
}
}
/**
* Select a task, opening its detail panel in the right-hand panel. Works
* regardless of the active view, including while a plugin embed occupies
* the work-view body.
*/
async selectTask(taskId: string): Promise<void> {
typia.assert<string>(taskId);
const task = await firstValueFrom(this._taskService.getByIdOnce$(taskId));
if (!task) {
throw new Error(
this._translateService.instant(T.PLUGINS.TASK_NOT_FOUND, { taskId }),
);
}
this._taskService.setSelectedId(taskId);
PluginLog.log('PluginBridge: Task selected', { taskId });
}
async getSelectedTask(): Promise<TaskCopy | null> {
return toPluginTaskCopy(await firstValueFrom(this._taskService.selectedTask$));
}
async getFocusedTask(): Promise<TaskCopy | null> {
const focusedTaskId = this._taskFocusService.focusedTaskId();
if (!focusedTaskId) {
return null;
}
return toPluginTaskCopy(
await firstValueFrom(this._taskService.getByIdOnce$(focusedTaskId)),
);
}
/**
* Batch update tasks for a project
* Only generate IDs here - let the reducer handle all validation
* Large batches are automatically chunked to prevent oversized operation payloads
*/
async batchUpdateForProject(request: BatchUpdateRequest): Promise<BatchUpdateResult> {
typia.assert<BatchUpdateRequest>(request);
// Generate IDs for all create operations upfront
// We need consistent IDs across all chunks
const createdTaskIds: { [tempId: string]: string } = {};
request.operations.forEach((op) => {
if (op.type === 'create') {
const createOp = op as BatchTaskCreate;
createdTaskIds[createOp.tempId] = nanoid();
}
});
// Chunk large operations to prevent oversized payloads
const chunks = this._chunkOperations(request.operations);
if (chunks.length > 1) {
PluginLog.log('PluginBridge: Chunking large batch operation', {
totalOperations: request.operations.length,
chunks: chunks.length,
chunkSize: MAX_BATCH_OPERATIONS_SIZE,
});
}
// Dispatch each chunk as a separate action
chunks.forEach((chunk) => {
this._store.dispatch(
TaskSharedActions.batchUpdateForProject({
projectId: request.projectId,
operations: chunk,
createdTaskIds, // Same IDs mapping for all chunks
}),
);
});
// Return the generated IDs immediately
// The reducer will validate everything including project existence
return {
success: true,
createdTaskIds,
};
}
/**
* Chunks operations array into smaller batches to prevent oversized payloads
*/
private _chunkOperations<T>(operations: T[]): T[][] {
if (operations.length <= MAX_BATCH_OPERATIONS_SIZE) {
return [operations];
}
const chunks: T[][] = [];
for (let i = 0; i < operations.length; i += MAX_BATCH_OPERATIONS_SIZE) {
chunks.push(operations.slice(i, i + MAX_BATCH_OPERATIONS_SIZE));
}
return chunks;
}
/**
* Internal method to persist plugin data.
* Includes size and rate limit validation; composeId enforces the
* `pluginId` keyspace contract at this transport boundary so the throw
* covers both iframe and direct API callers.
*/
private async _persistDataSynced(
pluginId: string,
dataStr: string,
key?: string,
): Promise<void> {
typia.assert<string>(dataStr);
assertPluginPersistenceKey(key);
try {
// Validates the pluginId synchronously; bubbles through the try/catch
// below as a normal Error.
const entityId = composeId(pluginId, key);
this._pluginUserPersistenceService.persistPluginUserData(entityId, dataStr);
PluginLog.log('PluginBridge: Plugin data persisted successfully', {
pluginId,
keyLen: key?.length ?? 0,
dataSize: new Blob([dataStr]).size,
});
} catch (error) {
// Log the specific error (rate limit, size, or composeId)
PluginLog.err('PluginBridge: Failed to persist plugin data:', error);
// Rethrow with the original error message for better debugging
if (error instanceof Error) {
throw error;
}
throw new Error(this._translateService.instant(T.PLUGINS.UNABLE_TO_PERSIST_DATA));
}
}
/**
* Internal method to load persisted plugin data.
*
* `composeId` runs outside the try/catch so a bad pluginId throws to the
* caller symmetrically with the persist path — silently returning `null`
* for "your pluginId is malformed" would look indistinguishable from "no
* data yet" and could mask a misconfiguration.
*/
private async _loadPersistedData(
pluginId: string,
key?: string,
): Promise<string | null> {
assertPluginPersistenceKey(key);
const entityId = composeId(pluginId, key);
try {
return await this._pluginUserPersistenceService.loadPluginUserData(entityId);
} catch (error) {
PluginLog.err('PluginBridge: Failed to get persisted plugin data:', error);
return null;
}
}
/**
* Internal method to get plugin configuration
*/
private async _getConfig(pluginId: string): Promise<unknown> {
try {
return await this._pluginConfigService.getPluginConfig(pluginId);
} catch (error) {
PluginLog.err('PluginBridge: Failed to get plugin config:', error);
return null;
}
}
/**
* Internal method to trigger sync
*/
private async _triggerSync(pluginId: string): Promise<void> {
try {
PluginLog.log('PluginBridge: Triggering sync for plugin', pluginId);
await this._syncWrapperService.sync();
PluginLog.log('PluginBridge: Sync completed successfully');
} catch (error) {
PluginLog.err('PluginBridge: Sync failed:', error);
throw error;
}
}
/**
* Register a hook handler for a plugin
*/
registerHook<T extends Hooks>(
pluginId: string,
hook: T,
handler: PluginHookHandler<T>,
): void {
typia.assert<string>(pluginId);
typia.assert<Hooks>(hook);
// Note: Can't assert generic function type with typia
this._pluginHooksService.registerHookHandler(pluginId, hook, handler);
}
/**
* Unregister all hooks for a plugin
*/
unregisterPluginHooks(pluginId: string): void {
typia.assert<string>(pluginId);
this._pluginHooksService.unregisterPluginHooks(pluginId);
this._removePluginHeaderButtons(pluginId);
this._removePluginMenuEntries(pluginId);
this._removePluginSidePanelButtons(pluginId);
this._removePluginWorkContextHeaderButtons(pluginId);
this.unregisterPluginShortcuts(pluginId);
this._configHandlers.delete(pluginId);
// Clean up window focus handler
this._windowFocusHandlers.delete(pluginId);
// Clean up issue provider and sync adapter
const registeredKey = this._pluginIssueProviderRegistry.getRegisteredKey(pluginId);
this._pluginIssueProviderRegistry.unregister(pluginId);
if (registeredKey) {
this._syncAdapterRegistry.unregister(registeredKey);
}
PluginLog.log('PluginBridge: All hooks unregistered for plugin', { pluginId });
}
/**
* Internal method to register header button
*/
private _registerHeaderButton(
pluginId: string,
headerBtnCfg: PluginHeaderBtnCfg,
): void {
typia.assert<Omit<PluginHeaderBtnCfg, 'pluginId'>>(headerBtnCfg);
const newButton: PluginHeaderBtnCfg = {
...headerBtnCfg,
pluginId,
};
const currentButtons = this._headerButtons();
this._headerButtons.set([...currentButtons, newButton]);
PluginLog.log('PluginBridge: Header button registered', {
pluginId,
headerBtnCfg,
});
}
/**
* Internal method to register menu entry
*/
private _registerMenuEntry(
pluginId: string,
menuEntryCfg: Omit<PluginMenuEntryCfg, 'pluginId'>,
): void {
// Validate required fields manually since typia has issues with optional fields
if (!menuEntryCfg.label || typeof menuEntryCfg.label !== 'string') {
throw new Error(
this._translateService.instant(T.PLUGINS.MENU_ENTRY_LABEL_REQUIRED),
);
}
if (!menuEntryCfg.onClick || typeof menuEntryCfg.onClick !== 'function') {
throw new Error(
this._translateService.instant(T.PLUGINS.MENU_ENTRY_ONCLICK_REQUIRED),
);
}
if (menuEntryCfg.icon !== undefined && typeof menuEntryCfg.icon !== 'string') {
throw new Error(this._translateService.instant(T.PLUGINS.MENU_ENTRY_ICON_STRING));
}
const newMenuEntry: PluginMenuEntryCfg = {
...menuEntryCfg,
pluginId,
};
const currentEntries = this._menuEntries();
// Check for duplicate entry (same plugin ID and label)
const isDuplicate = currentEntries.some(
(entry) => entry.pluginId === pluginId && entry.label === menuEntryCfg.label,
);
if (isDuplicate) {
PluginLog.err(
'PluginBridge: Duplicate menu entry detected, skipping registration',
{
pluginId,
label: menuEntryCfg.label,
},
);
return;
}
this._menuEntries.set([...currentEntries, newMenuEntry]);
PluginLog.log('PluginBridge: Menu entry registered', {
pluginId,
menuEntryCfg,
});
}
/**
* Register a work-context-scoped header button. Visibility is controlled by
* the `showFor` field on cfg, evaluated against the active context's type
* (PROJECT/TAG) or the special TODAY tag.
*/
private _registerWorkContextHeaderButton(
pluginId: string,
cfg: Omit<PluginWorkContextHeaderBtnCfg, 'pluginId'>,
): void {
if (!cfg.label || typeof cfg.label !== 'string') {
throw new Error('PluginBridge: registerWorkContextHeaderButton requires label');
}
if (!cfg.onClick || typeof cfg.onClick !== 'function') {
throw new Error('PluginBridge: registerWorkContextHeaderButton requires onClick');
}
if (!Array.isArray(cfg.showFor) || cfg.showFor.length === 0) {
throw new Error(
'PluginBridge: registerWorkContextHeaderButton requires non-empty showFor',
);
}
// Reject unknown showFor entries — otherwise typos are silently
// accepted and the button never shows up, which is hard to debug.
const allowedShowFor = new Set(['PROJECT', 'TAG', 'TODAY']);
const invalid = cfg.showFor.filter((v) => !allowedShowFor.has(v as string));
if (invalid.length > 0) {
throw new Error(
`PluginBridge: registerWorkContextHeaderButton showFor contains invalid value(s): ${invalid.join(', ')}. Expected one of ${[...allowedShowFor].join(', ')}.`,
);
}
const button: PluginWorkContextHeaderBtnCfg = { ...cfg, pluginId };
const current = this._workContextHeaderButtons();
const existingIdx = current.findIndex(
(b) => b.pluginId === pluginId && b.label === cfg.label,
);
if (existingIdx >= 0) {
// Re-registration: iframe reloads (e.g. work-view embed re-mount with
// skipCleanupOnDestroy: true) produce a fresh onClick closure
// pointing at the *new* iframe Window. Replace the old entry so the
// host's wrapper posts back into the live iframe instead of a
// detached one.
const next = current.slice();
next[existingIdx] = button;
this._workContextHeaderButtons.set(next);
return;
}
this._workContextHeaderButtons.set([...current, button]);
}
private _removePluginWorkContextHeaderButtons(pluginId: string): void {
this._workContextHeaderButtons.set(
this._workContextHeaderButtons().filter((b) => b.pluginId !== pluginId),
);
if (this._workContextEmbedPluginId() === pluginId) {
this._workContextEmbedPluginId.set(null);
}
}
/**
* Remove all header buttons for a specific plugin
*/
private _removePluginHeaderButtons(pluginId: string): void {
const currentButtons = this._headerButtons();
const filteredButtons = currentButtons.filter(
(button) => button.pluginId !== pluginId,
);
this._headerButtons.set(filteredButtons);
PluginLog.log('PluginBridge: Header buttons removed for plugin', { pluginId });
}
hasConfigHandler(pluginId: string): boolean {
return this._configHandlers.has(pluginId);
}
invokeConfigHandler(pluginId: string): void {
this._configHandlers.get(pluginId)?.();
}
/**
* Remove all menu entries for a specific plugin
*/
private _removePluginMenuEntries(pluginId: string): void {
const currentEntries = this._menuEntries();
const filteredEntries = currentEntries.filter((entry) => entry.pluginId !== pluginId);
this._menuEntries.set(filteredEntries);
PluginLog.log('PluginBridge: Menu entries removed for plugin', { pluginId });
}
/**
* Internal method to register side panel button
*/
private _registerSidePanelButton(
pluginId: string,
sidePanelBtnCfg: Omit<PluginSidePanelBtnCfg, 'pluginId'>,
): void {
// Validate required fields
if (!sidePanelBtnCfg.label || typeof sidePanelBtnCfg.label !== 'string') {
throw new Error(
this._translateService.instant(T.PLUGINS.SIDE_PANEL_LABEL_REQUIRED),
);
}
if (!sidePanelBtnCfg.onClick || typeof sidePanelBtnCfg.onClick !== 'function') {
throw new Error(
this._translateService.instant(T.PLUGINS.SIDE_PANEL_ONCLICK_REQUIRED),
);
}
const newButton: PluginSidePanelBtnCfg = {
...sidePanelBtnCfg,
pluginId,
};
const currentButtons = this._sidePanelButtons();
// Check for duplicate button (same plugin ID)
const isDuplicate = currentButtons.some((button) => button.pluginId === pluginId);
if (isDuplicate) {
PluginLog.err(
'PluginBridge: Duplicate side panel button detected, skipping registration',
{
pluginId,
label: sidePanelBtnCfg.label,
},
);
return;
}
this._sidePanelButtons.set([...currentButtons, newButton]);
PluginLog.log('PluginBridge: Side panel button registered', {
pluginId,
sidePanelBtnCfg,
});
}
/**
* Remove all side panel buttons for a specific plugin
*/
private _removePluginSidePanelButtons(pluginId: string): void {
const currentButtons = this._sidePanelButtons();
const filteredButtons = currentButtons.filter(
(button) => button.pluginId !== pluginId,
);
this._sidePanelButtons.set(filteredButtons);
PluginLog.log('PluginBridge: Side panel buttons removed for plugin', { pluginId });
}
/**
* Internal method to register shortcut
*/
private _registerShortcut(pluginId: string, shortcutCfg: PluginShortcutCfg): void {
const shortcutWithPluginId: PluginShortcutCfg = {
...shortcutCfg,
pluginId,
};
const currentShortcuts = this.shortcuts();
this.shortcuts.set([...currentShortcuts, shortcutWithPluginId]);
PluginLog.log('PluginBridge: Shortcut registered', {
pluginId,
shortcut: shortcutWithPluginId,
});
}
/**
* Execute a shortcut by its ID (pluginId:id)
*/
async executeShortcut(shortcutId: string): Promise<boolean> {
const shortcuts = this.shortcuts();
const shortcut = shortcuts.find((s) => `${s.pluginId}:${s.id}` === shortcutId);
if (shortcut) {
try {
await Promise.resolve(shortcut.onExec());
PluginLog.log(
`Executed shortcut "${shortcut.label}" from plugin ${shortcut.pluginId}`,
);
return true;
} catch (error) {
PluginLog.err(`Failed to execute shortcut "${shortcut.label}":`, error);
return false;
}
}
return false;
}
/**
* Unregister all shortcuts for a specific plugin
*/
unregisterPluginShortcuts(pluginId: string): void {
const currentShortcuts = this.shortcuts();
const filteredShortcuts = currentShortcuts.filter(
(shortcut) => shortcut.pluginId !== pluginId,
);
if (filteredShortcuts.length !== currentShortcuts.length) {
this.shortcuts.set(filteredShortcuts);
PluginLog.log(
`Unregistered ${currentShortcuts.length - filteredShortcuts.length} shortcuts for plugin ${pluginId}`,
);
}
}
/**
* Validate that referenced project, tags, and parent task exist
*/
// Mirrors MarkdownPasteService._parseTimeProps: respects the user's
// shortSyntax.isEnableDue config, returns the cleaned title and any parsed
// time fields. Used for subtasks because `addSubTask` doesn't trigger the
// ShortSyntaxEffects pipeline.
private _parseSubTaskTitleTimeProps(originalTitle: string): {
title: string;
timeProps: Partial<TaskCopy>;
} {
const shortSyntaxConfig =
this._globalConfigService.cfg()?.shortSyntax ?? DEFAULT_GLOBAL_CONFIG.shortSyntax;
if (!shortSyntaxConfig.isEnableDue) {
return { title: originalTitle, timeProps: {} };
}
const { title: cleanedTitle, ...timeProps } = parseTimeSpentChanges({
title: originalTitle,
});
return { title: cleanedTitle ?? originalTitle, timeProps };
}
private async _validateTaskReferences(
projectId?: string | null,
tagIds?: string[],
parentId?: string | null,
): Promise<void> {
const errors: string[] = [];
// Validate project exists if provided
if (projectId) {
const projects = await this._projectService.list$.pipe(first()).toPromise();
const projectExists = projects?.some((project) => project.id === projectId);
if (!projectExists) {
errors.push(
this._translateService.instant(T.PLUGINS.PROJECT_DOES_NOT_EXIST, { projectId }),
);
}
}
// Validate tags exist if provided
if (tagIds && tagIds.length > 0) {
const tags = await this._tagService.tags$.pipe(first()).toPromise();
const existingTagIds = tags?.map((tag) => tag.id) || [];
const nonExistentTags = tagIds.filter((tagId) => !existingTagIds.includes(tagId));
if (nonExistentTags.length > 0) {
errors.push(
this._translateService.instant(T.PLUGINS.TAGS_DO_NOT_EXIST, {
tagIds: nonExistentTags.join(', '),
}),
);
}
}
// Validate parent task exists if provided
if (parentId) {
const tasks = await this._taskService.allTasks$.pipe(first()).toPromise();
const parentExists = tasks?.some((task) => task.id === parentId);
if (!parentExists) {
errors.push(
this._translateService.instant(T.PLUGINS.PARENT_TASK_DOES_NOT_EXIST, {
parentId,
}),
);
}
}
// Throw error if any validation failed
if (errors.length > 0) {
throw new Error(
this._translateService.instant(T.PLUGINS.VALIDATION_FAILED, {
errors: errors.join('; '),
}),
);
}
}
/**
* Internal method to dispatch action
*/
private _dispatchAction(
pluginId: string,
action: { type: string; [key: string]: unknown },
): void {
// Check if the action is in the allowed list
if (!isAllowedPluginAction(action)) {
PluginLog.err(
`PluginBridge: Action type '${action.type}' is not allowed for plugins`,
);
throw new Error(
this._translateService.instant(T.PLUGINS.ACTION_TYPE_NOT_ALLOWED, {
actionType: action.type,
}),
);
}
// Dispatch the action
this._store.dispatch(action);
// Log the action TYPE only — the full action carries user content
// and the log history is user-exportable. See core/log.ts header / rule #9.
PluginLog.log(
`PluginBridge: Dispatched action '${action.type}' for plugin ${pluginId}`,
);
}
setNodeExecutionGrantToken(pluginId: string, grantToken: string): void {
this.#nodeExecutionGrantTokens.set(pluginId, grantToken);
}
hasNodeExecutionGrantToken(pluginId: string): boolean {
return this.#nodeExecutionGrantTokens.has(pluginId);
}
getNodeExecutionGrantToken(pluginId: string): string | undefined {
return this.#nodeExecutionGrantTokens.get(pluginId);
}
async requestNodeExecutionGrant(pluginId: string): Promise<{ token: string } | null> {
return (await this.#nodeExecutionApi?.requestGrant(pluginId)) ?? null;
}
revokeNodeExecutionGrantToken(pluginId: string): string | undefined {
const token = this.#nodeExecutionGrantTokens.get(pluginId);
this.#nodeExecutionGrantTokens.delete(pluginId);
return token;
}
async revokeNodeExecutionGrant(pluginId: string, grantToken: string): Promise<void> {
await this.#nodeExecutionApi?.revokeGrant(pluginId, grantToken);
}
/**
* Internal method to execute Node.js script
*/
private async _executeNodeScript(
pluginId: string,
manifest: PluginManifest | null,
request: PluginNodeScriptRequest,
): Promise<PluginNodeScriptResult> {
if (!this._isElectronRuntime()) {
return {
success: false,
error: this._translateService.instant(T.PLUGINS.NODE_ONLY_DESKTOP),
};
}
try {
typia.assert<PluginNodeScriptRequest>(request);
if (!manifest) {
return {
success: false,
error: this._translateService.instant(T.PLUGINS.NO_PLUGIN_MANIFEST_NODE),
};
}
const grantToken = this.#nodeExecutionGrantTokens.get(pluginId);
if (!grantToken) {
return {
success: false,
error: this._translateService.instant(
T.PLUGINS.NODE_EXECUTION_PERMISSION_DENIED,
),
};
}
if (!this.#nodeExecutionApi) {
return {
success: false,
error: this._translateService.instant(T.PLUGINS.ELECTRON_API_NOT_AVAILABLE),
};
}
// Call Electron main process via IPC
const result = await this.#nodeExecutionApi.executeScript(
pluginId,
grantToken,
request,
);
return result;
} catch (error) {
PluginLog.err('PluginBridge: Failed to execute Node.js script:', error);
return {
success: false,
error:
error instanceof Error
? error.message
: this._translateService.instant(T.PLUGINS.FAILED_TO_EXECUTE_SCRIPT),
};
}
}
private _isElectronRuntime(): boolean {
return IS_ELECTRON;
}
/**
* Consume the one-shot node-execution IPC handed off by the preload.
*
* SECURITY INVARIANT — must run at app bootstrap, before any plugin code
* executes. The handoff lives on the shared `window.ea`, and plugin code
* runs via `new Function` in this same renderer realm, so whoever calls
* `consumePluginNodeExecutionApi()` first owns the privileged node channel.
* This service is constructed during startup (PluginService injects it)
* and plugins only load later (post-sync, in `initializePlugins`), so the
* trusted side wins the race. The preload makes the handoff one-shot
* (returns null after the first read) as a backstop, but the ordering is
* the real guarantee: do NOT make this service lazy, and do not run plugin
* code before it is instantiated. Real fix = plugin realm isolation
* (tracked with the broader window.ea hardening).
*/
private _consumeNodeExecutionApi(): PluginNodeExecutionElectronApi | null {
if (!window.ea || typeof window.ea.consumePluginNodeExecutionApi !== 'function') {
return null;
}
return window.ea.consumePluginNodeExecutionApi();
}
/**
* Ping the Node.js IPC bridge for a plugin using a trivial vm-executed script.
* Returns true if the bridge responds successfully, false otherwise.
*/
async pingNodeBridge(pluginId: string, manifest: PluginManifest): Promise<boolean> {
// 1.5s is plenty for an in-process vm script that returns true — the ping
// is a bridge health check, not a long-running operation. Combined with
// pingWithRetry's defaults (3 attempts, 1s+2s delays) this caps cold-boot
// failure detection at ~7.5s instead of ~17s.
const result = await this._executeNodeScript(pluginId, manifest, {
script: 'return true',
timeout: 1500,
});
return result.success;
}
/**
* Send a message to a plugin's message handler
*/
async sendMessageToPlugin(pluginId: string, message: unknown): Promise<unknown> {
// Import and get the plugin runner service
// Using dynamic import to avoid circular dependency at compile time
const { PluginRunner } = await import('./plugin-runner');
const pluginRunner = this._injector.get(PluginRunner);
return pluginRunner.sendMessageToPlugin(pluginId, message);
}
/**
* Track window focus state
*/
private _isWindowFocused = true;
private _windowFocusHandlers = new Map<string, (isFocused: boolean) => void>();
// Named listener methods for proper cleanup
private _onWindowFocus = (): void => {
this._isWindowFocused = true;
this._notifyFocusHandlers(true);
};
private _onWindowBlur = (): void => {
this._isWindowFocused = false;
this._notifyFocusHandlers(false);
};
private _onVisibilityChange = (): void => {
const isFocused = !document.hidden;
this._isWindowFocused = isFocused;
this._notifyFocusHandlers(isFocused);
};
/**
* Initialize window focus tracking
*/
private _initWindowFocusTracking(): void {
// Track window focus/blur events
window.addEventListener('focus', this._onWindowFocus);
window.addEventListener('blur', this._onWindowBlur);
// Also track document visibility changes
document.addEventListener('visibilitychange', this._onVisibilityChange);
}
/**
* Clean up window focus tracking listeners
*/
private _cleanupWindowFocusTracking(): void {
window.removeEventListener('focus', this._onWindowFocus);
window.removeEventListener('blur', this._onWindowBlur);
document.removeEventListener('visibilitychange', this._onVisibilityChange);
}
/**
* Notify all registered focus handlers
*/
private _notifyFocusHandlers(isFocused: boolean): void {
this._windowFocusHandlers.forEach((handler) => {
try {
handler(isFocused);
} catch (error) {
PluginLog.err('Error in window focus handler:', error);
}
});
}
/**
* Check if the window is currently focused
*/
isWindowFocused(): boolean {
return this._isWindowFocused;
}
/**
* Register a handler for window focus changes
*/
onWindowFocusChange(pluginId: string, handler: (isFocused: boolean) => void): void {
this._windowFocusHandlers.set(pluginId, handler);
// Immediately notify the handler of the current state
handler(this._isWindowFocused);
}
/**
* Gets all simple counters as { [key: string]: number }.
*/
async getAllCounters(): Promise<{ [key: string]: number }> {
const today = getDbDateStr();
const countersArray = await this._store
.select(selectAllSimpleCounters)
.pipe(
take(1),
map((counters: SimpleCounter[]) =>
counters.reduce(
(acc, c) => ({ ...acc, [c.id]: c.countOnDay?.[today] ?? 0 }),
{} as { [key: string]: number },
),
),
)
.toPromise();
return countersArray || {};
}
/**
* Gets a single simple counter value (undefined if unset).
* @param key The counter key (e.g., 'daily-commits').
*/
async getCounter(id: string): Promise<number | null> {
typia.assert<string>(id);
// Allow uppercase, lowercase, numbers, hyphens, underscores
if (!/^[A-Za-z0-9_-]+$/.test(id)) {
throw new Error('Invalid counter key: must be alphanumeric with hyphens');
}
const counters = await this.getAllCounters();
return counters[id] ?? null;
}
async setCounter(id: string, value: number): Promise<void> {
typia.assert<string>(id);
typia.assert<number>(value);
// Allow uppercase, lowercase, numbers, hyphens, underscores
if (!/^[A-Za-z0-9_-]+$/.test(id)) {
throw new Error('Invalid counter key: must be alphanumeric with hyphens');
}
if (typeof value !== 'number' || !isFinite(value) || value < 0) {
throw new Error('Invalid counter value: must be a non-negative number');
}
const today = getDbDateStr();
// Check if counter already exists
const existingCounter = await this.getSimpleCounter(id);
if (existingCounter) {
// Update existing counter's countOnDay only
this._store.dispatch(
updateSimpleCounter({
simpleCounter: {
id,
changes: {
countOnDay: {
...existingCounter.countOnDay,
[today]: value,
},
},
},
}),
);
} else {
// Create new counter with all mandatory fields
this._store.dispatch(
upsertSimpleCounter({
simpleCounter: {
...EMPTY_SIMPLE_COUNTER,
id,
title: id,
isEnabled: true,
type: SimpleCounterType.ClickCounter,
countOnDay: { [today]: value },
},
}),
);
}
}
async incrementCounter(id: string, incrementBy = 1): Promise<number> {
typia.assert<string>(id);
typia.assert<number>(incrementBy);
if (typeof incrementBy !== 'number' || !isFinite(incrementBy) || incrementBy <= 0) {
throw new Error('Invalid increment amount: must be a positive number');
}
const current = (await this.getCounter(id)) ?? 0;
const newValue = current + incrementBy;
await this.setCounter(id, newValue);
return newValue;
}
async decrementCounter(id: string, decrementBy = 1): Promise<number> {
typia.assert<string>(id);
typia.assert<number>(decrementBy);
if (typeof decrementBy !== 'number' || !isFinite(decrementBy) || decrementBy <= 0) {
throw new Error('Invalid decrement amount: must be a positive number');
}
const current = (await this.getCounter(id)) ?? 0;
const newValue = Math.max(0, current - decrementBy);
await this.setCounter(id, Math.max(0, newValue));
return newValue;
}
/**
* Gets all simple counters as SimpleCounter[].
*/
async getAllSimpleCounters(): Promise<SimpleCounter[]> {
return this._store.select(selectAllSimpleCounters).pipe(take(1)).toPromise();
}
/**
* Gets a single simple counter by ID.
* @param id The counter ID.
*/
async getSimpleCounter(id: string): Promise<SimpleCounter | undefined> {
const all = await this.getAllSimpleCounters();
return all.find((c) => c.id === id);
}
/**
* Updates a simple counter (partial).
* @param id The counter ID.
* @param updates Partial updates.
*/
async updateSimpleCounter(id: string, updates: Partial<SimpleCounter>): Promise<void> {
this._store.dispatch(
updateSimpleCounter({
simpleCounter: { id, changes: updates },
}),
);
}
/**
* Toggles a simple counter's isOn state.
* @param id The counter ID.
*/
async toggleSimpleCounter(id: string): Promise<void> {
const counter = await this.getSimpleCounter(id);
if (!counter) {
throw new Error(`Counter ${id} not found`);
}
this._store.dispatch(toggleSimpleCounterCounter({ id }));
}
/**
* Sets a simple counter's isEnabled state.
* @param id The counter ID.
* @param isEnabled Enabled state.
*/
async setSimpleCounterEnabled(id: string, isEnabled: boolean): Promise<void> {
return this.updateSimpleCounter(id, { isEnabled });
}
/**
* Deletes a simple counter.
* @param id The counter ID.
*/
async deleteSimpleCounter(id: string): Promise<void> {
this._store.dispatch(deleteSimpleCounter({ id }));
}
/**
* Sets a simple counter value for today.
* @param id The counter ID.
* @param value The numeric value.
*/
async setSimpleCounterToday(id: string, value: number): Promise<void> {
const today = getDbDateStr();
return this.setSimpleCounterDate(id, today, value);
}
/**
* Sets a simple counter value for a specific date.
* @param id The counter ID.
* @param date The date (`YYYY-MM-DD`).
* @param value The numeric value.
*/
async setSimpleCounterDate(id: string, date: string, value: number): Promise<void> {
if (!date.match(/^\d{4}-\d{2}-\d{2}$/)) {
throw new Error('Invalid date format: use YYYY-MM-DD');
}
const dateObj = new Date(date);
if (isNaN(dateObj.getTime())) {
throw new Error('Invalid date: must be valid YYYY-MM-DD');
}
const counter = await this.getSimpleCounter(id);
if (!counter) {
throw new Error(`Counter ${id} not found`);
}
if (typeof value !== 'number' || !isFinite(value) || value < 0) {
throw new Error('Invalid counter value: must be a non-negative number');
}
const newCountOnDay = { ...counter.countOnDay, [date]: value };
return this.updateSimpleCounter(id, { countOnDay: newCountOnDay });
}
/**
* Clean up all resources when service is destroyed
*/
ngOnDestroy(): void {
PluginLog.log('PluginBridgeService: Cleaning up resources');
this._cleanupWindowFocusTracking();
// Note: Signals don't need explicit cleanup like BehaviorSubjects
PluginLog.log('PluginBridgeService: Cleanup complete');
}
}