mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 08:15:55 +00:00
feat(plugins): tasks carry alerts
api.tasks.create and update take alerts by id: a trigger from the task's due (or start) as an ISO 8601 duration, or at an instant in UTC; display or email; at most five. An update merges by id, each alert written by its JMAP patch path: an alert given is set, one given null is removed, and alerts the plugin did not name, such as one another app wrote, stay as the server holds them; null for the whole map removes every alert. Whether the map is patched or written whole is decided on the task as the server holds it, not an older copy. An alert kept with the same trigger and action stays acknowledged, so a snooze is an update of its trigger, which then fires again. While a task uses its calendar's default alerts, a change makes them the task's own under new ids, as the task form does, an alert under a default's id is refused rather than dropped, and removing every alert turns the defaults off too. TaskView returns the alerts, each with when it was acknowledged, and useDefaultAlerts. api.tasks.acknowledgeAlert dismisses one alert (or undoes that with null) by writing only its acknowledged. A plugin's writes to one task go one after another, since Stalwart refuses a set while another is under way on the same object. The task store applies patch paths to the task the caller read, so a task the list does not hold is shown and reported whole.
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { applyPatchObject, patchPath, patchedProperties } from '../jmap/patch-object';
|
||||
|
||||
describe('patch-object', () => {
|
||||
it('escapes path segments as JSON Pointer does', () => {
|
||||
expect(patchPath('relatedTo', '<a/b~c@x>')).toBe('relatedTo/<a~1b~0c@x>');
|
||||
});
|
||||
|
||||
it('applies plain keys and member paths without touching the target', () => {
|
||||
const target = { title: 'T', alerts: { a: { acknowledged: null, action: 'display' }, b: { acknowledged: null } } };
|
||||
const after = applyPatchObject(target, { title: 'U', 'alerts/a/acknowledged': '2026-05-04T07:00:00Z', 'alerts/b': null, 'links/x~1y': { href: 'h' } });
|
||||
expect(after).toEqual({
|
||||
title: 'U',
|
||||
alerts: { a: { acknowledged: '2026-05-04T07:00:00Z', action: 'display' } },
|
||||
links: { 'x/y': { href: 'h' } },
|
||||
});
|
||||
expect(target.alerts.b).toEqual({ acknowledged: null });
|
||||
});
|
||||
|
||||
it('keeps __proto__ as a plain key', () => {
|
||||
const after = applyPatchObject({ alerts: {} }, { 'alerts/__proto__': { polluted: true } }) as { alerts: object };
|
||||
expect(Object.getPrototypeOf(after.alerts)).toBe(Object.prototype);
|
||||
expect(Object.keys(after.alerts)).toEqual(['__proto__']);
|
||||
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
||||
});
|
||||
|
||||
it('shows a path patch as whole properties', () => {
|
||||
expect(patchedProperties({ alerts: { a: { x: 1 }, b: { x: 2 } } }, { 'alerts/a/x': 3 })).toEqual({ alerts: { a: { x: 3 }, b: { x: 2 } } });
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { InstalledPlugin } from '../plugin-types';
|
||||
import type { IJMAPClient } from '../jmap/client-interface';
|
||||
import type { Calendar, CalendarEvent, CalendarTask } from '../jmap/types';
|
||||
import type { Calendar, CalendarEvent, CalendarEventAlert, CalendarTask } from '../jmap/types';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useCalendarStore } from '@/stores/calendar-store';
|
||||
import { useTaskStore } from '@/stores/task-store';
|
||||
@@ -243,3 +243,242 @@ describe('api.tasks', () => {
|
||||
await expect(call(WRITE, 'tasks.delete', 'nope')).rejects.toThrow(/no task with id "nope"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('api.tasks alerts', () => {
|
||||
const at = (when: string, over: Partial<CalendarEventAlert> = {}): CalendarEventAlert => ({
|
||||
'@type': 'Alert', trigger: { '@type': 'AbsoluteTrigger', when }, action: 'display', acknowledged: null, relatedTo: null, ...over,
|
||||
});
|
||||
const updates = () => client.updateCalendarTask.mock.calls.map((c) => c[1] as Record<string, unknown>);
|
||||
|
||||
it('creates a task with alerts, turning the calendar\'s defaults off', async () => {
|
||||
await call(WRITE, 'tasks.create', {
|
||||
title: 'Follow up: Offer',
|
||||
due: '2026-05-04',
|
||||
alerts: {
|
||||
'follow-up': { trigger: { at: '2026-05-04T07:00:00Z' } },
|
||||
'follow-up-mail': { trigger: { offset: '-PT1H' }, action: 'email' },
|
||||
},
|
||||
});
|
||||
expect(client.createCalendarTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||
due: '2026-05-04', showWithoutTime: true, useDefaultAlerts: false,
|
||||
alerts: {
|
||||
'follow-up': at('2026-05-04T07:00:00Z'),
|
||||
'follow-up-mail': {
|
||||
'@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT1H', relativeTo: 'end' },
|
||||
action: 'email', acknowledged: null, relatedTo: null,
|
||||
},
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it('returns them, each alert with whether it was acknowledged, and whether the defaults are in use', async () => {
|
||||
client.getCalendarTasks.mockResolvedValue([task({
|
||||
alerts: {
|
||||
a: at('2026-05-04T07:00:00Z', { acknowledged: '2026-05-04T07:01:00Z' }),
|
||||
b: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' }, action: 'email', acknowledged: null, relatedTo: null },
|
||||
},
|
||||
})]);
|
||||
expect(await call(READ, 'tasks.get', 't1')).toEqual(expect.objectContaining({
|
||||
alerts: {
|
||||
a: { trigger: { at: '2026-05-04T07:00:00Z' }, action: 'display', acknowledged: '2026-05-04T07:01:00Z' },
|
||||
b: { trigger: { offset: '-PT15M', relativeTo: 'start' }, action: 'email', acknowledged: null },
|
||||
},
|
||||
useDefaultAlerts: false,
|
||||
}));
|
||||
const [listed] = await call(READ, 'tasks.list') as { alerts: object }[];
|
||||
expect(Object.keys(listed.alerts)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('refuses bad triggers, ids and too many alerts', async () => {
|
||||
const create = (over: Record<string, unknown>) => call(WRITE, 'tasks.create', { title: 'x', ...over });
|
||||
await expect(create({ alerts: [{ id: 'a', trigger: { offset: 'PT0S' } }] })).rejects.toThrow(/object keyed by id/);
|
||||
const six = Object.fromEntries([0, 1, 2, 3, 4, 5].map((i) => [`a${i}`, { trigger: { offset: 'PT0S' } }]));
|
||||
await expect(create({ alerts: six })).rejects.toThrow(/at most 5 entries/);
|
||||
await expect(create({ alerts: { a: { trigger: { offset: 'P400D' } } } })).rejects.toThrow(/at most 366 days/);
|
||||
await expect(create({ alerts: { a: { trigger: { offset: 'P' } } } })).rejects.toThrow(/ISO 8601 duration/);
|
||||
await expect(create({ alerts: { a: { trigger: { at: '2026-05-04T09:00' } } } })).rejects.toThrow(/in UTC/);
|
||||
await expect(create({ alerts: { a: { trigger: { offset: 'PT0S' }, action: 'audio' } } })).rejects.toThrow(/display or email/);
|
||||
await expect(create({ alerts: { 'a b': { trigger: { offset: 'PT0S' } } } })).rejects.toThrow(/letters, digits/);
|
||||
expect(client.createCalendarTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('merges by id with patch paths, leaving alerts it did not name and other fields alone', async () => {
|
||||
useTaskStore.setState({ tasks: [task({
|
||||
title: 'Kept',
|
||||
alerts: { foreign: at('2026-05-04T06:00:00Z', { action: 'email' }), old: at('2026-05-04T05:00:00Z') },
|
||||
})] });
|
||||
await call(WRITE, 'tasks.update', 't1', {
|
||||
alerts: { 'follow-up': { trigger: { at: '2026-05-04T07:00:00Z' } }, old: null },
|
||||
});
|
||||
expect(updates()[0]).toEqual({
|
||||
'alerts/follow-up': at('2026-05-04T07:00:00Z'),
|
||||
'alerts/old': null,
|
||||
});
|
||||
const held = useTaskStore.getState().tasks[0];
|
||||
expect(held.title).toBe('Kept');
|
||||
expect(Object.keys(held.alerts ?? {})).toEqual(['foreign', 'follow-up']);
|
||||
|
||||
await call(WRITE, 'tasks.update', 't1', { alerts: null });
|
||||
expect(updates()[1]).toEqual({ alerts: null });
|
||||
});
|
||||
|
||||
it('writes the alerts whole on a task that has none yet', async () => {
|
||||
useTaskStore.setState({ tasks: [task({ alerts: null })] });
|
||||
await call(WRITE, 'tasks.update', 't1', { alerts: { a: { trigger: { at: '2026-05-04T07:00:00Z' } } } });
|
||||
expect(updates()[0]).toEqual({ alerts: { a: at('2026-05-04T07:00:00Z') } });
|
||||
});
|
||||
|
||||
it('keeps an unchanged alert acknowledged and lets a snoozed one fire again', async () => {
|
||||
useTaskStore.setState({ tasks: [task({
|
||||
alerts: {
|
||||
a: at('2026-05-04T07:00:00Z', { acknowledged: '2026-05-04T07:01:00Z' }),
|
||||
b: at('2026-05-04T07:00:00Z', { acknowledged: '2026-05-04T07:01:00Z' }),
|
||||
},
|
||||
})] });
|
||||
await call(WRITE, 'tasks.update', 't1', { alerts: {
|
||||
a: { trigger: { at: '2026-05-04T07:00:00Z' } },
|
||||
b: { trigger: { at: '2026-05-04T08:00:00Z' } },
|
||||
} });
|
||||
expect(updates()[0]).toEqual({ 'alerts/b': at('2026-05-04T08:00:00Z') });
|
||||
const held = useTaskStore.getState().tasks[0];
|
||||
expect(held.alerts?.a.acknowledged).toBe('2026-05-04T07:01:00Z');
|
||||
expect(held.alerts?.b.acknowledged).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a sixth alert counting those another app wrote', async () => {
|
||||
useTaskStore.setState({ tasks: [task({
|
||||
alerts: Object.fromEntries([1, 2, 3, 4, 5].map((i) => [`x${i}`, at('2026-05-04T07:00:00Z')])),
|
||||
})] });
|
||||
await expect(call(WRITE, 'tasks.update', 't1', { alerts: { new: { trigger: { offset: 'PT0S' } } } })).rejects.toThrow(/more than 5 alerts/);
|
||||
await call(WRITE, 'tasks.update', 't1', { alerts: { x1: null, new: { trigger: { offset: 'PT0S' } } } });
|
||||
expect(updates()[0]).toEqual({ 'alerts/x1': null, 'alerts/new': expect.objectContaining({ action: 'display' }) });
|
||||
});
|
||||
|
||||
it('makes the calendar\'s default alerts the task\'s own under new ids, refusing a plugin alert under a default\'s id', async () => {
|
||||
useTaskStore.setState({ tasks: [task({
|
||||
useDefaultAlerts: true,
|
||||
alerts: { d1: at('2026-05-04T06:00:00Z'), d2: at('2026-05-04T06:30:00Z') },
|
||||
})] });
|
||||
expect(await call(READ, 'tasks.get', 't1')).toEqual(expect.objectContaining({ useDefaultAlerts: true }));
|
||||
await expect(call(WRITE, 'tasks.update', 't1', { alerts: { d1: { trigger: { offset: 'PT0S' } } } })).rejects.toThrow(/default alerts.*another id/);
|
||||
await expect(call(WRITE, 'tasks.acknowledgeAlert', 't1', 'd1')).rejects.toThrow(/useDefaultAlerts/);
|
||||
expect(client.updateCalendarTask).not.toHaveBeenCalled();
|
||||
|
||||
await call(WRITE, 'tasks.update', 't1', { alerts: { d2: null, mine: { trigger: { at: '2026-05-04T07:00:00Z' } } } });
|
||||
expect(updates()[0]).toEqual({
|
||||
useDefaultAlerts: false,
|
||||
alerts: { alert1: at('2026-05-04T06:00:00Z'), mine: at('2026-05-04T07:00:00Z') },
|
||||
});
|
||||
useTaskStore.setState({ tasks: [task({ useDefaultAlerts: true, alerts: { d1: at('2026-05-04T06:00:00Z') } })] });
|
||||
await call(WRITE, 'tasks.update', 't1', { alerts: {} });
|
||||
expect(updates()[1]).toEqual({ useDefaultAlerts: false, alerts: { alert1: at('2026-05-04T06:00:00Z') } });
|
||||
});
|
||||
|
||||
it('takes ids such as __proto__ and constructor as plain keys', async () => {
|
||||
await call(WRITE, 'tasks.create', {
|
||||
title: 'x',
|
||||
alerts: JSON.parse('{"__proto__": {"trigger": {"offset": "PT0S"}}, "constructor": {"trigger": {"offset": "PT0S"}}}'),
|
||||
});
|
||||
const created = client.createCalendarTask.mock.calls[0][0] as CalendarTask;
|
||||
expect(Object.getPrototypeOf(created.alerts)).toBe(Object.prototype);
|
||||
expect(Object.keys(created.alerts ?? {})).toEqual(['__proto__', 'constructor']);
|
||||
|
||||
useTaskStore.setState({ tasks: [task({ alerts: { a: at('2026-05-04T07:00:00Z') } })] });
|
||||
await expect(call(WRITE, 'tasks.acknowledgeAlert', 't1', 'constructor')).rejects.toThrow(/no alert "constructor"/);
|
||||
await expect(call(WRITE, 'tasks.acknowledgeAlert', 't1', '__proto__')).rejects.toThrow(/no alert "__proto__"/);
|
||||
await call(WRITE, 'tasks.update', 't1', { alerts: JSON.parse('{"__proto__": {"trigger": {"offset": "PT0S"}}}') });
|
||||
expect(updates()[0]).toEqual(JSON.parse('{"alerts/__proto__": {"@type": "Alert", "trigger": {"@type": "OffsetTrigger", "offset": "PT0S", "relativeTo": "end"}, "action": "display", "acknowledged": null, "relatedTo": null}}'));
|
||||
const held = useTaskStore.getState().tasks[0];
|
||||
expect(Object.getPrototypeOf(held.alerts)).toBe(Object.prototype);
|
||||
expect(Object.keys(held.alerts ?? {})).toEqual(['a', '__proto__']);
|
||||
});
|
||||
|
||||
it('acknowledges an alert by its path, now or at a given time, and undoes it', async () => {
|
||||
vi.useFakeTimers({ now: new Date('2026-05-04T07:02:03.456Z'), toFake: ['Date'] });
|
||||
try {
|
||||
useTaskStore.setState({ tasks: [task({ alerts: { a: at('2026-05-04T07:00:00Z'), b: at('2026-05-04T07:00:00Z', { action: 'email' }) } })] });
|
||||
await call(WRITE, 'tasks.acknowledgeAlert', 't1', 'a');
|
||||
expect(client.updateCalendarTask).toHaveBeenLastCalledWith('t1', { 'alerts/a/acknowledged': '2026-05-04T07:02:03Z' });
|
||||
expect(useTaskStore.getState().tasks[0].alerts?.a.acknowledged).toBe('2026-05-04T07:02:03Z');
|
||||
await call(WRITE, 'tasks.acknowledgeAlert', 't1', 'b', '2026-05-04T06:00:00Z');
|
||||
expect(useTaskStore.getState().tasks[0].alerts?.b.acknowledged).toBe('2026-05-04T06:00:00Z');
|
||||
await call(WRITE, 'tasks.acknowledgeAlert', 't1', 'a', null);
|
||||
expect(client.updateCalendarTask).toHaveBeenLastCalledWith('t1', { 'alerts/a/acknowledged': null });
|
||||
expect(await call(READ, 'tasks.get', 't1')).toEqual(expect.objectContaining({
|
||||
alerts: expect.objectContaining({ a: expect.objectContaining({ acknowledged: null }) }),
|
||||
}));
|
||||
|
||||
client.updateCalendarTask.mockClear();
|
||||
await call(WRITE, 'tasks.acknowledgeAlert', 't1', 'a', null);
|
||||
expect(client.updateCalendarTask).not.toHaveBeenCalled();
|
||||
await expect(call(WRITE, 'tasks.acknowledgeAlert', 't1', 'zz')).rejects.toThrow(/no alert "zz"/);
|
||||
await expect(call(WRITE, 'tasks.acknowledgeAlert', 't1', 'a', '2026-05-04T06:00')).rejects.toThrow(/in UTC/);
|
||||
await expect(call(READ, 'tasks.acknowledgeAlert', 't1', 'a')).rejects.toThrow(/lacks permission "tasks:write"/);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('acknowledges two alerts at once one after the other, neither undoing the other', async () => {
|
||||
useTaskStore.setState({ tasks: [task({ alerts: { a: at('2026-05-04T07:00:00Z'), b: at('2026-05-04T07:00:00Z') } })] });
|
||||
let inFlight = 0;
|
||||
let most = 0;
|
||||
client.updateCalendarTask.mockImplementation(async (id: string) => {
|
||||
most = Math.max(most, ++inFlight);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
inFlight -= 1;
|
||||
return id;
|
||||
});
|
||||
await Promise.all([
|
||||
call(WRITE, 'tasks.acknowledgeAlert', 't1', 'a', '2026-05-04T07:01:00Z'),
|
||||
call(WRITE, 'tasks.acknowledgeAlert', 't1', 'b', '2026-05-04T07:02:00Z'),
|
||||
]);
|
||||
// Stalwart refuses a second set on an object while one is under way.
|
||||
expect(most).toBe(1);
|
||||
expect(updates()).toEqual([
|
||||
{ 'alerts/a/acknowledged': '2026-05-04T07:01:00Z' },
|
||||
{ 'alerts/b/acknowledged': '2026-05-04T07:02:00Z' },
|
||||
]);
|
||||
const alerts = useTaskStore.getState().tasks[0].alerts;
|
||||
expect([alerts?.a.acknowledged, alerts?.b.acknowledged]).toEqual(['2026-05-04T07:01:00Z', '2026-05-04T07:02:00Z']);
|
||||
});
|
||||
|
||||
it('acknowledges an alert of a task the list does not hold, emitting the whole task', async () => {
|
||||
const full = task({ title: 'From the server', alerts: { a: at('2026-05-04T07:00:00Z'), b: at('2026-05-04T08:00:00Z') } });
|
||||
client.getCalendarTasks.mockResolvedValue([full]);
|
||||
const seen: { patch: Record<string, unknown> }[] = [];
|
||||
const emitted: { title: string; alerts: Record<string, { acknowledged: string | null }> }[] = [];
|
||||
taskHooks.onBeforeTaskUpdate.register('watch', (e: unknown) => { seen.push(e as { patch: Record<string, unknown> }); return true; });
|
||||
taskHooks.onTaskUpdate.register('watch', (v: unknown) => { emitted.push(v as never); });
|
||||
try {
|
||||
await call(WRITE, 'tasks.acknowledgeAlert', 't1', 'a', '2026-05-04T07:01:00Z');
|
||||
expect(updates()).toEqual([{ 'alerts/a/acknowledged': '2026-05-04T07:01:00Z' }]);
|
||||
expect(Object.keys(seen[0].patch.alerts as object)).toEqual(['a', 'b']);
|
||||
await vi.waitFor(() => expect(emitted).toHaveLength(1));
|
||||
expect(emitted[0].title).toBe('From the server');
|
||||
expect(emitted[0].alerts.a.acknowledged).toBe('2026-05-04T07:01:00Z');
|
||||
expect(emitted[0].alerts.b.acknowledged).toBeNull();
|
||||
} finally {
|
||||
taskHooks.onBeforeTaskUpdate.removePlugin('watch');
|
||||
taskHooks.onTaskUpdate.removePlugin('watch');
|
||||
}
|
||||
});
|
||||
|
||||
it('decides on the alerts as the server holds them, not an older copy in the list', async () => {
|
||||
useTaskStore.setState({ tasks: [task({ alerts: null })] });
|
||||
client.getCalendarEvent.mockImplementation(async (id: string) => (id === 't1'
|
||||
? { id: 't1', alerts: { other: at('2026-05-04T06:00:00Z', { action: 'email' }) } }
|
||||
: null));
|
||||
await call(WRITE, 'tasks.update', 't1', { alerts: { mine: { trigger: { at: '2026-05-04T07:00:00Z' } } } });
|
||||
expect(updates()[0]).toEqual({ 'alerts/mine': at('2026-05-04T07:00:00Z') });
|
||||
const held = useTaskStore.getState().tasks[0];
|
||||
expect(Object.keys(held.alerts ?? {})).toEqual(['other', 'mine']);
|
||||
expect(client.getCalendarEvent).toHaveBeenCalledWith('t1', undefined);
|
||||
});
|
||||
|
||||
it('turns the calendar\'s default alerts off when every alert is removed', async () => {
|
||||
useTaskStore.setState({ tasks: [task({ useDefaultAlerts: true, alerts: { d1: at('2026-05-04T06:00:00Z') } })] });
|
||||
await call(WRITE, 'tasks.update', 't1', { alerts: null });
|
||||
expect(updates()[0]).toEqual({ alerts: null, useDefaultAlerts: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,6 +67,7 @@ describe('plugin permissions', () => {
|
||||
'calendar.createEvent': 'calendar:write',
|
||||
'tasks.list': 'tasks:read',
|
||||
'tasks.setComplete': 'tasks:write',
|
||||
'tasks.acknowledgeAlert': 'tasks:write',
|
||||
'templates.list': 'templates:read',
|
||||
'templates.create': 'templates:write',
|
||||
'identities.update': 'identity:write',
|
||||
|
||||
+3
-1
@@ -28,6 +28,7 @@ import { buildShareSetCalls, failAll, parseShareTargets, readShareSetResults, SE
|
||||
import type { ShareKind } from "./share-rights";
|
||||
import { ARCHIVED_ITEM_USING, STALWART_CAPABILITY, buildRestoreCalls, hasArchivedEmail, listArchivedEmailsPaged, readRestoreResults, type ArchiveIo, type ArchiveListResult, type ArchiveQueryPage, type RestoreResult } from "@/lib/recovery/archived-items";
|
||||
import { RescheduleRestoredError } from "./reschedule-error";
|
||||
import { applyPatchObject } from "./patch-object";
|
||||
|
||||
export { SchedulingDeniedError, RescheduleRestoredError };
|
||||
import { acceptedFileName, fileNameRulesFrom, type FileNameRules } from "@/lib/file-name-rules";
|
||||
@@ -9621,7 +9622,8 @@ export class JMAPClient implements IJMAPClient {
|
||||
|
||||
// Drop what the server sets itself; keep the uid so it stays the same task.
|
||||
const { id: _id, created: _cr, updated: _up, sequence: _sq, isDraft: _idr, isOrigin: _io, baseEventId: _be, blobId: _bl, utcStart: _us, utcEnd: _ue, ...rest } = original as Record<string, unknown>;
|
||||
const created = await this.createCalendarTask({ ...rest, ...patch } as Partial<CalendarTask>, destAccountId);
|
||||
// The patch may name members by path ("alerts/<id>/acknowledged").
|
||||
const created = await this.createCalendarTask(applyPatchObject(rest, patch) as Partial<CalendarTask>, destAccountId);
|
||||
await this.deleteCalendarEvent(source.id, false, source.accountId);
|
||||
return created.id;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* JMAP PatchObject paths (RFC 8620 §5.3): a key such as
|
||||
* "alerts/a1/acknowledged" changes that one member and leaves the rest of the
|
||||
* object as the server holds it, so a change made meanwhile elsewhere (another
|
||||
* device, another plugin call) is not overwritten.
|
||||
*/
|
||||
|
||||
/** The JSON Pointer path of nested members, each segment escaped (RFC 6901). */
|
||||
export function patchPath(...segments: string[]): string {
|
||||
return segments.map((s) => s.replace(/~/g, '~0').replace(/\//g, '~1')).join('/');
|
||||
}
|
||||
|
||||
function unescapeSegment(segment: string): string {
|
||||
return segment.replace(/~1/g, '/').replace(/~0/g, '~');
|
||||
}
|
||||
|
||||
/** A shallow copy that keeps every own key, `__proto__` included, as data. */
|
||||
function copyOf(object: object | null | undefined): Record<string, unknown> {
|
||||
return Object.fromEntries(Object.entries(object ?? {}));
|
||||
}
|
||||
|
||||
/** Sets `key` as an own data property, never the prototype. */
|
||||
function put(object: Record<string, unknown>, key: string, value: unknown): void {
|
||||
Object.defineProperty(object, key, { value, enumerable: true, configurable: true, writable: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* The object as the server holds it after `patch`: plain keys replace the
|
||||
* property, path keys change the member they name (null removes a map
|
||||
* member). Nothing is changed in place.
|
||||
*/
|
||||
export function applyPatchObject<T extends object>(target: T, patch: object): T {
|
||||
let result = copyOf(target);
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (!key.includes('/')) {
|
||||
put(result, key, value);
|
||||
continue;
|
||||
}
|
||||
result = setIn(result, key.split('/').map(unescapeSegment), value);
|
||||
}
|
||||
return result as T;
|
||||
}
|
||||
|
||||
function setIn(object: Record<string, unknown>, [head, ...rest]: string[], value: unknown): Record<string, unknown> {
|
||||
const copy = copyOf(object);
|
||||
if (rest.length === 0) {
|
||||
if (value === null) delete copy[head];
|
||||
else put(copy, head, value);
|
||||
return copy;
|
||||
}
|
||||
const child = Object.hasOwn(copy, head) ? copy[head] : undefined;
|
||||
put(copy, head, setIn(child && typeof child === 'object' ? (child as Record<string, unknown>) : {}, rest, value));
|
||||
return copy;
|
||||
}
|
||||
|
||||
/**
|
||||
* The top-level properties `patch` changes, each whole as it is after the
|
||||
* patch: a path key ("alerts/a1/acknowledged") becomes its property
|
||||
* ("alerts") with every member as the patch leaves it. For whoever reads a
|
||||
* patch as plain properties (plugin hooks), while the server gets the paths.
|
||||
*/
|
||||
export function patchedProperties<T extends object>(target: T, patch: object): Partial<T> {
|
||||
const after = applyPatchObject(target, patch) as Record<string, unknown>;
|
||||
const changed: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(patch)) {
|
||||
const property = unescapeSegment(key.split('/')[0]);
|
||||
put(changed, property, Object.hasOwn(after, property) ? after[property] : null);
|
||||
}
|
||||
return changed as Partial<T>;
|
||||
}
|
||||
@@ -175,6 +175,14 @@ export function taskToView(task: CalendarTask): TaskView {
|
||||
priority: task.priority ?? 0,
|
||||
calendarId: trueKeys(task.calendarIds)[0] || '',
|
||||
keywords: trueKeys(task.keywords),
|
||||
alerts: Object.fromEntries(Object.entries(task.alerts || {}).filter(([, alert]) => alert?.trigger).map(([id, alert]) => [id, {
|
||||
trigger: alert.trigger['@type'] === 'AbsoluteTrigger'
|
||||
? { at: alert.trigger.when }
|
||||
: { offset: alert.trigger.offset, relativeTo: alert.trigger.relativeTo === 'end' ? 'due' as const : 'start' as const },
|
||||
action: alert.action === 'email' ? 'email' as const : 'display' as const,
|
||||
acknowledged: alert.acknowledged || null,
|
||||
}])),
|
||||
useDefaultAlerts: !!task.useDefaultAlerts,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ import { createRecurrenceRule } from '@/lib/recurrence-rule';
|
||||
import type {
|
||||
CalendarEventInput, CalendarEventView, CalendarView, TaskInput, TaskView,
|
||||
} from '../plugin-types';
|
||||
import type { Calendar, CalendarEvent, CalendarParticipant, CalendarRecurrenceRule, CalendarTask } from '../jmap/types';
|
||||
import type {
|
||||
Calendar, CalendarEvent, CalendarEventAlert, CalendarParticipant, CalendarRecurrenceRule, CalendarTask,
|
||||
} from '../jmap/types';
|
||||
import type { IJMAPClient } from '../jmap/client-interface';
|
||||
import { useCalendarStore } from '@/stores/calendar-store';
|
||||
import { useTaskStore } from '@/stores/task-store';
|
||||
@@ -19,10 +21,12 @@ import { eventToView, taskToView } from '../plugin-projection';
|
||||
import { expandRecurringEvents } from '../recurrence-expansion';
|
||||
import { buildParticipantMap } from '../calendar-participants';
|
||||
import { parseDurationSeconds } from '../calendar-event-normalization';
|
||||
import { parseAlertOffset, withOwnAlertIds } from '../calendar-alerts';
|
||||
import { patchPath } from '../jmap/patch-object';
|
||||
import { getEffectiveTimeZone } from '../timezone';
|
||||
import { generateUUID } from '../utils';
|
||||
import {
|
||||
readBoolean, readId, readInteger, readObject, readString, sessionClient,
|
||||
isPlainRecord, readBoolean, readId, readInteger, readObject, readString, sessionClient,
|
||||
} from './host-api-input';
|
||||
|
||||
/** Longest range calendar.getEvents covers in one call. */
|
||||
@@ -486,7 +490,218 @@ export async function doCalendarDeleteEvent(id: unknown): Promise<void> {
|
||||
|
||||
// ─── Tasks ────────────────────────────────────────────────────
|
||||
|
||||
const TASK_FIELDS = ['title', 'description', 'due', 'priority', 'calendarId'] as const;
|
||||
const TASK_FIELDS = ['title', 'description', 'due', 'priority', 'calendarId', 'alerts'] as const;
|
||||
/** Most alerts a plugin may give a task. */
|
||||
export const MAX_PLUGIN_TASK_ALERTS = 5;
|
||||
const MAX_ALERT_OFFSET_MS = MAX_PLUGIN_EVENT_RANGE_DAYS * DAY_MS;
|
||||
/** Longest key a plugin may name to remove an entry it did not write. */
|
||||
const MAX_REMOVED_KEY = 1024;
|
||||
|
||||
const MEMBER_ID_RE = /^[A-Za-z0-9._-]{1,64}$/;
|
||||
const OFFSET_RE = /^-?P(?:\d+W|(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)?)$/;
|
||||
const CONTROL_RE = /\p{Cc}/u;
|
||||
|
||||
/** The maps a plugin edits on a task, each keyed as JSCalendar keys it. */
|
||||
const TASK_MAPS = ['alerts'] as const;
|
||||
type TaskMap = typeof TASK_MAPS[number];
|
||||
|
||||
/**
|
||||
* A map change: `null` for the whole map (removes every entry), else the
|
||||
* entries named, each a value to set or null to remove. Entries not named
|
||||
* are left as they are.
|
||||
*/
|
||||
type MapChange<T> = null | [key: string, value: T | null][];
|
||||
|
||||
/** An instant in UTC, as JSCalendar's UTCDateTime (`2026-05-01T07:30:00Z`). */
|
||||
function readInstant(value: unknown, method: string, field: string): string {
|
||||
const when = readWhen(value, method, field);
|
||||
if (!when.utc || when.time === null) throw new Error(`${method}: ${field} must be a time in UTC, like 2026-05-01T07:30:00Z`);
|
||||
return `${when.date}T${when.time}Z`;
|
||||
}
|
||||
|
||||
function utcNow(): string {
|
||||
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
}
|
||||
|
||||
/**
|
||||
* `{ key: value | null }` → its entries, each value read by `read`. A key
|
||||
* set must pass `keyOk`; a key removed (null) may be any key the task could
|
||||
* hold, so a plugin can remove entries another app wrote.
|
||||
*/
|
||||
function readMapChange<T>(
|
||||
value: unknown,
|
||||
method: string,
|
||||
field: string,
|
||||
max: number,
|
||||
keyOk: (key: string) => boolean,
|
||||
keyRule: string,
|
||||
read: (value: unknown, field: string) => T,
|
||||
): MapChange<T> {
|
||||
if (value === null) return null;
|
||||
if (!isPlainRecord(value)) throw new Error(`${method}: ${field} must be an object keyed by id, or null`);
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length > max) throw new Error(`${method}: ${field} may name at most ${max} entries`);
|
||||
return entries.map(([key, entry]) => {
|
||||
const where = `${field}["${key.slice(0, 64)}"]`;
|
||||
if (entry === null) {
|
||||
if (!key || key.length > MAX_REMOVED_KEY || CONTROL_RE.test(key)) throw new Error(`${method}: ${where} is not a key a task holds`);
|
||||
return [key, null];
|
||||
}
|
||||
if (!keyOk(key)) throw new Error(`${method}: ${field} key "${key.slice(0, 64)}" must be ${keyRule}`);
|
||||
return [key, read(entry, where)];
|
||||
});
|
||||
}
|
||||
|
||||
function readTrigger(value: unknown, method: string, field: string): CalendarEventAlert['trigger'] {
|
||||
if (isPlainRecord(value) && Object.hasOwn(value, 'at')) {
|
||||
const t = readObject(value, method, field, ['at']);
|
||||
return { '@type': 'AbsoluteTrigger', when: readInstant(t.at, method, `${field}.at`) };
|
||||
}
|
||||
const t = readObject(value, method, field, ['offset', 'relativeTo']);
|
||||
if (typeof t.offset !== 'string' || !OFFSET_RE.test(t.offset) || !/\d/.test(t.offset) || t.offset.endsWith('T')) {
|
||||
throw new Error(`${method}: ${field}.offset must be an ISO 8601 duration such as -PT15M, or give ${field}.at`);
|
||||
}
|
||||
const ms = parseAlertOffset(t.offset);
|
||||
if (ms === null || Math.abs(ms) > MAX_ALERT_OFFSET_MS) {
|
||||
throw new Error(`${method}: ${field}.offset may be at most ${MAX_PLUGIN_EVENT_RANGE_DAYS} days`);
|
||||
}
|
||||
if (t.relativeTo !== undefined && t.relativeTo !== 'due' && t.relativeTo !== 'start') {
|
||||
throw new Error(`${method}: ${field}.relativeTo must be due or start`);
|
||||
}
|
||||
// JSCalendar names a task's due its "end".
|
||||
return { '@type': 'OffsetTrigger', offset: t.offset, relativeTo: t.relativeTo === 'start' ? 'start' : 'end' };
|
||||
}
|
||||
|
||||
function alertsOf(value: unknown, method: string): MapChange<CalendarEventAlert> {
|
||||
return readMapChange(value, method, 'alerts', MAX_PLUGIN_TASK_ALERTS, (k) => MEMBER_ID_RE.test(k),
|
||||
'1-64 letters, digits or . _ -', (entry, field) => {
|
||||
const a = readObject(entry, method, field, ['trigger', 'action']);
|
||||
if (a.action !== undefined && a.action !== 'display' && a.action !== 'email') {
|
||||
throw new Error(`${method}: ${field}.action must be display or email`);
|
||||
}
|
||||
return {
|
||||
'@type': 'Alert',
|
||||
trigger: readTrigger(a.trigger, method, `${field}.trigger`),
|
||||
action: a.action === 'email' ? 'email' : 'display',
|
||||
acknowledged: null,
|
||||
relatedTo: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
interface TaskMapChanges {
|
||||
alerts?: MapChange<CalendarEventAlert>;
|
||||
}
|
||||
|
||||
/** The plugin's map changes, read and checked before anything is asked of the server. */
|
||||
function mapChangesOf(data: Record<string, unknown>, method: string): TaskMapChanges {
|
||||
const changes: TaskMapChanges = {};
|
||||
if (data.alerts !== undefined) changes.alerts = alertsOf(data.alerts, method);
|
||||
return changes;
|
||||
}
|
||||
|
||||
/** The entries set, as a map (null when none), for a task that has none yet. */
|
||||
function wholeMap<T>(change: MapChange<T>): Record<string, T> | null {
|
||||
const set = (change ?? []).filter((entry): entry is [string, T] => entry[1] !== null);
|
||||
return set.length ? Object.fromEntries(set) : null;
|
||||
}
|
||||
|
||||
/** Map changes on a new task: the entries set. Alerts turn the calendar's defaults off. */
|
||||
function createMaps(changes: TaskMapChanges): Partial<CalendarTask> {
|
||||
const task: Partial<CalendarTask> = {};
|
||||
for (const name of TASK_MAPS) {
|
||||
const change = changes[name];
|
||||
if (change !== undefined) Object.assign(task, { [name]: wholeMap(change as MapChange<unknown>) });
|
||||
}
|
||||
if (task.alerts) task.useDefaultAlerts = false;
|
||||
return task;
|
||||
}
|
||||
|
||||
function sameAlert(a: CalendarEventAlert, b: CalendarEventAlert): boolean {
|
||||
const key = (t: CalendarEventAlert['trigger']) => t['@type'] === 'AbsoluteTrigger'
|
||||
? `at ${Date.parse(t.when)}`
|
||||
: `offset ${parseAlertOffset(t.offset)} ${t.relativeTo === 'end' ? 'end' : 'start'}`;
|
||||
return a.action === b.action && key(a.trigger) === key(b.trigger);
|
||||
}
|
||||
|
||||
function countAfter(held: object | null | undefined, change: [string, unknown][]): number {
|
||||
const keys = new Set(Object.keys(held ?? {}));
|
||||
for (const [key, value] of change) {
|
||||
if (value === null) keys.delete(key);
|
||||
else keys.add(key);
|
||||
}
|
||||
return keys.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* The patch for a task's maps: each entry named by its JMAP path, so entries
|
||||
* the plugin did not name, and members changed meanwhile elsewhere, stay as
|
||||
* the server holds them. A map the task does not have yet is written whole.
|
||||
* An alert set with the trigger and action it has keeps its `acknowledged`;
|
||||
* a new trigger (a snooze) makes it fire again.
|
||||
*/
|
||||
function updateMaps(method: string, task: CalendarTask, changes: TaskMapChanges): Record<string, unknown> {
|
||||
const patch: Record<string, unknown> = {};
|
||||
const limits: Record<TaskMap, number> = {
|
||||
alerts: MAX_PLUGIN_TASK_ALERTS,
|
||||
};
|
||||
for (const name of TASK_MAPS) {
|
||||
const change = changes[name] as MapChange<unknown> | undefined;
|
||||
if (change === undefined) continue;
|
||||
if (change === null) {
|
||||
patch[name] = null;
|
||||
// Else the calendar's defaults would still show and fire.
|
||||
if (name === 'alerts' && task.useDefaultAlerts) patch.useDefaultAlerts = false;
|
||||
continue;
|
||||
}
|
||||
const held = task[name] as Record<string, unknown> | null | undefined;
|
||||
const adds = change.some(([key, value]) => value !== null && !(held && Object.hasOwn(held, key)));
|
||||
if (adds && countAfter(held, change) > limits[name]) {
|
||||
throw new Error(`${method}: the task would hold more than ${limits[name]} ${name}`);
|
||||
}
|
||||
if (name === 'alerts' && task.useDefaultAlerts) {
|
||||
Object.assign(patch, ownAlerts(method, task, change as [string, CalendarEventAlert | null][]));
|
||||
continue;
|
||||
}
|
||||
if (!held || Object.keys(held).length === 0) {
|
||||
patch[name] = wholeMap(change);
|
||||
continue;
|
||||
}
|
||||
for (const [key, value] of change) {
|
||||
if (name === 'alerts' && value !== null && Object.hasOwn(held, key) && sameAlert(held[key] as CalendarEventAlert, value as CalendarEventAlert)) {
|
||||
continue;
|
||||
}
|
||||
patch[patchPath(name, key)] = value;
|
||||
}
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alerts of a task that uses its calendar's default alerts. Stalwart shows
|
||||
* the defaults in `alerts` under the defaults' ids and drops on save every
|
||||
* alert under one of those ids, so the defaults the plugin keeps become the
|
||||
* task's own under new ids (as the task form does), and an alert of the
|
||||
* plugin's under a default's id is refused rather than lost.
|
||||
*/
|
||||
function ownAlerts(
|
||||
method: string,
|
||||
task: CalendarTask,
|
||||
change: [string, CalendarEventAlert | null][],
|
||||
): Partial<CalendarTask> {
|
||||
const defaults = task.alerts ?? {};
|
||||
const removed = new Set<string>();
|
||||
const set: [string, CalendarEventAlert][] = [];
|
||||
for (const [key, value] of change) {
|
||||
if (value === null) removed.add(key);
|
||||
else if (Object.hasOwn(defaults, key)) {
|
||||
throw new Error(`${method}: alert "${key}" is one of the calendar's default alerts the task uses (useDefaultAlerts); give yours another id`);
|
||||
} else set.push([key, value]);
|
||||
}
|
||||
const kept = Object.entries(defaults).filter(([key]) => !removed.has(key)).map(([, alert]) => alert);
|
||||
const alerts = { ...withOwnAlertIds(kept, [...Object.keys(defaults), ...set.map(([key]) => key)]), ...Object.fromEntries(set) };
|
||||
return { useDefaultAlerts: false, alerts: Object.keys(alerts).length ? alerts : null };
|
||||
}
|
||||
|
||||
function isDone(task: CalendarTask): boolean {
|
||||
return task.progress === 'completed' || task.progress === 'cancelled';
|
||||
@@ -505,6 +720,22 @@ export async function doTasksList(options: unknown): Promise<TaskView[]> {
|
||||
.map(taskToView);
|
||||
}
|
||||
|
||||
// A plugin's writes to one task, one after another: Stalwart refuses a
|
||||
// CalendarEvent/set while another is under way on the same object, and each
|
||||
// write is worked out against the task as the one before left it.
|
||||
const taskWrites = new Map<string, Promise<unknown>>();
|
||||
|
||||
function oneAtATime<T>(taskId: string, write: () => Promise<T>): Promise<T> {
|
||||
const before = taskWrites.get(taskId) ?? Promise.resolve();
|
||||
const next = before.catch(() => undefined).then(write);
|
||||
const settled = next.catch(() => undefined);
|
||||
taskWrites.set(taskId, settled);
|
||||
void settled.then(() => {
|
||||
if (taskWrites.get(taskId) === settled) taskWrites.delete(taskId);
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
async function loadTask(client: IJMAPClient, id: string): Promise<CalendarTask | null> {
|
||||
const inStore = useTaskStore.getState().tasks.find((t) => t.id === id);
|
||||
if (inStore) return inStore;
|
||||
@@ -517,6 +748,20 @@ async function requireTask(client: IJMAPClient, method: string, id: string): Pro
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* The task with its alerts as the server holds them now: the store's copy may be older, and a map the task seems
|
||||
* not to have is written whole, which would drop what the server gained.
|
||||
*/
|
||||
async function withServerMaps(client: IJMAPClient, task: CalendarTask): Promise<CalendarTask> {
|
||||
const server = await client.getCalendarEvent(task.originalId ?? task.id, task.accountId);
|
||||
if (!server) return task;
|
||||
return {
|
||||
...task,
|
||||
alerts: server.alerts ?? null,
|
||||
useDefaultAlerts: server.useDefaultAlerts,
|
||||
};
|
||||
}
|
||||
|
||||
export async function doTasksGet(id: unknown): Promise<TaskView | null> {
|
||||
const method = 'tasks.get';
|
||||
const task = await loadTask(sessionClient(method), readId(id, method));
|
||||
@@ -547,6 +792,7 @@ export async function doTasksCreate(input: unknown): Promise<TaskView | null> {
|
||||
...(data.due === undefined ? { due: null, showWithoutTime: true } : dueOf(data.due, method)),
|
||||
priority: data.priority === undefined ? 0 : readInteger(data.priority, method, 'priority', 0, 9),
|
||||
progress: 'needs-action',
|
||||
...createMaps(mapChangesOf(data, method)),
|
||||
calendarIds: { [await pickCalendar(client, method, data.calendarId, true)]: true },
|
||||
};
|
||||
const created = await useTaskStore.getState().createTask(client, task);
|
||||
@@ -569,8 +815,37 @@ export async function doTasksUpdate(id: unknown, patch: unknown): Promise<void>
|
||||
if (data.calendarId !== undefined) {
|
||||
updates.calendarIds = { [await pickCalendar(client, method, data.calendarId, true)]: true };
|
||||
}
|
||||
await requireTask(client, method, taskId);
|
||||
await useTaskStore.getState().updateTask(client, taskId, updates);
|
||||
const maps = mapChangesOf(data, method);
|
||||
await oneAtATime(taskId, async () => {
|
||||
let task = await requireTask(client, method, taskId);
|
||||
if (Object.keys(maps).length) task = await withServerMaps(client, task);
|
||||
Object.assign(updates, updateMaps(method, task, maps));
|
||||
if (Object.keys(updates).length === 0) return;
|
||||
await useTaskStore.getState().updateTask(client, taskId, updates, task);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark one of a task's alerts dismissed at `when` (UTC; now when left out),
|
||||
* or not dismissed with null. Only that alert's `acknowledged` is written.
|
||||
*/
|
||||
export async function doTasksAcknowledgeAlert(id: unknown, alertId: unknown, when: unknown): Promise<void> {
|
||||
const method = 'tasks.acknowledgeAlert';
|
||||
const taskId = readId(id, method);
|
||||
const key = readId(alertId, method, 'alertId');
|
||||
const acknowledged = when === undefined ? utcNow() : when === null ? null : readInstant(when, method, 'when');
|
||||
const client = sessionClient(method);
|
||||
await oneAtATime(taskId, async () => {
|
||||
const task = await withServerMaps(client, await requireTask(client, method, taskId));
|
||||
const alert = task.alerts && Object.hasOwn(task.alerts, key) ? task.alerts[key] : null;
|
||||
if (!alert) throw new Error(`${method}: the task has no alert "${key}"`);
|
||||
if (task.useDefaultAlerts) {
|
||||
throw new Error(`${method}: the task uses its calendar's default alerts (useDefaultAlerts); make them its own with tasks.update(id, { alerts: {} }) first`);
|
||||
}
|
||||
if ((alert.acknowledged ?? null) === acknowledged) return;
|
||||
const patch = { [patchPath('alerts', key, 'acknowledged')]: acknowledged } as Partial<CalendarTask>;
|
||||
await useTaskStore.getState().updateTask(client, taskId, patch, task);
|
||||
});
|
||||
}
|
||||
|
||||
export async function doTasksSetComplete(id: unknown, complete: unknown): Promise<void> {
|
||||
@@ -578,9 +853,11 @@ export async function doTasksSetComplete(id: unknown, complete: unknown): Promis
|
||||
const taskId = readId(id, method);
|
||||
const done = readBoolean(complete, method, 'complete');
|
||||
const client = sessionClient(method);
|
||||
const task = await requireTask(client, method, taskId);
|
||||
if ((task.progress === 'completed') === done) return;
|
||||
await useTaskStore.getState().toggleTaskComplete(client, task);
|
||||
await oneAtATime(taskId, async () => {
|
||||
const task = await requireTask(client, method, taskId);
|
||||
if ((task.progress === 'completed') === done) return;
|
||||
await useTaskStore.getState().toggleTaskComplete(client, task);
|
||||
});
|
||||
}
|
||||
|
||||
export async function doTasksDelete(id: unknown): Promise<void> {
|
||||
|
||||
@@ -46,7 +46,7 @@ import { EncryptionAtRestConfig, PublicKeyInfo, PublicKeyInput, useAccountSecuri
|
||||
import {
|
||||
doCalendarCreateEvent, doCalendarDeleteEvent, doCalendarGetEvent, doCalendarGetEvents,
|
||||
doCalendarListCalendars, doCalendarUpdateEvent, doTasksCreate, doTasksDelete, doTasksGet,
|
||||
doTasksList, doTasksSetComplete, doTasksUpdate,
|
||||
doTasksAcknowledgeAlert, doTasksList, doTasksSetComplete, doTasksUpdate,
|
||||
} from './host-api-calendar';
|
||||
import {
|
||||
doIdentitiesUpdate, doTemplatesCreate, doTemplatesDelete, doTemplatesGet, doTemplatesList,
|
||||
@@ -270,6 +270,7 @@ export const PERM_PER_METHOD: Readonly<Record<string, Permission | null>> = {
|
||||
'tasks.create': 'tasks:write',
|
||||
'tasks.update': 'tasks:write',
|
||||
'tasks.setComplete': 'tasks:write',
|
||||
'tasks.acknowledgeAlert': 'tasks:write',
|
||||
'tasks.delete': 'tasks:write',
|
||||
// message templates
|
||||
'templates.list': 'templates:read',
|
||||
@@ -2291,6 +2292,7 @@ export async function dispatchApiCall(
|
||||
case 'tasks.create': return doTasksCreate(args[0]);
|
||||
case 'tasks.update': return doTasksUpdate(args[0], args[1]);
|
||||
case 'tasks.setComplete': return doTasksSetComplete(args[0], args[1]);
|
||||
case 'tasks.acknowledgeAlert': return doTasksAcknowledgeAlert(args[0], args[1], args[2]);
|
||||
case 'tasks.delete': return doTasksDelete(args[0]);
|
||||
|
||||
case 'templates.list': return doTemplatesList();
|
||||
|
||||
@@ -993,10 +993,22 @@ function buildPluginApi(manifest: PluginManifest) {
|
||||
get: (id: string) => callApi('tasks.get', [id]) as Promise<TaskView | null>,
|
||||
/** Resolves to the new task, or null when another plugin stopped it. */
|
||||
create: (task: TaskInput) => callApi('tasks.create', [task]) as Promise<TaskView | null>,
|
||||
/** Change the given fields. */
|
||||
/**
|
||||
* Change the given fields. `alerts` merges by id: an alert given is
|
||||
* set, one given null is removed, the rest stay as they are (each
|
||||
* written by its own JMAP patch path); null for the whole map clears it.
|
||||
*/
|
||||
update: (id: string, patch: Partial<TaskInput>) => callApi('tasks.update', [id, patch]) as Promise<void>,
|
||||
/** Mark a task done or not done. */
|
||||
setComplete: (id: string, complete: boolean) => callApi('tasks.setComplete', [id, complete]) as Promise<void>,
|
||||
/**
|
||||
* Dismiss one of a task's alerts at `when` (UTC, `2026-05-01T07:30:00Z`;
|
||||
* now when left out), or undo that with null. Bulwark shows no reminder
|
||||
* for a dismissed alert. Refused while the task uses its calendar's
|
||||
* default alerts (TaskView.useDefaultAlerts).
|
||||
*/
|
||||
acknowledgeAlert: (id: string, alertId: string, when?: string | null) =>
|
||||
callApi('tasks.acknowledgeAlert', when === undefined ? [id, alertId] : [id, alertId, when]) as Promise<void>,
|
||||
delete: (id: string) => callApi('tasks.delete', [id]) as Promise<void>,
|
||||
},
|
||||
// Message templates (permissions: templates:read / templates:write).
|
||||
|
||||
@@ -586,6 +586,33 @@ export interface TaskInput {
|
||||
priority?: number;
|
||||
/** Defaults to the tasks calendar, else the default calendar. */
|
||||
calendarId?: string;
|
||||
/**
|
||||
* The task's reminders by id (1-64 of `A-Z a-z 0-9 . _ -`); a task holds
|
||||
* at most 5. On update only the ids named change: an alert given is set
|
||||
* (one kept with the same trigger and action stays acknowledged; a new
|
||||
* trigger, a snooze, makes it fire again), an id given null is removed,
|
||||
* and alerts not named are left as they are. null for the whole map
|
||||
* removes every alert. While the task uses its calendar's default alerts
|
||||
* (TaskView.useDefaultAlerts), any change makes the defaults the task's
|
||||
* own under new ids; `{}` does only that.
|
||||
*/
|
||||
alerts?: Record<string, TaskAlertInput | null> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* When a task's alert fires: an ISO 8601 duration from the task's due
|
||||
* (`-PT15M` before, `PT0S` at it; `relativeTo: 'start'` counts from its
|
||||
* start, else from its due), at most a year either way; or an instant in
|
||||
* UTC (`2026-05-01T07:30:00Z`).
|
||||
*/
|
||||
export type TaskAlertTrigger =
|
||||
| { offset: string; relativeTo?: 'due' | 'start' }
|
||||
| { at: string };
|
||||
|
||||
export interface TaskAlertInput {
|
||||
trigger: TaskAlertTrigger;
|
||||
/** `display` (the default) shows a reminder; `email` has the server send one. */
|
||||
action?: 'display' | 'email';
|
||||
}
|
||||
|
||||
export interface ContactView {
|
||||
@@ -651,6 +678,22 @@ export interface TaskView {
|
||||
priority: number;
|
||||
calendarId: string;
|
||||
keywords: string[];
|
||||
/** By id; as written by any app, not only by plugins. */
|
||||
alerts: Record<string, TaskAlertView>;
|
||||
/**
|
||||
* True while the task uses its calendar's default alerts: `alerts` then
|
||||
* shows those defaults, which can't be acknowledged until an update of
|
||||
* `alerts` makes them the task's own (under new ids).
|
||||
*/
|
||||
useDefaultAlerts: boolean;
|
||||
}
|
||||
|
||||
export interface TaskAlertView {
|
||||
/** As in TaskAlertInput; `relativeTo` is always given for an offset. */
|
||||
trigger: { offset: string; relativeTo: 'due' | 'start' } | { at: string };
|
||||
action: 'display' | 'email';
|
||||
/** When the alert was dismissed (UTC), or null while it is not. */
|
||||
acknowledged: string | null;
|
||||
}
|
||||
|
||||
export interface TemplateView {
|
||||
|
||||
+19
-6
@@ -4,6 +4,7 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import { debug } from '@/lib/debug';
|
||||
import { taskHooks } from '@/lib/plugin-hooks';
|
||||
import { taskToView } from '@/lib/plugin-projection';
|
||||
import { applyPatchObject, patchedProperties } from '@/lib/jmap/patch-object';
|
||||
|
||||
export type TaskViewFilter = 'all' | 'pending' | 'completed' | 'overdue';
|
||||
|
||||
@@ -28,7 +29,12 @@ interface TaskStore {
|
||||
/** Resolves to null when a plugin stopped the create. */
|
||||
createTask: (client: IJMAPClient, task: Partial<CalendarTask>) => Promise<CalendarTask | null>;
|
||||
/** false when a plugin stopped it (the plugin tells the user why). */
|
||||
updateTask: (client: IJMAPClient, id: string, updates: Partial<CalendarTask>) => Promise<boolean>;
|
||||
/**
|
||||
* `updates` may name members by path (`alerts/<id>/acknowledged`). `base`
|
||||
* is the task as the caller read it, for a task the list does not hold
|
||||
* or holds in an older state.
|
||||
*/
|
||||
updateTask: (client: IJMAPClient, id: string, updates: Partial<CalendarTask>, base?: CalendarTask) => Promise<boolean>;
|
||||
deleteTask: (client: IJMAPClient, id: string) => Promise<boolean>;
|
||||
toggleTaskComplete: (client: IJMAPClient, task: CalendarTask) => Promise<void>;
|
||||
clearTasks: () => void;
|
||||
@@ -93,9 +99,12 @@ export const useTaskStore = create<TaskStore>((set, get) => ({
|
||||
return created;
|
||||
},
|
||||
|
||||
updateTask: async (client, id, updates) => {
|
||||
if (!(await taskHooks.onBeforeTaskUpdate.intercept({ taskId: id, patch: updates }))) return false;
|
||||
const before = get().tasks.find(t => t.id === id);
|
||||
updateTask: async (client, id, updates, base) => {
|
||||
const before = base ?? get().tasks.find(t => t.id === id);
|
||||
// `updates` may name members by path (`alerts/<id>/acknowledged`);
|
||||
// plugins read the patch as whole properties.
|
||||
const patch = patchedProperties(before ?? { id }, updates);
|
||||
if (!(await taskHooks.onBeforeTaskUpdate.intercept({ taskId: id, patch }))) return false;
|
||||
const newId = await client.updateCalendarTask(id, updates);
|
||||
if (newId && newId !== id) {
|
||||
// Moved into a calendar of another account: the server recreated it
|
||||
@@ -103,11 +112,15 @@ export const useTaskStore = create<TaskStore>((set, get) => ({
|
||||
set({ selectedTaskId: get().selectedTaskId === id ? newId : get().selectedTaskId });
|
||||
await get().fetchTasks(client);
|
||||
const moved = get().tasks.find(t => t.id === newId)
|
||||
?? ({ ...before, ...updates, id: newId } as CalendarTask);
|
||||
?? ({ ...applyPatchObject(before ?? {}, updates), id: newId } as CalendarTask);
|
||||
void taskHooks.onTaskUpdate.emit(taskToView(moved));
|
||||
return true;
|
||||
}
|
||||
const updated = { ...(before ?? { id }), ...updates, updated: new Date().toISOString() } as CalendarTask;
|
||||
// Applied to the task the caller read, else as the store holds it now:
|
||||
// another update may have finished meanwhile (two alerts acknowledged
|
||||
// at once).
|
||||
const current = base ?? get().tasks.find(t => t.id === id) ?? { id };
|
||||
const updated = { ...applyPatchObject(current, updates), updated: new Date().toISOString() } as CalendarTask;
|
||||
set({
|
||||
tasks: get().tasks.map(t => t.id === id ? updated : t),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user