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
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.

7 changes: 6 additions & 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.5",
"version": "0.3.0-rc.7",
"description": "TaskNotes model, mapping, validation, recurrence, and operation-planning reference implementation.",
"license": "MIT",
"type": "module",
Expand Down Expand Up @@ -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",
Expand Down
138 changes: 138 additions & 0 deletions src/attachments.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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;
}
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const ALL_FIELD_ROLES: FieldRole[] = [
"tags",
"contexts",
"projects",
"attachments",
"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 @@ -13,6 +13,7 @@ export const DEFAULT_FIELD_MAPPING: FieldMapping = {
scheduled: "scheduled",
contexts: "contexts",
projects: "projects",
attachments: "attachments",
timeEstimate: "timeEstimate",
completedDate: "completedDate",
dateCreated: "dateCreated",
Expand Down
13 changes: 11 additions & 2 deletions 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.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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -227,6 +235,7 @@ export const TASKNOTES_TASK_BINDING_SCHEMA = {
"enum": [
"dependencies",
"reminders",
"attachments",
"links",
"time-tracking",
"materialized-occurrences",
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
7 changes: 7 additions & 0 deletions src/mapping.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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]);
}
Expand Down Expand Up @@ -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;
Expand Down
14 changes: 12 additions & 2 deletions src/mdbase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const DEFAULT_TASKNOTES_MDBASE_PROFILES = [
export const DEFAULT_TASKNOTES_MDBASE_CAPABILITIES = [
"dependencies",
"reminders",
"attachments",
"links",
"time-tracking",
"materialized-occurrences",
Expand Down Expand Up @@ -175,7 +176,7 @@ interface FieldOptions {
defaultValue?: unknown;
createValue?: Record<string, unknown>;
updateValue?: Record<string, unknown>;
links?: Array<{ suffix?: string; targetType: "task" | "any" }>;
links?: Array<{ suffix?: string; targetType?: "task" | "any" }>;
}

/**
Expand Down Expand Up @@ -433,7 +434,7 @@ export function buildTaskNotesMdbaseResources(
continue;
}
links[path] = {
target_type: link.targetType,
...(link.targetType ? { target_type: link.targetType } : {}),
validate_exists: false,
};
}
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions src/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -991,6 +992,7 @@ export function taskInfoToSpecFields(task: Partial<TaskInfo>): Record<string, un
writeIfDefined(fields, "tags", task.tags);
writeIfDefined(fields, "contexts", task.contexts);
writeIfDefined(fields, "projects", task.projects);
writeIfDefined(fields, "attachments", task.attachments);
writeIfDefined(fields, "timeEstimate", task.timeEstimate);
writeIfDefined(fields, "blockedBy", task.blockedBy);
writeIfDefined(fields, "reminders", task.reminders);
Expand Down Expand Up @@ -1246,6 +1248,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, "attachments")) {
updatedTask.attachments = getStringArray(fields.attachments);
}
return updatedTask;
}

Expand Down Expand Up @@ -1281,6 +1286,11 @@ function addUnsetMappedFieldDeletes(
patch.push({ op: "delete", field: fieldMapping.projects });
}
}
if (Object.prototype.hasOwnProperty.call(updates, "attachments")) {
if (!Array.isArray(updates.attachments) || updates.attachments.length === 0) {
patch.push({ op: "delete", field: fieldMapping.attachments });
}
}
if (
Object.prototype.hasOwnProperty.call(updates, "googleCalendarMovedOriginalDates") &&
(!Array.isArray(updates.googleCalendarMovedOriginalDates) ||
Expand All @@ -1299,6 +1309,7 @@ function fieldNameForTaskProperty(fieldMapping: FieldMapping, property: keyof Ta
scheduled: "scheduled",
contexts: "contexts",
projects: "projects",
attachments: "attachments",
timeEstimate: "timeEstimate",
completedDate: "completedDate",
dateCreated: "dateCreated",
Expand Down Expand Up @@ -1371,6 +1382,7 @@ function buildInheritedOccurrenceTask(parentTask: TaskInfo, targetDate: string):
scheduled,
contexts: cloneArray(parentTask.contexts),
projects: cloneArray(parentTask.projects),
attachments: cloneArray(parentTask.attachments),
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 @@ -65,6 +65,7 @@ export const taskInfoSchema = z.object({
tags: z.array(z.string()).optional(),
contexts: z.array(z.string()).optional(),
projects: z.array(z.string()).optional(),
attachments: z.array(z.string().min(1)).optional(),
recurrence: z.string().optional(),
recurrence_anchor: z.enum(["scheduled", "completion"]).optional(),
complete_instances: z.array(z.string()).optional(),
Expand Down
5 changes: 4 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.1";
export const TASKNOTES_SPEC_VERSION = "0.3.0-rc.2";

export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
Expand Down Expand Up @@ -51,6 +51,7 @@ export interface TaskInfo {
tags?: string[];
contexts?: string[];
projects?: string[];
attachments?: string[];
recurrence?: string;
recurrence_anchor?: RecurrenceAnchor;
complete_instances?: string[];
Expand Down Expand Up @@ -111,6 +112,7 @@ export interface FieldMapping {
scheduled: string;
contexts: string;
projects: string;
attachments: string;
timeEstimate: string;
completedDate: string;
dateCreated: string;
Expand Down Expand Up @@ -280,6 +282,7 @@ export type FieldRole =
| "tags"
| "contexts"
| "projects"
| "attachments"
| "timeEstimate"
| "dateCreated"
| "dateModified"
Expand Down
Loading