From 7fa5d513a6745b274b7e566086ec01f2555f8a44 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 15 Sep 2026 00:57:22 +1000 Subject: [PATCH 1/3] Support portable assignees in mapped tasks and occurrences --- README.md | 2 ++ package-lock.json | 4 ++-- package.json | 2 +- src/config.ts | 1 + src/defaults.ts | 1 + src/generated/tasknotes-data-contract.ts | 12 ++++++++++- src/mapping.ts | 6 ++++++ src/mdbase.ts | 9 +++++--- src/operations.ts | 7 ++++++ src/schema.ts | 1 + src/types.ts | 6 +++++- test/assignments.test.mjs | 27 ++++++++++++++++++++++++ test/mdbase.test.mjs | 8 +++---- 13 files changed, 74 insertions(+), 12 deletions(-) create mode 100644 test/assignments.test.mjs diff --git a/README.md b/README.md index 4bc4970..3163d3a 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ It intentionally contains no Obsidian API usage, no vault IO, no process exits, - TaskNotes task, config, field mapping, status, priority, recurrence, and time-entry types - default model configuration - TaskNotes frontmatter mapping and normalization +- portable person-ID assignments, including custom field mappings, explicit + clearing, unresolved-reference preservation, and occurrence inheritance - attachment-list normalization, safe collection-path resolution, canonical references, and validation - date parsing, date comparison, and storage-date semantics diff --git a/package-lock.json b/package-lock.json index 81e6db5..c855ed4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tasknotes/model", - "version": "0.3.0-rc.11", + "version": "0.3.0-rc.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tasknotes/model", - "version": "0.3.0-rc.11", + "version": "0.3.0-rc.12", "license": "MIT", "dependencies": { "rrule": "^2.8.1", diff --git a/package.json b/package.json index da06fac..30ab801 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tasknotes/model", - "version": "0.3.0-rc.11", + "version": "0.3.0-rc.12", "description": "TaskNotes model, mapping, validation, recurrence, and operation-planning reference implementation.", "license": "MIT", "type": "module", diff --git a/src/config.ts b/src/config.ts index 1982472..90890f8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -19,6 +19,7 @@ export const ALL_FIELD_ROLES: FieldRole[] = [ "contexts", "projects", "attachments", + "assignees", "timeEstimate", "dateCreated", "dateModified", diff --git a/src/defaults.ts b/src/defaults.ts index 9d8f141..9571ffc 100644 --- a/src/defaults.ts +++ b/src/defaults.ts @@ -14,6 +14,7 @@ export const DEFAULT_FIELD_MAPPING: FieldMapping = { contexts: "contexts", projects: "projects", attachments: "attachments", + assignees: "assignees", timeEstimate: "timeEstimate", completedDate: "completedDate", dateCreated: "dateCreated", diff --git a/src/generated/tasknotes-data-contract.ts b/src/generated/tasknotes-data-contract.ts index 0894b3b..c57848b 100644 --- a/src/generated/tasknotes-data-contract.ts +++ b/src/generated/tasknotes-data-contract.ts @@ -5,7 +5,7 @@ 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.3.0-rc.3 record contract.", + "description": "The storage-neutral record view exposed by the tasknotes.task 0.3.0-rc.4 record contract.", "type": "object", "required": [ "status", @@ -65,6 +65,16 @@ export const TASKNOTES_TASK_SCHEMA = { "type": "string" } }, + "assignees": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "pattern": "\\S" + }, + "description": "Stable mdbase.person IDs. These references never grant collection access." + }, "attachments": { "type": "array", "uniqueItems": true, diff --git a/src/mapping.ts b/src/mapping.ts index efa6bcf..6e23266 100644 --- a/src/mapping.ts +++ b/src/mapping.ts @@ -125,6 +125,11 @@ export function mapTaskFromFrontmatter( if (frontmatter[mapping.projects] !== undefined) { mapped.projects = normalizeStringArrayValue(frontmatter[mapping.projects]); } + if (frontmatter[mapping.assignees] !== undefined) { + mapped.assignees = Array.isArray(frontmatter[mapping.assignees]) + ? (frontmatter[mapping.assignees] as unknown[]).filter((value): value is string => typeof value === "string") + : []; + } if (frontmatter[mapping.attachments] !== undefined) { mapped.attachments = normalizeAttachmentList(frontmatter[mapping.attachments]); } @@ -268,6 +273,7 @@ export function mapTaskToFrontmatter( if (taskData.projects !== undefined && (!Array.isArray(taskData.projects) || taskData.projects.length > 0)) { frontmatter[mapping.projects] = taskData.projects; } + if (taskData.assignees !== undefined) frontmatter[mapping.assignees] = [...taskData.assignees]; if (taskData.attachments !== undefined && taskData.attachments.length > 0) { frontmatter[mapping.attachments] = taskData.attachments; } diff --git a/src/mdbase.ts b/src/mdbase.ts index d23d2fd..fe12699 100644 --- a/src/mdbase.ts +++ b/src/mdbase.ts @@ -134,9 +134,9 @@ export interface TaskNotesMdbaseTypePack { } /** Version of the generated definition bundle, independent of contract version. */ -export const TASKNOTES_MDBASE_PACK_VERSION = "0.3.0-rc.10"; +export const TASKNOTES_MDBASE_PACK_VERSION = "0.3.0-rc.13"; export const TASKNOTES_CONTRACT_DIGEST = - "sha256:48b9017ac0896ffbdee4d14fc0325ef86916bf8789da4ac8a4cd246435887155"; + "sha256:6948f659df6af05ef95aa893f1d57849032546e8c6cfee8afd5afe566e4ceeb0"; export interface TaskNotesMdbaseTypeSettingsPatch { defaultStatus?: string; @@ -477,6 +477,9 @@ export function buildTaskNotesMdbaseResources( arraySchema(stringSchema({}, legacyCompatibility), legacyCompatibility), { links: [{ suffix: "[]", targetType: "any" }] } ); + addField("assignees", mapping.assignees, { + type: "array", items: { type: "string", minLength: 1, pattern: "\\S" }, uniqueItems: true, + }); addField( "attachments", mapping.attachments, @@ -732,7 +735,7 @@ export function buildTaskNotesMdbaseResources( const type: Record = { kind: "mdbase.type", name: typeName, - version: 1, + version: 2, description: "A task managed by TaskNotes.", match: buildMatch(modelConfig), schema: { diff --git a/src/operations.ts b/src/operations.ts index 9007a58..d8620d6 100644 --- a/src/operations.ts +++ b/src/operations.ts @@ -972,6 +972,7 @@ export function specFrontmatterToTaskInfo( contexts: getStringArray(frontmatter.contexts), projects: getStringArray(frontmatter.projects), attachments: getStringArray(frontmatter.attachments), + assignees: getStringArray(frontmatter.assignees), timeEstimate: typeof frontmatter.timeEstimate === "number" ? frontmatter.timeEstimate : undefined, blockedBy: normalizeBlockedByValue(frontmatter.blockedBy), @@ -1007,6 +1008,7 @@ export function taskInfoToSpecFields(task: Partial): Record { + const ids = ["Person_A", "person_missing"]; + const task = mapTaskFromFrontmatter(mapping, { title: "Assigned", owners: ids }, "tasks/assigned.md"); + assert.deepEqual(task.assignees, ids); + assert.deepEqual(mapTaskToFrontmatter(mapping, task).owners, ids); + assert.deepEqual(mapTaskToFrontmatter(mapping, { ...task, assignees: [] }).owners, []); +}); + +test("ordinary task updates retain unresolved assignments and explicitly clear them", () => { + const originalTask = { title: "Assigned", path: "tasks/assigned.md", status: "open", priority: "normal", archived: false, assignees: ["person_missing"] }; + const options = { originalTask, fieldMapping: mapping, now: "2026-09-13T12:00:00Z", currentDateString: "2026-09-13" }; + assert.deepEqual(buildTaskUpdatePlan({ ...options, updates: { title: "Renamed" } }).updatedTask.assignees, ["person_missing"]); + assert.deepEqual(buildTaskUpdatePlan({ ...options, updates: { assignees: [] } }).updatedTask.assignees, []); +}); + +test("the new task contract declares assignments without pretending they are file links", () => { + const resources = buildTaskNotesMdbaseResources(); + assert.equal(resources.contract.version, "0.3.0-rc.4"); + assert.equal(resources.taskSchema.properties.assignees.uniqueItems, true); + assert.equal(resources.type.implements[0].fields.assignees, "assignees"); + assert.equal(resources.type.collection.links["assignees[]"], undefined); +}); diff --git a/test/mdbase.test.mjs b/test/mdbase.test.mjs index dc72982..aca6a91 100644 --- a/test/mdbase.test.mjs +++ b/test/mdbase.test.mjs @@ -11,7 +11,7 @@ import { function implementation(type) { return type.implements.find( (entry) => - entry.contract === "tasknotes.task" && entry.version === "0.3.0-rc.3" + entry.contract === "tasknotes.task" && entry.version === "0.3.0-rc.4" ); } @@ -30,10 +30,10 @@ test("builds one canonical TaskNotes and mdbase collection contract", () => { assert.equal(resources.config.settings.contracts_folder, "_contracts"); assert.equal(resources.contract.id, "tasknotes.task"); assert.equal(resources.contract.contract_type, "record"); - assert.equal(resources.contract.version, "0.3.0-rc.3"); + assert.equal(resources.contract.version, "0.3.0-rc.4"); assert.ok(resources.contract.record_schema); assert.equal(taskImplementation.contract, "tasknotes.task"); - assert.equal(taskImplementation.version, "0.3.0-rc.3"); + assert.equal(taskImplementation.version, "0.3.0-rc.4"); assert.deepEqual(extension.profiles, [ "core-lite", "recurrence", @@ -102,7 +102,7 @@ test("packages the contract, implementation, and schemas as one digest-pinned ty assert.deepEqual(pack.provides, [ { id: "tasknotes.task", - version: "0.3.0-rc.3", + version: "0.3.0-rc.4", digest: pack.provides[0].digest, }, ]); From 64050f468ec553342613a1e025fe4057e7d5b32c Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sun, 27 Sep 2026 12:12:35 +1000 Subject: [PATCH 2/3] Leave unreadable assignee values untouched A single string is read as one person ID. Any other shape that is not a list of strings is left unread, so saving an unrelated edit no longer rewrites it to an empty list. --- src/mapping.ts | 17 ++++++++++++----- src/operations.ts | 3 ++- test/assignments.test.mjs | 10 ++++++++++ 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/mapping.ts b/src/mapping.ts index 6e23266..38fa60f 100644 --- a/src/mapping.ts +++ b/src/mapping.ts @@ -125,11 +125,8 @@ export function mapTaskFromFrontmatter( if (frontmatter[mapping.projects] !== undefined) { mapped.projects = normalizeStringArrayValue(frontmatter[mapping.projects]); } - if (frontmatter[mapping.assignees] !== undefined) { - mapped.assignees = Array.isArray(frontmatter[mapping.assignees]) - ? (frontmatter[mapping.assignees] as unknown[]).filter((value): value is string => typeof value === "string") - : []; - } + const assignees = readAssigneeIds(frontmatter[mapping.assignees]); + if (assignees) mapped.assignees = assignees; if (frontmatter[mapping.attachments] !== undefined) { mapped.attachments = normalizeAttachmentList(frontmatter[mapping.attachments]); } @@ -510,6 +507,16 @@ function normalizeStringValue(value: unknown): string | undefined { return undefined; } +/** + * Person IDs from hand-edited frontmatter. A single ID is accepted as a list. + * Any other shape is left unread, so saving an unrelated edit never rewrites it. + */ +export function readAssigneeIds(value: unknown): string[] | undefined { + if (typeof value === "string") return [value]; + if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) return [...value]; + return undefined; +} + function normalizeStringArrayValue(value: unknown): string[] { return Array.isArray(value) ? value.map(String) : [String(value)]; } diff --git a/src/operations.ts b/src/operations.ts index d8620d6..cd343d7 100644 --- a/src/operations.ts +++ b/src/operations.ts @@ -20,6 +20,7 @@ import { mapTaskToFrontmatter, normalizeDependencyEntry, parseLinkToPath, + readAssigneeIds, serializeDependencies, } from "./mapping"; import type { @@ -972,7 +973,7 @@ export function specFrontmatterToTaskInfo( contexts: getStringArray(frontmatter.contexts), projects: getStringArray(frontmatter.projects), attachments: getStringArray(frontmatter.attachments), - assignees: getStringArray(frontmatter.assignees), + assignees: readAssigneeIds(frontmatter.assignees), timeEstimate: typeof frontmatter.timeEstimate === "number" ? frontmatter.timeEstimate : undefined, blockedBy: normalizeBlockedByValue(frontmatter.blockedBy), diff --git a/test/assignments.test.mjs b/test/assignments.test.mjs index 6d52310..148d443 100644 --- a/test/assignments.test.mjs +++ b/test/assignments.test.mjs @@ -25,3 +25,13 @@ test("the new task contract declares assignments without pretending they are fil assert.equal(resources.type.implements[0].fields.assignees, "assignees"); assert.equal(resources.type.collection.links["assignees[]"], undefined); }); + +test("hand-edited assignee values are read as IDs or left exactly as written", () => { + const scalar = mapTaskFromFrontmatter(mapping, { title: "One", owners: "alex" }, "tasks/one.md"); + assert.deepEqual(scalar.assignees, ["alex"]); + for (const owners of [["alex", 5], { id: "alex" }, 7]) { + const task = mapTaskFromFrontmatter(mapping, { title: "Odd", owners }, "tasks/odd.md"); + assert.equal(task.assignees, undefined); + assert.equal(Object.hasOwn(mapTaskToFrontmatter(mapping, task), "owners"), false); + } +}); From 70e9b9a8d7811ff4a9798357f44b56caef5302ae Mon Sep 17 00:00:00 2001 From: callumalpass Date: Sun, 27 Sep 2026 13:41:17 +1000 Subject: [PATCH 3/3] Declare assignees as links for contract rc.5 The generated starter type (v3) declares assignees[] in collection.links without a target type, since several local types may implement mdbase.person. Pins the rc.5 contract digest and pack rc.15. --- package-lock.json | 4 ++-- package.json | 2 +- src/generated/tasknotes-data-contract.ts | 4 ++-- src/mdbase.ts | 16 ++++++++++------ src/types.ts | 2 +- test/assignments.test.mjs | 12 ++++++++---- test/mdbase.test.mjs | 8 ++++---- 7 files changed, 28 insertions(+), 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index c855ed4..0010c26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tasknotes/model", - "version": "0.3.0-rc.12", + "version": "0.3.0-rc.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tasknotes/model", - "version": "0.3.0-rc.12", + "version": "0.3.0-rc.13", "license": "MIT", "dependencies": { "rrule": "^2.8.1", diff --git a/package.json b/package.json index 30ab801..afe27df 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tasknotes/model", - "version": "0.3.0-rc.12", + "version": "0.3.0-rc.13", "description": "TaskNotes model, mapping, validation, recurrence, and operation-planning reference implementation.", "license": "MIT", "type": "module", diff --git a/src/generated/tasknotes-data-contract.ts b/src/generated/tasknotes-data-contract.ts index c57848b..f7568bd 100644 --- a/src/generated/tasknotes-data-contract.ts +++ b/src/generated/tasknotes-data-contract.ts @@ -5,7 +5,7 @@ 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.3.0-rc.4 record contract.", + "description": "The storage-neutral record view exposed by the tasknotes.task 0.3.0-rc.5 record contract.", "type": "object", "required": [ "status", @@ -73,7 +73,7 @@ export const TASKNOTES_TASK_SCHEMA = { "minLength": 1, "pattern": "\\S" }, - "description": "Stable mdbase.person IDs. These references never grant collection access." + "description": "Links to records implementing mdbase.person, such as [[Alex Rivera]]. Implementing types declare this field in collection.links. These references never grant collection access." }, "attachments": { "type": "array", diff --git a/src/mdbase.ts b/src/mdbase.ts index fe12699..b40d2d4 100644 --- a/src/mdbase.ts +++ b/src/mdbase.ts @@ -134,9 +134,9 @@ export interface TaskNotesMdbaseTypePack { } /** Version of the generated definition bundle, independent of contract version. */ -export const TASKNOTES_MDBASE_PACK_VERSION = "0.3.0-rc.13"; +export const TASKNOTES_MDBASE_PACK_VERSION = "0.3.0-rc.15"; export const TASKNOTES_CONTRACT_DIGEST = - "sha256:6948f659df6af05ef95aa893f1d57849032546e8c6cfee8afd5afe566e4ceeb0"; + "sha256:dde71d0d776aa0069ee627fe3c10b1a20f87ba36929a16790598ebfb4e8ebf04"; export interface TaskNotesMdbaseTypeSettingsPatch { defaultStatus?: string; @@ -477,9 +477,13 @@ export function buildTaskNotesMdbaseResources( arraySchema(stringSchema({}, legacyCompatibility), legacyCompatibility), { links: [{ suffix: "[]", targetType: "any" }] } ); - addField("assignees", mapping.assignees, { - type: "array", items: { type: "string", minLength: 1, pattern: "\\S" }, uniqueItems: true, - }); + // Links to mdbase.person records, resolved by the collection's own link rules. + addField( + "assignees", + mapping.assignees, + { type: "array", items: { type: "string", minLength: 1, pattern: "\\S" }, uniqueItems: true }, + { links: [{ suffix: "[]" }] } + ); addField( "attachments", mapping.attachments, @@ -735,7 +739,7 @@ export function buildTaskNotesMdbaseResources( const type: Record = { kind: "mdbase.type", name: typeName, - version: 2, + version: 3, description: "A task managed by TaskNotes.", match: buildMatch(modelConfig), schema: { diff --git a/src/types.ts b/src/types.ts index d54ea66..6b69149 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -export const TASKNOTES_SPEC_VERSION = "0.3.0-rc.4"; +export const TASKNOTES_SPEC_VERSION = "0.3.0-rc.5"; export type JsonPrimitive = string | number | boolean | null; export type JsonValue = JsonPrimitive | JsonObject | JsonValue[]; diff --git a/test/assignments.test.mjs b/test/assignments.test.mjs index 148d443..095851e 100644 --- a/test/assignments.test.mjs +++ b/test/assignments.test.mjs @@ -4,7 +4,7 @@ import { DEFAULT_FIELD_MAPPING, mapTaskFromFrontmatter, mapTaskToFrontmatter, bu const mapping = { ...DEFAULT_FIELD_MAPPING, assignees: "owners" }; test("portable assignments preserve exact IDs through custom field mappings", () => { - const ids = ["Person_A", "person_missing"]; + const ids = ["[[People/Alex Rivera]]", "[[Former teammate]]"]; const task = mapTaskFromFrontmatter(mapping, { title: "Assigned", owners: ids }, "tasks/assigned.md"); assert.deepEqual(task.assignees, ids); assert.deepEqual(mapTaskToFrontmatter(mapping, task).owners, ids); @@ -18,12 +18,16 @@ test("ordinary task updates retain unresolved assignments and explicitly clear t assert.deepEqual(buildTaskUpdatePlan({ ...options, updates: { assignees: [] } }).updatedTask.assignees, []); }); -test("the new task contract declares assignments without pretending they are file links", () => { +test("the task contract declares assignments as links resolved by the collection", () => { const resources = buildTaskNotesMdbaseResources(); - assert.equal(resources.contract.version, "0.3.0-rc.4"); + assert.equal(resources.contract.version, "0.3.0-rc.5"); + assert.equal(resources.type.version, 3); assert.equal(resources.taskSchema.properties.assignees.uniqueItems, true); assert.equal(resources.type.implements[0].fields.assignees, "assignees"); - assert.equal(resources.type.collection.links["assignees[]"], undefined); + // No target_type: several local types may implement mdbase.person. + assert.deepEqual(resources.type.collection.links["assignees[]"], { validate_exists: false }); + const custom = buildTaskNotesMdbaseResources({ modelConfig: { fieldMapping: { ...DEFAULT_FIELD_MAPPING, assignees: "owners" } } }); + assert.deepEqual(custom.type.collection.links["owners[]"], { validate_exists: false }); }); test("hand-edited assignee values are read as IDs or left exactly as written", () => { diff --git a/test/mdbase.test.mjs b/test/mdbase.test.mjs index aca6a91..b8af74f 100644 --- a/test/mdbase.test.mjs +++ b/test/mdbase.test.mjs @@ -11,7 +11,7 @@ import { function implementation(type) { return type.implements.find( (entry) => - entry.contract === "tasknotes.task" && entry.version === "0.3.0-rc.4" + entry.contract === "tasknotes.task" && entry.version === "0.3.0-rc.5" ); } @@ -30,10 +30,10 @@ test("builds one canonical TaskNotes and mdbase collection contract", () => { assert.equal(resources.config.settings.contracts_folder, "_contracts"); assert.equal(resources.contract.id, "tasknotes.task"); assert.equal(resources.contract.contract_type, "record"); - assert.equal(resources.contract.version, "0.3.0-rc.4"); + assert.equal(resources.contract.version, "0.3.0-rc.5"); assert.ok(resources.contract.record_schema); assert.equal(taskImplementation.contract, "tasknotes.task"); - assert.equal(taskImplementation.version, "0.3.0-rc.4"); + assert.equal(taskImplementation.version, "0.3.0-rc.5"); assert.deepEqual(extension.profiles, [ "core-lite", "recurrence", @@ -102,7 +102,7 @@ test("packages the contract, implementation, and schemas as one digest-pinned ty assert.deepEqual(pack.provides, [ { id: "tasknotes.task", - version: "0.3.0-rc.4", + version: "0.3.0-rc.5", digest: pack.provides[0].digest, }, ]);