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..0010c26 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.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tasknotes/model", - "version": "0.3.0-rc.11", + "version": "0.3.0-rc.13", "license": "MIT", "dependencies": { "rrule": "^2.8.1", diff --git a/package.json b/package.json index da06fac..afe27df 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.13", "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..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.3 record contract.", + "description": "The storage-neutral record view exposed by the tasknotes.task 0.3.0-rc.5 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": "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", "uniqueItems": true, diff --git a/src/mapping.ts b/src/mapping.ts index efa6bcf..38fa60f 100644 --- a/src/mapping.ts +++ b/src/mapping.ts @@ -125,6 +125,8 @@ export function mapTaskFromFrontmatter( if (frontmatter[mapping.projects] !== undefined) { mapped.projects = normalizeStringArrayValue(frontmatter[mapping.projects]); } + const assignees = readAssigneeIds(frontmatter[mapping.assignees]); + if (assignees) mapped.assignees = assignees; if (frontmatter[mapping.attachments] !== undefined) { mapped.attachments = normalizeAttachmentList(frontmatter[mapping.attachments]); } @@ -268,6 +270,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; } @@ -504,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/mdbase.ts b/src/mdbase.ts index d23d2fd..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.10"; +export const TASKNOTES_MDBASE_PACK_VERSION = "0.3.0-rc.15"; export const TASKNOTES_CONTRACT_DIGEST = - "sha256:48b9017ac0896ffbdee4d14fc0325ef86916bf8789da4ac8a4cd246435887155"; + "sha256:dde71d0d776aa0069ee627fe3c10b1a20f87ba36929a16790598ebfb4e8ebf04"; export interface TaskNotesMdbaseTypeSettingsPatch { defaultStatus?: string; @@ -477,6 +477,13 @@ export function buildTaskNotesMdbaseResources( arraySchema(stringSchema({}, legacyCompatibility), legacyCompatibility), { links: [{ suffix: "[]", targetType: "any" }] } ); + // 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, @@ -732,7 +739,7 @@ export function buildTaskNotesMdbaseResources( const type: Record = { kind: "mdbase.type", name: typeName, - version: 1, + version: 3, description: "A task managed by TaskNotes.", match: buildMatch(modelConfig), schema: { diff --git a/src/operations.ts b/src/operations.ts index 9007a58..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,6 +973,7 @@ export function specFrontmatterToTaskInfo( contexts: getStringArray(frontmatter.contexts), projects: getStringArray(frontmatter.projects), attachments: getStringArray(frontmatter.attachments), + assignees: readAssigneeIds(frontmatter.assignees), timeEstimate: typeof frontmatter.timeEstimate === "number" ? frontmatter.timeEstimate : undefined, blockedBy: normalizeBlockedByValue(frontmatter.blockedBy), @@ -1007,6 +1009,7 @@ export function taskInfoToSpecFields(task: Partial): Record { + 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); + 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 task contract declares assignments as links resolved by the collection", () => { + const resources = buildTaskNotesMdbaseResources(); + 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"); + // 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", () => { + 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); + } +}); diff --git a/test/mdbase.test.mjs b/test/mdbase.test.mjs index dc72982..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.3" + 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.3"); + 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.3"); + 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.3", + version: "0.3.0-rc.5", digest: pack.provides[0].digest, }, ]);