import { canonicalJson, sha256 } from '../../business/src/util.mjs'; import { BusError } from '../../bus/src/broker.mjs'; // Vikunja writes this for an unset date. export const NULL_DATE = '0001-01-01T00:00:00Z'; const positive = (x) => Number.isSafeInteger(x) && x > 0; const ids = (list, where) => { if (list !== null && list !== undefined && !Array.isArray(list)) throw new BusError('tracker-shape'); const out = (list ?? []).map((x) => x?.id); if (out.some((x) => !positive(x))) throw new BusError('tracker-shape'); return out.sort((a, b) => a - b); }; export const ref = (project, task) => `vikunja:${project}/${task}`; export function parseRef(taskRef) { const m = /^vikunja:([1-9][0-9]*)\/([1-9][0-9]*)$/.exec(taskRef ?? ''); if (!m) throw new BusError('invalid-request'); return { project: Number(m[1]), task: Number(m[2]) }; } // Checks a task object from any v2 read and returns its updated time. Throws tracker-shape. export function checkTask(task) { if ( !task || typeof task !== 'object' || !positive(task.id) || !positive(task.project_id) || typeof task.title !== 'string' || typeof (task.description ?? '') !== 'string' || typeof task.done !== 'boolean' || !Number.isSafeInteger(task.priority) || typeof task.percent_done !== 'number' || typeof task.updated !== 'string' || !Number.isFinite(Date.parse(task.updated)) ) throw new BusError('tracker-shape'); return task.updated; } // The digest set from addendum B section 5. `bucket` comes from the board or a move, never the task. export function taskFields(task, bucket) { checkTask(task); if (!positive(bucket)) throw new BusError('tracker-shape'); const due = task.due_date; if (due !== undefined && due !== null && (typeof due !== 'string' || !Number.isFinite(Date.parse(due)))) throw new BusError('tracker-shape'); return { project_id: task.project_id, title: task.title, description: task.description ?? '', done: task.done, // One text form, so an expected value written by S3 digests the same as the value read back. due_date: !due || due === NULL_DATE ? null : new Date(due).toISOString(), priority: task.priority, percent_done: task.percent_done, bucket, labels: ids(task.labels), assignees: ids(task.assignees), }; } export const digest = (fields) => sha256(canonicalJson(fields)); // A tombstone keeps only the reason, and the new project for a move. export const tombstone = (gone, project = null) => (project === null ? { gone } : { gone, project });