super-productivity/packages/plugin-dev/automations/src/plugin.ts
Johannes Millan f94a46decf feat(plugin-automations): add taskStarted/taskStopped triggers and removeTag action
Adds two new triggers and a new action to the bundled automations plugin,
along with the host-side and validator changes needed to make them
reliable end to end.

Plugin

- New triggers TriggerTaskStarted / TriggerTaskStopped that fire on timer
  start / stop. Switching from task A to task B emits taskStopped(A) and
  then taskStarted(B), so a rule subscribed to either trigger gets the
  full lifecycle. The trigger descriptions document this explicitly.
- New ActionRemoveTag mirroring ActionAddTag, with a shared resolveTagId
  helper covering id/title lookup, missing-tag warning, and idempotent
  short-circuiting.
- Import goes through a new RuleRegistry.addRules(rules[]) + addRules
  message, so the host's 1 call/sec persistDataSynced rate limit no
  longer rejects multi-rule imports.
- Validators in core/rule-registry.ts and utils/rule-validator.ts share a
  set of `as const` arrays guarded by a TS _AssertEq exhaustiveness check
  against the union types in types.ts. The arrays are duplicated rather
  than exported from types.ts because that would turn types.ts into a
  shared runtime chunk and break plugin.js (which the host evaluates via
  `new Function`, not as an ES module).
- Import validator no longer requires a truthy rule name — the UI saves
  empty-named rules, the load-path accepts them, and the import path
  needed to match.
- ActionDialog gets a removeTag placeholder. Manifest's hooks list is
  updated to include the hooks the plugin actually registers
  (taskCreated, currentTaskChange) so the permission disclosure UI is
  accurate.

Host

- plugin-hooks.effects.ts onCurrentTaskChange$ now observes
  selectCurrentTask directly instead of only setCurrentTask /
  unsetCurrentTask actions. This catches transitions through reducer
  paths that don't dispatch those actions (loadAllData, deleteProject,
  bulk task delete via the shared CRUD meta-reducer).
- The payload changes from the raw new Task to { current, previous },
  matching the long-declared CurrentTaskChangePayload shape. The effect
  uses pairwise on the selector and filters same-id pairs at the event
  boundary (not via distinctUntilChanged on the source) so the
  `previous` task always carries the latest snapshot — including
  in-place updates a plugin made while the task was running. Without
  this, addTag-on-start / removeTag-on-stop only worked every other
  cycle because `previous` reflected the pre-mutation snapshot.

Other plugins consuming currentTaskChange (voice-reminder, api-test-plugin)
read payload.current instead of the raw Task.
2026-05-14 21:14:00 +02:00

115 lines
3.6 KiB
TypeScript

import {
CurrentTaskChangePayload,
PluginAPI,
TaskCompletePayload,
TaskUpdatePayload,
TaskCreatedPayload,
} from '@super-productivity/plugin-api';
declare const plugin: PluginAPI;
import { AutomationManager } from './core/automation-manager';
import { globalRegistry } from './core/registry';
// Plugin initialization
plugin.log.info('Automation plugin initialized');
const automationManager = new AutomationManager(plugin);
// Hook into task creation
plugin.registerHook('taskCreated' as any, (payload: TaskCreatedPayload) => {
if (!payload.task) {
plugin.log.warn('Received taskCreated hook without task data');
return;
}
automationManager.onTaskEvent({
type: 'taskCreated',
task: payload.task,
});
});
// Hook into task completion
plugin.registerHook('taskComplete' as any, (payload: TaskCompletePayload) => {
if (!payload.task) {
plugin.log.warn('Received taskComplete hook without task data');
return;
}
automationManager.onTaskEvent({
type: 'taskCompleted',
task: payload.task,
});
});
// Hook into task updates
plugin.registerHook('taskUpdate' as any, (payload: TaskUpdatePayload) => {
if (!payload.task) {
plugin.log.warn('Received taskUpdate hook without task data');
return;
}
automationManager.onTaskEvent({
type: 'taskUpdated',
task: payload.task,
changes: payload.changes,
previousTaskState: undefined, // TODO: How to get previous state? Payload changes only has partial.
});
});
// The host provides both the new current task and the previous one on every
// transition, so we don't need to track state locally. A switch between two
// tasks arrives as { current: B, previous: A } and fires both stop+start.
plugin.registerHook(
'currentTaskChange' as any,
({ current, previous }: CurrentTaskChangePayload) => {
if (previous) {
automationManager.onTaskEvent({ type: 'taskStopped', task: previous });
}
if (current) {
automationManager.onTaskEvent({ type: 'taskStarted', task: current });
}
},
);
// Register UI commands
if (plugin.onMessage) {
plugin.onMessage(async (message: any) => {
switch (message?.type) {
case 'getRules':
return await automationManager.getRegistry().getRules();
case 'getDefinitions':
return {
triggers: globalRegistry
.getTriggers()
.map((t) => ({ id: t.id, name: t.name, description: t.description })),
conditions: globalRegistry
.getConditions()
.map((c) => ({ id: c.id, name: c.name, description: c.description })),
actions: globalRegistry
.getActions()
.map((a) => ({ id: a.id, name: a.name, description: a.description })),
};
case 'saveRule':
await automationManager.getRegistry().addOrUpdateRule(message.payload);
return { success: true };
case 'addRules':
await automationManager.getRegistry().addRules(message.payload);
return { success: true };
case 'deleteRule':
await automationManager.getRegistry().deleteRule(message.payload.id);
return { success: true };
case 'toggleRuleStatus':
await automationManager
.getRegistry()
.toggleRuleStatus(message.payload.id, message.payload.isEnabled);
return { success: true };
case 'getProjects':
return await plugin.getAllProjects();
case 'getTags':
return await plugin.getAllTags();
case 'downloadFile':
await plugin.downloadFile(message.payload.filename, message.payload.data);
return { success: true };
default:
return { error: 'Unknown message type' };
}
});
}