diff --git a/package-lock.json b/package-lock.json index 017b4f8..222a970 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tasknotes/model", - "version": "0.3.0-rc.5", + "version": "0.3.0-rc.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tasknotes/model", - "version": "0.3.0-rc.5", + "version": "0.3.0-rc.7", "license": "MIT", "dependencies": { "rrule": "^2.8.1", diff --git a/package.json b/package.json index eae52c5..63eb66f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tasknotes/model", - "version": "0.3.0-rc.5", + "version": "0.3.0-rc.7", "description": "TaskNotes model, mapping, validation, recurrence, and operation-planning reference implementation.", "license": "MIT", "type": "module", @@ -51,6 +51,11 @@ "import": "./dist/esm/date.js", "require": "./dist/cjs/date.cjs" }, + "./attachments": { + "types": "./dist/types/attachments.d.ts", + "import": "./dist/esm/attachments.js", + "require": "./dist/cjs/attachments.cjs" + }, "./mapping": { "types": "./dist/types/mapping.d.ts", "import": "./dist/esm/mapping.js", diff --git a/src/attachments.ts b/src/attachments.ts new file mode 100644 index 0000000..c245e2c --- /dev/null +++ b/src/attachments.ts @@ -0,0 +1,138 @@ +export interface AttachmentReferenceIssue { + code: "invalid_attachment_reference" | "duplicate_attachment_reference"; + message: string; + index: number; + reference?: string; +} + +export interface AttachmentReferenceValidation { + valid: boolean; + issues: AttachmentReferenceIssue[]; +} + +/** + * Normalize scalar compatibility input to the canonical runtime list shape. + * Invalid entries are retained (trimmed) so validation can report them rather + * than silently changing task membership. + */ +export function normalizeAttachmentList(value: unknown): string[] | undefined { + if (value === null || value === undefined) return undefined; + const values = Array.isArray(value) ? value : [value]; + const normalized = values + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + return normalized.length > 0 ? normalized : undefined; +} + +/** Return a safe, normalized collection-relative path for one reference. */ +export function attachmentPathFromReference( + reference: string, + sourcePath = "" +): string | undefined { + let target = reference.trim(); + let format: "wikilink" | "markdown" | "path" = "path"; + const wikilink = target.match(/^\[\[([^|\]#]+)(?:#[^|\]]*)?(?:\|[^\]]*)?\]\]$/); + if (wikilink) { + format = "wikilink"; + target = wikilink[1].trim(); + } else { + const markdownLink = target.match(/^\[[^\]]*\]\(([^)#]+)(?:#[^)]*)?\)$/); + if (markdownLink) { + format = "markdown"; + target = markdownLink[1].trim(); + } + } + + try { + target = decodeURI(target); + } catch { + return undefined; + } + target = target.replace(/\\/g, "/"); + if (!target || /^[A-Za-z][A-Za-z\d+.-]*:/.test(target)) return undefined; + + const fromRoot = target.startsWith("/") || + (format === "wikilink" && !target.startsWith("./") && !target.startsWith("../")); + const sourceSegments = sourcePath.replace(/\\/g, "/").split("/").filter(Boolean); + if (sourceSegments.length > 0) sourceSegments.pop(); + const segments: string[] = fromRoot ? [] : sourceSegments; + for (const segment of target.replace(/^\/+/, "").split("/")) { + if (!segment || segment === ".") continue; + if (segment === "..") { + if (segments.length === 0) return undefined; + segments.pop(); + continue; + } + if (segment.includes("\0") || segment.includes("[") || segment.includes("]")) { + return undefined; + } + segments.push(segment); + } + if (segments.length === 0) return undefined; + + const filename = segments[segments.length - 1] ?? ""; + if (!/^.+\.[^.]+$/.test(filename)) return undefined; + return segments.join("/"); +} + +export function canonicalAttachmentReference(path: string): string { + const normalized = normalizeCollectionPath(path); + if (!normalized) throw new Error(`Invalid attachment path: ${path}`); + return `[[${normalized}]]`; +} + +export function validateAttachmentReferences( + attachments: readonly string[] | undefined, + sourcePath = "" +): AttachmentReferenceValidation { + const issues: AttachmentReferenceIssue[] = []; + const seen = new Set(); + for (const [index, reference] of (attachments ?? []).entries()) { + const path = attachmentPathFromReference(reference, sourcePath); + if (!path) { + issues.push({ + code: "invalid_attachment_reference", + message: "Attachment references must identify a collection file using an explicit extension", + index, + reference, + }); + continue; + } + if (seen.has(path)) { + issues.push({ + code: "duplicate_attachment_reference", + message: `Attachment target is listed more than once: ${path}`, + index, + reference, + }); + continue; + } + seen.add(path); + } + return { valid: issues.length === 0, issues }; +} + +function normalizeCollectionPath(path: string): string | undefined { + const normalized = path.trim().replace(/\\/g, "/").replace(/^\/+/, ""); + if (!normalized || normalized.startsWith("./") || normalized.startsWith("../")) { + return undefined; + } + if (/^[A-Za-z][A-Za-z\d+.-]*:/.test(normalized)) return undefined; + const segments = normalized.split("/"); + if ( + segments.some( + (segment) => + !segment || + segment === "." || + segment === ".." || + segment.includes("\0") || + segment.includes("[") || + segment.includes("]") + ) + ) { + return undefined; + } + const filename = segments[segments.length - 1] ?? ""; + return /^.+\.[^.]+$/.test(filename) ? segments.join("/") : undefined; +} diff --git a/src/config.ts b/src/config.ts index 73904bb..1982472 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,6 +18,7 @@ export const ALL_FIELD_ROLES: FieldRole[] = [ "tags", "contexts", "projects", + "attachments", "timeEstimate", "dateCreated", "dateModified", diff --git a/src/defaults.ts b/src/defaults.ts index b081b38..9d8f141 100644 --- a/src/defaults.ts +++ b/src/defaults.ts @@ -13,6 +13,7 @@ export const DEFAULT_FIELD_MAPPING: FieldMapping = { scheduled: "scheduled", contexts: "contexts", projects: "projects", + attachments: "attachments", timeEstimate: "timeEstimate", completedDate: "completedDate", dateCreated: "dateCreated", diff --git a/src/generated/tasknotes-data-contract.ts b/src/generated/tasknotes-data-contract.ts index 9c78135..f57be13 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.1 record contract.", + "description": "The storage-neutral record view exposed by the tasknotes.task 0.3.0-rc.2 record contract.", "type": "object", "required": [ "status", @@ -65,6 +65,14 @@ export const TASKNOTES_TASK_SCHEMA = { "type": "string" } }, + "attachments": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, "timeEstimate": { "type": "integer", "minimum": 0 @@ -190,7 +198,7 @@ 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.3.0-rc.1.", + "description": "Semantic configuration supplied by an mdbase type that implements tasknotes.task 0.3.0-rc.2.", "type": "object", "required": [ "profiles", @@ -227,6 +235,7 @@ export const TASKNOTES_TASK_BINDING_SCHEMA = { "enum": [ "dependencies", "reminders", + "attachments", "links", "time-tracking", "materialized-occurrences", diff --git a/src/index.ts b/src/index.ts index bdaa96b..07b38bd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ export * from "./types"; export * from "./defaults"; export * from "./config"; export * from "./date"; +export * from "./attachments"; export * from "./mapping"; export * from "./schema"; export * from "./recurrence"; diff --git a/src/mapping.ts b/src/mapping.ts index 7499cc6..efa6bcf 100644 --- a/src/mapping.ts +++ b/src/mapping.ts @@ -1,4 +1,5 @@ import { DEFAULT_FIELD_MAPPING, DEFAULT_PRIORITIES, DEFAULT_STATUSES } from "./defaults"; +import { normalizeAttachmentList } from "./attachments"; import { validateCompleteInstances } from "./date"; import type { FieldMapping, @@ -124,6 +125,9 @@ export function mapTaskFromFrontmatter( if (frontmatter[mapping.projects] !== undefined) { mapped.projects = normalizeStringArrayValue(frontmatter[mapping.projects]); } + if (frontmatter[mapping.attachments] !== undefined) { + mapped.attachments = normalizeAttachmentList(frontmatter[mapping.attachments]); + } if (frontmatter[mapping.timeEstimate] !== undefined) { mapped.timeEstimate = normalizeNumberValue(frontmatter[mapping.timeEstimate]); } @@ -264,6 +268,9 @@ export function mapTaskToFrontmatter( if (taskData.projects !== undefined && (!Array.isArray(taskData.projects) || taskData.projects.length > 0)) { frontmatter[mapping.projects] = taskData.projects; } + if (taskData.attachments !== undefined && taskData.attachments.length > 0) { + frontmatter[mapping.attachments] = taskData.attachments; + } if (taskData.timeEstimate !== undefined) frontmatter[mapping.timeEstimate] = taskData.timeEstimate; if (taskData.completedDate !== undefined) frontmatter[mapping.completedDate] = taskData.completedDate; if (taskData.recurrence !== undefined) frontmatter[mapping.recurrence] = taskData.recurrence; diff --git a/src/mdbase.ts b/src/mdbase.ts index da5142a..023b894 100644 --- a/src/mdbase.ts +++ b/src/mdbase.ts @@ -43,6 +43,7 @@ export const DEFAULT_TASKNOTES_MDBASE_PROFILES = [ export const DEFAULT_TASKNOTES_MDBASE_CAPABILITIES = [ "dependencies", "reminders", + "attachments", "links", "time-tracking", "materialized-occurrences", @@ -175,7 +176,7 @@ interface FieldOptions { defaultValue?: unknown; createValue?: Record; updateValue?: Record; - links?: Array<{ suffix?: string; targetType: "task" | "any" }>; + links?: Array<{ suffix?: string; targetType?: "task" | "any" }>; } /** @@ -433,7 +434,7 @@ export function buildTaskNotesMdbaseResources( continue; } links[path] = { - target_type: link.targetType, + ...(link.targetType ? { target_type: link.targetType } : {}), validate_exists: false, }; } @@ -470,6 +471,15 @@ export function buildTaskNotesMdbaseResources( arraySchema(stringSchema({}, legacyCompatibility), legacyCompatibility), { links: [{ suffix: "[]", targetType: "any" }] } ); + addField( + "attachments", + mapping.attachments, + { + ...arraySchema(stringSchema({ minLength: 1 }, legacyCompatibility), legacyCompatibility), + uniqueItems: true, + }, + { links: [{ suffix: "[]" }] } + ); addField( "timeEstimate", mapping.timeEstimate, diff --git a/src/operations.ts b/src/operations.ts index bacca28..d7de2ef 100644 --- a/src/operations.ts +++ b/src/operations.ts @@ -957,6 +957,7 @@ export function specFrontmatterToTaskInfo( tags: getStringArray(frontmatter.tags), contexts: getStringArray(frontmatter.contexts), projects: getStringArray(frontmatter.projects), + attachments: getStringArray(frontmatter.attachments), timeEstimate: typeof frontmatter.timeEstimate === "number" ? frontmatter.timeEstimate : undefined, blockedBy: normalizeBlockedByValue(frontmatter.blockedBy), @@ -991,6 +992,7 @@ export function taskInfoToSpecFields(task: Partial): Record): TaskValidationResult { issues.push(...validateTimeEntries(task.timeEntries).issues); } + for (const issue of validateAttachmentReferences(task.attachments, task.path).issues) { + issues.push({ + code: issue.code, + message: issue.message, + severity: "error", + path: ["attachments", String(issue.index)], + field: "attachments", + }); + } + return { valid: !issues.some((issue) => issue.severity === "error"), issues, diff --git a/test/mdbase.test.mjs b/test/mdbase.test.mjs index 0b76290..c89d91a 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.1" + entry.contract === "tasknotes.task" && entry.version === "0.3.0-rc.2" ); } @@ -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.1"); + assert.equal(resources.contract.version, "0.3.0-rc.2"); assert.ok(resources.contract.record_schema); assert.equal(taskImplementation.contract, "tasknotes.task"); - assert.equal(taskImplementation.version, "0.3.0-rc.1"); + assert.equal(taskImplementation.version, "0.3.0-rc.2"); assert.deepEqual(extension.profiles, [ "core-lite", "recurrence", @@ -44,6 +44,7 @@ test("builds one canonical TaskNotes and mdbase collection contract", () => { assert.deepEqual(extension.capabilities, [ "dependencies", "reminders", + "attachments", "links", "time-tracking", "materialized-occurrences", @@ -52,6 +53,15 @@ test("builds one canonical TaskNotes and mdbase collection contract", () => { ]); assert.equal(taskImplementation.fields.completedDate, "completedDate"); assert.equal(taskImplementation.fields.id, "id"); + assert.equal(taskImplementation.fields.attachments, "attachments"); + assert.deepEqual(schema.properties.attachments, { + type: "array", + items: { type: "string", minLength: 1 }, + uniqueItems: true, + }); + assert.deepEqual(type.collection.links["attachments[]"], { + validate_exists: false, + }); const taskDateSchema = { anyOf: [ { type: "string", format: "date" }, @@ -87,7 +97,7 @@ test("packages the contract, implementation, and schemas as one digest-pinned ty const pack = await buildTaskNotesMdbaseTypePack(resources); assert.deepEqual(pack.provides, [ - { id: "tasknotes.task", version: "0.3.0-rc.1" }, + { id: "tasknotes.task", version: "0.3.0-rc.2" }, ]); assert.equal(pack.manifest.kind, "mdbase.type-pack"); assert.equal(pack.manifest.id, "tasknotes.task"); diff --git a/test/model.test.mjs b/test/model.test.mjs index 7192ca8..e3aa78f 100644 --- a/test/model.test.mjs +++ b/test/model.test.mjs @@ -12,16 +12,20 @@ import { buildSpecStopTimeTrackingUpdate, buildStartTimeTrackingPlan, buildTaskUpdatePlan, + canonicalAttachmentReference, calculateTotalTrackedMinutes, executeConformanceOperation, formatDateForStorage, getDatePart, mapTaskFromFrontmatter, mapTaskToFrontmatter, + attachmentPathFromReference, parseDateToUTC, parseTaskDocument, recalculateRecurringSchedule, serializeTaskDocument, + validateAttachmentReferences, + validateTask, } from "../dist/esm/index.js"; test("maps TaskNotes frontmatter to normalized task data", () => { @@ -37,6 +41,7 @@ test("maps TaskNotes frontmatter to normalized task data", () => { recurrence_parent: "[[Tasks/Daily task]]", occurrence_date: "2026-06-01", occurrence_materialization: "on_completion", + attachments: "[[Attachments/receipt.jpg]]", }, "Tasks/Ship model.md", false, @@ -62,6 +67,7 @@ test("maps TaskNotes frontmatter to normalized task data", () => { assert.equal(task.recurrence_parent, "[[Tasks/Daily task]]"); assert.equal(task.occurrence_date, "2026-06-01"); assert.equal(task.occurrence_materialization, "on_completion"); + assert.deepEqual(task.attachments, ["[[Attachments/receipt.jpg]]"]); }); test("denormalizes task data to configured frontmatter", () => { @@ -75,6 +81,7 @@ test("denormalizes task data to configured frontmatter", () => { recurrence_parent: "[[Tasks/Daily task]]", occurrence_date: "2026-06-01", occurrence_next_trigger: "completion_or_skip", + attachments: ["[[Attachments/receipt.jpg]]", "[[Attachments/photo.png]]"], }); assert.equal(frontmatter.title, "Ship model"); @@ -83,6 +90,50 @@ test("denormalizes task data to configured frontmatter", () => { assert.equal(frontmatter.recurrence_parent, "[[Tasks/Daily task]]"); assert.equal(frontmatter.occurrence_date, "2026-06-01"); assert.equal(frontmatter.occurrence_next_trigger, "completion_or_skip"); + assert.deepEqual(frontmatter.attachments, [ + "[[Attachments/receipt.jpg]]", + "[[Attachments/photo.png]]", + ]); +}); + +test("canonicalizes and validates portable attachment references", () => { + assert.equal( + attachmentPathFromReference("[Receipt](Attachments/receipt%20copy.jpg)"), + "Attachments/receipt copy.jpg" + ); + assert.equal( + attachmentPathFromReference("[Receipt](../Attachments/receipt.jpg)", "Tasks/today.md"), + "Attachments/receipt.jpg" + ); + assert.equal( + attachmentPathFromReference("[[Attachments/receipt.jpg]]", "Tasks/today.md"), + "Attachments/receipt.jpg" + ); + assert.equal( + canonicalAttachmentReference("Attachments/receipt.jpg"), + "[[Attachments/receipt.jpg]]" + ); + assert.equal(attachmentPathFromReference("../../secret.jpg"), undefined); + assert.equal(attachmentPathFromReference("[[Attachments/no-extension]]"), undefined); + + const validation = validateAttachmentReferences([ + "[[Attachments/receipt.jpg]]", + "Attachments/receipt.jpg", + "[[Attachments/no-extension]]", + ]); + assert.equal(validation.valid, false); + assert.deepEqual( + validation.issues.map(({ code }) => code), + ["duplicate_attachment_reference", "invalid_attachment_reference"] + ); + + const taskValidation = validateTask({ + attachments: ["[[Attachments/no-extension]]"], + }); + assert.equal(taskValidation.valid, false); + assert.ok( + taskValidation.issues.some(({ code }) => code === "invalid_attachment_reference") + ); }); test("parses dates with UTC storage semantics", () => { @@ -200,6 +251,7 @@ test("materialized occurrences inherit planning fields but not parent history", due: "2026-06-02T11:00:00", contexts: ["office"], projects: ["[[Projects/Launch]]"], + attachments: ["[[Attachments/brief.png]]"], tags: ["task", "review"], timeEstimate: 45, timeEntries: [{ startTime: "2026-06-01T09:30:00Z", endTime: "2026-06-01T10:00:00Z" }], @@ -230,6 +282,7 @@ test("materialized occurrences inherit planning fields but not parent history", assert.equal(occurrence.due, "2026-06-09T11:00:00"); assert.deepEqual(occurrence.contexts, ["office"]); assert.deepEqual(occurrence.projects, ["[[Projects/Launch]]"]); + assert.deepEqual(occurrence.attachments, ["[[Attachments/brief.png]]"]); assert.deepEqual(occurrence.tags, ["task", "review"]); assert.equal(occurrence.timeEstimate, 45); assert.deepEqual(occurrence.reminders, parent.reminders);