Files
webmail/lib/plugin-sandbox/host-api-calendar.ts

593 lines
27 KiB
TypeScript

// Host side of api.calendar and api.tasks. Plugins read through the JMAP
// client (a read must not move the calendar the user is looking at) and
// write through the calendar and task stores, so the plugin hooks wired into
// those actions run and the open calendar shows the change.
//
// Plugins describe events in a small shape of their own (CalendarEventInput
// in plugin-types.ts); arbitrary JSCalendar objects are not accepted.
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 { IJMAPClient } from '../jmap/client-interface';
import { useCalendarStore } from '@/stores/calendar-store';
import { useTaskStore } from '@/stores/task-store';
import { useIdentityStore } from '@/stores/identity-store';
import { eventToView, taskToView } from '../plugin-projection';
import { expandRecurringEvents } from '../recurrence-expansion';
import { buildParticipantMap } from '../calendar-participants';
import { parseDurationSeconds } from '../calendar-event-normalization';
import { getEffectiveTimeZone } from '../timezone';
import { generateUUID } from '../utils';
import {
readBoolean, readId, readInteger, readObject, readString, sessionClient,
} from './host-api-input';
/** Longest range calendar.getEvents covers in one call. */
export const MAX_PLUGIN_EVENT_RANGE_DAYS = 366;
/** Most events calendar.getEvents returns; the earliest ones win. */
export const MAX_PLUGIN_EVENTS = 1000;
/** Most tasks tasks.list returns. */
export const MAX_PLUGIN_TASKS = 1000;
const MAX_TITLE = 500;
const MAX_DESCRIPTION = 10_000;
const MAX_LOCATION = 500;
const MAX_PARTICIPANTS = 50;
const DAY_MS = 86_400_000;
// ─── Dates and times ──────────────────────────────────────────
interface WhenParts {
/** YYYY-MM-DD */
date: string;
/** HH:MM:SS, or null for a date without a time. */
time: string | null;
/** True when the value ended in Z. */
utc: boolean;
}
const WHEN_RE = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?(Z)?)?$/;
/**
* `YYYY-MM-DD`, `YYYY-MM-DDTHH:MM[:SS]` (a wall-clock time) or the same
* followed by `Z` (UTC). Offsets like `+02:00` are refused: pass a timeZone.
*/
function readWhen(value: unknown, method: string, field: string): WhenParts {
if (typeof value !== 'string') throw new Error(`${method}: ${field} must be a date string`);
const m = WHEN_RE.exec(value);
if (!m) {
throw new Error(`${method}: ${field} must look like 2026-05-01, 2026-05-01T09:30 or 2026-05-01T07:30:00Z`);
}
const [, y, mo, d, h, mi, s, z] = m;
const check = new Date(Date.UTC(+y, +mo - 1, +d, h ? +h : 0, mi ? +mi : 0, s ? +s : 0));
if (
check.getUTCFullYear() !== +y || check.getUTCMonth() !== +mo - 1 || check.getUTCDate() !== +d ||
(h !== undefined && (check.getUTCHours() !== +h || check.getUTCMinutes() !== +mi || check.getUTCSeconds() !== +(s ?? 0)))
) {
throw new Error(`${method}: ${field} is not a real date or time`);
}
return {
date: `${y}-${mo}-${d}`,
time: h === undefined ? null : `${h}:${mi}:${s ?? '00'}`,
utc: z === 'Z',
};
}
/** Wall-clock milliseconds, for differences only (time zones don't matter). */
function wallMs(when: WhenParts): number {
return Date.parse(`${when.date}T${when.time ?? '00:00:00'}Z`);
}
function readTimeZone(value: unknown, method: string): string {
const zone = readString(value, method, 'timeZone', 64, { min: 1 });
try {
new Intl.DateTimeFormat('en-US', { timeZone: zone });
} catch {
throw new Error(`${method}: timeZone "${zone}" is not a known time zone`);
}
return zone;
}
function readDurationSeconds(value: unknown, method: string): number {
if (typeof value !== 'string') throw new Error(`${method}: duration must be an ISO 8601 duration such as PT1H`);
const seconds = parseDurationSeconds(value);
if (seconds === null || seconds <= 0) {
throw new Error(`${method}: duration must be an ISO 8601 duration such as PT1H or P1D`);
}
return seconds;
}
function formatDuration(seconds: number): string {
const days = Math.floor(seconds / 86_400);
let rest = seconds % 86_400;
const hours = Math.floor(rest / 3600);
rest %= 3600;
const minutes = Math.floor(rest / 60);
const secs = rest % 60;
const time = `${hours ? `${hours}H` : ''}${minutes ? `${minutes}M` : ''}${secs ? `${secs}S` : ''}`;
return `P${days ? `${days}D` : ''}${time ? `T${time}` : ''}`;
}
interface Timing {
start: string;
duration: string;
timeZone: string | null;
showWithoutTime: boolean;
}
/** Turn the plugin's start/end/duration/timeZone/isAllDay into JSCalendar fields. */
function buildTiming(
method: string,
input: { start: unknown; end?: unknown; duration?: unknown; timeZone?: unknown; isAllDay?: unknown },
): Timing {
const start = readWhen(input.start, method, 'start');
const isAllDay = input.isAllDay === undefined ? start.time === null : readBoolean(input.isAllDay, method, 'isAllDay');
if (input.end !== undefined && input.duration !== undefined) {
throw new Error(`${method}: pass end or duration, not both`);
}
const maxSeconds = MAX_PLUGIN_EVENT_RANGE_DAYS * 86_400;
if (isAllDay) {
if (input.timeZone !== undefined && input.timeZone !== null) {
throw new Error(`${method}: an all-day event has no timeZone`);
}
let days = 1;
if (input.end !== undefined) {
const end = readWhen(input.end, method, 'end');
days = Math.round((Date.parse(`${end.date}T00:00:00Z`) - Date.parse(`${start.date}T00:00:00Z`)) / DAY_MS);
if (days < 1) throw new Error(`${method}: end must be at least one day after start (end is exclusive)`);
} else if (input.duration !== undefined) {
const seconds = readDurationSeconds(input.duration, method);
if (seconds % 86_400 !== 0) throw new Error(`${method}: an all-day event lasts whole days (P1D, P2D, ...)`);
days = seconds / 86_400;
}
if (days * 86_400 > maxSeconds) throw new Error(`${method}: an event may last at most ${MAX_PLUGIN_EVENT_RANGE_DAYS} days`);
return { start: `${start.date}T00:00:00`, duration: `P${days}D`, timeZone: null, showWithoutTime: true };
}
if (start.time === null) throw new Error(`${method}: start needs a time unless isAllDay is true`);
let timeZone: string;
if (start.utc) {
if (input.timeZone !== undefined && input.timeZone !== null && input.timeZone !== 'Etc/UTC' && input.timeZone !== 'UTC') {
throw new Error(`${method}: start is in UTC (ends in Z), so timeZone must be left out`);
}
timeZone = 'Etc/UTC';
} else {
timeZone = input.timeZone === undefined || input.timeZone === null
? getEffectiveTimeZone()
: readTimeZone(input.timeZone, method);
}
let seconds = 3600;
if (input.end !== undefined) {
const end = readWhen(input.end, method, 'end');
if (end.time === null || end.utc !== start.utc) {
throw new Error(`${method}: end must be written like start (with a time${start.utc ? ', in UTC' : ''})`);
}
seconds = (wallMs(end) - wallMs(start)) / 1000;
if (seconds <= 0) throw new Error(`${method}: end must be after start`);
} else if (input.duration !== undefined) {
seconds = readDurationSeconds(input.duration, method);
}
if (seconds > maxSeconds) throw new Error(`${method}: an event may last at most ${MAX_PLUGIN_EVENT_RANGE_DAYS} days`);
return { start: `${start.date}T${start.time}`, duration: formatDuration(seconds), timeZone, showWithoutTime: false };
}
// ─── Calendars ────────────────────────────────────────────────
/** The store's calendars, loaded first when the calendar was never opened. */
async function ensureCalendars(client: IJMAPClient): Promise<Calendar[]> {
if (useCalendarStore.getState().calendars.length === 0) {
await useCalendarStore.getState().fetchCalendars(client);
}
return useCalendarStore.getState().calendars;
}
function calendarToView(calendar: Calendar): CalendarView {
return {
id: calendar.id,
name: calendar.name,
color: calendar.color || '',
isVisible: calendar.isVisible !== false,
isDefault: !!calendar.isDefault,
isTasksOnly: !!calendar.isTasksOnly,
};
}
async function pickCalendar(
client: IJMAPClient,
method: string,
requested: unknown,
forTasks: boolean,
): Promise<string> {
const calendars = await ensureCalendars(client);
if (requested !== undefined) {
const id = readId(requested, method, 'calendarId');
if (!calendars.some((c) => c.id === id)) throw new Error(`${method}: no calendar with id "${id}"`);
return id;
}
const choice = forTasks
? calendars.find((c) => c.isTasksOnly) ?? calendars.find((c) => c.isDefault) ?? calendars[0]
: calendars.find((c) => c.isDefault && !c.isTasksOnly) ?? calendars.find((c) => !c.isTasksOnly);
if (!choice) throw new Error(`${method}: there is no calendar to put it in`);
return choice.id;
}
export async function doCalendarListCalendars(): Promise<CalendarView[]> {
const client = sessionClient('calendar.listCalendars');
return (await ensureCalendars(client)).map(calendarToView);
}
// ─── Reading events ───────────────────────────────────────────
/** Bounds of an event query: wall clock without a zone, or UTC with Z. */
function rangeBound(when: WhenParts): string {
return `${when.date}T${when.time ?? '00:00:00'}${when.utc ? 'Z' : ''}`;
}
export async function doCalendarGetEvents(options: unknown): Promise<CalendarEventView[]> {
const method = 'calendar.getEvents';
const opts = readObject(options, method, 'options', ['from', 'to', 'calendarIds']);
const from = readWhen(opts.from, method, 'from');
const to = readWhen(opts.to, method, 'to');
if (from.utc !== to.utc) throw new Error(`${method}: from and to must both be in UTC or both without a zone`);
const span = wallMs(to) - wallMs(from);
if (span <= 0) throw new Error(`${method}: to must be after from`);
if (span > MAX_PLUGIN_EVENT_RANGE_DAYS * DAY_MS) {
throw new Error(`${method}: the range may cover at most ${MAX_PLUGIN_EVENT_RANGE_DAYS} days`);
}
let calendarIds: Set<string> | null = null;
if (opts.calendarIds !== undefined) {
if (!Array.isArray(opts.calendarIds) || opts.calendarIds.length > 100) {
throw new Error(`${method}: calendarIds must be an array of at most 100 ids`);
}
calendarIds = new Set(opts.calendarIds.map((id, i) => readId(id, method, `calendarIds[${i}]`)));
}
const client = sessionClient(method);
const start = rangeBound(from);
const end = rangeBound(to);
const raw = await client.queryAllCalendarEvents({ after: start, before: end });
// Same clean-up as the calendar's own range fetch (calendar-store.ts).
const valid = raw.filter((e) => typeof e.start === 'string' && e.start && !Number.isNaN(Date.parse(e.start)));
const events = expandRecurringEvents(valid, start, end)
.filter((e) => !calendarIds || Object.keys(e.calendarIds || {}).some((id) => e.calendarIds[id] && calendarIds.has(id)));
const sortKey = (e: CalendarEvent) => e.utcStart ?? e.start;
events.sort((a, b) => (sortKey(a) < sortKey(b) ? -1 : sortKey(a) > sortKey(b) ? 1 : 0));
return events.slice(0, MAX_PLUGIN_EVENTS).map(eventToView);
}
/**
* The event as the store addresses it. Events of shared calendars carry
* `<accountId>:<id>` ids (see queryAllCalendarEvents), which is resolved
* here the same way.
*/
async function loadEvent(client: IJMAPClient, id: string): Promise<CalendarEvent | null> {
const inStore = useCalendarStore.getState().events.find((e) => e.id === id);
if (inStore) return inStore;
const own = await client.getCalendarEvent(id);
if (own) return own;
const colon = id.indexOf(':');
if (colon <= 0) return null;
const accountId = id.slice(0, colon);
const rawId = id.slice(colon + 1);
const shared = await client.getCalendarEvent(rawId, accountId);
if (!shared) return null;
return {
...shared,
id,
originalId: rawId,
originalCalendarIds: shared.calendarIds,
calendarIds: Object.fromEntries(
Object.entries(shared.calendarIds || {}).map(([calId, v]) => [`${accountId}:${calId}`, v]),
),
accountId,
isShared: true,
};
}
export async function doCalendarGetEvent(id: unknown): Promise<CalendarEventView | null> {
const method = 'calendar.getEvent';
const eventId = readId(id, method);
const event = await loadEvent(sessionClient(method), eventId);
return event ? eventToView(event) : null;
}
/**
* Store mutations look the event up in the store's list to route it (shared
* calendars, occurrences) and to tell hooks its title. An event outside the
* range the calendar has loaded is added to that list first.
*/
async function eventForMutation(client: IJMAPClient, method: string, id: string): Promise<CalendarEvent> {
const event = await loadEvent(client, id);
if (!event) throw new Error(`${method}: no event with id "${id}"`);
if (!useCalendarStore.getState().events.some((e) => e.id === id)) {
useCalendarStore.setState((state) => ({ events: [...state.events, event] }));
}
return event;
}
// ─── Writing events ───────────────────────────────────────────
const EVENT_FIELDS = [
'title', 'description', 'start', 'end', 'duration', 'timeZone', 'isAllDay', 'location', 'calendarId', 'participants', 'repeat',
] as const;
const CREATE_FIELDS = [...EVENT_FIELDS, 'uid'] as const;
const UID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const FREQUENCIES = ['daily', 'weekly', 'monthly', 'yearly'] as const;
/**
* The uid an event a plugin created is stored under: the plugin's own uid in
* its own namespace, so it can't collide with events from other apps and the
* plugin finds its events again by uid (CalendarEventView.uid).
*/
export function pluginEventUid(pluginId: string, uid: string): string {
return `${uid}@${pluginId}.plugin.bulwark`;
}
/** `repeat: { frequency, interval?, count?, until? }` → JSCalendar rules; null removes them. */
function recurrenceOf(value: unknown, method: string): CalendarEvent['recurrenceRules'] {
if (value === null) return null;
const r = readObject(value, method, 'repeat', ['frequency', 'interval', 'count', 'until']);
if (!FREQUENCIES.includes(r.frequency as never)) throw new Error(`${method}: repeat.frequency must be one of ${FREQUENCIES.join(', ')}`);
const overrides: Partial<CalendarRecurrenceRule> = {};
if (r.interval !== undefined) {
if (!Number.isInteger(r.interval) || (r.interval as number) < 1 || (r.interval as number) > 99) throw new Error(`${method}: repeat.interval must be 1-99`);
overrides.interval = r.interval as number;
}
if (r.count !== undefined && r.until !== undefined) throw new Error(`${method}: give repeat.count or repeat.until, not both`);
if (r.count !== undefined) {
if (!Number.isInteger(r.count) || (r.count as number) < 1 || (r.count as number) > 1000) throw new Error(`${method}: repeat.count must be 1-1000`);
overrides.count = r.count as number;
}
if (r.until !== undefined) {
if (typeof r.until !== 'string' || !DATE_RE.test(r.until)) throw new Error(`${method}: repeat.until must be YYYY-MM-DD`);
overrides.until = `${r.until}T23:59:59`;
}
return [createRecurrenceRule(r.frequency as CalendarRecurrenceRule['frequency'], overrides)];
}
function locationsOf(location: string): CalendarEvent['locations'] {
if (!location) return null;
return {
loc1: {
'@type': 'Location', name: location, description: null, locationTypes: null,
coordinates: null, timeZone: null, links: null, relativeTo: null,
},
} as CalendarEvent['locations'];
}
/** Attendees as stored on the event, with the user as organizer. No invitations are sent. */
function participantsOf(value: unknown, method: string): Pick<CalendarEvent, 'participants' | 'organizerCalendarAddress'> {
if (!Array.isArray(value)) throw new Error(`${method}: participants must be an array`);
if (value.length > MAX_PARTICIPANTS) throw new Error(`${method}: at most ${MAX_PARTICIPANTS} participants are allowed`);
const attendees = value.map((entry, i) => {
const p = readObject(entry, method, `participants[${i}]`, ['email', 'name']);
const email = readString(p.email, method, `participants[${i}].email`, 254, { min: 3, trim: true });
if (!/^[^\s@<>]+@[^\s@<>]+$/.test(email)) throw new Error(`${method}: participants[${i}].email is not an address`);
const name = p.name === undefined ? '' : readString(p.name, method, `participants[${i}].name`, 200, { trim: true });
return { email, name };
});
if (attendees.length === 0) return { participants: null, organizerCalendarAddress: null };
const { identities, preferredPrimaryId } = useIdentityStore.getState();
const organizer = identities.find((i) => i.id === preferredPrimaryId) ?? identities[0];
if (!organizer) throw new Error(`${method}: the account has no sender address to organise the event from`);
return {
participants: buildParticipantMap(
{ name: organizer.name || '', email: organizer.email },
attendees,
) as Record<string, CalendarParticipant>,
organizerCalendarAddress: `mailto:${organizer.email}`,
};
}
/**
* Create an event. Resolves to the new event, or null when it was not
* created (another plugin's onBeforeEventCreate stopped it, or the server
* refused it). Never sends invitations.
*/
export async function doCalendarCreateEvent(input: unknown, pluginId = 'unknown'): Promise<CalendarEventView | null> {
const method = 'calendar.createEvent';
const data = readObject(input, method, 'event', CREATE_FIELDS) as CalendarEventInput & Record<string, unknown>;
const title = readString(data.title, method, 'title', MAX_TITLE, { min: 1, trim: true });
const timing = buildTiming(method, data);
const client = sessionClient(method);
const calendarId = await pickCalendar(client, method, data.calendarId, false);
let uid = generateUUID();
if (data.uid !== undefined) {
if (typeof data.uid !== 'string' || !UID_RE.test(data.uid)) throw new Error(`${method}: uid must be 1-128 letters, digits or . _ : -`);
uid = pluginEventUid(pluginId, data.uid);
}
const event: Partial<CalendarEvent> = {
uid,
title,
...timing,
calendarIds: { [calendarId]: true },
status: 'confirmed',
freeBusyStatus: 'busy',
privacy: 'public',
};
if (data.description !== undefined) {
const description = readString(data.description, method, 'description', MAX_DESCRIPTION, { trim: true });
if (description) event.description = description;
}
if (data.location !== undefined) {
const location = readString(data.location, method, 'location', MAX_LOCATION, { trim: true });
if (location) event.locations = locationsOf(location);
}
if (data.participants !== undefined) {
const p = participantsOf(data.participants, method);
if (p.participants) Object.assign(event, p);
}
if (data.repeat !== undefined) event.recurrenceRules = recurrenceOf(data.repeat, method);
const created = await useCalendarStore.getState().createEvent(client, event, false);
return created ? eventToView(created) : null;
}
/**
* Change an event. Only the fields in `patch` change; `start`, `end`,
* `duration`, `timeZone` and `isAllDay` are worked out together against the
* event's current times. Never sends updates to participants.
*/
export async function doCalendarUpdateEvent(id: unknown, patch: unknown): Promise<void> {
const method = 'calendar.updateEvent';
const eventId = readId(id, method);
const data = readObject(patch, method, 'patch', EVENT_FIELDS);
if (Object.keys(data).length === 0) throw new Error(`${method}: patch is empty`);
const updates: Partial<CalendarEvent> = {};
if (data.title !== undefined) updates.title = readString(data.title, method, 'title', MAX_TITLE, { min: 1, trim: true });
if (data.description !== undefined) {
updates.description = readString(data.description, method, 'description', MAX_DESCRIPTION, { trim: true });
}
if (data.location !== undefined) {
updates.locations = locationsOf(readString(data.location, method, 'location', MAX_LOCATION, { trim: true }));
}
if (data.participants !== undefined) Object.assign(updates, participantsOf(data.participants, method));
if (data.repeat !== undefined) updates.recurrenceRules = recurrenceOf(data.repeat, method);
const client = sessionClient(method);
if (data.calendarId !== undefined) {
updates.calendarIds = { [await pickCalendar(client, method, data.calendarId, false)]: true };
}
const existing = await eventForMutation(client, method, eventId);
const timeKeys = ['start', 'end', 'duration', 'timeZone', 'isAllDay'] as const;
if (timeKeys.some((k) => data[k] !== undefined)) {
const wasAllDay = !!existing.showWithoutTime;
const isAllDay = data.isAllDay === undefined ? wasAllDay : data.isAllDay;
const keepLength = data.end === undefined && data.duration === undefined && isAllDay === wasAllDay;
Object.assign(updates, buildTiming(method, {
start: data.start ?? (wasAllDay ? existing.start.slice(0, 10) : existing.start.slice(0, 19)),
end: data.end,
duration: keepLength ? existing.duration : data.duration,
timeZone: data.timeZone !== undefined ? data.timeZone : (isAllDay ? undefined : existing.timeZone ?? undefined),
isAllDay,
}));
}
await useCalendarStore.getState().updateEvent(client, eventId, updates, false);
}
/** Delete an event (one occurrence when the id is an occurrence's). Never sends cancellations. */
export async function doCalendarDeleteEvent(id: unknown): Promise<void> {
const method = 'calendar.deleteEvent';
const eventId = readId(id, method);
const client = sessionClient(method);
await eventForMutation(client, method, eventId);
await useCalendarStore.getState().deleteEvent(client, eventId, false);
}
// ─── Tasks ────────────────────────────────────────────────────
const TASK_FIELDS = ['title', 'description', 'due', 'priority', 'calendarId'] as const;
function isDone(task: CalendarTask): boolean {
return task.progress === 'completed' || task.progress === 'cancelled';
}
export async function doTasksList(options: unknown): Promise<TaskView[]> {
const method = 'tasks.list';
const opts = readObject(options, method, 'options', ['includeCompleted'], true);
const includeCompleted = opts.includeCompleted === undefined
? false
: readBoolean(opts.includeCompleted, method, 'includeCompleted');
const tasks = await sessionClient(method).getCalendarTasks();
return tasks
.filter((t) => includeCompleted || !isDone(t))
.slice(0, MAX_PLUGIN_TASKS)
.map(taskToView);
}
async function loadTask(client: IJMAPClient, id: string): Promise<CalendarTask | null> {
const inStore = useTaskStore.getState().tasks.find((t) => t.id === id);
if (inStore) return inStore;
return (await client.getCalendarTasks()).find((t) => t.id === id) ?? null;
}
async function requireTask(client: IJMAPClient, method: string, id: string): Promise<CalendarTask> {
const task = await loadTask(client, id);
if (!task) throw new Error(`${method}: no task with id "${id}"`);
return task;
}
export async function doTasksGet(id: unknown): Promise<TaskView | null> {
const method = 'tasks.get';
const task = await loadTask(sessionClient(method), readId(id, method));
return task ? taskToView(task) : null;
}
/** `due` as a task stores it: a date (no time) or a wall-clock time. */
function dueOf(value: unknown, method: string): Pick<CalendarTask, 'due' | 'showWithoutTime'> {
if (value === null) return { due: null, showWithoutTime: true };
const when = readWhen(value, method, 'due');
if (when.utc) throw new Error(`${method}: due is a local date or time, without Z`);
return when.time === null
? { due: when.date, showWithoutTime: true }
: { due: `${when.date}T${when.time}`, showWithoutTime: false };
}
/** Resolves to the new task, or null when another plugin's onBeforeTaskCreate stopped it. */
export async function doTasksCreate(input: unknown): Promise<TaskView | null> {
const method = 'tasks.create';
const data = readObject(input, method, 'task', TASK_FIELDS) as TaskInput & Record<string, unknown>;
const client = sessionClient(method);
const task: Partial<CalendarTask> = {
'@type': 'Task',
title: readString(data.title, method, 'title', MAX_TITLE, { min: 1, trim: true }),
description: data.description === undefined
? ''
: readString(data.description, method, 'description', MAX_DESCRIPTION, { trim: true }),
...(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',
calendarIds: { [await pickCalendar(client, method, data.calendarId, true)]: true },
};
const created = await useTaskStore.getState().createTask(client, task);
return created ? taskToView(created) : null;
}
export async function doTasksUpdate(id: unknown, patch: unknown): Promise<void> {
const method = 'tasks.update';
const taskId = readId(id, method);
const data = readObject(patch, method, 'patch', TASK_FIELDS);
if (Object.keys(data).length === 0) throw new Error(`${method}: patch is empty`);
const updates: Partial<CalendarTask> = {};
if (data.title !== undefined) updates.title = readString(data.title, method, 'title', MAX_TITLE, { min: 1, trim: true });
if (data.description !== undefined) {
updates.description = readString(data.description, method, 'description', MAX_DESCRIPTION, { trim: true });
}
if (data.due !== undefined) Object.assign(updates, dueOf(data.due, method));
if (data.priority !== undefined) updates.priority = readInteger(data.priority, method, 'priority', 0, 9);
const client = sessionClient(method);
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);
}
export async function doTasksSetComplete(id: unknown, complete: unknown): Promise<void> {
const method = 'tasks.setComplete';
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);
}
export async function doTasksDelete(id: unknown): Promise<void> {
const method = 'tasks.delete';
const taskId = readId(id, method);
const client = sessionClient(method);
await requireTask(client, method, taskId);
await useTaskStore.getState().deleteTask(client, taskId);
}