Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const ALL_FIELD_ROLES: FieldRole[] = [
"contexts",
"projects",
"attachments",
"assignees",
"timeEstimate",
"dateCreated",
"dateModified",
Expand Down
1 change: 1 addition & 0 deletions src/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const DEFAULT_FIELD_MAPPING: FieldMapping = {
contexts: "contexts",
projects: "projects",
attachments: "attachments",
assignees: "assignees",
timeEstimate: "timeEstimate",
completedDate: "completedDate",
dateCreated: "dateCreated",
Expand Down
12 changes: 11 additions & 1 deletion src/generated/tasknotes-data-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions src/mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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)];
}
Expand Down
13 changes: 10 additions & 3 deletions src/mdbase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -732,7 +739,7 @@ export function buildTaskNotesMdbaseResources(
const type: Record<string, unknown> = {
kind: "mdbase.type",
name: typeName,
version: 1,
version: 3,
description: "A task managed by TaskNotes.",
match: buildMatch(modelConfig),
schema: {
Expand Down
8 changes: 8 additions & 0 deletions src/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
mapTaskToFrontmatter,
normalizeDependencyEntry,
parseLinkToPath,
readAssigneeIds,
serializeDependencies,
} from "./mapping";
import type {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -1007,6 +1009,7 @@ export function taskInfoToSpecFields(task: Partial<TaskInfo>): Record<string, un
writeIfDefined(fields, "contexts", task.contexts);
writeIfDefined(fields, "projects", task.projects);
writeIfDefined(fields, "attachments", task.attachments);
writeIfDefined(fields, "assignees", task.assignees);
writeIfDefined(fields, "timeEstimate", task.timeEstimate);
writeIfDefined(fields, "blockedBy", task.blockedBy);
writeIfDefined(fields, "reminders", task.reminders);
Expand Down Expand Up @@ -1287,6 +1290,9 @@ function applySpecFieldsToTaskInfo(task: TaskInfo, fields: Record<string, unknow
if (Object.prototype.hasOwnProperty.call(fields, "timeEntries")) {
updatedTask.timeEntries = sanitizeTimeEntries(fields.timeEntries as TimeEntry[] | undefined);
}
if (Object.prototype.hasOwnProperty.call(fields, "assignees")) {
updatedTask.assignees = getStringArray(fields.assignees);
}
if (Object.prototype.hasOwnProperty.call(fields, "attachments")) {
updatedTask.attachments = getStringArray(fields.attachments);
}
Expand All @@ -1303,6 +1309,7 @@ function fieldNameForTaskProperty(fieldMapping: FieldMapping, property: keyof Ta
contexts: "contexts",
projects: "projects",
attachments: "attachments",
assignees: "assignees",
timeEstimate: "timeEstimate",
completedDate: "completedDate",
dateCreated: "dateCreated",
Expand Down Expand Up @@ -1376,6 +1383,7 @@ function buildInheritedOccurrenceTask(parentTask: TaskInfo, targetDate: string):
contexts: cloneArray(parentTask.contexts),
projects: cloneArray(parentTask.projects),
attachments: cloneArray(parentTask.attachments),
assignees: cloneArray(parentTask.assignees),
tags: cloneArray(parentTask.tags),
timeEstimate: parentTask.timeEstimate,
reminders: cloneObjectArray(parentTask.reminders),
Expand Down
1 change: 1 addition & 0 deletions src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export const taskInfoSchema = z.object({
contexts: z.array(z.string()).optional(),
projects: z.array(z.string()).optional(),
attachments: z.array(z.string().min(1)).optional(),
assignees: z.array(z.string().regex(/\S/)).optional(),
recurrence: z.string().optional(),
recurrence_anchor: z.enum(["scheduled", "completion"]).optional(),
complete_instances: z.array(z.string()).optional(),
Expand Down
6 changes: 5 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export const TASKNOTES_SPEC_VERSION = "0.3.0-rc.3";
export const TASKNOTES_SPEC_VERSION = "0.3.0-rc.5";

export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
Expand Down Expand Up @@ -52,6 +52,8 @@ export interface TaskInfo {
contexts?: string[];
projects?: string[];
attachments?: string[];
/** Stable mdbase.person IDs, not account subjects or file links. */
assignees?: string[];
recurrence?: string;
recurrence_anchor?: RecurrenceAnchor;
complete_instances?: string[];
Expand Down Expand Up @@ -113,6 +115,7 @@ export interface FieldMapping {
contexts: string;
projects: string;
attachments: string;
assignees: string;
timeEstimate: string;
completedDate: string;
dateCreated: string;
Expand Down Expand Up @@ -283,6 +286,7 @@ export type FieldRole =
| "contexts"
| "projects"
| "attachments"
| "assignees"
| "timeEstimate"
| "dateCreated"
| "dateModified"
Expand Down
41 changes: 41 additions & 0 deletions test/assignments.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DEFAULT_FIELD_MAPPING, mapTaskFromFrontmatter, mapTaskToFrontmatter, buildTaskUpdatePlan, buildTaskNotesMdbaseResources } from "../dist/esm/index.js";

const mapping = { ...DEFAULT_FIELD_MAPPING, assignees: "owners" };
test("portable assignments preserve exact IDs through custom field mappings", () => {
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);
}
});
8 changes: 4 additions & 4 deletions test/mdbase.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}

Expand All @@ -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",
Expand Down Expand Up @@ -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,
},
]);
Expand Down
Loading