← Public packages
@charlesmatte/todos
Shared todo, task and reminder store that any of your Kody-connected agents can use.
src/update.ts
130 lines · 4.9 KB · TypeScriptimport { InputError, parseInput } from './lib/validate.ts'
import { byField, tzField, dueField, idField, kindField, notesField, priorityField, tagsField, titleField } from './lib/fields.ts'
import {
addDays,
dueInstant,
line,
localDate,
mutateTodo,
normalizeId,
normalizeTags,
parseDue,
resolveTimeZone,
type Due,
type Kind,
type Priority,
} from './lib/store.ts'
/**
* Edit a todo/task in the shared todo list: change any field, add/remove tags, clear the due date, or snooze it.
* Pass only the fields to change. To clear: `clearDue: true`, `notes: ""`, `project: ""`, `client: ""`.
*
* @param input - `{ id: string, by: string, title?, notes?, due?: date|datetime|"today"|"tomorrow", clearDue?: boolean, snooze?: { days?: int, hours?: int, until?: date|datetime }, priority?, kind?, tags?: string[] (replace), addTags?: string[], removeTags?: string[], project?: string, client?: string, timeZone?: string }`
* @returns `{ todo, line, changes }`
*
* @example
* import updateTodo from 'kody:@charlesmatte/todos/update'
*
* await updateTodo({ id: 'T12', by: 'assistant', priority: 'high', snooze: { days: 2 } })
*/
export default async function updateTodo(input: unknown) {
const p = parseInput<{
id: string
by: string
title?: string
notes?: string
due?: string
clearDue?: boolean
snooze?: Record<string, unknown>
priority?: Priority
kind?: Kind
tags?: Array<string>
addTags?: Array<string>
removeTags?: Array<string>
project?: string
client?: string
timeZone?: string
}>(
input,
{
id: idField,
by: byField,
title: titleField,
notes: notesField,
due: dueField,
clearDue: { type: 'boolean' },
snooze: { type: 'object' },
priority: priorityField,
kind: kindField,
tags: tagsField,
addTags: tagsField,
removeTags: tagsField,
project: { type: 'string', maxLength: 120, allowEmpty: true },
client: { type: 'string', maxLength: 120, allowEmpty: true },
timeZone: tzField,
},
'todos/update',
)
const dueModes = [p.due !== undefined, p.clearDue === true, p.snooze !== undefined].filter(Boolean).length
if (dueModes > 1) throw new InputError('todos/update: pass only one of "due", "clearDue", "snooze".')
if (p.tags && (p.addTags || p.removeTags)) throw new InputError('todos/update: use "tags" (replace) or "addTags"/"removeTags", not both.')
const tz = await resolveTimeZone(p.timeZone)
let snoozeFn: ((cur: Due | null) => Due) | null = null
if (p.snooze) {
const s = parseInput<{ days?: number; hours?: number; until?: string }>(
p.snooze,
{ days: { type: 'integer', min: 1, max: 365 }, hours: { type: 'integer', min: 1, max: 24 * 30 }, until: dueField },
'todos/update snooze',
)
const n = [s.days, s.hours, s.until].filter((v) => v !== undefined).length
if (n !== 1) throw new InputError('todos/update: "snooze" needs exactly one of { days: int } | { hours: int } | { until: date|datetime }.')
if (s.until) {
const d = parseDue(s.until, tz, 'snooze.until')
snoozeFn = () => d
} else if (s.days) {
const days = s.days
// Shift from the current due (or from today if undated/overdue) by N days, keeping the time of day.
snoozeFn = (cur) => {
const today = localDate(tz)
const base = cur && cur.date >= today ? cur.date : today
const date = addDays(base, days)
return cur?.time ? parseDue(`${date}T${cur.time}`, cur.timeZone ?? tz) : { date, at: null, time: null, timeZone: tz }
}
} else {
const hours = s.hours!
snoozeFn = (cur) => {
const base = Math.max(Date.now(), cur ? dueInstant(cur, tz) : 0)
return parseDue(new Date(base + hours * 3600_000).toISOString(), tz)
}
}
}
const newDue = p.due !== undefined ? parseDue(p.due, tz) : undefined
const action = p.snooze ? 'snoozed' : 'updated'
const todo = await mutateTodo(normalizeId(p.id), p.by, action, (t) => {
const changes: Record<string, unknown> = {}
const set = <K extends keyof typeof t>(k: K, v: (typeof t)[K]) => {
if (JSON.stringify(t[k]) !== JSON.stringify(v)) {
changes[k as string] = { from: t[k], to: v }
t[k] = v
}
}
if (p.title !== undefined) set('title', p.title)
if (p.notes !== undefined) set('notes', p.notes.trim() ? p.notes.trim() : null)
if (newDue !== undefined) set('due', newDue)
if (p.clearDue) set('due', null)
if (snoozeFn) set('due', snoozeFn(t.due))
if (p.priority) set('priority', p.priority)
if (p.kind) set('kind', p.kind)
if (p.tags) set('tags', normalizeTags(p.tags))
if (p.addTags || p.removeTags) {
const rm = new Set(normalizeTags(p.removeTags ?? []))
set('tags', normalizeTags([...t.tags, ...(p.addTags ?? [])]).filter((x) => !rm.has(x)))
}
if (p.project !== undefined) set('project', p.project.trim() || null)
if (p.client !== undefined) set('client', p.client.trim() || null)
if (Object.keys(changes).length === 0) throw new InputError('todos/update: nothing to change (all values already match).')
return changes
})
return { todo, line: line(todo, tz), changes: todo.history[todo.history.length - 1]?.changes ?? {} }
}