From ef9d60b2f844b1cd9bd93fa13a70986031b7343b Mon Sep 17 00:00:00 2001 From: callumalpass Date: Mon, 28 Sep 2026 14:02:56 +1000 Subject: [PATCH] Define the published TaskNotes starter in the model The starter type installed by the tasknotes.task pack was configured in tasknotes-app (palette, the skipped cancelled status, three profiles), while mdbase-contracts generated packs from the model's plugin defaults. rc.15 did, and dropped cancelled. The starter now lives here as buildTaskNotesStarterResources, and a test pins it to the published rc.16 pack's resource digests byte for byte. Pack version rc.16; model rc.14. Co-Authored-By: Claude --- README.md | 1 + package-lock.json | 4 +- package.json | 7 ++- scripts/build.mjs | 1 + src/index.ts | 1 + src/mdbase.ts | 2 +- src/starter.ts | 110 ++++++++++++++++++++++++++++++++++++++++++ test/starter.test.mjs | 41 ++++++++++++++++ 8 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 src/starter.ts create mode 100644 test/starter.test.mjs diff --git a/README.md b/README.md index 3163d3a..76de7e1 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ The package exports both the root module and focused subpath modules: | `@tasknotes/model/operations` | Host-independent task mutation planning, including materialized occurrence plans | | `@tasknotes/model/frontmatter` | Markdown task document parse/serialize helpers | | `@tasknotes/model/mdbase` | Canonical mdbase v0.3 config, contract, schema, and implementing-type generation | +| `@tasknotes/model/starter` | The published TaskNotes starter type (`tasknotes.task` pack seed), pinned to mdbase-contracts by the test suite. Use it, not the plugin defaults in `defaults`, to build starter types | | `@tasknotes/model/conformance` | tasknotes-spec conformance operation dispatcher | ## Examples diff --git a/package-lock.json b/package-lock.json index 0010c26..e8cc085 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tasknotes/model", - "version": "0.3.0-rc.13", + "version": "0.3.0-rc.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tasknotes/model", - "version": "0.3.0-rc.13", + "version": "0.3.0-rc.14", "license": "MIT", "dependencies": { "rrule": "^2.8.1", diff --git a/package.json b/package.json index afe27df..27ff9fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tasknotes/model", - "version": "0.3.0-rc.13", + "version": "0.3.0-rc.14", "description": "TaskNotes model, mapping, validation, recurrence, and operation-planning reference implementation.", "license": "MIT", "type": "module", @@ -96,6 +96,11 @@ "import": "./dist/esm/mdbase.js", "require": "./dist/cjs/mdbase.cjs" }, + "./starter": { + "types": "./dist/types/starter.d.ts", + "import": "./dist/esm/starter.js", + "require": "./dist/cjs/starter.cjs" + }, "./conformance": { "types": "./dist/types/conformance.d.ts", "import": "./dist/esm/conformance.js", diff --git a/scripts/build.mjs b/scripts/build.mjs index 3455f9f..d8fb568 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -16,6 +16,7 @@ const entries = [ "operations", "frontmatter", "mdbase", + "starter", "conformance", ]; diff --git a/src/index.ts b/src/index.ts index 07b38bd..a8336f6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,4 +11,5 @@ export * from "./validation"; export * from "./operations"; export * from "./frontmatter"; export * from "./mdbase"; +export * from "./starter"; export * from "./conformance"; diff --git a/src/mdbase.ts b/src/mdbase.ts index b40d2d4..c7a1b3f 100644 --- a/src/mdbase.ts +++ b/src/mdbase.ts @@ -134,7 +134,7 @@ export interface TaskNotesMdbaseTypePack { } /** Version of the generated definition bundle, independent of contract version. */ -export const TASKNOTES_MDBASE_PACK_VERSION = "0.3.0-rc.15"; +export const TASKNOTES_MDBASE_PACK_VERSION = "0.3.0-rc.16"; export const TASKNOTES_CONTRACT_DIGEST = "sha256:dde71d0d776aa0069ee627fe3c10b1a20f87ba36929a16790598ebfb4e8ebf04"; diff --git a/src/starter.ts b/src/starter.ts new file mode 100644 index 0000000..7bef75f --- /dev/null +++ b/src/starter.ts @@ -0,0 +1,110 @@ +import { cloneDefaultModelConfig } from "./defaults"; +import { serializeMarkdownDocument } from "./frontmatter"; +import { + buildTaskNotesMdbaseResources, + type TaskNotesMdbaseOptions, + type TaskNotesMdbaseResources, +} from "./mdbase"; +import { TASKNOTES_SPEC_VERSION, type TaskNotesModelConfig } from "./types"; + +/** + * The published TaskNotes starter: the `tasknotes.task` pack's seed type that + * applications install into new collections (mdbase-contracts + * `types/tasknotes-task/.md`). + * + * It differs from the plugin defaults in `DEFAULT_STATUSES`: it adds the + * skipped `cancelled` status, uses its own palette and declares only the + * profiles its binding implements. Collections keep whatever they customise, + * because pack upgrades merge against the previous starter; so any change + * here is a new starter version and must be deliberate. The test suite checks + * this output against the published type. + */ +export const TASKNOTES_STARTER_TYPE_VERSION = 4; + +export const TASKNOTES_STARTER_PROFILES = [ + "core-lite", + "recurrence", + "materialized-occurrences", +] as const; + +export const TASKNOTES_STARTER_STATUS_COLORS: Readonly> = { + none: "#94a3b8", + open: "#64748b", + "in-progress": "#3b82f6", + done: "#22c55e", + cancelled: "#94a3b8", +}; + +export const TASKNOTES_STARTER_PRIORITY_COLORS: Readonly> = { + none: "#94a3b8", + low: "#3b82f6", + normal: "#f59e0b", + high: "#ef4444", +}; + +/** Model configuration that the published starter type is generated from. */ +export function cloneTaskNotesStarterModelConfig(): TaskNotesModelConfig { + const modelConfig = cloneDefaultModelConfig(); + modelConfig.statuses = modelConfig.statuses.map((status) => ({ + ...status, + color: TASKNOTES_STARTER_STATUS_COLORS[status.value] ?? status.color, + })); + modelConfig.priorities = modelConfig.priorities.map((priority) => ({ + ...priority, + color: TASKNOTES_STARTER_PRIORITY_COLORS[priority.value] ?? priority.color, + })); + modelConfig.statuses.push({ + id: "cancelled", + value: "cancelled", + label: "Cancelled", + color: TASKNOTES_STARTER_STATUS_COLORS.cancelled, + isCompleted: false, + isSkipped: true, + excludeFromCycle: true, + order: modelConfig.statuses.length, + autoArchive: false, + autoArchiveDelay: 5, + }); + return modelConfig; +} + +/** The published starter's type, schemas and contract resources. */ +export function buildTaskNotesStarterResources( + options: Omit = {}, +): TaskNotesMdbaseResources { + const modelConfig = cloneTaskNotesStarterModelConfig(); + const resources = buildTaskNotesMdbaseResources({ + ...options, + profiles: TASKNOTES_STARTER_PROFILES, + modelConfig, + }); + const type = structuredClone(resources.type) as Record; + const implementation = type.implements.find( + (candidate: { contract: string; version: string }) => + candidate.contract === "tasknotes.task" && candidate.version === TASKNOTES_SPEC_VERSION, + ); + if (!implementation) { + throw new Error(`The generated type does not implement tasknotes.task ${TASKNOTES_SPEC_VERSION}.`); + } + type.version = TASKNOTES_STARTER_TYPE_VERSION; + // One shared date schema, so due and scheduled cannot drift apart. + const taskDateSchema = { + anyOf: [ + { type: "string", format: "date" }, + { type: "string", format: "date-time" }, + ], + }; + type.schema.value.properties[implementation.fields.due] = taskDateSchema; + type.schema.value.properties[implementation.fields.scheduled] = taskDateSchema; + implementation.binding.status = { + ...implementation.binding.status, + skipped_values: ["cancelled"], + default_skipped: "cancelled", + }; + const body = `# Task\n\nTask records live under \`${resources.paths.records}/\`.\n`; + return { + ...resources, + type, + typeDocument: serializeMarkdownDocument(type, body), + }; +} diff --git a/test/starter.test.mjs b/test/starter.test.mjs new file mode 100644 index 0000000..863b681 --- /dev/null +++ b/test/starter.test.mjs @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { buildTaskNotesMdbaseTypePack } from "../dist/esm/mdbase.js"; +import { + buildTaskNotesStarterResources, + TASKNOTES_STARTER_TYPE_VERSION, +} from "../dist/esm/starter.js"; + +// The published tasknotes.task 0.3.0-rc.16 pack (mdbase-contracts 9cda221e, +// https://mdbase.dev/contracts/packs/tasknotes.task/0.3.0-rc.16.json). If the +// starter changes, publish a new starter type version in mdbase-contracts and +// update these digests together; collections upgrade by merging against the +// previous starter, so an unintended change here reaches every collection. +const PUBLISHED_RC16_RESOURCES = { + "_contracts/tasknotes.task.md": + "sha256:ecadd00700e249940f4244aaa5b5985a96557d13340463ec491dfa5e89b032a4", + "_types/task.md": + "sha256:2b6016c3543c0dfff491cc6262aed3afd4d672360484174042a261acd1d8ff5f", + "_schemas/tasknotes/tasknotes-task.schema.json": + "sha256:aec95578a093be95cbce0fb500964e7aeaeeacc611624fc2893074637dbd4125", + "_schemas/tasknotes/tasknotes-task-binding.schema.json": + "sha256:5b38e8a1c426b7f7fa92d8353fe35dd98e632305cbb15ffccde387473166b267", +}; + +test("the starter reproduces the published TaskNotes pack byte for byte", async () => { + const pack = await buildTaskNotesMdbaseTypePack(buildTaskNotesStarterResources()); + assert.deepEqual( + Object.fromEntries(pack.manifest.resources.map((resource) => [resource.target, resource.digest])), + PUBLISHED_RC16_RESOURCES, + ); +}); + +test("the starter keeps cancelled as a skipped status and declares only implemented profiles", () => { + const { type } = buildTaskNotesStarterResources(); + const binding = type.implements.find((entry) => entry.contract === "tasknotes.task").binding; + assert.equal(type.version, TASKNOTES_STARTER_TYPE_VERSION); + assert.ok(type.schema.value.properties.status.enum.includes("cancelled")); + assert.deepEqual(binding.status.skipped_values, ["cancelled"]); + assert.equal(binding.status.default_skipped, "cancelled"); + assert.deepEqual(binding.profiles, ["core-lite", "recurrence", "materialized-occurrences"]); +});