mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-08-02 04:22:32 +00:00
feat(plugins): update plugin infrastructure and cleanup
This commit is contained in:
parent
37244c7d2f
commit
24fced4617
17 changed files with 227 additions and 340 deletions
|
|
@ -2,6 +2,7 @@
|
|||
"name": "@super-productivity/plugin-api",
|
||||
"version": "1.0.1",
|
||||
"description": "TypeScript definitions for Super Productivity plugin development",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
|
|
@ -31,6 +32,7 @@
|
|||
},
|
||||
"homepage": "https://github.com/johannesjo/super-productivity#readme",
|
||||
"files": [
|
||||
"dist/**/*.js",
|
||||
"dist/**/*.d.ts",
|
||||
"README.md"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,35 +1,2 @@
|
|||
// Super Productivity Plugin API Types
|
||||
// Official TypeScript definitions for developing Super Productivity plugins
|
||||
|
||||
export * from './types';
|
||||
|
||||
// Re-export commonly used types with cleaner names
|
||||
export type {
|
||||
PluginAPI,
|
||||
PluginManifest,
|
||||
TaskData,
|
||||
ProjectData,
|
||||
TagData,
|
||||
PluginCreateTaskData,
|
||||
PluginBaseCfg,
|
||||
DialogCfg,
|
||||
DialogButtonCfg,
|
||||
SnackCfg,
|
||||
SnackCfgLimited,
|
||||
NotifyCfg,
|
||||
PluginMenuEntryCfg,
|
||||
PluginShortcutCfg,
|
||||
PluginHeaderBtnCfg,
|
||||
PluginHookHandler,
|
||||
PluginInstance,
|
||||
PluginHookHandlerRegistration,
|
||||
TaskCopy,
|
||||
ProjectCopy,
|
||||
TagCopy,
|
||||
} from './types';
|
||||
|
||||
// Re-export enums as values (not just types) so they can be used
|
||||
export { PluginHooks, type Hooks } from './types';
|
||||
|
||||
// Export app-specific types that extend the plugin-api versions
|
||||
export type { PluginMenuEntryCfg as PluginMenuEntryCfgApp } from './types';
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
"module": "commonjs",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
// Types for the batchUpdateForProject PluginAPI method
|
||||
|
||||
export interface BatchTaskCreate {
|
||||
type: 'create';
|
||||
tempId: string; // Temporary ID to reference in other operations
|
||||
data: {
|
||||
title: string;
|
||||
notes?: string;
|
||||
isDone?: boolean;
|
||||
parentId?: string | null; // Can reference tempId or existing task ID
|
||||
timeEstimate?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BatchTaskUpdate {
|
||||
type: 'update';
|
||||
taskId: string; // Existing task ID
|
||||
updates: {
|
||||
title?: string;
|
||||
notes?: string;
|
||||
isDone?: boolean;
|
||||
parentId?: string | null;
|
||||
timeEstimate?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BatchTaskDelete {
|
||||
type: 'delete';
|
||||
taskId: string; // Existing task ID
|
||||
}
|
||||
|
||||
export interface BatchTaskReorder {
|
||||
type: 'reorder';
|
||||
taskIds: string[]; // Can include tempIds for newly created tasks
|
||||
}
|
||||
|
||||
export type BatchOperation =
|
||||
| BatchTaskCreate
|
||||
| BatchTaskUpdate
|
||||
| BatchTaskDelete
|
||||
| BatchTaskReorder;
|
||||
|
||||
export interface BatchUpdateRequest {
|
||||
projectId: string;
|
||||
operations: BatchOperation[];
|
||||
}
|
||||
|
||||
export interface BatchUpdateResult {
|
||||
success: boolean;
|
||||
// Map temporary IDs to actual created task IDs
|
||||
createdTaskIds: { [tempId: string]: string };
|
||||
errors?: BatchUpdateError[];
|
||||
}
|
||||
|
||||
export interface BatchUpdateError {
|
||||
operationIndex: number;
|
||||
type:
|
||||
| 'VALIDATION_ERROR'
|
||||
| 'CIRCULAR_DEPENDENCY'
|
||||
| 'TASK_NOT_FOUND'
|
||||
| 'OUTSIDE_PROJECT'
|
||||
| 'UNKNOWN';
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Validation rules for batch updates
|
||||
export interface BatchUpdateValidation {
|
||||
// Maximum depth of task nesting
|
||||
maxNestingDepth?: number;
|
||||
// Whether to allow tasks to be moved outside the project
|
||||
enforceProjectBoundary?: boolean;
|
||||
// Whether to validate circular dependencies
|
||||
checkCircularDependencies?: boolean;
|
||||
}
|
||||
11
src/app/plugins/plugin-api-extensions.d.ts
vendored
11
src/app/plugins/plugin-api-extensions.d.ts
vendored
|
|
@ -1,11 +0,0 @@
|
|||
import { BatchUpdateRequest, BatchUpdateResult } from '../api/batch-update-types';
|
||||
|
||||
// Extend the PluginAPI interface from @super-productivity/plugin-api
|
||||
declare module '@super-productivity/plugin-api' {
|
||||
interface PluginAPI {
|
||||
batchUpdateForProject(request: BatchUpdateRequest): Promise<BatchUpdateResult>;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export types for convenience
|
||||
export * from '../api/batch-update-types';
|
||||
|
|
@ -1,34 +1,35 @@
|
|||
import {
|
||||
PluginCreateTaskData,
|
||||
BatchUpdateRequest,
|
||||
BatchUpdateResult,
|
||||
DialogCfg,
|
||||
Hooks,
|
||||
NotifyCfg,
|
||||
PluginAPI as PluginAPIInterface,
|
||||
PluginBaseCfg,
|
||||
PluginCreateTaskData,
|
||||
PluginHeaderBtnCfg,
|
||||
PluginHookHandler,
|
||||
PluginHooks,
|
||||
PluginManifest,
|
||||
PluginMenuEntryCfg,
|
||||
PluginShortcutCfg,
|
||||
PluginHeaderBtnCfg,
|
||||
PluginNodeScriptRequest,
|
||||
PluginNodeScriptResult,
|
||||
PluginShortcutCfg,
|
||||
PluginSidePanelBtnCfg,
|
||||
Task,
|
||||
Project,
|
||||
Tag,
|
||||
SnackCfg,
|
||||
PluginAPI as PluginAPIInterface,
|
||||
PluginManifest,
|
||||
Tag,
|
||||
Task,
|
||||
} from '@super-productivity/plugin-api';
|
||||
import { PluginBridgeService } from './plugin-bridge.service';
|
||||
import {
|
||||
taskCopyToTaskData,
|
||||
projectCopyToProjectData,
|
||||
tagCopyToTagData,
|
||||
taskDataToPartialTaskCopy,
|
||||
projectDataToPartialProjectCopy,
|
||||
tagCopyToTagData,
|
||||
tagDataToPartialTagCopy,
|
||||
taskCopyToTaskData,
|
||||
taskDataToPartialTaskCopy,
|
||||
} from './plugin-api-mapper';
|
||||
import { BatchUpdateRequest } from '../api/batch-update-types';
|
||||
|
||||
/**
|
||||
* PluginAPI implementation that uses direct bridge service injection
|
||||
|
|
@ -36,7 +37,7 @@ import { BatchUpdateRequest } from '../api/batch-update-types';
|
|||
*/
|
||||
export class PluginAPI implements PluginAPIInterface {
|
||||
readonly Hooks = PluginHooks;
|
||||
private _hookHandlers = new Map<string, Map<Hooks, Array<PluginHookHandler>>>();
|
||||
private _hookHandlers = new Map<string, Map<Hooks, Array<PluginHookHandler<any>>>>();
|
||||
private _headerButtons: Array<PluginHeaderBtnCfg> = [];
|
||||
private _menuEntries: Array<PluginMenuEntryCfg> = [];
|
||||
private _shortcuts: Array<PluginShortcutCfg> = [];
|
||||
|
|
@ -67,7 +68,7 @@ export class PluginAPI implements PluginAPIInterface {
|
|||
}
|
||||
}
|
||||
|
||||
registerHook(hook: Hooks, fn: PluginHookHandler): void {
|
||||
registerHook<T extends Hooks>(hook: T, fn: PluginHookHandler<T>): void {
|
||||
if (!this._hookHandlers.has(this._pluginId)) {
|
||||
this._hookHandlers.set(this._pluginId, new Map());
|
||||
}
|
||||
|
|
@ -77,11 +78,11 @@ export class PluginAPI implements PluginAPIInterface {
|
|||
pluginHooks.set(hook, []);
|
||||
}
|
||||
|
||||
pluginHooks.get(hook)!.push(fn);
|
||||
pluginHooks.get(hook)!.push(fn as PluginHookHandler<any>);
|
||||
console.log(`Plugin ${this._pluginId} registered hook: ${hook}`);
|
||||
|
||||
// Register hook with bridge
|
||||
this._pluginBridge.registerHook(this._pluginId, hook, fn);
|
||||
this._pluginBridge.registerHook(this._pluginId, hook, fn as PluginHookHandler<any>);
|
||||
}
|
||||
|
||||
registerHeaderButton(headerBtnCfg: PluginHeaderBtnCfg): void {
|
||||
|
|
@ -215,12 +216,12 @@ export class PluginAPI implements PluginAPIInterface {
|
|||
return this._pluginBridge.reorderTasks(taskIds, contextId, contextType);
|
||||
}
|
||||
|
||||
async batchUpdateForProject(request: unknown): Promise<unknown> {
|
||||
async batchUpdateForProject(request: BatchUpdateRequest): Promise<BatchUpdateResult> {
|
||||
console.log(
|
||||
`Plugin ${this._pluginId} requested batch update for project ${(request as { projectId: string }).projectId}`,
|
||||
request,
|
||||
);
|
||||
return this._pluginBridge.batchUpdateForProject(request as BatchUpdateRequest);
|
||||
return this._pluginBridge.batchUpdateForProject(request);
|
||||
}
|
||||
|
||||
showSnack(snackCfg: SnackCfg): void {
|
||||
|
|
|
|||
|
|
@ -17,20 +17,14 @@ import {
|
|||
PluginNodeScriptRequest,
|
||||
PluginNodeScriptResult,
|
||||
} from './plugin-api.model';
|
||||
|
||||
import {
|
||||
SnackCfg,
|
||||
PluginManifest,
|
||||
BatchUpdateRequest,
|
||||
BatchUpdateResult,
|
||||
BatchUpdateError,
|
||||
BatchOperation,
|
||||
BatchTaskCreate,
|
||||
BatchTaskUpdate,
|
||||
BatchTaskDelete,
|
||||
BatchTaskReorder,
|
||||
} from '../api/batch-update-types';
|
||||
import { Task as TaskCopy } from '../features/tasks/task.model';
|
||||
import { Project as ProjectCopy } from '../features/project/project.model';
|
||||
import { Tag as TagCopy } from '../features/tag/tag.model';
|
||||
import { SnackCfg, PluginManifest } from '@super-productivity/plugin-api';
|
||||
} from '@super-productivity/plugin-api';
|
||||
import { snackCfgToSnackParams } from './plugin-api-mapper';
|
||||
import { PluginHooksService } from './plugin-hooks';
|
||||
import { TaskService } from '../features/tasks/task.service';
|
||||
|
|
@ -52,6 +46,9 @@ 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 { TaskCopy } from '../features/tasks/task.model';
|
||||
import { ProjectCopy } from '../features/project/project.model';
|
||||
import { TagCopy } from '../features/tag/tag.model';
|
||||
|
||||
/**
|
||||
* PluginBridge acts as an intermediary layer between plugins and the main application services.
|
||||
|
|
@ -549,58 +546,15 @@ export class PluginBridgeService implements OnDestroy {
|
|||
}
|
||||
|
||||
/**
|
||||
* Batch update tasks for a project with validation
|
||||
* Allows multiple operations to be performed atomically with proper validation
|
||||
* Batch update tasks for a project
|
||||
* Only generate IDs here - let the reducer handle all validation
|
||||
*/
|
||||
async batchUpdateForProject(request: BatchUpdateRequest): Promise<BatchUpdateResult> {
|
||||
typia.assert<BatchUpdateRequest>(request);
|
||||
|
||||
const errors: BatchUpdateError[] = [];
|
||||
const createdTaskIds: { [tempId: string]: string } = {};
|
||||
|
||||
// Validate project exists
|
||||
const project = await this._projectService
|
||||
.getByIdOnce$(request.projectId)
|
||||
.pipe(first())
|
||||
.toPromise();
|
||||
|
||||
if (!project) {
|
||||
throw new Error(
|
||||
this._translateService.instant(T.PLUGINS.PROJECT_NOT_FOUND, {
|
||||
contextId: request.projectId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Get all current tasks for validation
|
||||
const allTasks = await this._taskService.allTasks$.pipe(first()).toPromise();
|
||||
const projectTasks = allTasks?.filter((t) => t.projectId === request.projectId) || [];
|
||||
|
||||
// Validate operations
|
||||
for (let i = 0; i < request.operations.length; i++) {
|
||||
const op = request.operations[i];
|
||||
const validationError = this._validateBatchOperation(
|
||||
op,
|
||||
request.projectId,
|
||||
projectTasks,
|
||||
createdTaskIds,
|
||||
i,
|
||||
);
|
||||
if (validationError) {
|
||||
errors.push(validationError);
|
||||
}
|
||||
}
|
||||
|
||||
// If there are validation errors, return early
|
||||
if (errors.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
createdTaskIds: {},
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
// Generate IDs for all create operations
|
||||
// We need to do this here so we can return them to the plugin immediately
|
||||
const createdTaskIds: { [tempId: string]: string } = {};
|
||||
request.operations.forEach((op) => {
|
||||
if (op.type === 'create') {
|
||||
const createOp = op as BatchTaskCreate;
|
||||
|
|
@ -608,98 +562,21 @@ export class PluginBridgeService implements OnDestroy {
|
|||
}
|
||||
});
|
||||
|
||||
// Dispatch the batch update action
|
||||
try {
|
||||
this._store.dispatch(
|
||||
TaskSharedActions.batchUpdateForProject({
|
||||
projectId: request.projectId,
|
||||
operations: request.operations,
|
||||
createdTaskIds,
|
||||
}),
|
||||
);
|
||||
|
||||
console.log('PluginBridge: Batch update dispatched successfully', {
|
||||
// Dispatch the batch update action - let the reducer handle all validation
|
||||
this._store.dispatch(
|
||||
TaskSharedActions.batchUpdateForProject({
|
||||
projectId: request.projectId,
|
||||
operationCount: request.operations.length,
|
||||
createdTasks: Object.keys(createdTaskIds).length,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
operations: request.operations,
|
||||
createdTaskIds,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('PluginBridge: Batch update failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
private _validateBatchOperation(
|
||||
op: BatchOperation,
|
||||
projectId: string,
|
||||
projectTasks: TaskCopy[],
|
||||
createdTaskIds: { [tempId: string]: string },
|
||||
operationIndex: number,
|
||||
): BatchUpdateError | null {
|
||||
switch (op.type) {
|
||||
case 'create':
|
||||
const createOp = op as BatchTaskCreate;
|
||||
// Check for circular dependencies
|
||||
if (createOp.data.parentId === createOp.tempId) {
|
||||
return {
|
||||
operationIndex,
|
||||
type: 'CIRCULAR_DEPENDENCY',
|
||||
message: 'Task cannot be its own parent',
|
||||
};
|
||||
}
|
||||
// Basic validation for required fields
|
||||
if (!createOp.data.title || createOp.data.title.trim() === '') {
|
||||
return {
|
||||
operationIndex,
|
||||
type: 'VALIDATION_ERROR',
|
||||
message: 'Task title is required',
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
const updateOp = op as BatchTaskUpdate;
|
||||
const taskToUpdate = projectTasks.find((t) => t.id === updateOp.taskId);
|
||||
if (!taskToUpdate) {
|
||||
return {
|
||||
operationIndex,
|
||||
type: 'TASK_NOT_FOUND',
|
||||
message: `Task ${updateOp.taskId} not found in project`,
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
const deleteOp = op as BatchTaskDelete;
|
||||
const taskToDelete = projectTasks.find((t) => t.id === deleteOp.taskId);
|
||||
if (!taskToDelete) {
|
||||
return {
|
||||
operationIndex,
|
||||
type: 'TASK_NOT_FOUND',
|
||||
message: `Task ${deleteOp.taskId} not found in project`,
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
case 'reorder':
|
||||
// Basic validation - the reducer will handle the actual reordering
|
||||
const reorderOp = op as BatchTaskReorder;
|
||||
if (!reorderOp.taskIds || reorderOp.taskIds.length === 0) {
|
||||
return {
|
||||
operationIndex,
|
||||
type: 'VALIDATION_ERROR',
|
||||
message: 'Reorder operation requires at least one task ID',
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return null;
|
||||
// Return the generated IDs immediately
|
||||
// The reducer will validate everything including project existence
|
||||
return {
|
||||
success: true,
|
||||
createdTaskIds,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -748,10 +625,14 @@ export class PluginBridgeService implements OnDestroy {
|
|||
/**
|
||||
* Register a hook handler for a plugin
|
||||
*/
|
||||
registerHook(pluginId: string, hook: Hooks, handler: PluginHookHandler): void {
|
||||
registerHook<T extends Hooks>(
|
||||
pluginId: string,
|
||||
hook: T,
|
||||
handler: PluginHookHandler<T>,
|
||||
): void {
|
||||
typia.assert<string>(pluginId);
|
||||
typia.assert<Hooks>(hook);
|
||||
typia.assert<PluginHookHandler>(handler);
|
||||
// Note: Can't assert generic function type with typia
|
||||
|
||||
this._pluginHooksService.registerHookHandler(pluginId, hook, handler);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { cleanupAllPluginIframeUrls } from './util/plugin-iframe.util';
|
||||
|
||||
/**
|
||||
* Simplified cleanup service following KISS principles.
|
||||
|
|
@ -33,5 +34,8 @@ export class PluginCleanupService {
|
|||
cleanupAll(): void {
|
||||
// Just clear all references - let Angular manage iframe DOM lifecycle
|
||||
this._pluginIframes.clear();
|
||||
|
||||
// Clean up all blob URLs to prevent memory leaks
|
||||
cleanupAllPluginIframeUrls();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,9 +14,24 @@ import { PluginService } from './plugin.service';
|
|||
import { PluginHooks } from './plugin-api.model';
|
||||
import { setActiveWorkContext } from '../features/work-context/store/work-context.actions';
|
||||
import { TaskSharedActions } from '../root-store/meta/task-shared.actions';
|
||||
import { setCurrentTask, unsetCurrentTask } from '../features/tasks/store/task.actions';
|
||||
import {
|
||||
setCurrentTask,
|
||||
unsetCurrentTask,
|
||||
moveSubTask,
|
||||
moveSubTaskUp,
|
||||
moveSubTaskDown,
|
||||
moveSubTaskToTop,
|
||||
moveSubTaskToBottom,
|
||||
} from '../features/tasks/store/task.actions';
|
||||
import * as projectActions from '../features/project/store/project.actions';
|
||||
import { updateProject } from '../features/project/store/project.actions';
|
||||
import {
|
||||
moveTaskDownInTodayList,
|
||||
moveTaskInTodayList,
|
||||
moveTaskToBottomInTodayList,
|
||||
moveTaskToTopInTodayList,
|
||||
moveTaskUpInTodayList,
|
||||
} from '../features/work-context/store/work-context-meta.actions';
|
||||
|
||||
@Injectable()
|
||||
export class PluginHooksEffects {
|
||||
|
|
@ -152,7 +167,7 @@ export class PluginHooksEffects {
|
|||
{ dispatch: false },
|
||||
);
|
||||
|
||||
// Trigger for ANY task update (add, update, delete)
|
||||
// Trigger for ANY task update (add, update, delete, move subtasks)
|
||||
anyTaskUpdate$ = createEffect(
|
||||
() =>
|
||||
this.actions$.pipe(
|
||||
|
|
@ -161,6 +176,12 @@ export class PluginHooksEffects {
|
|||
TaskSharedActions.updateTask,
|
||||
TaskSharedActions.deleteTask,
|
||||
TaskSharedActions.deleteTasks,
|
||||
// Include subtask move actions
|
||||
moveSubTask,
|
||||
moveSubTaskUp,
|
||||
moveSubTaskDown,
|
||||
moveSubTaskToTop,
|
||||
moveSubTaskToBottom,
|
||||
),
|
||||
withLatestFrom(this.store.pipe(select(selectTaskFeatureState))),
|
||||
tap(([action, taskState]) => {
|
||||
|
|
@ -196,6 +217,13 @@ export class PluginHooksEffects {
|
|||
projectActions.moveProjectTaskToBacklogList,
|
||||
projectActions.moveProjectTaskToRegularList,
|
||||
projectActions.moveAllProjectBacklogTasksToRegularList,
|
||||
|
||||
// cross model
|
||||
moveTaskInTodayList,
|
||||
moveTaskUpInTodayList,
|
||||
moveTaskDownInTodayList,
|
||||
moveTaskToTopInTodayList,
|
||||
moveTaskToBottomInTodayList,
|
||||
),
|
||||
withLatestFrom(this.store.pipe(select(selectProjectFeatureState))),
|
||||
tap(([action, projectState]) => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { Hooks, PluginHookHandler } from './plugin-api.model';
|
||||
import { Hooks, PluginHookHandler } from '@super-productivity/plugin-api';
|
||||
|
||||
/**
|
||||
* Simplified plugin hooks service following KISS principles.
|
||||
|
|
@ -9,12 +9,16 @@ import { Hooks, PluginHookHandler } from './plugin-api.model';
|
|||
providedIn: 'root',
|
||||
})
|
||||
export class PluginHooksService {
|
||||
private _handlers = new Map<Hooks, Map<string, PluginHookHandler>>();
|
||||
private _handlers = new Map<Hooks, Map<string, PluginHookHandler<any>>>();
|
||||
|
||||
/**
|
||||
* Register a hook handler
|
||||
*/
|
||||
registerHookHandler(pluginId: string, hook: Hooks, handler: PluginHookHandler): void {
|
||||
registerHookHandler<T extends Hooks>(
|
||||
pluginId: string,
|
||||
hook: T,
|
||||
handler: PluginHookHandler<T>,
|
||||
): void {
|
||||
if (!this._handlers.has(hook)) {
|
||||
this._handlers.set(hook, new Map());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -223,8 +223,12 @@ export class PluginService implements OnDestroy {
|
|||
|
||||
/**
|
||||
* Activate a plugin (load it if not already loaded)
|
||||
* @param isManualActivation - true when user manually enables plugin, false on startup
|
||||
*/
|
||||
async activatePlugin(pluginId: string): Promise<PluginInstance | null> {
|
||||
async activatePlugin(
|
||||
pluginId: string,
|
||||
isManualActivation: boolean = false,
|
||||
): Promise<PluginInstance | null> {
|
||||
const state = this._pluginStates.get(pluginId);
|
||||
if (!state) {
|
||||
console.error(`Plugin ${pluginId} not found`);
|
||||
|
|
@ -253,6 +257,28 @@ export class PluginService implements OnDestroy {
|
|||
return updatedState?.instance || null;
|
||||
}
|
||||
|
||||
// Only check for permission if plugin is actually enabled
|
||||
if (state.isEnabled) {
|
||||
// Only check permission on startup - manual activation already checked in UI
|
||||
if (!isManualActivation) {
|
||||
const hasConsent = await this._checkNodeExecutionPermissionForStartup(
|
||||
state.manifest,
|
||||
);
|
||||
if (!hasConsent) {
|
||||
console.log(
|
||||
'Plugin requires Node.js execution permission but no stored consent found:',
|
||||
state.manifest.id,
|
||||
);
|
||||
// Don't disable the plugin on startup - user may have enabled it but not granted permission yet
|
||||
return null;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Plugin is not enabled, don't activate it
|
||||
console.log(`Plugin ${pluginId} is not enabled, skipping activation`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Load the plugin
|
||||
state.status = 'loading';
|
||||
this._updatePluginStates();
|
||||
|
|
@ -407,15 +433,8 @@ export class PluginService implements OnDestroy {
|
|||
return placeholderInstance;
|
||||
}
|
||||
|
||||
// Only check for consent if plugin is enabled
|
||||
if (isPluginEnabled) {
|
||||
const hasConsent = await this._getNodeExecutionConsent(manifest);
|
||||
if (!hasConsent) {
|
||||
throw new Error(
|
||||
this._translateService.instant(T.PLUGINS.USER_DECLINED_NODE_PERMISSION),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Skip consent check during startup - will be checked when plugin is activated
|
||||
// This prevents showing multiple dialogs at once during app startup
|
||||
}
|
||||
|
||||
// Analyze plugin code (informational only - KISS approach)
|
||||
|
|
@ -1228,22 +1247,47 @@ export class PluginService implements OnDestroy {
|
|||
return this._getNodeExecutionConsent(manifest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check node execution permission on startup (uses stored consent)
|
||||
*/
|
||||
private async _checkNodeExecutionPermissionForStartup(
|
||||
manifest: PluginManifest,
|
||||
): Promise<boolean> {
|
||||
// Check if plugin has nodeExecution permission
|
||||
if (!manifest.permissions?.includes('nodeExecution')) {
|
||||
return true; // No node execution permission needed
|
||||
}
|
||||
|
||||
// Only check consent in Electron environment
|
||||
if (!IS_ELECTRON) {
|
||||
console.warn(
|
||||
`Plugin ${manifest.id} requires nodeExecution permission which is not available in web environment`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// On startup, use stored consent if available
|
||||
const storedConsent =
|
||||
await this._pluginMetaPersistenceService.getNodeExecutionConsent(manifest.id);
|
||||
|
||||
// Only allow if consent was explicitly granted and stored
|
||||
return storedConsent === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get consent for Node.js execution permissions (KISS approach)
|
||||
*/
|
||||
private async _getNodeExecutionConsent(manifest: PluginManifest): Promise<boolean> {
|
||||
// Check if consent was already given and stored
|
||||
const storedConsent =
|
||||
// Check if we should pre-check the "remember" checkbox based on previous consent
|
||||
const previousConsent =
|
||||
await this._pluginMetaPersistenceService.getNodeExecutionConsent(manifest.id);
|
||||
if (storedConsent === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Single, simple consent dialog
|
||||
// Always show dialog for nodeExecution permission
|
||||
const result = await this._dialog
|
||||
.open(PluginNodeConsentDialogComponent, {
|
||||
data: {
|
||||
manifest,
|
||||
rememberChoice: previousConsent === true, // Pre-check if previously consented
|
||||
} as PluginNodeConsentDialogData,
|
||||
disableClose: false,
|
||||
width: '500px',
|
||||
|
|
@ -1259,10 +1303,20 @@ export class PluginService implements OnDestroy {
|
|||
setTimeout(() => {
|
||||
this._pluginMetaPersistenceService.setNodeExecutionConsent(manifest.id, true);
|
||||
}, 5000);
|
||||
} else {
|
||||
// User unchecked remember - remove stored consent
|
||||
setTimeout(() => {
|
||||
this._pluginMetaPersistenceService.setNodeExecutionConsent(manifest.id, false);
|
||||
}, 5000);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// User denied permission - remove any stored consent
|
||||
setTimeout(() => {
|
||||
this._pluginMetaPersistenceService.setNodeExecutionConsent(manifest.id, false);
|
||||
}, 5000);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
PluginIframeConfig,
|
||||
createPluginIframeUrl,
|
||||
handlePluginMessage,
|
||||
cleanupPluginIframeUrl,
|
||||
} from '../../util/plugin-iframe.util';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
|
|
@ -91,6 +92,7 @@ export class PluginIndexComponent implements OnInit, OnDestroy {
|
|||
|
||||
private _messageListener?: EventListener;
|
||||
private _routeSubscription?: Subscription;
|
||||
private _currentIframeUrl: string | null = null;
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
// If directPluginId is provided, load that plugin directly
|
||||
|
|
@ -233,9 +235,12 @@ export class PluginIndexComponent implements OnInit, OnDestroy {
|
|||
boundMethods: this._pluginBridge.createBoundMethods(pluginId, plugin.manifest),
|
||||
};
|
||||
|
||||
// Create iframe URL
|
||||
// Create iframe URL using blob URL
|
||||
const iframeUrl = createPluginIframeUrl(config);
|
||||
|
||||
// Store the URL for cleanup
|
||||
this._currentIframeUrl = iframeUrl;
|
||||
|
||||
// Store message handler for cleanup
|
||||
this._messageListener = async (event: Event) => {
|
||||
await handlePluginMessage(event as MessageEvent, config);
|
||||
|
|
@ -248,7 +253,9 @@ export class PluginIndexComponent implements OnInit, OnDestroy {
|
|||
const safeUrl = this._sanitizer.bypassSecurityTrustResourceUrl(iframeUrl);
|
||||
console.log(
|
||||
`Setting iframe src for plugin ${pluginId}:`,
|
||||
iframeUrl.substring(0, 100) + '...',
|
||||
iframeUrl.startsWith('blob:')
|
||||
? `blob:${iframeUrl.split(':')[1].substring(0, 20)}...`
|
||||
: iframeUrl.substring(0, 100) + '...',
|
||||
);
|
||||
this.iframeSrc.set(safeUrl);
|
||||
this.isLoading.set(false);
|
||||
|
|
@ -266,6 +273,13 @@ export class PluginIndexComponent implements OnInit, OnDestroy {
|
|||
console.log(`Removed message listener for plugin: ${currentPluginId}`);
|
||||
}
|
||||
|
||||
// Cleanup blob URL if it exists
|
||||
if (this._currentIframeUrl) {
|
||||
cleanupPluginIframeUrl(this._currentIframeUrl);
|
||||
console.log(`Cleaned up blob URL for plugin: ${currentPluginId}`);
|
||||
this._currentIframeUrl = null;
|
||||
}
|
||||
|
||||
// Clear iframe reference from cleanup service (but don't remove from DOM)
|
||||
if (currentPluginId) {
|
||||
this._cleanupService.cleanupPlugin(currentPluginId);
|
||||
|
|
|
|||
|
|
@ -155,14 +155,15 @@ export class PluginManagementComponent implements OnInit {
|
|||
return;
|
||||
}
|
||||
|
||||
// Set plugin as enabled in persistence
|
||||
// Set plugin as enabled in persistence ONLY after consent is granted
|
||||
await this._pluginMetaPersistenceService.setPluginEnabled(plugin.manifest.id, true);
|
||||
|
||||
// Update the plugin state immediately
|
||||
plugin.isEnabled = true;
|
||||
|
||||
// Activate the plugin (lazy load if needed)
|
||||
const instance = await this._pluginService.activatePlugin(plugin.manifest.id);
|
||||
// Pass true to indicate this is a manual activation from UI
|
||||
const instance = await this._pluginService.activatePlugin(plugin.manifest.id, true);
|
||||
if (instance) {
|
||||
console.log('Plugin activated successfully:', plugin.manifest.id);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { T } from '../../../t.const';
|
|||
|
||||
export interface PluginNodeConsentDialogData {
|
||||
manifest: PluginManifest;
|
||||
rememberChoice?: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
|
|
@ -89,6 +90,10 @@ export interface PluginNodeConsentDialogData {
|
|||
display: block;
|
||||
}
|
||||
|
||||
ul li {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
mat-dialog-content {
|
||||
min-width: 400px;
|
||||
max-width: 500px;
|
||||
|
|
@ -124,7 +129,7 @@ export interface PluginNodeConsentDialogData {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #e3f2fd;
|
||||
border: 1px solid var(--extra-border-color);
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin: 16px 0;
|
||||
|
|
@ -154,6 +159,13 @@ export class PluginNodeConsentDialogComponent {
|
|||
T = T;
|
||||
rememberChoice = false;
|
||||
|
||||
constructor() {
|
||||
// Pre-check the remember checkbox if provided in data
|
||||
if (this.data.rememberChoice !== undefined) {
|
||||
this.rememberChoice = this.data.rememberChoice;
|
||||
}
|
||||
}
|
||||
|
||||
onConfirm(): void {
|
||||
this.dialogRef.close({ granted: true, remember: this.rememberChoice });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,36 +1,12 @@
|
|||
import { PluginBridgeService } from '../plugin-bridge.service';
|
||||
import { PluginBaseCfg, PluginManifest } from '../plugin-api.model';
|
||||
import { PluginIframeMessageType } from '@super-productivity/plugin-api';
|
||||
|
||||
/**
|
||||
* Simplified plugin iframe utilities following KISS principles.
|
||||
* One way to do things, no complex options.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Enum for plugin iframe message types
|
||||
*/
|
||||
export enum PluginIframeMessageType {
|
||||
// API communication
|
||||
API_CALL = 'PLUGIN_API_CALL',
|
||||
API_RESPONSE = 'PLUGIN_API_RESPONSE',
|
||||
API_ERROR = 'PLUGIN_API_ERROR',
|
||||
|
||||
// Hook events
|
||||
HOOK_EVENT = 'PLUGIN_HOOK_EVENT',
|
||||
|
||||
// Dialog interaction
|
||||
DIALOG_BUTTON_CLICK = 'PLUGIN_DIALOG_BUTTON_CLICK',
|
||||
DIALOG_BUTTON_RESPONSE = 'PLUGIN_DIALOG_BUTTON_RESPONSE',
|
||||
|
||||
// Message forwarding
|
||||
MESSAGE = 'PLUGIN_MESSAGE',
|
||||
MESSAGE_RESPONSE = 'PLUGIN_MESSAGE_RESPONSE',
|
||||
MESSAGE_ERROR = 'PLUGIN_MESSAGE_ERROR',
|
||||
|
||||
// Plugin lifecycle
|
||||
READY = 'plugin-ready',
|
||||
}
|
||||
|
||||
export interface PluginIframeConfig {
|
||||
pluginId: string;
|
||||
manifest: PluginManifest;
|
||||
|
|
@ -340,8 +316,11 @@ export const createPluginApiScript = (config: PluginIframeConfig): string => {
|
|||
`;
|
||||
};
|
||||
|
||||
// Store blob URLs for cleanup
|
||||
const activeBlobUrls = new Set<string>();
|
||||
|
||||
/**
|
||||
* Create iframe URL with injected API
|
||||
* Create iframe URL with injected API using blob URLs instead of data URLs
|
||||
*/
|
||||
export const createPluginIframeUrl = (config: PluginIframeConfig): string => {
|
||||
const apiScript = createPluginApiScript(config);
|
||||
|
|
@ -367,8 +346,34 @@ export const createPluginIframeUrl = (config: PluginIframeConfig): string => {
|
|||
html = html + apiScript;
|
||||
}
|
||||
|
||||
// Convert to data URL
|
||||
return 'data:text/html;base64,' + btoa(unescape(encodeURIComponent(html)));
|
||||
// Create blob URL instead of data URL
|
||||
const blob = new Blob([html], { type: 'text/html' });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
// Track the blob URL for cleanup
|
||||
activeBlobUrls.add(blobUrl);
|
||||
|
||||
return blobUrl;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clean up a blob URL when iframe is no longer needed
|
||||
*/
|
||||
export const cleanupPluginIframeUrl = (url: string): void => {
|
||||
if (url.startsWith('blob:') && activeBlobUrls.has(url)) {
|
||||
URL.revokeObjectURL(url);
|
||||
activeBlobUrls.delete(url);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clean up all active blob URLs (useful for cleanup on destroy)
|
||||
*/
|
||||
export const cleanupAllPluginIframeUrls = (): void => {
|
||||
activeBlobUrls.forEach((url) => {
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
activeBlobUrls.clear();
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Task, TaskWithSubTasks } from '../../features/tasks/task.model';
|
|||
import { IssueDataReduced } from '../../features/issue/issue.model';
|
||||
import { WorkContextType } from '../../features/work-context/work-context.model';
|
||||
import { Project } from '../../features/project/project.model';
|
||||
import { BatchOperation } from '../../api/batch-update-types';
|
||||
import { BatchOperation } from '@super-productivity/plugin-api';
|
||||
|
||||
/**
|
||||
* Shared actions that affect multiple reducers (tasks, projects, tags)
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ TODO configure more restrictive Content-Security-Policy
|
|||
@see http://www.html5rocks.com/en/tutorials/security/content-security-policy/ -->
|
||||
<meta
|
||||
content="default-src * uploaded:;
|
||||
frame-src * data:;
|
||||
frame-src * data: blob:;
|
||||
font-src * data:;
|
||||
img-src * data: filesystem: file:;
|
||||
style-src * 'unsafe-inline' ;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue