Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions backend/src/textTemplates/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = new Set([
Expand All @@ -21,7 +21,7 @@ const HTML_NAMES: ReadonlySet<string> = 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(' '));

Expand Down
30 changes: 30 additions & 0 deletions cli/src/commands/tasks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <task-id> [--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]
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
55 changes: 52 additions & 3 deletions cli/src/commands/templates.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Usage
dataops templates update <id|id-prefix|type|name> (--from-file <definition.yaml|json>
| --task <taskRef> --offset-days <int> [--anchor start|end] [--task-name <text>] [--name <text>])
[--expected-version N] [--revision R] [--dry-run]
dataops templates add-task-refs <id|id-prefix|type|name> --from-file <tasks.yaml|json> [--expected-version N] [--revision R] [--dry-run]
dataops templates reset <id|id-prefix|type|name> [--expected-version N] [--revision R] [--dry-run]
dataops templates delete <id|id-prefix|type|name> --yes [--expected-version N] [--revision R]
dataops templates sync
Expand All @@ -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 <id|type|name>

Writes pin (PUT) or restore (reset) the whole effective definition: Git stays
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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 <id|type|name> --from-file <tasks.yaml|json>");
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 <id|type|name> [--expected-version N] [--revision R] [--dry-run]");
const api = apiFor(args);
Expand Down Expand Up @@ -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 };
33 changes: 33 additions & 0 deletions cli/test/tasks-proof-requirement.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
});
34 changes: 34 additions & 0 deletions cli/test/templates.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/surfaces/knowledge/template-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/template-placeholders.js
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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(' '));

Expand Down Expand Up @@ -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 <NAME>, explanation, and example.');
continue;
}
Expand Down
7 changes: 7 additions & 0 deletions frontend/test/template-placeholders.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,10 @@ test('joins variant fences and checks tokens across all variants', () => {
assert.deepEqual(parseTemplatePlaceholders(markdown).variants,[{label:'Template',body:'Hello <NAME>'},{label:'Alternate',body:'Goodbye <NAME>'}]);
assert.deepEqual(parseTemplatePlaceholders(markdown).issues,[]);
});
test('accepts literal placeholders such as <TIME> and <CET/CEST> in body and table', () => {
const rows = '| `<TIME>` | Start time | 18:00 |\n| `<CET/CEST>` | Timezone label | CET |';
const result = parseTemplatePlaceholders(doc('Call at <TIME> <CET/CEST>', rows));
assert.deepEqual(result.issues, []);
assert.deepEqual(result.placeholders.map(item => item.name), ['<TIME>', '<CET/CEST>']);
assert.ok(parseTemplatePlaceholders(doc('Call at <TIME>', '| `<NAME>` | Name | Alex |')).issues.some(i => i.code === 'missing-explanation'));
});