From af6d3f6f6b246c395f5bacce02cb062795ef445b Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sun, 26 Jul 2026 22:22:23 +1000 Subject: [PATCH 1/2] add portable NLP trigger contract --- src/config.ts | 5 +++++ src/mdbase.ts | 39 +++++++++++++++++++++++++++++++++++++++ src/types.ts | 11 +++++++++++ test/mdbase.test.mjs | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+) diff --git a/src/config.ts b/src/config.ts index 0c27b3b..73904bb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -53,6 +53,11 @@ export function resolveModelConfig(input: Partial = {}): T recurrence: { ...DEFAULT_MODEL_CONFIG.recurrence, ...input.recurrence }, occurrences: { ...DEFAULT_MODEL_CONFIG.occurrences, ...input.occurrences }, timeTracking: { ...DEFAULT_MODEL_CONFIG.timeTracking, ...input.timeTracking }, + nlp: input.nlp + ? { + triggers: input.nlp.triggers.map((trigger) => ({ ...trigger })), + } + : undefined, }; } diff --git a/src/mdbase.ts b/src/mdbase.ts index 3b355b6..3a484f8 100644 --- a/src/mdbase.ts +++ b/src/mdbase.ts @@ -427,6 +427,17 @@ export function buildTaskNotesMdbaseResources( time_tracking: { auto_stop_on_complete: modelConfig.timeTracking.autoStopOnComplete, }, + ...(modelConfig.nlp + ? { + nlp: { + triggers: modelConfig.nlp.triggers.map((trigger) => ({ + property_id: trigger.propertyId, + trigger: trigger.trigger, + enabled: trigger.enabled, + })), + }, + } + : {}), templating: { enabled: templateEnabled, ...(templateEnabled ? { template_path: templatePath } : {}), @@ -554,6 +565,7 @@ 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 nlp = isRecord(extension.nlp) ? extension.nlp : {}; const defaultStatus = stringValue(status.default) ?? base.defaults.status; const defaultPriority = stringValue(priority.default) ?? base.defaults.priority; @@ -604,7 +616,34 @@ export function resolveTaskNotesModelConfigFromMdbaseType( booleanValue(timeTracking.auto_stop_on_complete) ?? base.timeTracking.autoStopOnComplete, }, + nlp: resolveNlpConfig(nlp, base.nlp), + }); +} + +function resolveNlpConfig( + value: Record, + fallback: TaskNotesModelConfig["nlp"] +): TaskNotesModelConfig["nlp"] { + if (!Array.isArray(value.triggers)) { + return fallback + ? { triggers: fallback.triggers.map((trigger) => ({ ...trigger })) } + : undefined; + } + const triggers = value.triggers.flatMap((raw) => { + if (!isRecord(raw)) return []; + const propertyId = + stringValue(raw.property_id) ?? stringValue(raw.propertyId); + const trigger = stringValue(raw.trigger); + if (!propertyId || !trigger) return []; + return [ + { + propertyId, + trigger, + enabled: booleanValue(raw.enabled) ?? true, + }, + ]; }); + return { triggers }; } function validateVocabulary( diff --git a/src/types.ts b/src/types.ts index cf407eb..9be9957 100644 --- a/src/types.ts +++ b/src/types.ts @@ -177,6 +177,16 @@ export interface UserMappedField { defaultValue?: string | number | boolean | string[]; } +export interface NlpTriggerConfig { + propertyId: string; + trigger: string; + enabled: boolean; +} + +export interface NlpConfig { + triggers: NlpTriggerConfig[]; +} + export type HideIdentifyingTagsMode = "all" | "exact-only"; export interface TaskIdentificationConfig { @@ -222,6 +232,7 @@ export interface TaskNotesModelConfig { recurrence: RecurrenceConfig; occurrences: OccurrenceConfig; timeTracking: TimeTrackingConfig; + nlp?: NlpConfig; } export interface TaskDocument { diff --git a/test/mdbase.test.mjs b/test/mdbase.test.mjs index 51821f9..9ef9e35 100644 --- a/test/mdbase.test.mjs +++ b/test/mdbase.test.mjs @@ -43,6 +43,38 @@ test("builds one canonical TaskNotes and mdbase collection contract", () => { assert.deepEqual(YAML.parse(typeFrontmatter[1]), resources.type); }); +test("round-trips optional NLP trigger settings through the TaskNotes contract", () => { + const resources = buildTaskNotesMdbaseResources({ + modelConfig: { + nlp: { + triggers: [ + { propertyId: "tags", trigger: "#", enabled: true }, + { propertyId: "priority", trigger: "!", enabled: false }, + { propertyId: "energy", trigger: "~", enabled: true }, + ], + }, + }, + }); + + assert.deepEqual(resources.type["x-tasknotes"].nlp, { + triggers: [ + { property_id: "tags", trigger: "#", enabled: true }, + { property_id: "priority", trigger: "!", enabled: false }, + { property_id: "energy", trigger: "~", enabled: true }, + ], + }); + assert.deepEqual( + resolveTaskNotesModelConfigFromMdbaseType(resources.type).nlp, + { + triggers: [ + { propertyId: "tags", trigger: "#", enabled: true }, + { propertyId: "priority", trigger: "!", enabled: false }, + { propertyId: "energy", trigger: "~", enabled: true }, + ], + } + ); +}); + test("loads configured mappings and vocabularies from an mdbase task type", () => { const resources = buildTaskNotesMdbaseResources({ modelConfig: { From fb77c8252bc0f5cb9c985c85ae58a8dd3637eb36 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sun, 26 Jul 2026 22:54:14 +1000 Subject: [PATCH 2/2] add portable task model settings patcher --- package-lock.json | 4 +- package.json | 2 +- src/mdbase.ts | 206 +++++++++++++++++++++++++++++++++++++++++++ test/mdbase.test.mjs | 108 +++++++++++++++++++++++ 4 files changed, 317 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 586a4c0..311eb11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tasknotes/model", - "version": "0.3.0-rc.2", + "version": "0.3.0-rc.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tasknotes/model", - "version": "0.3.0-rc.2", + "version": "0.3.0-rc.3", "license": "MIT", "dependencies": { "@callumalpass/mdbase-runtime": "0.1.0-rc.1", diff --git a/package.json b/package.json index 29fe1b7..1abb911 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tasknotes/model", - "version": "0.3.0-rc.2", + "version": "0.3.0-rc.3", "description": "TaskNotes model, mapping, validation, recurrence, and operation-planning reference implementation.", "license": "MIT", "type": "module", diff --git a/src/mdbase.ts b/src/mdbase.ts index 3a484f8..942bf85 100644 --- a/src/mdbase.ts +++ b/src/mdbase.ts @@ -81,6 +81,42 @@ export interface TaskNotesMdbaseResources { modelConfig: TaskNotesModelConfig; } +export interface TaskNotesMdbaseTypeSettingsPatch { + defaultStatus?: string; + defaultPriority?: string; + recurrence?: { + maintainDueDateOffset?: boolean; + resetCheckboxesOnRecurrence?: boolean; + }; + occurrences?: { + defaultMaterialization?: "manual" | "on_completion" | "rolling"; + defaultNextTrigger?: "completion" | "completion_or_skip"; + pastHorizon?: string; + futureHorizon?: string; + }; + timeTracking?: { + autoStopOnComplete?: boolean; + }; + links?: { + writeFormat?: "wikilink" | "markdown"; + }; + archive?: { + moveOnArchive?: boolean; + folder?: string; + }; + templating?: { + enabled?: boolean; + templatePath?: string; + }; + statusAutomation?: Record< + string, + { + autoArchive?: boolean; + autoArchiveDelay?: number; + } + >; +} + type LifecycleEvent = "on_create" | "on_update"; interface FieldOptions { @@ -92,6 +128,147 @@ interface FieldOptions { links?: Array<{ suffix?: string; targetType: "task" | "any" }>; } +/** + * Patch portable TaskNotes model settings in an existing mdbase type. + * + * The patch is deliberately constrained to settings whose schema vocabulary + * does not change. Custom properties, collection paths, lifecycle rules, and + * host extensions remain untouched. + */ +export function patchTaskNotesMdbaseTypeSettings( + type: Record, + patch: TaskNotesMdbaseTypeSettingsPatch +): Record { + const result = cloneValue(type) as Record; + const extension = isRecord(result["x-tasknotes"]) ? result["x-tasknotes"] : {}; + if (extension.contract !== "tasknotes.task") { + throw new Error("The mdbase type is not a TaskNotes task contract."); + } + result["x-tasknotes"] = extension; + 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 properties = isRecord(isRecord(result.schema) ? result.schema.value : undefined) + ? (result.schema as Record).value.properties + : undefined; + const collection = isRecord(result.collection) ? result.collection : {}; + const readDefaults = isRecord(collection.read_defaults) + ? collection.read_defaults + : {}; + collection.read_defaults = readDefaults; + result.collection = collection; + + if (patch.defaultStatus !== undefined) { + const value = requiredConfiguredValue( + patch.defaultStatus, + model.statuses.map((entry) => entry.value), + "default status" + ); + status.default = value; + readDefaults[model.fieldMapping.status] = value; + if (isRecord(properties)) + applySchemaDefault(properties[model.fieldMapping.status], value); + } + if (patch.defaultPriority !== undefined) { + const value = requiredConfiguredValue( + patch.defaultPriority, + model.priorities.map((entry) => entry.value), + "default priority" + ); + priority.default = value; + readDefaults[model.fieldMapping.priority] = value; + if (isRecord(properties)) + applySchemaDefault(properties[model.fieldMapping.priority], value); + } + if (patch.recurrence?.maintainDueDateOffset !== undefined) + recurrence.maintain_due_date_offset = + patch.recurrence.maintainDueDateOffset; + if (patch.recurrence?.resetCheckboxesOnRecurrence !== undefined) + recurrence.reset_body_checkboxes = + patch.recurrence.resetCheckboxesOnRecurrence; + if (patch.occurrences?.defaultMaterialization !== undefined) { + occurrences.default_materialization = + patch.occurrences.defaultMaterialization; + readDefaults[model.fieldMapping.occurrenceMaterialization] = + patch.occurrences.defaultMaterialization; + } + if (patch.occurrences?.defaultNextTrigger !== undefined) { + occurrences.default_next_trigger = patch.occurrences.defaultNextTrigger; + readDefaults[model.fieldMapping.occurrenceNextTrigger] = + patch.occurrences.defaultNextTrigger; + } + if (patch.occurrences?.pastHorizon !== undefined) + occurrences.past_horizon = requiredDuration( + patch.occurrences.pastHorizon, + "past occurrence horizon" + ); + if (patch.occurrences?.futureHorizon !== undefined) + occurrences.future_horizon = requiredDuration( + patch.occurrences.futureHorizon, + "future occurrence horizon" + ); + if (patch.timeTracking?.autoStopOnComplete !== undefined) + timeTracking.auto_stop_on_complete = + patch.timeTracking.autoStopOnComplete; + if (patch.links?.writeFormat !== undefined) + links.write_format = patch.links.writeFormat; + if (patch.archive?.moveOnArchive !== undefined) + archive.move_on_archive = patch.archive.moveOnArchive; + if (patch.archive?.folder !== undefined) + archive.folder = cleanPath(patch.archive.folder, "archive folder"); + if (patch.templating?.templatePath !== undefined) { + const path = patch.templating.templatePath.trim(); + if (path) templating.template_path = cleanPath(path, "template path"); + else delete templating.template_path; + } + if (patch.templating?.enabled !== undefined) { + if ( + patch.templating.enabled && + !stringValue(templating.template_path) + ) + throw new Error( + "template path must be set before task templating is enabled." + ); + templating.enabled = patch.templating.enabled; + } + if (patch.statusAutomation) { + const supplied = new Map(Object.entries(patch.statusAutomation)); + const definitions = definitionMap(status.definitions); + for (const value of supplied.keys()) { + if (!model.statuses.some((candidate) => candidate.value === value)) + throw new Error(`Unknown task status "${value}".`); + } + status.definitions = model.statuses.map((entry) => { + const automation = supplied.get(entry.value); + const definition = { + ...(definitions.get(entry.value) ?? statusDefinition(entry)), + }; + if (automation?.autoArchive !== undefined) + definition.auto_archive = automation.autoArchive; + if (automation?.autoArchiveDelay !== undefined) { + if ( + !Number.isInteger(automation.autoArchiveDelay) || + automation.autoArchiveDelay < 0 + ) + throw new Error( + `Auto-archive delay for "${entry.value}" must be a non-negative integer.` + ); + definition.auto_archive_delay_minutes = + automation.autoArchiveDelay; + } + return definition; + }); + } + + return result; +} + /** * Build the canonical mdbase v0.3 collection files shared by every TaskNotes host. * @@ -993,6 +1170,35 @@ function definitionMap(value: unknown): Map> { return result; } +function contractSection( + extension: Record, + key: string +): Record { + const section = isRecord(extension[key]) ? extension[key] : {}; + extension[key] = section; + return section; +} + +function requiredConfiguredValue( + value: string, + allowed: readonly string[], + label: string +): string { + const normalized = value.trim(); + if (!allowed.includes(normalized)) + throw new Error( + `${label} must be one of: ${allowed.join(", ")}.` + ); + return normalized; +} + +function requiredDuration(value: string, label: string): string { + const normalized = value.trim().toUpperCase(); + if (!/^P(?=.+)(?:\d+(?:\.\d+)?Y)?(?:\d+(?:\.\d+)?M)?(?:\d+(?:\.\d+)?W)?(?:\d+(?:\.\d+)?D)?(?:T(?=.+)(?:\d+(?:\.\d+)?H)?(?:\d+(?:\.\d+)?M)?(?:\d+(?:\.\d+)?S)?)?$/.test(normalized)) + throw new Error(`${label} must be an ISO 8601 duration.`); + return normalized; +} + function addLifecycleValue( lifecycle: Record, event: LifecycleEvent, diff --git a/test/mdbase.test.mjs b/test/mdbase.test.mjs index 9ef9e35..d0c1543 100644 --- a/test/mdbase.test.mjs +++ b/test/mdbase.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import YAML from "yaml"; import { buildTaskNotesMdbaseResources, + patchTaskNotesMdbaseTypeSettings, resolveTaskNotesModelConfigFromMdbaseType, } from "../dist/esm/mdbase.js"; @@ -75,6 +76,113 @@ test("round-trips optional NLP trigger settings through the TaskNotes contract", ); }); +test("patches portable model settings without replacing custom type content", () => { + const resources = buildTaskNotesMdbaseResources({ + modelConfig: { + nlp: { + triggers: [ + { propertyId: "contexts", trigger: "@", enabled: true }, + ], + }, + }, + }); + resources.type.schema.value.properties.client = { + type: "string", + description: "Preserve me", + }; + resources.type["x-host"] = { custom: true }; + + const patched = patchTaskNotesMdbaseTypeSettings(resources.type, { + defaultStatus: "in-progress", + defaultPriority: "high", + recurrence: { + maintainDueDateOffset: false, + resetCheckboxesOnRecurrence: true, + }, + occurrences: { + defaultMaterialization: "rolling", + defaultNextTrigger: "completion_or_skip", + pastHorizon: "p2d", + futureHorizon: "P30D", + }, + timeTracking: { autoStopOnComplete: true }, + links: { writeFormat: "markdown" }, + archive: { moveOnArchive: true, folder: "Tasks/Archive" }, + templating: { + templatePath: "Templates/Task.md", + enabled: true, + }, + statusAutomation: { + done: { autoArchive: true, autoArchiveDelay: 15 }, + }, + }); + const extension = patched["x-tasknotes"]; + const resolved = resolveTaskNotesModelConfigFromMdbaseType(patched); + + assert.equal(resolved.defaults.status, "in-progress"); + assert.equal(resolved.defaults.priority, "high"); + assert.equal(resolved.recurrence.maintainDueDateOffset, false); + assert.equal(resolved.recurrence.resetCheckboxesOnRecurrence, true); + assert.equal(resolved.occurrences.defaultMaterialization, "rolling"); + assert.equal(resolved.occurrences.defaultNextTrigger, "completion_or_skip"); + assert.equal(resolved.occurrences.pastHorizon, "P2D"); + assert.equal(resolved.occurrences.futureHorizon, "P30D"); + 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", + }); + assert.equal(extension.templating.enabled, true); + assert.equal(extension.templating.template_path, "Templates/Task.md"); + assert.equal( + extension.status.definitions.find(({ value }) => value === "done") + .auto_archive_delay_minutes, + 15 + ); + assert.equal(patched.schema.value.properties.status.default, "in-progress"); + assert.equal(patched.schema.value.properties.priority.default, "high"); + assert.equal(patched.collection.read_defaults.status, "in-progress"); + assert.equal( + patched.collection.read_defaults.occurrence_materialization, + "rolling" + ); + assert.deepEqual(patched.schema.value.properties.client, { + type: "string", + 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"); +}); + +test("rejects invalid contract setting patches", () => { + const type = buildTaskNotesMdbaseResources().type; + assert.throws( + () => + patchTaskNotesMdbaseTypeSettings(type, { + defaultStatus: "missing", + }), + /default status/ + ); + assert.throws( + () => + patchTaskNotesMdbaseTypeSettings(type, { + templating: { enabled: true }, + }), + /template path/ + ); + assert.throws( + () => + patchTaskNotesMdbaseTypeSettings(type, { + occurrences: { futureHorizon: "tomorrow" }, + }), + /ISO 8601/ + ); +}); + test("loads configured mappings and vocabularies from an mdbase task type", () => { const resources = buildTaskNotesMdbaseResources({ modelConfig: {