import type { UniDeskConfig } from "./config"; import { coreInternalFetch } from "./microservices"; import type { RenderedCliResult } from "./output"; interface TodoInstanceSummary { id: string; name: string; todoCount: number; completedCount: number; updatedAt: string; } interface TodoItem { id: string; title: string; completed: boolean; reminderAt: string | null; updatedAt: string; children: TodoItem[]; } interface TodoInstance { id: string; name: string; historyPointer: number; updatedAt: string; todos: TodoItem[]; } interface FlatTodo { item: TodoItem; depth: number; path: string; } type TodoNoteFetcher = (path: string, init?: { method?: string; body?: unknown }) => Promise; class TodoNoteRequestError extends Error { constructor(message: string, readonly status: number | null) { super(message); this.name = "TodoNoteRequestError"; } } function asRecord(value: unknown): Record | null { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; } function stringField(value: unknown, label: string): string { if (typeof value !== "string" || value.length === 0) throw new Error(`${label} must be a non-empty string`); return value; } function option(args: string[], name: string): string | null { const index = args.indexOf(name); if (index === -1) return null; const value = args[index + 1]; if (value === undefined || value.startsWith("--")) throw new Error(`${name} requires a value`); return value; } function hasFlag(args: string[], name: string): boolean { return args.includes(name); } function positiveIntegerOption(args: string[], name: string, fallback: number): number { const raw = option(args, name); if (raw === null) return fallback; const value = Number(raw); if (!Number.isInteger(value) || value < 1) throw new Error(`${name} must be a positive integer`); return value; } function errorMessage(body: unknown): string { const record = asRecord(body); return typeof record?.error === "string" ? record.error : typeof record?.message === "string" ? record.message : "request failed"; } function responseStatus(value: unknown): number | null { const status = typeof value === "number" ? value : Number(value); return Number.isInteger(status) && status > 0 ? status : null; } function isRetryableProxyError(error: unknown): boolean { return error instanceof TodoNoteRequestError && error.status !== null && [502, 503, 504].includes(error.status); } async function request(fetcher: TodoNoteFetcher, path: string, init?: { method?: string; body?: unknown }): Promise { const response = asRecord(await fetcher(`/api/microservices/todo-note/proxy${path}`, init)); if (response === null) throw new Error(`todo-note ${path} returned an invalid response`); const body = response.body; if (response.ok !== true || asRecord(body)?.ok === false) { const status = responseStatus(response.status); throw new TodoNoteRequestError(`todo-note ${init?.method ?? "GET"} ${path} failed (${String(status ?? "unknown")}): ${errorMessage(body)}`, status); } return body; } async function listInstances(fetcher: TodoNoteFetcher): Promise { const body = asRecord(await request(fetcher, "/api/instances")); const rows = Array.isArray(body?.instances) ? body.instances : []; return rows.map((value, index) => { const row = asRecord(value); if (row === null) throw new Error(`todo-note instances[${index}] must be an object`); return { id: stringField(row.id, `instances[${index}].id`), name: stringField(row.name, `instances[${index}].name`), todoCount: Number(row.todoCount ?? 0), completedCount: Number(row.completedCount ?? 0), updatedAt: typeof row.updatedAt === "string" ? row.updatedAt : "-", }; }); } async function resolveInstance(fetcher: TodoNoteFetcher, token: string | undefined): Promise { if (!token) throw new Error("todo-note command requires a list name or instance id"); const rows = await listInstances(fetcher); const exact = rows.filter((row) => row.id === token || row.name === token); if (exact.length === 1) return exact[0]; const folded = rows.filter((row) => row.name.toLocaleLowerCase() === token.toLocaleLowerCase()); if (folded.length === 1) return folded[0]; if (exact.length > 1 || folded.length > 1) throw new Error(`todo-note list is ambiguous: ${token}`); throw new Error(`todo-note list not found: ${token}; available: ${rows.map((row) => row.name).join(", ")}`); } function parseTodo(value: unknown, label: string): TodoItem { const row = asRecord(value); if (row === null) throw new Error(`${label} must be an object`); return { id: stringField(row.id, `${label}.id`), title: stringField(row.title, `${label}.title`), completed: row.completed === true, reminderAt: typeof row.reminderAt === "string" ? row.reminderAt : null, updatedAt: typeof row.updatedAt === "string" ? row.updatedAt : "-", children: Array.isArray(row.children) ? row.children.map((child, index) => parseTodo(child, `${label}.children[${index}]`)) : [], }; } async function getInstance(fetcher: TodoNoteFetcher, summary: TodoInstanceSummary): Promise { const body = asRecord(await request(fetcher, `/api/instances/${encodeURIComponent(summary.id)}`)); if (body === null) throw new Error(`todo-note list ${summary.name} returned an invalid body`); return { id: stringField(body.id, "instance.id"), name: stringField(body.name, "instance.name"), historyPointer: Number(body.historyPointer ?? 0), updatedAt: typeof body.updatedAt === "string" ? body.updatedAt : "-", todos: Array.isArray(body.todos) ? body.todos.map((todo, index) => parseTodo(todo, `todos[${index}]`)) : [], }; } function flattenTodos(items: TodoItem[], depth = 0, parents: string[] = []): FlatTodo[] { const rows: FlatTodo[] = []; for (const item of items) { const path = [...parents, item.title]; rows.push({ item, depth, path: path.join(" > ") }); rows.push(...flattenTodos(item.children, depth + 1, path)); } return rows; } function resolveTodo(instance: TodoInstance, token: string | undefined): FlatTodo { if (!token) throw new Error("todo-note command requires a todo id or exact title"); const rows = flattenTodos(instance.todos); const exact = rows.filter((row) => row.item.id === token || row.item.title === token); if (exact.length === 1) return exact[0]; const folded = rows.filter((row) => row.item.title.toLocaleLowerCase() === token.toLocaleLowerCase()); if (folded.length === 1) return folded[0]; if (exact.length > 1 || folded.length > 1) throw new Error(`todo-note title is ambiguous; use todo id: ${token}`); throw new Error(`todo-note item not found in ${instance.name}: ${token}`); } async function applyAction(fetcher: TodoNoteFetcher, instanceId: string, action: Record): Promise { const body = await request(fetcher, `/api/instances/${encodeURIComponent(instanceId)}/actions`, { method: "POST", body: { action } }); const record = asRecord(body); if (record === null) throw new Error("todo-note action returned an invalid instance"); const summary: TodoInstanceSummary = { id: stringField(record.id, "instance.id"), name: stringField(record.name, "instance.name"), todoCount: 0, completedCount: 0, updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : "-", }; return { ...summary, historyPointer: Number(record.historyPointer ?? 0), todos: Array.isArray(record.todos) ? record.todos.map((todo, index) => parseTodo(todo, `todos[${index}]`)) : [], }; } async function applyCompletion( fetcher: TodoNoteFetcher, summary: TodoInstanceSummary, todoId: string, desiredCompleted: boolean, ): Promise<{ instance: TodoInstance; changed: boolean }> { const apply = () => applyAction(fetcher, summary.id, { type: "toggleTodoCompleted", todoId }); try { return { instance: await apply(), changed: true }; } catch (firstError) { if (!isRetryableProxyError(firstError)) throw firstError; const observed = await getInstance(fetcher, summary); if (resolveTodo(observed, todoId).item.completed === desiredCompleted) return { instance: observed, changed: true }; try { return { instance: await apply(), changed: true }; } catch (secondError) { if (!isRetryableProxyError(secondError)) throw secondError; const final = await getInstance(fetcher, summary); if (resolveTodo(final, todoId).item.completed === desiredCompleted) return { instance: final, changed: true }; throw secondError; } } } function short(value: string, max = 64): string { const compact = value.replace(/\s+/gu, " ").trim(); return compact.length <= max ? compact : `${compact.slice(0, Math.max(1, max - 3))}...`; } function timeText(value: string | null): string { if (value === null) return "-"; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return new Intl.DateTimeFormat("zh-CN", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false, }).format(date).replaceAll("/", "-"); } function displayWidth(value: string): number { return Array.from(value).reduce((width, character) => width + ((character.codePointAt(0) ?? 0) > 0xff ? 2 : 1), 0); } function table(headers: string[], rows: string[][]): string[] { const widths = headers.map((header, index) => Math.max(displayWidth(header), ...rows.map((row) => displayWidth(row[index] ?? "")))); const line = (row: string[]) => row.map((cell, index) => `${cell}${" ".repeat(Math.max(0, (widths[index] ?? 0) - displayWidth(cell)))}`).join(" ").trimEnd(); return [line(headers), widths.map((width) => "-".repeat(width)).join(" "), ...rows.map(line)]; } function rendered(command: string, lines: string[], ok = true): RenderedCliResult { return { ok, command, renderedText: `${lines.join("\n")}\n`, contentType: "text/plain" }; } function mutationResult(command: string, action: string, instance: TodoInstance, todo: FlatTodo | null, changed = true): RenderedCliResult { return rendered(command, [ `TODO NOTE ${action.toUpperCase()}`, ...table(["LIST", "TODO", "STATE", "CHANGED", "TITLE"], [[ instance.name, todo?.item.id ?? "-", todo === null ? "-" : todo.item.completed ? "completed" : "open", String(changed), short(todo?.item.title ?? instance.name, 72), ]]), ]); } function help(): RenderedCliResult { return rendered("todo-note help", [ "TODO NOTE", " unidesk todo-note list [--json]", " unidesk todo-note show [--all] [--limit N] [--json]", " unidesk todo-note add --title [--parent ]", " unidesk todo-note update --title ", " unidesk todo-note complete|reopen ", " unidesk todo-note remind (--at |--clear)", " unidesk todo-note delete --confirm", " unidesk todo-note undo|redo ", " unidesk todo-note create ", " unidesk todo-note rename --name ", " unidesk todo-note delete-list --confirm", " unidesk todo-note health [--json]", ]); } export async function runTodoNoteCommand(_config: UniDeskConfig, args: string[]): Promise { const fetcher: TodoNoteFetcher = async (path, init) => coreInternalFetch(path, { method: init?.method, body: init?.body, maxResponseBytes: 5_000_000, timeoutMs: 30_000, }); return runTodoNoteCommandAsync(_config, args, fetcher); } export async function runTodoNoteCommandAsync(_config: UniDeskConfig, args: string[], fetcher: TodoNoteFetcher): Promise { try { return await runTodoNoteCommandInner(args, fetcher); } catch (error) { if (hasFlag(args, "--json")) throw error; const message = error instanceof Error ? error.message : String(error); return rendered("todo-note error", ["TODO NOTE ERROR", ` ${message}`, " help: unidesk todo-note --help"], false); } } async function runTodoNoteCommandInner(args: string[], fetcher: TodoNoteFetcher): Promise { const [action = "list", listToken, todoToken] = args; const json = hasFlag(args, "--json"); if (action === "help" || action === "--help" || action === "-h") return help(); if (action === "list") { const rows = await listInstances(fetcher); if (json) return { ok: true, instances: rows }; return rendered("todo-note list", [ "TODO NOTE LISTS", ...table(["LIST", "OPEN", "DONE", "TOTAL", "ID"], rows.map((row) => [ row.name, String(row.todoCount - row.completedCount), String(row.completedCount), String(row.todoCount), row.id, ])), ]); } if (action === "health") { const response = asRecord(await fetcher("/api/microservices/todo-note/health")); const body = asRecord(response?.body); if (response?.ok !== true || body?.ok !== true) throw new Error(`todo-note health failed: ${errorMessage(response?.body)}`); if (json) return { ok: true, health: body }; const storage = asRecord(body.storage); const config = asRecord(storage?.config); const verification = asRecord(storage?.verification); return rendered("todo-note health", [ "TODO NOTE HEALTH", ...table(["STATUS", "STORAGE", "INSTANCES", "DB/GIT", "HEAD"], [[ String(body.status ?? "-"), String(config?.primary ?? "-"), String(verification?.instances ?? "-"), verification?.databaseHash === verification?.gitHash ? "aligned" : "mismatch", String(asRecord(config?.head)?.shortCommit ?? "-"), ]]), ]); } if (action === "create") { const name = option(args, "--name") ?? listToken; if (!name) throw new Error("todo-note create requires a name or --name "); const body = asRecord(await request(fetcher, "/api/instances", { method: "POST", body: { name } })); if (body === null) throw new Error("todo-note create returned an invalid instance"); const created = await getInstance(fetcher, { id: stringField(body.id, "instance.id"), name: stringField(body.name, "instance.name"), todoCount: 0, completedCount: 0, updatedAt: String(body.updatedAt ?? "-") }); return json ? { ok: true, instance: created } : mutationResult("todo-note create", "create", created, null); } const summary = await resolveInstance(fetcher, listToken); const instance = await getInstance(fetcher, summary); if (action === "show") { const all = hasFlag(args, "--all"); const limit = positiveIntegerOption(args, "--limit", 100); const rows = flattenTodos(instance.todos).filter((row) => all || !row.item.completed).slice(0, limit); if (json) return { ok: true, instance, items: rows.map((row) => ({ ...row.item, depth: row.depth, path: row.path })) }; return rendered("todo-note show", [ `TODO NOTE ${instance.name}`, `OPEN ${flattenTodos(instance.todos).filter((row) => !row.item.completed).length} SHOWN ${rows.length} HISTORY ${instance.historyPointer}`, ...table(["STATE", "ID", "REMINDER", "TITLE"], rows.map((row) => [ row.item.completed ? "done" : "open", row.item.id, timeText(row.item.reminderAt), `${" ".repeat(row.depth)}${short(row.item.title, 76)}`, ])), ]); } if (action === "undo" || action === "redo") { const body = asRecord(await request(fetcher, `/api/instances/${encodeURIComponent(instance.id)}/${action}`, { method: "POST", body: {} })); if (body === null) throw new Error(`todo-note ${action} returned an invalid instance`); const updated = await getInstance(fetcher, { ...summary, updatedAt: String(body.updatedAt ?? summary.updatedAt) }); return json ? { ok: true, instance: updated } : mutationResult(`todo-note ${action}`, action, updated, null); } if (action === "rename") { const name = option(args, "--name"); if (!name) throw new Error("todo-note rename requires --name "); const updated = await applyAction(fetcher, instance.id, { type: "renameInstance", name }); return json ? { ok: true, instance: updated } : mutationResult("todo-note rename", "rename", updated, null); } if (action === "delete-list") { if (!hasFlag(args, "--confirm")) throw new Error("todo-note delete-list requires --confirm"); await request(fetcher, `/api/instances/${encodeURIComponent(instance.id)}`, { method: "DELETE" }); return json ? { ok: true, deleted: true, instance: summary } : rendered("todo-note delete-list", [ "TODO NOTE DELETE LIST", ...table(["LIST", "ID", "DELETED"], [[summary.name, summary.id, "true"]]), ]); } if (action === "add") { const title = option(args, "--title"); if (!title) throw new Error("todo-note add requires --title "); const beforeIds = new Set(flattenTodos(instance.todos).map((row) => row.item.id)); const parentToken = option(args, "--parent"); const parentId = parentToken === null ? undefined : resolveTodo(instance, parentToken).item.id; const updated = await applyAction(fetcher, instance.id, { type: "addTodo", title, ...(parentId ? { parentId } : {}) }); const added = flattenTodos(updated.todos).find((row) => !beforeIds.has(row.item.id)) ?? null; if (added === null) throw new Error("todo-note add succeeded but the new todo was not found"); return json ? { ok: true, instance: updated, todo: added.item } : mutationResult("todo-note add", "add", updated, added); } const selected = resolveTodo(instance, todoToken); if (action === "update") { const title = option(args, "--title"); if (!title) throw new Error("todo-note update requires --title "); const updated = await applyAction(fetcher, instance.id, { type: "updateTodoTitle", todoId: selected.item.id, title }); const changed = resolveTodo(updated, selected.item.id); return json ? { ok: true, instance: updated, todo: changed.item } : mutationResult("todo-note update", "update", updated, changed); } if (action === "complete" || action === "done" || action === "reopen") { const desiredCompleted = action !== "reopen"; if (selected.item.completed === desiredCompleted) { return json ? { ok: true, changed: false, instance, todo: selected.item } : mutationResult(`todo-note ${action}`, action, instance, selected, false); } const result = await applyCompletion(fetcher, summary, selected.item.id, desiredCompleted); const changed = resolveTodo(result.instance, selected.item.id); return json ? { ok: true, changed: result.changed, instance: result.instance, todo: changed.item } : mutationResult(`todo-note ${action}`, action, result.instance, changed, result.changed); } if (action === "remind") { const clear = hasFlag(args, "--clear"); const reminderAt = clear ? null : option(args, "--at"); if (!clear && reminderAt === null) throw new Error("todo-note remind requires --at or --clear"); if (reminderAt !== null && Number.isNaN(new Date(reminderAt).getTime())) throw new Error("--at must be a valid ISO date/time"); const updated = await applyAction(fetcher, instance.id, { type: "setTodoReminder", todoId: selected.item.id, reminderAt }); const changed = resolveTodo(updated, selected.item.id); return json ? { ok: true, instance: updated, todo: changed.item } : mutationResult("todo-note remind", "remind", updated, changed); } if (action === "delete") { if (!hasFlag(args, "--confirm")) throw new Error("todo-note delete requires --confirm"); const updated = await applyAction(fetcher, instance.id, { type: "deleteTodo", todoId: selected.item.id }); return json ? { ok: true, deleted: true, instance: updated, todo: selected.item } : rendered("todo-note delete", [ "TODO NOTE DELETE", ...table(["LIST", "TODO", "DELETED", "TITLE"], [[instance.name, selected.item.id, "true", short(selected.item.title, 72)]]), ]); } throw new Error("todo-note command must be one of: list, show, add, update, complete, reopen, remind, delete, undo, redo, create, rename, delete-list, health, help"); }