mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-28 10:13:52 +00:00
2126 lines
72 KiB
TypeScript
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');
|
|
}
|
|
}
|