mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
feat(automationPlugin): improve
weekday condition logic and fix memory management
This commit is contained in:
parent
47512fdf25
commit
f1c71ec84f
8 changed files with 84 additions and 91 deletions
|
|
@ -9,6 +9,7 @@ export interface PluginMenuEntryCfg {
|
|||
}
|
||||
|
||||
export enum PluginHooks {
|
||||
TASK_CREATED = 'taskCreated',
|
||||
TASK_COMPLETE = 'taskComplete',
|
||||
TASK_UPDATE = 'taskUpdate',
|
||||
TASK_DELETE = 'taskDelete',
|
||||
|
|
@ -30,7 +31,7 @@ export interface PluginBaseCfg {
|
|||
isDev: boolean;
|
||||
lang?: {
|
||||
code: string;
|
||||
[key: string]: any;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -118,6 +119,11 @@ export interface PluginManifest {
|
|||
}
|
||||
|
||||
// Hook payload types
|
||||
export interface TaskCreatedPayload {
|
||||
taskId: string;
|
||||
task: Task;
|
||||
}
|
||||
|
||||
export interface TaskCompletePayload {
|
||||
taskId: string;
|
||||
task: Task;
|
||||
|
|
@ -145,7 +151,7 @@ export interface FinishDayPayload {
|
|||
export interface LanguageChangePayload {
|
||||
code: string;
|
||||
|
||||
[key: string]: any;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PersistedDataUpdatePayload {
|
||||
|
|
@ -154,7 +160,7 @@ export interface PersistedDataUpdatePayload {
|
|||
|
||||
export interface ActionPayload {
|
||||
action: string;
|
||||
payload?: any;
|
||||
payload?: unknown;
|
||||
}
|
||||
|
||||
export interface AnyTaskUpdatePayload {
|
||||
|
|
@ -173,6 +179,7 @@ export interface ProjectListUpdatePayload {
|
|||
|
||||
// Map hook types to their payload types
|
||||
export interface HookPayloadMap {
|
||||
[PluginHooks.TASK_CREATED]: TaskCreatedPayload;
|
||||
[PluginHooks.TASK_COMPLETE]: TaskCompletePayload;
|
||||
[PluginHooks.TASK_UPDATE]: TaskUpdatePayload;
|
||||
[PluginHooks.TASK_DELETE]: TaskDeletePayload;
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export class AutomationManager {
|
|||
globalRegistry.registerCondition(Conditions.ConditionTitleContains);
|
||||
globalRegistry.registerCondition(Conditions.ConditionProjectIs);
|
||||
globalRegistry.registerCondition(Conditions.ConditionHasTag);
|
||||
globalRegistry.registerCondition(Conditions.ConditionWeekdayIs);
|
||||
|
||||
// Actions
|
||||
globalRegistry.registerAction(Actions.ActionCreateTask);
|
||||
|
|
@ -77,6 +78,10 @@ export class AutomationManager {
|
|||
private async checkTimeBasedRules() {
|
||||
try {
|
||||
const rules = await this.ruleRegistry.getEnabledRules();
|
||||
|
||||
// Cleanup execution times for deleted rules to prevent memory leaks
|
||||
this.syncExecutionTimes(rules.map((r) => r.id));
|
||||
|
||||
const now = new Date();
|
||||
const currentHours = now.getHours();
|
||||
const currentMinutes = now.getMinutes();
|
||||
|
|
@ -121,6 +126,15 @@ export class AutomationManager {
|
|||
}
|
||||
}
|
||||
|
||||
private syncExecutionTimes(activeRuleIds: string[]) {
|
||||
const activeSet = new Set(activeRuleIds);
|
||||
for (const id of this.lastExecutionTimes.keys()) {
|
||||
if (!activeSet.has(id)) {
|
||||
this.lastExecutionTimes.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onTaskEvent(event: TaskEvent) {
|
||||
if (!event.task) {
|
||||
this.plugin.log.warn(`[Automation] Event ${event.type} received without task data`);
|
||||
|
|
|
|||
|
|
@ -30,3 +30,26 @@ export const ConditionHasTag: IAutomationCondition = {
|
|||
return tag ? event.task.tagIds.includes(tag.id) : false;
|
||||
},
|
||||
};
|
||||
|
||||
export const ConditionWeekdayIs: IAutomationCondition = {
|
||||
id: 'weekdayIs',
|
||||
name: 'Weekday is',
|
||||
description: 'Checks if the current day is one of the specified days (e.g. "Monday", "Mon,Tue")',
|
||||
check: async (ctx, event, value) => {
|
||||
if (!value) return false;
|
||||
const days = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
||||
const todayIndex = new Date().getDay();
|
||||
const todayName = days[todayIndex];
|
||||
|
||||
const allowedDays = value
|
||||
.toLowerCase()
|
||||
.split(',')
|
||||
.map((d) => d.trim());
|
||||
|
||||
// Use exact match for full names or 3-letter abbreviations
|
||||
return allowedDays.some((day) => {
|
||||
if (day.length < 3) return false; // Prevent short ambiguous matches
|
||||
return day === todayName || (todayName.startsWith(day) && day.length === 3);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ export class RuleRegistry {
|
|||
}
|
||||
|
||||
const validTriggers = new Set(['taskCompleted', 'taskCreated', 'taskUpdated', 'timeBased']);
|
||||
const validConditions = new Set(['titleContains', 'projectIs', 'hasTag']);
|
||||
const validConditions = new Set(['titleContains', 'projectIs', 'hasTag', 'weekdayIs']);
|
||||
const validActions = new Set([
|
||||
'createTask',
|
||||
'addTag',
|
||||
|
|
@ -116,13 +116,18 @@ export class RuleRegistry {
|
|||
}
|
||||
|
||||
private async saveRules() {
|
||||
this.saveQueue = this.saveQueue.then(async () => {
|
||||
try {
|
||||
await this.plugin.persistDataSynced(JSON.stringify(this.rules));
|
||||
} catch (e) {
|
||||
this.plugin.log.error('Failed to save rules', e);
|
||||
}
|
||||
});
|
||||
this.saveQueue = this.saveQueue
|
||||
.then(async () => {
|
||||
try {
|
||||
await this.plugin.persistDataSynced(JSON.stringify(this.rules));
|
||||
} catch (e) {
|
||||
this.plugin.log.error('Failed to save rules', e);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Catch any errors from the promise chain itself to prevent blocking future saves
|
||||
this.plugin.log.error('Critical error in save queue');
|
||||
});
|
||||
await this.saveQueue;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
PluginAPI,
|
||||
TaskCompletePayload,
|
||||
TaskUpdatePayload,
|
||||
TaskCreatedPayload,
|
||||
} from '@super-productivity/plugin-api';
|
||||
import type { PluginHooks } from '@super-productivity/plugin-api';
|
||||
|
||||
|
|
@ -10,52 +11,23 @@ declare const plugin: PluginAPI;
|
|||
|
||||
import { AutomationManager } from './core/automation-manager';
|
||||
import { globalRegistry } from './core/registry';
|
||||
import { TASK_SHARED_ADD_TASK_ACTION } from './core/definitions';
|
||||
|
||||
// Plugin initialization
|
||||
plugin.log.info('Automation plugin initialized');
|
||||
|
||||
const automationManager = new AutomationManager(plugin);
|
||||
|
||||
// Deduplicate creation handling across multiple hooks (taskUpdate + anyTaskUpdate)
|
||||
// because there is no dedicated taskCreated hook in the Plugin API.
|
||||
const handledCreations = new Set<string>();
|
||||
const markCreationHandled = (taskId?: string) => {
|
||||
if (!taskId) {
|
||||
return false;
|
||||
}
|
||||
if (handledCreations.has(taskId)) {
|
||||
return true;
|
||||
}
|
||||
handledCreations.add(taskId);
|
||||
// Avoid unbounded growth while still deduplicating bursts
|
||||
setTimeout(() => handledCreations.delete(taskId), 5000);
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleTaskCreated = (task: TaskUpdatePayload['task']) => {
|
||||
if (!task) {
|
||||
plugin.log.warn('Received taskCreated event without task data');
|
||||
return;
|
||||
}
|
||||
if (markCreationHandled(task.id)) {
|
||||
// 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,
|
||||
task: payload.task,
|
||||
});
|
||||
};
|
||||
|
||||
// Be defensive about action names from different sources/import paths.
|
||||
const isAddTaskAction = (action: string | undefined) => {
|
||||
if (!action) return false;
|
||||
return (
|
||||
action === TASK_SHARED_ADD_TASK_ACTION ||
|
||||
action.toLowerCase().includes('addtask') ||
|
||||
action.toLowerCase().includes('add task')
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
// Hook into task completion
|
||||
plugin.registerHook('taskComplete' as any, (payload: TaskCompletePayload) => {
|
||||
|
|
@ -75,12 +47,7 @@ plugin.registerHook('taskUpdate' as any, (payload: TaskUpdatePayload) => {
|
|||
plugin.log.warn('Received taskUpdate hook without task data');
|
||||
return;
|
||||
}
|
||||
const isCreationEvent = !payload.changes || Object.keys(payload.changes).length === 0;
|
||||
if (isCreationEvent) {
|
||||
plugin.log.info('[Automation] Detected creation via taskUpdate hook');
|
||||
handleTaskCreated(payload.task);
|
||||
return;
|
||||
}
|
||||
// We no longer need to heuristically detect creation here
|
||||
automationManager.onTaskEvent({
|
||||
type: 'taskUpdated',
|
||||
task: payload.task,
|
||||
|
|
@ -88,35 +55,6 @@ plugin.registerHook('taskUpdate' as any, (payload: TaskUpdatePayload) => {
|
|||
});
|
||||
});
|
||||
|
||||
// Hook into task creation?
|
||||
// There is no explicit TASK_CREATE hook in PluginHooks enum from types.ts (Step 23).
|
||||
// We might need to infer it or use ANY_TASK_UPDATE or check if there is a missing hook.
|
||||
// Looking at types.ts: TASK_COMPLETE, TASK_UPDATE, TASK_DELETE, CURRENT_TASK_CHANGE, FINISH_DAY, ...
|
||||
// Wait, is there no TASK_CREATE?
|
||||
// Let's check if ANY_TASK_UPDATE covers creation.
|
||||
// Or maybe we need to request a new hook.
|
||||
// For now, let's assume we can't easily detect creation unless we monitor ANY_TASK_UPDATE and check if it's new?
|
||||
// Actually, `addTask` returns a promise with ID.
|
||||
// But if the user creates a task via UI, the plugin needs to know.
|
||||
// Let's check if `TASK_UPDATE` is fired on creation? Usually creation is separate.
|
||||
// If TASK_CREATE is missing, I should note it.
|
||||
// However, the user request explicitly asked for "Task created" trigger.
|
||||
// I will use `ANY_TASK_UPDATE` and check if I can detect creation, or just leave a comment.
|
||||
// Actually, let's look at `PluginHooks` again.
|
||||
// Step 23: TASK_COMPLETE, TASK_UPDATE, TASK_DELETE, CURRENT_TASK_CHANGE, FINISH_DAY, LANGUAGE_CHANGE, PERSISTED_DATA_UPDATE, ACTION, ANY_TASK_UPDATE, PROJECT_LIST_UPDATE.
|
||||
// No TASK_CREATE.
|
||||
// Maybe `ANY_TASK_UPDATE` with a specific action?
|
||||
// `AnyTaskUpdatePayload` has `action`, `taskId`, `task`, `changes`.
|
||||
// If `action` is 'ADD', that might be it.
|
||||
|
||||
plugin.registerHook('anyTaskUpdate' as any, (payload: AnyTaskUpdatePayload) => {
|
||||
plugin.log.info(`[Automation] anyTaskUpdate action: ${payload.action}`);
|
||||
|
||||
if (isAddTaskAction(payload.action) && payload.task) {
|
||||
handleTaskCreated(payload.task);
|
||||
}
|
||||
});
|
||||
|
||||
// Register UI commands
|
||||
if (plugin.onMessage) {
|
||||
plugin.onMessage(async (message: any) => {
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ export interface AutomationTrigger {
|
|||
export interface TaskEvent {
|
||||
type: AutomationTriggerType;
|
||||
task?: Task;
|
||||
previousTaskState?: Task; // only used for "updated"
|
||||
previousTaskState?: unknown; // only used for "updated"
|
||||
}
|
||||
|
||||
export type ConditionType = 'titleContains' | 'projectIs' | 'hasTag';
|
||||
export type ConditionType = 'titleContains' | 'projectIs' | 'hasTag' | 'weekdayIs';
|
||||
|
||||
export interface Condition {
|
||||
type: ConditionType;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
PluginNodeScriptResult,
|
||||
PluginShortcutCfg,
|
||||
PluginSidePanelBtnCfg,
|
||||
Task,
|
||||
} from './plugin-api.model';
|
||||
|
||||
import {
|
||||
|
|
@ -363,10 +364,10 @@ export class PluginBridgeService implements OnDestroy {
|
|||
taskData.parentId,
|
||||
);
|
||||
|
||||
// Check if this is a subtask
|
||||
let createdTask: Task;
|
||||
if (taskData.parentId) {
|
||||
// For subtasks, we need to use the addSubTask action to properly update parent
|
||||
const task = this._taskService.createNewTaskWithDefaults({
|
||||
const newTask = this._taskService.createNewTaskWithDefaults({
|
||||
title: taskData.title,
|
||||
additional: {
|
||||
notes: taskData.notes || '',
|
||||
|
|
@ -380,16 +381,18 @@ export class PluginBridgeService implements OnDestroy {
|
|||
// Dispatch the addSubTask action which properly updates parent's subTaskIds
|
||||
this._store.dispatch(
|
||||
addSubTask({
|
||||
task,
|
||||
task: newTask,
|
||||
parentId: taskData.parentId,
|
||||
}),
|
||||
);
|
||||
createdTask = newTask;
|
||||
|
||||
PluginLog.log('PluginBridge: Subtask added successfully', {
|
||||
taskId: task.id,
|
||||
taskId: createdTask.id,
|
||||
taskData,
|
||||
});
|
||||
return task.id;
|
||||
|
||||
return createdTask.id;
|
||||
} else {
|
||||
// For main tasks, use the regular add method
|
||||
const additional: Partial<TaskCopy> = {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
moveSubTaskDown,
|
||||
moveSubTaskToTop,
|
||||
moveSubTaskToBottom,
|
||||
addSubTask, // Added
|
||||
} from '../features/tasks/store/task.actions';
|
||||
import * as projectActions from '../features/project/store/project.actions';
|
||||
import { updateProject } from '../features/project/store/project.actions';
|
||||
|
|
@ -131,7 +132,7 @@ export class PluginHooksEffects {
|
|||
taskAdd$ = createEffect(
|
||||
() =>
|
||||
this.actions$.pipe(
|
||||
ofType(TaskSharedActions.addTask),
|
||||
ofType(TaskSharedActions.addTask, addSubTask),
|
||||
switchMap((action) =>
|
||||
this.store.pipe(
|
||||
select(selectTaskById, { id: action.task.id }),
|
||||
|
|
@ -139,11 +140,13 @@ export class PluginHooksEffects {
|
|||
filter((task) => !!task),
|
||||
tap((task: Task | undefined) => {
|
||||
if (task) {
|
||||
this.pluginService.dispatchHook(PluginHooks.TASK_UPDATE, {
|
||||
this.pluginService.dispatchHook(PluginHooks.TASK_CREATED, {
|
||||
taskId: task.id,
|
||||
task,
|
||||
changes: {}, // Initial add, no changes diff
|
||||
});
|
||||
// Also dispatch legacy update for backward compatibility if needed,
|
||||
// but generally TASK_CREATED should be preferred.
|
||||
// Leaving TASK_UPDATE out as 'taskCreated' is the specific hook now.
|
||||
}
|
||||
}),
|
||||
map(() => EMPTY),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue