diff --git a/backend/src/textTemplates/render.ts b/backend/src/textTemplates/render.ts index 6234056f..22edb828 100644 --- a/backend/src/textTemplates/render.ts +++ b/backend/src/textTemplates/render.ts @@ -10,7 +10,7 @@ */ /** Tokens include their angle brackets. */ -const TOKEN = /<[A-Z][A-Z0-9_]*>/g; +const TOKEN = /<[A-Z][A-Z0-9_]*(?:\/[A-Z][A-Z0-9_]*)*>/g; /** Tag names that must never count as placeholders (same list as the KB convention). */ const HTML_NAMES: ReadonlySet = new Set([ @@ -21,7 +21,7 @@ const HTML_NAMES: ReadonlySet = new Set([ 'INPUT INS KBD LABEL LEGEND LI LINK MAIN MAP MARK MENU META METER', 'NAV NOSCRIPT OBJECT OL OPTGROUP OPTION OUTPUT P PICTURE PRE PROGRESS Q RP', 'RT RUBY S SAMP SCRIPT SEARCH SECTION SELECT SLOT SMALL SOURCE SPAN STRONG', - 'STYLE SUB SUMMARY SUP TABLE TBODY TD TEMPLATE TEXTAREA TFOOT TH THEAD TIME', + 'STYLE SUB SUMMARY SUP TABLE TBODY TD TEMPLATE TEXTAREA TFOOT TH THEAD', 'TITLE TR TRACK U UL VAR VIDEO WBR', ].join(' ').split(' ')); diff --git a/cli/src/commands/tasks.mjs b/cli/src/commands/tasks.mjs index 6effb5fc..fbe69e65 100644 --- a/cli/src/commands/tasks.mjs +++ b/cli/src/commands/tasks.mjs @@ -20,6 +20,7 @@ Usage [--subject-type sponsor-deal|sponsor-company|sponsor-person|tuition-agreement --subject-id ID] [--comment C] [--link URL] [--tags a,b] [--required-link-name NAME] [--instruction-doc DOC-ID] [--checklist KB-CHECKLIST-ID] [--item "text"]... [--requires youtube,luma] [--browser required|headless] [--depends-on ID,ID] + [--proof-requirement url|file|artifact|comment|external-status [--proof-label L] [--proof-optional]] dataops tasks update [--description D] [--date D] [--comment C] [--link URL] [--subject-type sponsor-deal|sponsor-company|sponsor-person|tuition-agreement --subject-id ID | --clear-subject] [--at "2026-10-23 19:00" --tz Europe/Berlin | --at +7d | --at none] @@ -115,6 +116,10 @@ Proof Every write reads the Task's current version first (or pass --expected-version N); a 409 means someone else changed it: re-read and retry. + create/update --proof-requirement TYPE sets what proof completing the Task needs + (same types as the API and web); --proof-label names it, --proof-optional makes it + non-blocking. + Task checklists --checklist copies the ordered items from a KB checklist; repeat --item to add Task-specific rows. Use check/uncheck with a 1-based row number or item id. @@ -665,6 +670,30 @@ async function resolveAssignee(api, value) { return value; } +const PROOF_REQUIREMENT_TYPES = ["url", "file", "artifact", "comment", "external-status"]; + +/** The writable `proofRequirement` from --proof-requirement TYPE [--proof-label L] [--proof-optional]. */ +function proofRequirementField(args) { + if (args.proofRequirement === undefined) { + if (args.proofLabel !== undefined || args.proofOptional !== undefined) { + throw new UsageError("--proof-label and --proof-optional need --proof-requirement TYPE."); + } + return {}; + } + const type = optionalString(args, "proofRequirement"); + if (!PROOF_REQUIREMENT_TYPES.includes(type)) { + throw new UsageError(`--proof-requirement must be one of: ${PROOF_REQUIREMENT_TYPES.join(", ")}.`); + } + const label = optionalString(args, "proofLabel"); + return { + proofRequirement: { + type, + ...(label === undefined ? {} : { label }), + ...(args.proofOptional === true ? { required: false } : {}), + }, + }; +} + function writableFields(args) { const body = {}; if (args.clearSubject) { @@ -715,6 +744,7 @@ function writableFields(args) { body.requires = requires === "none" ? [] : csv(requires); } Object.assign(body, browserRequirementField(args)); + Object.assign(body, proofRequirementField(args)); if (args.dependsOn !== undefined) { const dependsOn = required(args, "dependsOn", "depends-on"); body.dependsOn = dependsOn === "none" ? [] : csv(dependsOn); diff --git a/cli/src/commands/templates.mjs b/cli/src/commands/templates.mjs index be2c016a..1af303c0 100644 --- a/cli/src/commands/templates.mjs +++ b/cli/src/commands/templates.mjs @@ -16,6 +16,7 @@ Usage dataops templates update (--from-file | --task --offset-days [--anchor start|end] [--task-name ] [--name ]) [--expected-version N] [--revision R] [--dry-run] + dataops templates add-task-refs --from-file [--expected-version N] [--revision R] [--dry-run] dataops templates reset [--expected-version N] [--revision R] [--dry-run] dataops templates delete --yes [--expected-version N] [--revision R] dataops templates sync @@ -25,6 +26,10 @@ Templates; it needs the knowledge repository's CI identity and runs in its publish workflow, so a push to main refreshes them without a deploy. Pinned (edited) Templates keep their pin and are reported. +add-task-refs appends Task definitions (a list, or an object with taskDefinitions) +whose refId the Template does not have yet, keeping order and the new definitions' +own fields; refIds already present are skipped and nothing is ever removed or edited. + Create a Card from one with: dataops cards create --title T --date D --template Writes pin (PUT) or restore (reset) the whole effective definition: Git stays @@ -118,7 +123,7 @@ async function loadYaml() { } } -async function definitionFile(args) { +async function definitionFile(args, { allowList = false } = {}) { const path = optionalString(args, "fromFile"); if (path === undefined) return undefined; let text; @@ -136,7 +141,7 @@ async function definitionFile(args) { } else { parsed = JSON.parse(text); } - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + if (!parsed || typeof parsed !== "object" || (Array.isArray(parsed) && !allowList)) { throw new UsageError(`--from-file ${path} must hold a template definition object.`); } return parsed; @@ -337,6 +342,50 @@ export async function update(args, io) { return result; } +/** Append Task definitions whose refId the Template lacks; existing ones are never changed or removed. */ +export async function addTaskRefs(args, io) { + const ref = positional(args, 0, "dataops templates add-task-refs --from-file "); + const api = apiFor(args); + const parsed = await definitionFile(args, { allowList: true }); + if (parsed === undefined) throw new UsageError("--from-file is required: a list of Task definitions, or an object with taskDefinitions."); + const incoming = Array.isArray(parsed) ? parsed : parsed.taskDefinitions; + if (!Array.isArray(incoming) || incoming.length === 0) throw new UsageError("--from-file must hold a non-empty list of Task definitions."); + const template = await findTemplate(api, ref); + const { expectedVersion, revision } = versionPair(args, template, undefined); + const base = stripServerOwned(structuredClone(template)); + const definitions = [...(base.taskDefinitions || [])]; + const known = new Set(definitions.map((definition) => definition.refId)); + const added = []; + const skipped = []; + for (const definition of incoming) { + if (!definition || typeof definition.refId !== "string" || !definition.refId) { + throw new UsageError("Every Task definition needs a refId."); + } + if (known.has(definition.refId)) skipped.push(definition.refId); + else { + known.add(definition.refId); + definitions.push(definition); + added.push(definition.refId); + } + } + const summary = { template: template.id, added, skipped }; + if (!added.length) { + if (args.json) return { ...summary, unchanged: true }; + io.print(`Nothing to add: ${template.id} already has ${skipped.join(", ")}.`); + return { ...summary, unchanged: true }; + } + const query = args.dryRun ? { dryRun: "true" } : undefined; + let result; + try { + result = await api.put(`/api/templates/${enc(template.id)}`, { expectedVersion, revision, definition: { ...base, taskDefinitions: definitions } }, { query }); + } catch (error) { + describeTemplateWriteError(error, ref); + } + if (args.json) return { ...summary, result }; + io.print(`${result.dryRun ? "Dry run: would add" : "Added"} ${added.join(", ")} to ${template.id}${skipped.length ? ` (already present: ${skipped.join(", ")})` : ""}.`); + return { ...summary, result }; +} + export async function reset(args, io) { const ref = positional(args, 0, "dataops templates reset [--expected-version N] [--revision R] [--dry-run]"); const api = apiFor(args); @@ -391,4 +440,4 @@ export async function sync(args, io) { return result; } -export const commands = { list, show, create, update, reset, delete: remove, sync }; +export const commands = { list, show, create, update, "add-task-refs": addTaskRefs, reset, delete: remove, sync }; diff --git a/cli/test/tasks-proof-requirement.test.mjs b/cli/test/tasks-proof-requirement.test.mjs new file mode 100644 index 00000000..ff90b109 --- /dev/null +++ b/cli/test/tasks-proof-requirement.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, test } from "node:test"; + +import { captureIo, fakePortal, isolateConfig } from "./helpers.mjs"; + +isolateConfig(); +const realFetch = globalThis.fetch; +const { run } = await import("../src/cli.mjs"); + +afterEach(() => { + globalThis.fetch = realFetch; + process.env.DATAOPS_TOKEN = "dops_test"; +}); + +describe("tasks --proof-requirement", () => { + test("create sends the proofRequirement with label and optional flag", async () => { + const calls = fakePortal({ "POST /api/tasks": ({ body }) => ({ id: "t1", date: "2026-11-09", ...body }) }); + assert.equal(await run([ + "tasks", "create", "--description", "Publish", "--proof-requirement", "url", + "--proof-label", "Published post", "--proof-optional", "--json", + ], captureIo()), 0); + assert.deepEqual(calls[0].body.proofRequirement, { type: "url", label: "Published post", required: false }); + }); + + test("a bare type sends only the type; an unknown type is a usage error", async () => { + const calls = fakePortal({ "POST /api/tasks": ({ body }) => ({ id: "t1", date: "2026-11-09", ...body }) }); + assert.equal(await run(["tasks", "create", "--description", "Publish", "--proof-requirement", "file", "--json"], captureIo()), 0); + assert.deepEqual(calls[0].body.proofRequirement, { type: "file" }); + assert.equal(await run(["tasks", "create", "--description", "Publish", "--proof-requirement", "photo"], captureIo()), 2); + assert.equal(await run(["tasks", "create", "--description", "Publish", "--proof-label", "x"], captureIo()), 2); + assert.equal(calls.length, 1); + }); +}); diff --git a/cli/test/templates.test.mjs b/cli/test/templates.test.mjs index 67f4c551..210950e8 100644 --- a/cli/test/templates.test.mjs +++ b/cli/test/templates.test.mjs @@ -143,6 +143,40 @@ describe("templates write commands", () => { assert.match(io.text(), /version 4/); }); + test("add-task-refs appends only new refIds in order and never edits or removes", async () => { + const calls = fakePortal({ + ...findRoutes, + "PUT /api/templates/tpl-1": { template: { ...template, version: 4 } }, + }); + const file = path.join(dir, "tasks.json"); + writeFileSync(file, JSON.stringify([ + { refId: "b", description: "Changed B", offsetDays: 99 }, + { refId: "c", description: "Task C", offsetDays: 9 }, + { refId: "d", description: "Task D", offsetDays: 10 }, + { refId: "c", description: "Duplicate C", offsetDays: 1 }, + ])); + const io = captureIo(); + assert.equal(await run(["templates", "add-task-refs", "tpl-1", "--from-file", file], io), 0); + const put = calls.at(-1); + assert.deepEqual(put.body.definition.taskDefinitions, [ + ...template.taskDefinitions, + { refId: "c", description: "Task C", offsetDays: 9 }, + { refId: "d", description: "Task D", offsetDays: 10 }, + ]); + assert.deepEqual({ expectedVersion: put.body.expectedVersion, revision: put.body.revision }, { expectedVersion: 3, revision: "rev-1" }); + assert.match(io.text(), /Added c, d .*already present: b/); + }); + + test("add-task-refs with only known refIds writes nothing", async () => { + const calls = fakePortal(findRoutes); + const file = path.join(dir, "tasks.json"); + writeFileSync(file, JSON.stringify({ taskDefinitions: [{ refId: "a", description: "again", offsetDays: 5 }] })); + const io = captureIo(); + assert.equal(await run(["templates", "add-task-refs", "tpl-1", "--from-file", file], io), 0); + assert.ok(calls.every((call) => call.method === "GET")); + assert.match(io.text(), /Nothing to add/); + }); + test("explicit --expected-version/--revision win over a fresh fetch", async () => { const calls = fakePortal({ ...findRoutes, diff --git a/frontend/src/surfaces/knowledge/template-view.js b/frontend/src/surfaces/knowledge/template-view.js index 11528571..1b6e9536 100644 --- a/frontend/src/surfaces/knowledge/template-view.js +++ b/frontend/src/surfaces/knowledge/template-view.js @@ -71,7 +71,7 @@ export function createTemplateViewSurface({ clipboard } = {}) { const tokens = new Set(templateTokens(variant.body)); const textNode = (value) => (typeof doc.createTextNode === "function" ? doc.createTextNode(value) : value); let offset = 0; - for (const match of variant.body.matchAll(/<[A-Z][A-Z0-9_]*>/g)) { + for (const match of variant.body.matchAll(/<[A-Z][A-Z0-9_]*(?:\/[A-Z][A-Z0-9_]*)*>/g)) { if (!tokens.has(match[0])) continue; const entry = definitions.get(match[0]); body.append(textNode(variant.body.slice(offset, match.index))); diff --git a/frontend/src/template-placeholders.js b/frontend/src/template-placeholders.js index 9e765865..85336786 100644 --- a/frontend/src/template-placeholders.js +++ b/frontend/src/template-placeholders.js @@ -1,5 +1,5 @@ /** Shared, dependency-free KB template convention. Names include their angle brackets. */ -const TOKEN = /<[A-Z][A-Z0-9_]*>/g; +const TOKEN = /<[A-Z][A-Z0-9_]*(?:\/[A-Z][A-Z0-9_]*)*>/g; const HTML = new Set([ 'A ABBR ADDRESS AREA ARTICLE ASIDE AUDIO B BASE BDI BDO BLOCKQUOTE BODY', 'BR BUTTON CANVAS CAPTION CITE CODE COL COLGROUP DATA DATALIST DD DEL DETAILS', @@ -8,7 +8,7 @@ const HTML = new Set([ 'INPUT INS KBD LABEL LEGEND LI LINK MAIN MAP MARK MENU META METER', 'NAV NOSCRIPT OBJECT OL OPTGROUP OPTION OUTPUT P PICTURE PRE PROGRESS Q RP', 'RT RUBY S SAMP SCRIPT SEARCH SECTION SELECT SLOT SMALL SOURCE SPAN STRONG', - 'STYLE SUB SUMMARY SUP TABLE TBODY TD TEMPLATE TEXTAREA TFOOT TH THEAD TIME', + 'STYLE SUB SUMMARY SUP TABLE TBODY TD TEMPLATE TEXTAREA TFOOT TH THEAD', 'TITLE TR TRACK U UL VAR VIDEO WBR', ].join(' ').split(' ')); @@ -79,7 +79,7 @@ export function parseTemplatePlaceholders(markdown) { for (const row of rows.slice(2)) { const values = cells(row); const name = (values[0] || '').replace(/^`([^`]+)`$/, '$1'); - if (values.length !== 3 || !/^<[A-Z][A-Z0-9_]*>$/.test(name) || !values[1] || !values[2]) { + if (values.length !== 3 || !/^<[A-Z][A-Z0-9_]*(?:\/[A-Z][A-Z0-9_]*)*>$/.test(name) || !values[1] || !values[2]) { issue('malformed-row', 'Each placeholder row needs a , explanation, and example.'); continue; } diff --git a/frontend/test/template-placeholders.test.mjs b/frontend/test/template-placeholders.test.mjs index 35a4d3b4..2dd2e394 100644 --- a/frontend/test/template-placeholders.test.mjs +++ b/frontend/test/template-placeholders.test.mjs @@ -33,3 +33,10 @@ test('joins variant fences and checks tokens across all variants', () => { assert.deepEqual(parseTemplatePlaceholders(markdown).variants,[{label:'Template',body:'Hello '},{label:'Alternate',body:'Goodbye '}]); assert.deepEqual(parseTemplatePlaceholders(markdown).issues,[]); }); +test('accepts literal placeholders such as