From 858b8a3a3a2ba0696f915b869683c23e64c53570 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Mon, 27 Jul 2026 19:37:46 +1000 Subject: [PATCH 1/2] extend canonical mdbase configuration resolution --- README.md | 4 +- src/mdbase.ts | 148 +++++++++++++++++++++++++++++++++++++++++-- test/mdbase.test.mjs | 41 ++++++++++++ 3 files changed, 188 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 28029fe..316538f 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,8 @@ The conformance adapter reports its claimed TaskNotes profile and implements the official `core-lite` operation surface. Its fixture run currently passes 4,974 cases with zero failures; the extended-profile fixture remains outside that claim. The mdbase generator is covered by round-trip tests that resolve -its emitted field roles and vocabularies back into model configuration. +its emitted field roles, vocabularies, task-identification rule, and +user-defined schema fields back into model configuration so filesystem-backed +hosts can treat the type as a configuration provider. The Obsidian plugin uses this package through its service layer and keeps runtime-only behavior, such as Obsidian vault writes, metadata-cache link resolution, notices, and plugin-specific clock hooks, outside the model package. diff --git a/src/mdbase.ts b/src/mdbase.ts index 3b355b6..8345d1c 100644 --- a/src/mdbase.ts +++ b/src/mdbase.ts @@ -429,9 +429,9 @@ export function buildTaskNotesMdbaseResources( }, templating: { enabled: templateEnabled, - ...(templateEnabled ? { template_path: templatePath } : {}), + ...(templatePath ? { template_path: templatePath } : {}), occurrence_enabled: occurrenceTemplateEnabled, - ...(occurrenceTemplateEnabled + ...(occurrenceTemplatePath ? { occurrence_template_path: occurrenceTemplatePath } : {}), }, @@ -445,6 +445,9 @@ export function buildTaskNotesMdbaseResources( ...(legacyCompatibility ? { legacy_compatibility: true } : {}), }; } + const generator = isRecord(extension.generator) ? extension.generator : {}; + generator.managed_fields = Object.keys(properties).sort(); + extension.generator = generator; const schema: Record = { $schema: "https://json-schema.org/draft/2020-12/schema", @@ -494,12 +497,13 @@ export function buildTaskNotesMdbaseResources( "", "# Task", "", - "This type definition is generated from TaskNotes settings for mdbase v0.3.", + "This type definition is the canonical TaskNotes contract for this mdbase collection.", "Its JSON Schema describes persisted task frontmatter; collection and lifecycle", "metadata describe generic mdbase behavior; `x-tasknotes` records the optional", "TaskNotes task contract.", "", - "This file is automatically generated and should not be edited manually.", + "Changes made here are loaded by TaskNotes. Portable changes made in TaskNotes", + "settings are written back while unknown extensions are preserved.", "", ].join("\n"), paths: { @@ -554,6 +558,8 @@ export function resolveTaskNotesModelConfigFromMdbaseType( const recurrence = isRecord(extension.recurrence) ? extension.recurrence : {}; const occurrences = isRecord(extension.occurrences) ? extension.occurrences : {}; const timeTracking = isRecord(extension.time_tracking) ? extension.time_tracking : {}; + const taskIdentification = resolveTaskIdentification(value.match, base.taskIdentification); + const userFields = resolveUserFields(schemaProperties, fieldMapping, base.userFields); const defaultStatus = stringValue(status.default) ?? base.defaults.status; const defaultPriority = stringValue(priority.default) ?? base.defaults.priority; @@ -570,8 +576,14 @@ export function resolveTaskNotesModelConfigFromMdbaseType( priority: priorities.some((entry) => entry.value === defaultPriority) ? defaultPriority : priorities[0]?.value ?? base.defaults.priority, + taskTag: + taskIdentification.method === "tag" + ? taskIdentification.tag + : base.defaults.taskTag, }, + taskIdentification, storeTitleInFilename: title.storage === "filename", + userFields, recurrence: { ...base.recurrence, maintainDueDateOffset: @@ -607,6 +619,134 @@ export function resolveTaskNotesModelConfigFromMdbaseType( }); } +function resolveTaskIdentification( + value: unknown, + fallback: TaskNotesModelConfig["taskIdentification"] +): TaskNotesModelConfig["taskIdentification"] { + if (!isRecord(value) || !isRecord(value.where)) { + return { ...fallback }; + } + + const entries = Object.entries(value.where); + if (entries.length !== 1) { + return { ...fallback }; + } + + const [propertyName, predicate] = entries[0]; + if (!isRecord(predicate)) { + return { ...fallback }; + } + + if (propertyName === "tags") { + const tag = stringValue(predicate.contains); + return tag + ? { + ...fallback, + method: "tag", + tag, + } + : { ...fallback }; + } + + if (Object.prototype.hasOwnProperty.call(predicate, "eq")) { + const rawValue = predicate.eq; + if ( + typeof rawValue !== "string" && + typeof rawValue !== "number" && + typeof rawValue !== "boolean" + ) { + return { ...fallback }; + } + return { + ...fallback, + method: "property", + propertyName, + propertyValue: String(rawValue), + }; + } + + if (predicate.exists === true) { + return { + ...fallback, + method: "property", + propertyName, + propertyValue: "", + }; + } + + return { ...fallback }; +} + +function resolveUserFields( + schemaProperties: Record, + fieldMapping: FieldMapping, + fallback: UserMappedField[] +): UserMappedField[] { + const reserved = new Set([...Object.values(fieldMapping), "id", "tags"]); + const resolved: UserMappedField[] = []; + + for (const [key, schema] of Object.entries(schemaProperties)) { + if (reserved.has(key)) continue; + const type = resolveUserFieldType(schema); + if (!type) continue; + const existing = fallback.find((field) => field.key === key); + const defaultValue = readSchemaDefault(schema); + resolved.push({ + id: existing?.id ?? key, + displayName: existing?.displayName ?? humanize(key), + key, + type, + ...(defaultValue !== undefined ? { defaultValue } : {}), + }); + } + + return resolved; +} + +function resolveUserFieldType(value: unknown): UserMappedField["type"] | null { + if (!isRecord(value)) return null; + if (Array.isArray(value.anyOf)) { + for (const branch of value.anyOf) { + const resolved = resolveUserFieldType(branch); + if (resolved) return resolved; + } + return null; + } + if (value.format === "date") return "date"; + if (value.type === "string") return "text"; + if (value.type === "number" || value.type === "integer") return "number"; + if (value.type === "boolean") return "boolean"; + if (value.type === "array") return "list"; + return null; +} + +function readSchemaDefault(value: unknown): UserMappedField["defaultValue"] | undefined { + if (!isRecord(value)) return undefined; + if (value.default !== undefined) { + return isUserFieldDefault(value.default) + ? Array.isArray(value.default) + ? [...value.default] + : value.default + : undefined; + } + if (Array.isArray(value.anyOf)) { + for (const branch of value.anyOf) { + const resolved = readSchemaDefault(branch); + if (resolved !== undefined) return resolved; + } + } + return undefined; +} + +function isUserFieldDefault(value: unknown): value is UserMappedField["defaultValue"] { + return ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + (Array.isArray(value) && value.every((entry) => typeof entry === "string")) + ); +} + function validateVocabulary( config: TaskNotesModelConfig, statusValues: string[], diff --git a/test/mdbase.test.mjs b/test/mdbase.test.mjs index 51821f9..2128556 100644 --- a/test/mdbase.test.mjs +++ b/test/mdbase.test.mjs @@ -186,6 +186,15 @@ test("projects and resolves the complete portable TaskNotes settings snapshot", propertyValue: "true", }, storeTitleInFilename: false, + userFields: [ + { + id: "effort", + displayName: "Effort", + key: "effort_points", + type: "number", + defaultValue: 3, + }, + ], recurrence: { maintainDueDateOffset: true, resetCheckboxesOnRecurrence: true, @@ -243,10 +252,41 @@ test("projects and resolves the complete portable TaskNotes settings snapshot", assert.equal(resolved.statuses.find(({ value }) => value === "done").autoArchiveDelay, 15); assert.equal(resolved.statuses.find(({ value }) => value === "cancelled").isSkipped, true); assert.equal(resolved.priorities.find(({ value }) => value === "critical").icon, "flame"); + assert.equal(resolved.taskIdentification.method, "property"); + assert.equal(resolved.taskIdentification.propertyName, "isTask"); + assert.equal(resolved.taskIdentification.propertyValue, "true"); + assert.deepEqual(resolved.userFields, [ + { + id: "effort_points", + displayName: "Effort points", + key: "effort_points", + type: "number", + defaultValue: 3, + }, + ]); assert.equal(resolved.occurrences.defaultMaterialization, "rolling"); assert.equal(resolved.timeTracking.autoStopOnComplete, false); }); +test("resolves tag identification from the canonical match rule", () => { + const resources = buildTaskNotesMdbaseResources({ + modelConfig: { + defaults: { taskTag: "action" }, + taskIdentification: { + method: "tag", + tag: "action", + propertyName: "", + propertyValue: "", + }, + }, + }); + const resolved = resolveTaskNotesModelConfigFromMdbaseType(resources.type); + + assert.equal(resolved.taskIdentification.method, "tag"); + assert.equal(resolved.taskIdentification.tag, "action"); + assert.equal(resolved.defaults.taskTag, "action"); +}); + test("emits a disclosed coercion-compatible schema for migrated v0.2 collections", () => { const resources = buildTaskNotesMdbaseResources({ legacyCompatibility: true }); const properties = resources.type.schema.value.properties; @@ -258,4 +298,5 @@ test("emits a disclosed coercion-compatible schema for migrated v0.2 collections coercion_compatible_schema: true, }); assert.equal(resources.type["x-tasknotes"].generator.legacy_compatibility, true); + assert.ok(resources.type["x-tasknotes"].generator.managed_fields.includes("title")); }); From 9a021baf6c469015263848d9fee322ca7dca0535 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 28 Jul 2026 14:04:10 +1000 Subject: [PATCH 2/2] Publish the TaskNotes data contract type pack --- README.md | 11 +- package.json | 1 + scripts/sync-tasknotes-contract.mjs | 31 ++ src/generated/tasknotes-data-contract.ts | 644 +++++++++++++++++++++++ src/mdbase.ts | 262 +++++++-- test/mdbase.test.mjs | 93 +++- 6 files changed, 991 insertions(+), 51 deletions(-) create mode 100644 scripts/sync-tasknotes-contract.mjs create mode 100644 src/generated/tasknotes-data-contract.ts diff --git a/README.md b/README.md index 316538f..2cc941d 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ The package exports both the root module and focused subpath modules: | `@tasknotes/model/validation` | Task and time-entry validation | | `@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/type generation and TaskNotes extension resolution | +| `@tasknotes/model/mdbase` | Canonical mdbase v0.3 config, contract, schema, and implementing-type generation | | `@tasknotes/model/conformance` | tasknotes-spec conformance operation dispatcher | | `@tasknotes/model/runtime` | Host-independent mdbase runtime provider and host contracts | @@ -198,9 +198,10 @@ The package build emits ESM, CommonJS, and TypeScript declaration output under ` The conformance adapter reports its claimed TaskNotes profile and implements the official `core-lite` operation surface. Its fixture run currently passes 4,974 cases with zero failures; the extended-profile fixture remains outside -that claim. The mdbase generator is covered by round-trip tests that resolve -its emitted field roles, vocabularies, task-identification rule, and -user-defined schema fields back into model configuration so filesystem-backed -hosts can treat the type as a configuration provider. +that claim. The mdbase generator emits the `tasknotes.task 0.2.0` contract, +its two JSON Schemas, and a type whose `implements` entry contains field +mappings and TaskNotes behavior. Round-trip tests resolve those resources back +into model configuration so filesystem-backed hosts can treat the type as a +configuration provider. The Obsidian plugin uses this package through its service layer and keeps runtime-only behavior, such as Obsidian vault writes, metadata-cache link resolution, notices, and plugin-specific clock hooks, outside the model package. diff --git a/package.json b/package.json index 1abb911..11172ab 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ }, "scripts": { "clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"", + "sync:mdbase-contract": "node scripts/sync-tasknotes-contract.mjs", "build": "npm run clean && node scripts/build.mjs && tsc -p tsconfig.types.json", "prepack": "npm run build", "test": "npm run build && node --test test/**/*.test.mjs", diff --git a/scripts/sync-tasknotes-contract.mjs b/scripts/sync-tasknotes-contract.mjs new file mode 100644 index 0000000..f98df78 --- /dev/null +++ b/scripts/sync-tasknotes-contract.mjs @@ -0,0 +1,31 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const specRoot = + process.env.TASKNOTES_SPEC_ROOT ?? + path.resolve(import.meta.dirname, "../../tasknotes-spec"); +const generatedPath = path.resolve( + import.meta.dirname, + "../src/generated/tasknotes-data-contract.ts" +); + +const [taskSchema, bindingSchema] = await Promise.all( + ["tasknotes-task.schema.json", "tasknotes-task-binding.schema.json"].map( + async (name) => + JSON.parse( + await readFile(path.join(specRoot, "schemas", name), "utf8") + ) + ) +); + +const source = `// Generated by scripts/sync-tasknotes-contract.mjs from tasknotes-spec. +// Do not edit this file directly. + +export const TASKNOTES_TASK_SCHEMA = ${JSON.stringify(taskSchema, null, "\t")} as const; + +export const TASKNOTES_TASK_BINDING_SCHEMA = ${JSON.stringify(bindingSchema, null, "\t")} as const; +`; + +await mkdir(path.dirname(generatedPath), { recursive: true }); +await writeFile(generatedPath, source); +console.log(`Synced TaskNotes data-contract schemas from ${specRoot}`); diff --git a/src/generated/tasknotes-data-contract.ts b/src/generated/tasknotes-data-contract.ts new file mode 100644 index 0000000..4b2c0ff --- /dev/null +++ b/src/generated/tasknotes-data-contract.ts @@ -0,0 +1,644 @@ +// Generated by scripts/sync-tasknotes-contract.mjs from tasknotes-spec. +// Do not edit this file directly. + +export const TASKNOTES_TASK_SCHEMA = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://tasknotes.dev/schemas/tasknotes-task.schema.json", + "title": "TaskNotes portable task view", + "description": "The storage-neutral record view exposed by the tasknotes.task 0.2.0 data contract.", + "type": "object", + "required": [ + "status", + "dateCreated" + ], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "title": { + "type": "string", + "minLength": 1 + }, + "status": { + "type": "string", + "minLength": 1 + }, + "priority": { + "type": "string", + "minLength": 1 + }, + "due": { + "type": "string", + "format": "date" + }, + "scheduled": { + "type": "string", + "format": "date" + }, + "contexts": { + "type": "array", + "items": { + "type": "string" + } + }, + "projects": { + "type": "array", + "items": { + "type": "string" + } + }, + "timeEstimate": { + "type": "integer", + "minimum": 0 + }, + "completedDate": { + "type": "string", + "format": "date" + }, + "dateCreated": { + "type": "string", + "format": "date-time" + }, + "dateModified": { + "type": "string", + "format": "date-time" + }, + "recurrence": { + "type": "string" + }, + "recurrenceAnchor": { + "enum": [ + "scheduled", + "completion" + ] + }, + "recurrenceParent": { + "type": "string" + }, + "occurrenceDate": { + "type": "string", + "format": "date" + }, + "occurrenceMaterialization": { + "enum": [ + "manual", + "on_completion", + "rolling" + ] + }, + "occurrenceNextTrigger": { + "enum": [ + "completion", + "completion_or_skip" + ] + }, + "occurrenceTemplate": { + "type": "string" + }, + "occurrencePastHorizon": { + "type": "string" + }, + "occurrenceFutureHorizon": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "timeEntries": { + "type": "array", + "items": { + "type": "object" + } + }, + "reminders": { + "type": "array", + "items": { + "type": "object" + } + }, + "blockedBy": { + "type": "array", + "items": { + "type": "object" + } + }, + "completeInstances": { + "type": "array", + "items": { + "type": "string", + "format": "date" + } + }, + "skippedInstances": { + "type": "array", + "items": { + "type": "string", + "format": "date" + } + }, + "icsEventId": { + "type": "array", + "items": { + "type": "string" + } + }, + "googleCalendarEventId": { + "type": "string" + }, + "googleCalendarExceptionEventId": { + "type": "string" + }, + "googleCalendarExceptionOriginalScheduled": { + "type": "string", + "format": "date" + }, + "googleCalendarMovedOriginalDates": { + "type": "array", + "items": { + "type": "string", + "format": "date" + } + }, + "sortOrder": { + "type": "number" + } + } +} as const; + +export const TASKNOTES_TASK_BINDING_SCHEMA = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://tasknotes.dev/schemas/tasknotes-task-binding.schema.json", + "title": "TaskNotes task data-contract binding", + "description": "Semantic configuration supplied by an mdbase type that implements tasknotes.task 0.2.0.", + "type": "object", + "required": [ + "profiles", + "capabilities", + "title", + "status", + "priority", + "recurrence", + "occurrences", + "links", + "archive", + "time_tracking", + "templating" + ], + "properties": { + "profiles": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": [ + "core-lite", + "recurrence", + "templating", + "materialized-occurrences", + "extended" + ] + } + }, + "capabilities": { + "type": "array", + "uniqueItems": true, + "items": { + "enum": [ + "dependencies", + "reminders", + "links", + "time-tracking", + "materialized-occurrences", + "rename", + "archive", + "batch", + "concurrency", + "dry-run", + "migration", + "templating" + ] + } + }, + "title": { + "$ref": "#/$defs/titlePolicy" + }, + "status": { + "$ref": "#/$defs/statusPolicy" + }, + "priority": { + "$ref": "#/$defs/priorityPolicy" + }, + "runtime_timezone": { + "type": "string", + "minLength": 1 + }, + "recurrence": { + "$ref": "#/$defs/recurrencePolicy" + }, + "occurrences": { + "$ref": "#/$defs/occurrencePolicy" + }, + "links": { + "$ref": "#/$defs/linkPolicy" + }, + "archive": { + "$ref": "#/$defs/archivePolicy" + }, + "time_tracking": { + "$ref": "#/$defs/timeTrackingPolicy" + }, + "templating": { + "$ref": "#/$defs/templatingPolicy" + }, + "nlp": { + "$ref": "#/$defs/nlpPolicy" + } + }, + "additionalProperties": false, + "$defs": { + "nonEmptyString": { + "type": "string", + "minLength": 1 + }, + "stringSet": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/nonEmptyString" + } + }, + "titlePolicy": { + "type": "object", + "required": [ + "storage" + ], + "properties": { + "storage": { + "enum": [ + "filename", + "frontmatter" + ] + }, + "filename_format": { + "enum": [ + "title", + "zettel", + "timestamp", + "uuid", + "custom" + ] + }, + "custom_filename_template": { + "$ref": "#/$defs/nonEmptyString" + } + }, + "allOf": [ + { + "if": { + "properties": { + "filename_format": { + "const": "custom" + } + }, + "required": [ + "filename_format" + ] + }, + "then": { + "properties": { + "custom_filename_template": { + "$ref": "#/$defs/nonEmptyString" + } + }, + "required": [ + "custom_filename_template" + ] + } + } + ], + "additionalProperties": true + }, + "statusDefinition": { + "type": "object", + "required": [ + "value", + "label", + "color", + "is_completed", + "is_skipped", + "exclude_from_cycle", + "order", + "auto_archive", + "auto_archive_delay_minutes" + ], + "properties": { + "value": { + "$ref": "#/$defs/nonEmptyString" + }, + "label": { + "type": "string" + }, + "color": { + "type": "string" + }, + "icon": { + "$ref": "#/$defs/nonEmptyString" + }, + "is_completed": { + "type": "boolean" + }, + "is_skipped": { + "type": "boolean" + }, + "exclude_from_cycle": { + "type": "boolean" + }, + "next_status": { + "$ref": "#/$defs/nonEmptyString" + }, + "order": { + "type": "number" + }, + "auto_archive": { + "type": "boolean" + }, + "auto_archive_delay_minutes": { + "type": "number", + "minimum": 0 + } + }, + "additionalProperties": true + }, + "statusPolicy": { + "type": "object", + "required": [ + "values", + "default", + "completed_values", + "definitions" + ], + "properties": { + "values": { + "$ref": "#/$defs/stringSet" + }, + "default": { + "$ref": "#/$defs/nonEmptyString" + }, + "completed_values": { + "$ref": "#/$defs/stringSet" + }, + "skipped_values": { + "$ref": "#/$defs/stringSet" + }, + "default_skipped": { + "$ref": "#/$defs/nonEmptyString" + }, + "definitions": { + "type": "array", + "items": { + "$ref": "#/$defs/statusDefinition" + } + } + }, + "additionalProperties": false + }, + "priorityDefinition": { + "type": "object", + "required": [ + "value", + "label", + "color", + "weight" + ], + "properties": { + "value": { + "$ref": "#/$defs/nonEmptyString" + }, + "label": { + "type": "string" + }, + "color": { + "type": "string" + }, + "icon": { + "$ref": "#/$defs/nonEmptyString" + }, + "weight": { + "type": "number" + } + }, + "additionalProperties": true + }, + "priorityPolicy": { + "type": "object", + "required": [ + "values", + "default", + "definitions" + ], + "properties": { + "values": { + "$ref": "#/$defs/stringSet" + }, + "default": { + "$ref": "#/$defs/nonEmptyString" + }, + "definitions": { + "type": "array", + "items": { + "$ref": "#/$defs/priorityDefinition" + } + } + }, + "additionalProperties": false + }, + "recurrencePolicy": { + "type": "object", + "required": [ + "syntax", + "maintain_due_date_offset", + "reset_body_checkboxes" + ], + "properties": { + "syntax": { + "const": "tasknotes" + }, + "maintain_due_date_offset": { + "type": "boolean" + }, + "reset_body_checkboxes": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "occurrencePolicy": { + "type": "object", + "required": [ + "identity_roles", + "default_materialization", + "default_next_trigger" + ], + "properties": { + "identity_roles": { + "const": [ + "recurrenceParent", + "occurrenceDate" + ] + }, + "default_materialization": { + "enum": [ + "manual", + "on_completion", + "rolling" + ] + }, + "default_next_trigger": { + "enum": [ + "completion", + "completion_or_skip" + ] + }, + "past_horizon": { + "$ref": "#/$defs/nonEmptyString" + }, + "future_horizon": { + "$ref": "#/$defs/nonEmptyString" + } + }, + "additionalProperties": false + }, + "linkPolicy": { + "type": "object", + "required": [ + "accepted_formats", + "write_format" + ], + "properties": { + "accepted_formats": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": [ + "wikilink", + "markdown" + ] + } + }, + "write_format": { + "enum": [ + "wikilink", + "markdown" + ] + } + }, + "additionalProperties": false + }, + "archivePolicy": { + "type": "object", + "required": [ + "archived_tag", + "move_on_archive" + ], + "properties": { + "archived_tag": { + "$ref": "#/$defs/nonEmptyString" + }, + "move_on_archive": { + "type": "boolean" + }, + "folder": { + "$ref": "#/$defs/nonEmptyString" + } + }, + "additionalProperties": false + }, + "timeTrackingPolicy": { + "type": "object", + "required": [ + "auto_stop_on_complete" + ], + "properties": { + "auto_stop_on_complete": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "templatingPolicy": { + "type": "object", + "required": [ + "enabled", + "occurrence_enabled" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "template_path": { + "$ref": "#/$defs/nonEmptyString" + }, + "occurrence_enabled": { + "type": "boolean" + }, + "occurrence_template_path": { + "$ref": "#/$defs/nonEmptyString" + }, + "failure_mode": { + "enum": [ + "error", + "warning_fallback" + ] + }, + "unknown_variable_policy": { + "enum": [ + "preserve", + "empty" + ] + } + }, + "additionalProperties": false + }, + "nlpTrigger": { + "type": "object", + "required": [ + "property_id", + "trigger", + "enabled" + ], + "properties": { + "property_id": { + "$ref": "#/$defs/nonEmptyString" + }, + "trigger": { + "$ref": "#/$defs/nonEmptyString" + }, + "enabled": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "nlpPolicy": { + "type": "object", + "required": [ + "triggers" + ], + "properties": { + "triggers": { + "type": "array", + "items": { + "$ref": "#/$defs/nlpTrigger" + } + } + }, + "additionalProperties": false + } + } +} as const; diff --git a/src/mdbase.ts b/src/mdbase.ts index 8e59970..282e2ce 100644 --- a/src/mdbase.ts +++ b/src/mdbase.ts @@ -1,5 +1,9 @@ import YAML from "yaml"; import { resolveModelConfig } from "./config"; +import { + TASKNOTES_TASK_BINDING_SCHEMA, + TASKNOTES_TASK_SCHEMA, +} from "./generated/tasknotes-data-contract"; import { TASKNOTES_SPEC_VERSION } from "./types"; import type { FieldMapping, @@ -33,6 +37,8 @@ export interface TaskNotesMdbaseOptions { typeName?: string; tasksFolder?: string; typesFolder?: string; + contractsFolder?: string; + schemasFolder?: string; modelConfig?: Partial; profiles?: readonly string[]; capabilities?: readonly string[]; @@ -70,17 +76,44 @@ export interface TaskNotesMdbaseOptions { export interface TaskNotesMdbaseResources { config: Record; + contract: Record; type: Record; + taskSchema: Record; + bindingSchema: Record; configDocument: string; + contractDocument: string; typeDocument: string; + taskSchemaDocument: string; + bindingSchemaDocument: string; paths: { config: "mdbase.yaml"; + contract: string; type: string; + taskSchema: string; + bindingSchema: string; records: string; }; modelConfig: TaskNotesModelConfig; } +export interface TaskNotesMdbaseTypePack { + manifest: { + kind: "mdbase.type-pack"; + id: "tasknotes.task"; + version: string; + name: string; + description: string; + resources: Array<{ + kind: "contract" | "type" | "schema"; + source: string; + target: string; + digest: string; + }>; + }; + resources: Array<{ source: string; document: string }>; + provides: Array<{ id: "tasknotes.task"; version: string }>; +} + export interface TaskNotesMdbaseTypeSettingsPatch { defaultStatus?: string; defaultPriority?: string; @@ -140,20 +173,21 @@ export function patchTaskNotesMdbaseTypeSettings( patch: TaskNotesMdbaseTypeSettingsPatch ): Record { const result = cloneValue(type) as Record; - const extension = isRecord(result["x-tasknotes"]) ? result["x-tasknotes"] : {}; - if (extension.contract !== "tasknotes.task") { + const implementation = taskNotesImplementation(result); + if (!implementation) { throw new Error("The mdbase type is not a TaskNotes task contract."); } - result["x-tasknotes"] = extension; + const binding = isRecord(implementation.binding) ? implementation.binding : {}; + implementation.binding = binding; const model = resolveTaskNotesModelConfigFromMdbaseType(result); - const status = contractSection(extension, "status"); - const priority = contractSection(extension, "priority"); - const recurrence = contractSection(extension, "recurrence"); - const occurrences = contractSection(extension, "occurrences"); - const timeTracking = contractSection(extension, "time_tracking"); - const links = contractSection(extension, "links"); - const archive = contractSection(extension, "archive"); - const templating = contractSection(extension, "templating"); + const status = contractSection(binding, "status"); + const priority = contractSection(binding, "priority"); + const recurrence = contractSection(binding, "recurrence"); + const occurrences = contractSection(binding, "occurrences"); + const timeTracking = contractSection(binding, "time_tracking"); + const links = contractSection(binding, "links"); + const archive = contractSection(binding, "archive"); + const templating = contractSection(binding, "templating"); const properties = isRecord(isRecord(result.schema) ? result.schema.value : undefined) ? (result.schema as Record).value.properties : undefined; @@ -282,6 +316,17 @@ export function buildTaskNotesMdbaseResources( const typeName = cleanSegment(options.typeName ?? "task", "typeName"); const tasksFolder = cleanOptionalPath(options.tasksFolder ?? "tasks", "tasksFolder"); const typesFolder = cleanPath(options.typesFolder ?? "_types", "typesFolder"); + const contractsFolder = cleanPath( + options.contractsFolder ?? "_contracts", + "contractsFolder" + ); + const schemasFolder = cleanPath( + options.schemasFolder ?? "_schemas/tasknotes", + "schemasFolder" + ); + if (contractsFolder === typesFolder) { + throw new Error("contractsFolder must differ from typesFolder."); + } const modelConfig = resolveModelConfig(options.modelConfig); const profiles = uniqueStrings(options.profiles ?? DEFAULT_TASKNOTES_MDBASE_PROFILES); const capabilities = uniqueStrings( @@ -310,6 +355,7 @@ export function buildTaskNotesMdbaseResources( description: collectionDescription, settings: { types_folder: typesFolder, + contracts_folder: contractsFolder, record_extensions: ["md"], validation: options.collection?.validation ?? "warn", explicit_type_keys: ["type", "types"], @@ -548,13 +594,9 @@ export function buildTaskNotesMdbaseResources( const templateEnabled = options.templating?.enabled === true && Boolean(templatePath); const occurrenceTemplateEnabled = options.templating?.occurrenceEnabled === true && Boolean(occurrenceTemplatePath); - const extension: Record = { - contract: "tasknotes.task", - version: 1, - spec_version: TASKNOTES_SPEC_VERSION, + const binding: Record = { profiles, capabilities, - field_roles: fieldRoles, title: { storage: modelConfig.storeTitleInFilename ? "filename" : "frontmatter", filename_format: filenameFormat, @@ -596,7 +638,6 @@ export function buildTaskNotesMdbaseResources( write_format: options.links?.writeFormat ?? "wikilink", }, archive: { - tags_field: "tags", archived_tag: mapping.archiveTag, move_on_archive: options.archive?.moveOnArchive === true, ...(options.archive?.folder?.trim() ? { folder: options.archive.folder.trim() } : {}), @@ -623,19 +664,18 @@ export function buildTaskNotesMdbaseResources( ? { occurrence_template_path: occurrenceTemplatePath } : {}), }, - compatibility: { read_aliases: true }, + }; + const generator: Record = { + managed_fields: Object.keys(properties).sort(), }; if (omittedCollectionPaths.size > 0 || legacyCompatibility) { - extension.generator = { + Object.assign(generator, { ...(omittedCollectionPaths.size > 0 ? { omitted_collection_paths: [...omittedCollectionPaths].sort() } : {}), ...(legacyCompatibility ? { legacy_compatibility: true } : {}), - }; + }); } - const generator = isRecord(extension.generator) ? extension.generator : {}; - generator.managed_fields = Object.keys(properties).sort(); - extension.generator = generator; const schema: Record = { $schema: "https://json-schema.org/draft/2020-12/schema", @@ -668,16 +708,66 @@ export function buildTaskNotesMdbaseResources( }, collection, lifecycle, - "x-tasknotes": extension, + implements: [ + { + contract: "tasknotes.task", + version: TASKNOTES_SPEC_VERSION, + fields: fieldRoles, + binding, + }, + ], + "x-tasknotes-generator": generator, ...(legacyCompatibility ? { "x-legacy-v0.2": { coercion_compatible_schema: true } } : {}), }; + const taskSchema = cloneValue(TASKNOTES_TASK_SCHEMA) as Record; + const bindingSchema = cloneValue( + TASKNOTES_TASK_BINDING_SCHEMA + ) as Record; + const contract: Record = { + kind: "mdbase.contract", + id: "tasknotes.task", + version: TASKNOTES_SPEC_VERSION, + name: "TaskNotes task", + description: `Portable task data and behavior defined by tasknotes-spec ${TASKNOTES_SPEC_VERSION}.`, + schema: { + dialect: "json-schema-2020-12", + ref: relativeResourceReference( + `${contractsFolder}/tasknotes.task.md`, + `${schemasFolder}/tasknotes-task.schema.json` + ), + }, + binding_schema: { + dialect: "json-schema-2020-12", + ref: relativeResourceReference( + `${contractsFolder}/tasknotes.task.md`, + `${schemasFolder}/tasknotes-task-binding.schema.json` + ), + }, + }; + const taskSchemaDocument = `${JSON.stringify(taskSchema, null, 2)}\n`; + const bindingSchemaDocument = `${JSON.stringify(bindingSchema, null, 2)}\n`; + const contractDocument = [ + "---", + YAML.stringify(contract, { lineWidth: 0 }).trimEnd(), + "---", + "", + "# TaskNotes task contract", + "", + "Types implement this contract through `implements`; applications consume", + "the normalized contract view rather than assuming frontmatter names.", + "", + ].join("\n"); return { config, + contract, type, + taskSchema, + bindingSchema, configDocument: `${YAML.stringify(config, { lineWidth: 0 }).trimEnd()}\n`, + contractDocument, typeDocument: [ "---", YAML.stringify(type, { lineWidth: 0 }).trimEnd(), @@ -685,33 +775,104 @@ export function buildTaskNotesMdbaseResources( "", "# Task", "", - "This type definition is the canonical TaskNotes contract for this mdbase collection.", + "This type definition implements the TaskNotes contract for this mdbase collection.", "Its JSON Schema describes persisted task frontmatter; collection and lifecycle", - "metadata describe generic mdbase behavior; `x-tasknotes` records the optional", - "TaskNotes task contract.", + "metadata describe generic mdbase behavior; `implements` maps the portable", + "TaskNotes task view and supplies TaskNotes behavior.", "", "Changes made here are loaded by TaskNotes. Portable changes made in TaskNotes", "settings are written back while unknown extensions are preserved.", "", ].join("\n"), + taskSchemaDocument, + bindingSchemaDocument, paths: { config: "mdbase.yaml", + contract: `${contractsFolder}/tasknotes.task.md`, type: `${typesFolder}/${typeName}.md`, + taskSchema: `${schemasFolder}/tasknotes-task.schema.json`, + bindingSchema: `${schemasFolder}/tasknotes-task-binding.schema.json`, records: tasksFolder, }, modelConfig, }; } +/** + * Package generated TaskNotes artifacts as one connector-installable transaction. + * + * The pack deliberately excludes `mdbase.yaml`: applications may add this + * contract to an existing collection without taking ownership of collection + * configuration. + */ +export async function buildTaskNotesMdbaseTypePack( + resources: TaskNotesMdbaseResources +): Promise { + const typeSource = `types/${resources.paths.type.split("/").slice(-1)[0]}`; + const definitions = [ + { + kind: "contract" as const, + source: "contracts/tasknotes.task.md", + target: resources.paths.contract, + document: resources.contractDocument, + }, + { + kind: "type" as const, + source: typeSource, + target: resources.paths.type, + document: resources.typeDocument, + }, + { + kind: "schema" as const, + source: "schemas/tasknotes-task.schema.json", + target: resources.paths.taskSchema, + document: resources.taskSchemaDocument, + }, + { + kind: "schema" as const, + source: "schemas/tasknotes-task-binding.schema.json", + target: resources.paths.bindingSchema, + document: resources.bindingSchemaDocument, + }, + ]; + return { + manifest: { + kind: "mdbase.type-pack", + id: "tasknotes.task", + version: TASKNOTES_SPEC_VERSION, + name: "TaskNotes task", + description: + "TaskNotes task contract, implementation, and referenced JSON Schemas.", + resources: await Promise.all( + definitions.map(async ({ kind, source, target, document }) => ({ + kind, + source, + target, + digest: await sha256(document), + })) + ), + }, + resources: definitions.map(({ source, document }) => ({ + source, + document, + })), + provides: [{ id: "tasknotes.task", version: TASKNOTES_SPEC_VERSION }], + }; +} + /** Resolve the TaskNotes configuration advertised by an mdbase task type. */ export function resolveTaskNotesModelConfigFromMdbaseType( value: unknown, fallback: Partial = {} ): TaskNotesModelConfig { const base = resolveModelConfig(fallback); - if (!isRecord(value) || !isRecord(value["x-tasknotes"])) return base; - const extension = value["x-tasknotes"]; - const roles = isRecord(extension.field_roles) ? extension.field_roles : {}; + if (!isRecord(value)) return base; + const implementation = taskNotesImplementation(value); + if (!implementation) return base; + const extension = isRecord(implementation.binding) + ? implementation.binding + : {}; + const roles = isRecord(implementation.fields) ? implementation.fields : {}; const fieldMapping = { ...base.fieldMapping }; for (const key of Object.keys(fieldMapping) as (keyof FieldMapping)[]) { const candidate = roles[key]; @@ -1319,6 +1480,39 @@ function contractSection( return section; } +function taskNotesImplementation( + type: Record +): Record | undefined { + if (!Array.isArray(type.implements)) return undefined; + return type.implements.find( + (candidate): candidate is Record => + isRecord(candidate) && + candidate.contract === "tasknotes.task" && + candidate.version === TASKNOTES_SPEC_VERSION + ); +} + +function relativeResourceReference( + sourcePath: string, + targetPath: string +): string { + const sourceDirectory = sourcePath.split("/").slice(0, -1); + const target = targetPath.split("/"); + let common = 0; + while ( + common < sourceDirectory.length && + common < target.length && + sourceDirectory[common] === target[common] + ) { + common += 1; + } + const segments = [ + ...sourceDirectory.slice(common).map(() => ".."), + ...target.slice(common), + ]; + return segments.join("/"); +} + function requiredConfiguredValue( value: string, allowed: readonly string[], @@ -1413,6 +1607,14 @@ function cloneValue(value: unknown): unknown { return value; } +async function sha256(value: string): Promise { + const bytes = new TextEncoder().encode(value); + const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes); + return `sha256:${Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0") + ).join("")}`; +} + function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } diff --git a/test/mdbase.test.mjs b/test/mdbase.test.mjs index 9157180..d2e46e8 100644 --- a/test/mdbase.test.mjs +++ b/test/mdbase.test.mjs @@ -3,18 +3,35 @@ import test from "node:test"; import YAML from "yaml"; import { buildTaskNotesMdbaseResources, + buildTaskNotesMdbaseTypePack, patchTaskNotesMdbaseTypeSettings, resolveTaskNotesModelConfigFromMdbaseType, } from "../dist/esm/mdbase.js"; +function implementation(type) { + return type.implements.find( + (entry) => + entry.contract === "tasknotes.task" && entry.version === "0.2.0" + ); +} + +function binding(type) { + return implementation(type).binding; +} + test("builds one canonical TaskNotes and mdbase collection contract", () => { const resources = buildTaskNotesMdbaseResources(); const type = resources.type; - const extension = type["x-tasknotes"]; + const taskImplementation = implementation(type); + const extension = taskImplementation.binding; const schema = type.schema.value; assert.equal(resources.config.spec_version, "0.3.0"); - assert.equal(extension.spec_version, "0.2.0"); + assert.equal(resources.config.settings.contracts_folder, "_contracts"); + assert.equal(resources.contract.id, "tasknotes.task"); + assert.equal(resources.contract.version, "0.2.0"); + assert.equal(taskImplementation.contract, "tasknotes.task"); + assert.equal(taskImplementation.version, "0.2.0"); assert.deepEqual(extension.profiles, [ "core-lite", "recurrence", @@ -31,17 +48,56 @@ test("builds one canonical TaskNotes and mdbase collection contract", () => { "archive", "templating", ]); - assert.equal(extension.field_roles.completedDate, "completedDate"); - assert.equal(extension.field_roles.id, "id"); + assert.equal(taskImplementation.fields.completedDate, "completedDate"); + assert.equal(taskImplementation.fields.id, "id"); assert.deepEqual(type.collection.unique, [{ field: "id", scope: "type" }]); assert.deepEqual(type.lifecycle.on_create.set.id, { uuid: true }); assert.deepEqual(schema.properties.completedDate, { type: "string", format: "date" }); assert.deepEqual(schema.properties.dateModified, { type: "string", format: "date-time" }); assert.deepEqual(YAML.parse(resources.configDocument), resources.config); + assert.deepEqual(JSON.parse(resources.taskSchemaDocument), resources.taskSchema); + assert.deepEqual( + JSON.parse(resources.bindingSchemaDocument), + resources.bindingSchema + ); const typeFrontmatter = resources.typeDocument.match(/^---\n([\s\S]*?)\n---\n/); assert.ok(typeFrontmatter); assert.deepEqual(YAML.parse(typeFrontmatter[1]), resources.type); + const contractFrontmatter = + resources.contractDocument.match(/^---\n([\s\S]*?)\n---\n/); + assert.ok(contractFrontmatter); + assert.deepEqual(YAML.parse(contractFrontmatter[1]), resources.contract); +}); + +test("packages the contract, implementation, and schemas as one digest-pinned type pack", async () => { + const resources = buildTaskNotesMdbaseResources({ typeName: "action" }); + const pack = await buildTaskNotesMdbaseTypePack(resources); + + assert.deepEqual(pack.provides, [ + { id: "tasknotes.task", version: "0.2.0" }, + ]); + assert.equal(pack.manifest.kind, "mdbase.type-pack"); + assert.equal(pack.manifest.id, "tasknotes.task"); + assert.equal(pack.manifest.resources.length, 4); + assert.deepEqual( + pack.manifest.resources.map(({ kind, target }) => [kind, target]), + [ + ["contract", "_contracts/tasknotes.task.md"], + ["type", "_types/action.md"], + ["schema", "_schemas/tasknotes/tasknotes-task.schema.json"], + [ + "schema", + "_schemas/tasknotes/tasknotes-task-binding.schema.json", + ], + ] + ); + for (const declared of pack.manifest.resources) { + assert.match(declared.digest, /^sha256:[0-9a-f]{64}$/); + assert.ok( + pack.resources.some((resource) => resource.source === declared.source) + ); + } }); test("round-trips optional NLP trigger settings through the TaskNotes contract", () => { @@ -57,7 +113,7 @@ test("round-trips optional NLP trigger settings through the TaskNotes contract", }, }); - assert.deepEqual(resources.type["x-tasknotes"].nlp, { + assert.deepEqual(binding(resources.type).nlp, { triggers: [ { property_id: "tags", trigger: "#", enabled: true }, { property_id: "priority", trigger: "!", enabled: false }, @@ -116,7 +172,7 @@ test("patches portable model settings without replacing custom type content", () done: { autoArchive: true, autoArchiveDelay: 15 }, }, }); - const extension = patched["x-tasknotes"]; + const extension = binding(patched); const resolved = resolveTaskNotesModelConfigFromMdbaseType(patched); assert.equal(resolved.defaults.status, "in-progress"); @@ -130,7 +186,6 @@ test("patches portable model settings without replacing custom type content", () assert.equal(resolved.timeTracking.autoStopOnComplete, true); assert.equal(extension.links.write_format, "markdown"); assert.deepEqual(extension.archive, { - tags_field: "tags", archived_tag: "archived", move_on_archive: true, folder: "Tasks/Archive", @@ -154,8 +209,8 @@ test("patches portable model settings without replacing custom type content", () description: "Preserve me", }); assert.deepEqual(patched["x-host"], { custom: true }); - assert.deepEqual(extension.nlp, resources.type["x-tasknotes"].nlp); - assert.equal(resources.type["x-tasknotes"].status.default, "open"); + assert.deepEqual(extension.nlp, binding(resources.type).nlp); + assert.equal(binding(resources.type).status.default, "open"); }); test("rejects invalid contract setting patches", () => { @@ -216,10 +271,11 @@ test("projects configured mappings and status vocabularies into the type", () => defaults: { status: "todo" }, }, }); - const extension = resources.type["x-tasknotes"]; + const taskImplementation = implementation(resources.type); + const extension = taskImplementation.binding; const schema = resources.type.schema.value; - assert.equal(extension.field_roles.completedDate, "finished_on"); + assert.equal(taskImplementation.fields.completedDate, "finished_on"); assert.deepEqual(extension.status.completed_values, ["shipped"]); assert.deepEqual(schema.properties.state, { enum: ["todo", "shipped"], @@ -231,8 +287,8 @@ test("projects configured mappings and status vocabularies into the type", () => test("recovers vocabularies from schema enums in older generated types", () => { const resources = buildTaskNotesMdbaseResources(); const legacy = structuredClone(resources.type); - delete legacy["x-tasknotes"].status.values; - delete legacy["x-tasknotes"].priority.values; + delete binding(legacy).status.values; + delete binding(legacy).priority.values; const config = resolveTaskNotesModelConfigFromMdbaseType(legacy); assert.deepEqual(config.statuses.map(({ value }) => value), [ @@ -365,7 +421,7 @@ test("projects and resolves the complete portable TaskNotes settings snapshot", occurrenceTemplatePath: "Templates/Occurrence.md", }, }); - const extension = resources.type["x-tasknotes"]; + const extension = binding(resources.type); const resolved = resolveTaskNotesModelConfigFromMdbaseType(resources.type); assert.deepEqual(resources.type.match, { where: { isTask: { eq: true } } }); @@ -437,6 +493,11 @@ test("emits a disclosed coercion-compatible schema for migrated v0.2 collections assert.deepEqual(resources.type["x-legacy-v0.2"], { coercion_compatible_schema: true, }); - assert.equal(resources.type["x-tasknotes"].generator.legacy_compatibility, true); - assert.ok(resources.type["x-tasknotes"].generator.managed_fields.includes("title")); + assert.equal( + resources.type["x-tasknotes-generator"].legacy_compatibility, + true + ); + assert.ok( + resources.type["x-tasknotes-generator"].managed_fields.includes("title") + ); });