;
+}) {
if (!source.trim())
return (
@@ -26,10 +33,89 @@ export function MarkdownPreview({ source }: { source: string }) {
);
},
+ img: ({ alt, src }) => {
+ if (!src || !resolveImage || /^(?:https?:|data:|blob:)/i.test(src))
+ return
;
+ return (
+
+ );
+ },
}}
>
- {source}
+ {preprocessWikilinkImages(source)}
);
}
+
+function ResolvedMarkdownImage({
+ alt,
+ source,
+ resolveImage,
+}: {
+ alt: string;
+ source: string;
+ resolveImage(source: string): Promise;
+}) {
+ const [url, setUrl] = useState(null);
+ const [missing, setMissing] = useState(false);
+ const [failed, setFailed] = useState(false);
+ useEffect(() => {
+ let active = true;
+ let objectUrl = "";
+ void resolveImage(source)
+ .then((blob) => {
+ if (!active) return;
+ if (!blob) {
+ setMissing(true);
+ return;
+ }
+ objectUrl = URL.createObjectURL(blob);
+ setFailed(false);
+ setUrl(objectUrl);
+ })
+ .catch(() => {
+ if (active) setMissing(true);
+ });
+ return () => {
+ active = false;
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
+ };
+ }, [resolveImage, source]);
+ if (url && !failed)
+ return
setFailed(true)} />;
+ return (
+
+ {missing
+ ? "Image unavailable offline"
+ : failed
+ ? "Image preview unavailable"
+ : "Loading image…"}
+
+ );
+}
+
+function preprocessWikilinkImages(source: string): string {
+ return source.replace(
+ /!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
+ (_match, rawPath: string, rawAlt: string | undefined) => {
+ const path = rawPath.trim();
+ const alt = (rawAlt?.trim() || path.replace(/^.*\//, ""))
+ .replaceAll("[", "\\[")
+ .replaceAll("]", "\\]");
+ return `})`;
+ },
+ );
+}
+
+function attachmentImageSource(source: string): string {
+ const prefix = "/__tasknotes_attachment__/";
+ return source.startsWith(prefix)
+ ? `[[${decodeURIComponent(source.slice(prefix.length))}]]`
+ : `[image](${source})`;
+}
diff --git a/src/components/task-attachments.tsx b/src/components/task-attachments.tsx
new file mode 100644
index 0000000..cc6426a
--- /dev/null
+++ b/src/components/task-attachments.tsx
@@ -0,0 +1,304 @@
+import {
+ ExternalLink,
+ FileImage,
+ ImagePlus,
+ LoaderCircle,
+ Unlink,
+} from "lucide-react";
+import { useCallback, useEffect, useId, useRef, useState } from "react";
+
+import {
+ AttachmentService,
+ type ResolvedTaskAttachment,
+} from "../application/attachments/attachment-service";
+
+import type { Task } from "../domain/task";
+import type { CollectionFileStore } from "../application/ports/collection-file-store";
+
+export function TaskAttachments({
+ task,
+ service,
+ store,
+ beforeMutation,
+ onInsertInline,
+}: {
+ task: Task;
+ service: AttachmentService;
+ store: CollectionFileStore;
+ beforeMutation(): Promise;
+ onInsertInline(reference: string): Promise;
+}) {
+ const attachInputId = useId();
+ const insertInputId = useId();
+ const [items, setItems] = useState([]);
+ const [loading, setLoading] = useState(service.available());
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const mounted = useRef(true);
+ const loadGeneration = useRef(0);
+
+ const load = useCallback(async () => {
+ if (!service.available()) return;
+ const generation = ++loadGeneration.current;
+ setLoading(true);
+ try {
+ await service.recover();
+ const resolved = await service.resolve(
+ (await service.currentTask(task.id)) ?? task,
+ );
+ if (mounted.current && generation === loadGeneration.current) {
+ setItems(resolved);
+ setError(null);
+ }
+ } catch (reason) {
+ if (mounted.current && generation === loadGeneration.current)
+ setError(reason instanceof Error ? reason.message : String(reason));
+ } finally {
+ if (mounted.current && generation === loadGeneration.current)
+ setLoading(false);
+ }
+ }, [service, task]);
+
+ useEffect(() => {
+ mounted.current = true;
+ queueMicrotask(() => {
+ if (mounted.current) void load();
+ });
+ return () => {
+ mounted.current = false;
+ loadGeneration.current += 1;
+ };
+ }, [load]);
+
+ async function run(action: () => Promise): Promise {
+ if (busy) return;
+ setBusy(true);
+ setError(null);
+ try {
+ await beforeMutation();
+ await action();
+ await load();
+ } catch (reason) {
+ if (mounted.current)
+ setError(reason instanceof Error ? reason.message : String(reason));
+ } finally {
+ if (mounted.current) setBusy(false);
+ }
+ }
+
+ function selectImage(file: File | undefined, inline: boolean): void {
+ if (!file) return;
+ void run(async () => {
+ const result = await service.attachImage(task.id, file);
+ if (inline) await onInsertInline(result.reference);
+ });
+ }
+
+ function openImage(item: ResolvedTaskAttachment): void {
+ if (!item.file || busy) return;
+ const target = window.open("about:blank", "_blank");
+ if (!target) {
+ setError(
+ "Your browser blocked the image window. Allow pop-ups and try again.",
+ );
+ return;
+ }
+ try {
+ target.opener = null;
+ } catch {
+ // Some WebViews expose a read-only opener; the blank target is still safe.
+ }
+ setBusy(true);
+ setError(null);
+ void openAttachment(store, item.file, target)
+ .catch((reason) => {
+ target.close();
+ if (mounted.current)
+ setError(reason instanceof Error ? reason.message : String(reason));
+ })
+ .finally(() => {
+ if (mounted.current) setBusy(false);
+ });
+ }
+
+ if (!service.available()) return null;
+
+ return (
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ Detaching removes an image from this task but keeps the file in your
+ collection. Permanent deletion isn't available yet.
+
+ {loading && !items.length ? (
+
+ Loading
+ images…
+
+ ) : items.length ? (
+
+ {items.map((item) => (
+ -
+
+
+ {displayName(item.path ?? item.reference)}
+ {attachmentState(item)}
+
+
+ {item.file ? (
+
+ ) : null}
+
+
+
+
+ ))}
+
+ ) : (
+ No images attached.
+ )}
+
+ );
+}
+
+function AttachmentThumbnail({
+ item,
+ store,
+}: {
+ item: ResolvedTaskAttachment;
+ store?: CollectionFileStore;
+}) {
+ const [source, setSource] = useState(null);
+ useEffect(() => {
+ if (!store || !item.file) return;
+ let active = true;
+ let url = "";
+ void store
+ .download(item.file)
+ .then((blob) => {
+ if (!active) return;
+ url = URL.createObjectURL(blob);
+ setSource(url);
+ })
+ .catch(() => undefined);
+ return () => {
+ active = false;
+ if (url) URL.revokeObjectURL(url);
+ };
+ }, [item.file, store]);
+ return source ? (
+
setSource(null)}
+ />
+ ) : (
+
+
+
+ );
+}
+
+function displayName(path: string): string {
+ return path.replace(/^.*\//, "").replace(/^[0-9a-f-]{36}-/, "");
+}
+
+function attachmentState(item: ResolvedTaskAttachment): string {
+ if (!item.file) return "File missing";
+ if (item.file.pending === "upload")
+ return "Saved locally · Waiting to upload";
+ if (item.file.availability === "local") return "Saved on this device";
+ return formatBytes(item.file.size);
+}
+
+function formatBytes(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+async function openAttachment(
+ store: CollectionFileStore,
+ file: NonNullable,
+ target: Window,
+): Promise {
+ const url = URL.createObjectURL(await store.download(file));
+ target.location.href = url;
+ window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
+}
diff --git a/src/components/task-capture.test.tsx b/src/components/task-capture.test.tsx
index 832c9dc..8372703 100644
--- a/src/components/task-capture.test.tsx
+++ b/src/components/task-capture.test.tsx
@@ -211,6 +211,7 @@ function task(input: CreateTaskInput): Task {
tags: input.tags ?? [],
contexts: input.contexts ?? [],
projects: input.projects ?? [],
+ attachments: input.attachments ?? [],
blockedBy: input.blockedBy ?? [],
completeInstances: [],
skippedInstances: [],
diff --git a/src/domain/calendar-events.test.ts b/src/domain/calendar-events.test.ts
index 5a321a0..22b1f37 100644
--- a/src/domain/calendar-events.test.ts
+++ b/src/domain/calendar-events.test.ts
@@ -135,6 +135,7 @@ function baseTask(): Task {
tags: ["task"],
contexts: [],
projects: [],
+ attachments: [],
blockedBy: [],
completeInstances: [],
skippedInstances: [],
diff --git a/src/domain/kanban.test.ts b/src/domain/kanban.test.ts
index 7901d13..08681e9 100644
--- a/src/domain/kanban.test.ts
+++ b/src/domain/kanban.test.ts
@@ -79,6 +79,7 @@ function task(): Task {
tags: [],
contexts: [],
projects: [],
+ attachments: [],
blockedBy: [],
completeInstances: [],
skippedInstances: [],
diff --git a/src/domain/manual-order.test.ts b/src/domain/manual-order.test.ts
index ab18516..9d98df5 100644
--- a/src/domain/manual-order.test.ts
+++ b/src/domain/manual-order.test.ts
@@ -167,6 +167,7 @@ function task(id: string, sortOrder?: string): Task {
tags: [],
contexts: [],
projects: [],
+ attachments: [],
blockedBy: [],
completeInstances: [],
skippedInstances: [],
diff --git a/src/domain/task-capture.test.ts b/src/domain/task-capture.test.ts
index c29d012..ff48bb9 100644
--- a/src/domain/task-capture.test.ts
+++ b/src/domain/task-capture.test.ts
@@ -26,7 +26,7 @@ const configuration = resolveTaskCollectionConfiguration({
implements: [
{
contract: "tasknotes.task",
- version: "0.3.0-rc.1",
+ version: "0.3.0-rc.3",
fields: { title: "title", status: "status", priority: "priority" },
binding: {
status: {
diff --git a/src/domain/task-configuration.test.ts b/src/domain/task-configuration.test.ts
index ec4c2d5..fcf1cef 100644
--- a/src/domain/task-configuration.test.ts
+++ b/src/domain/task-configuration.test.ts
@@ -32,7 +32,7 @@ function taskType(input: Record): Record {
implements: [
{
contract: "tasknotes.task",
- version: "0.3.0-rc.1",
+ version: "0.3.0-rc.3",
fields,
binding,
},
diff --git a/src/domain/task-configuration.ts b/src/domain/task-configuration.ts
index 883b65c..70c5158 100644
--- a/src/domain/task-configuration.ts
+++ b/src/domain/task-configuration.ts
@@ -1,5 +1,6 @@
import { resolveModelConfig } from "@tasknotes/model/config";
import { resolveTaskNotesModelConfigFromMdbaseType } from "@tasknotes/model/mdbase";
+import { TASKNOTES_SPEC_VERSION } from "@tasknotes/model/types";
import type {
PriorityConfig,
@@ -123,7 +124,7 @@ function taskNotesImplementation(
.find(
(implementation) =>
implementation.contract === "tasknotes.task" &&
- implementation.version === "0.3.0-rc.1",
+ implementation.version === TASKNOTES_SPEC_VERSION,
) ?? {}
);
}
diff --git a/src/domain/task-list-sections.test.ts b/src/domain/task-list-sections.test.ts
index c391529..8588ce4 100644
--- a/src/domain/task-list-sections.test.ts
+++ b/src/domain/task-list-sections.test.ts
@@ -145,6 +145,7 @@ function row(id: string, patch: Partial = {}): TaskViewRow {
revision: 1,
frontmatter: {},
...patch,
+ attachments: patch.attachments ?? [],
},
};
}
diff --git a/src/domain/task-occurrence.test.ts b/src/domain/task-occurrence.test.ts
index 06deea5..08b7e85 100644
--- a/src/domain/task-occurrence.test.ts
+++ b/src/domain/task-occurrence.test.ts
@@ -28,6 +28,7 @@ const task: Task = {
tags: ["task"],
contexts: [],
projects: [],
+ attachments: [],
blockedBy: [],
recurrence: "FREQ=WEEKLY;BYDAY=MO,WE",
completeInstances: ["2026-08-05"],
diff --git a/src/domain/task-relationships.test.ts b/src/domain/task-relationships.test.ts
index 12ec3fb..8b78a68 100644
--- a/src/domain/task-relationships.test.ts
+++ b/src/domain/task-relationships.test.ts
@@ -106,5 +106,6 @@ function task(id: string, path: string, patch: Partial = {}): Task {
revision: 1,
frontmatter: {},
...patch,
+ attachments: patch.attachments ?? [],
};
}
diff --git a/src/domain/task.ts b/src/domain/task.ts
index 958ef25..33f44cd 100644
--- a/src/domain/task.ts
+++ b/src/domain/task.ts
@@ -40,6 +40,8 @@ export interface Task {
tags: string[];
contexts: string[];
projects: string[];
+ /** Canonical frontmatter links that define attachment membership. */
+ attachments: string[];
blockedBy: TaskDependency[];
recurrence?: string;
recurrenceAnchor?: "scheduled" | "completion";
@@ -73,6 +75,7 @@ export interface CreateTaskInput {
tags?: string[];
contexts?: string[];
projects?: string[];
+ attachments?: string[];
blockedBy?: TaskDependency[];
recurrence?: string;
recurrenceAnchor?: "scheduled" | "completion";
@@ -101,6 +104,7 @@ export interface UpdateTaskInput {
tags?: string[];
contexts?: string[];
projects?: string[];
+ attachments?: string[];
blockedBy?: TaskDependency[];
recurrence?: string | null;
recurrenceAnchor?: "scheduled" | "completion";
diff --git a/src/domain/tasknotes-model.test.ts b/src/domain/tasknotes-model.test.ts
index 212d723..e2aca6b 100644
--- a/src/domain/tasknotes-model.test.ts
+++ b/src/domain/tasknotes-model.test.ts
@@ -301,6 +301,28 @@ describe("TaskNotes task model app boundary", () => {
).toEqual(updated.blockedBy);
});
+ it("round-trips authoritative attachment links without changing the body", () => {
+ const created = model.create(
+ {
+ title: "Documented task",
+ body: "The body is presentation, not membership.",
+ attachments: ["[[Attachments/diagram.png]]"],
+ },
+ { id: "documented", now: "2026-07-22T00:00:00.000Z" },
+ );
+
+ expect(created.attachments).toEqual(["[[Attachments/diagram.png]]"]);
+ expect(created.frontmatter.attachments).toEqual([
+ "[[Attachments/diagram.png]]",
+ ]);
+ expect(created.body).toBe("The body is presentation, not membership.");
+
+ const detached = model.update(created, { attachments: [] });
+ expect(detached.attachments).toEqual([]);
+ expect(detached.frontmatter).not.toHaveProperty("attachments");
+ expect(detached.body).toBe(created.body);
+ });
+
it("completes and skips individual recurring occurrences", () => {
const created = model.create(
{
diff --git a/src/domain/tasknotes-model.ts b/src/domain/tasknotes-model.ts
index d33ea86..b201f7a 100644
--- a/src/domain/tasknotes-model.ts
+++ b/src/domain/tasknotes-model.ts
@@ -181,6 +181,7 @@ export class TaskNotesTaskModel {
tags: input.tags,
contexts: input.contexts,
projects: input.projects,
+ attachments: input.attachments,
blockedBy: normalizeDependencies(input.blockedBy),
recurrence: input.recurrence,
recurrence_anchor: input.recurrenceAnchor,
@@ -297,6 +298,8 @@ export class TaskNotesTaskModel {
if (input.tags !== undefined) updates.tags = input.tags;
if (input.contexts !== undefined) updates.contexts = input.contexts;
if (input.projects !== undefined) updates.projects = input.projects;
+ if (input.attachments !== undefined)
+ updates.attachments = input.attachments;
if (input.blockedBy !== undefined)
updates.blockedBy = normalizeDependencies(input.blockedBy);
if (input.recurrence !== undefined)
@@ -757,6 +760,7 @@ export class TaskNotesTaskModel {
tags: info.tags ?? [],
contexts: info.contexts ?? [],
projects: info.projects ?? [],
+ attachments: info.attachments ?? [],
blockedBy: storedDependencies ?? info.blockedBy ?? [],
recurrence: info.recurrence,
recurrenceAnchor: info.recurrence_anchor,
@@ -1120,6 +1124,7 @@ function taskAsCreateInput(task: Task): CreateTaskInput {
tags: task.tags,
contexts: task.contexts,
projects: task.projects,
+ attachments: task.attachments,
blockedBy: task.blockedBy,
reminders: task.reminders,
timeEstimate: task.timeEstimate,
diff --git a/src/domain/view-mutation.test.ts b/src/domain/view-mutation.test.ts
index 1e18f2f..7ec239f 100644
--- a/src/domain/view-mutation.test.ts
+++ b/src/domain/view-mutation.test.ts
@@ -112,5 +112,6 @@ function task(patch: Partial = {}): Task {
revision: 1,
frontmatter: {},
...patch,
+ attachments: patch.attachments ?? [],
};
}
diff --git a/src/domain/view-values.test.ts b/src/domain/view-values.test.ts
index e46461b..5c65002 100644
--- a/src/domain/view-values.test.ts
+++ b/src/domain/view-values.test.ts
@@ -24,6 +24,7 @@ const task: Task = {
tags: ["task"],
contexts: [],
projects: ["mdbase"],
+ attachments: [],
blockedBy: [],
completeInstances: [],
skippedInstances: [],
diff --git a/src/generated/mdbase-app.json b/src/generated/mdbase-app.json
index 1ce2d51..83721fc 100644
--- a/src/generated/mdbase-app.json
+++ b/src/generated/mdbase-app.json
@@ -12,7 +12,7 @@
"contracts": [
{
"id": "tasknotes.task",
- "version": "0.3.0-rc.1"
+ "version": "0.3.0-rc.3"
}
],
"access": "full_collection",
@@ -29,7 +29,7 @@
"manifest": {
"kind": "mdbase.type-pack",
"id": "tasknotes.task",
- "version": "0.3.0-rc.1",
+ "version": "0.3.0-rc.3",
"name": "TaskNotes task",
"description": "TaskNotes task contract, implementation, and referenced JSON Schemas.",
"resources": [
@@ -37,50 +37,50 @@
"kind": "contract",
"source": "contracts/tasknotes.task.md",
"target": "_contracts/tasknotes.task.md",
- "digest": "sha256:5e07a8ffb7182db52c5c8a89cf6df523ff4d2ce26fb51530f15df914ac461407"
+ "digest": "sha256:7f00349e4128b85067d715ad11e54b18244f9ee15d6dcfbc89989f334b095b93"
},
{
"kind": "type",
"source": "types/task.md",
"target": "_types/task.md",
- "digest": "sha256:d138fafa1282c5af21aa146ddca4115b2b9f43da8284eedeaa914b2d6011fce3"
+ "digest": "sha256:dbfe2535df3e9bc4a3cfce0ee0e4ea3d01f7e66dd869f793f10db485f144f19e"
},
{
"kind": "schema",
"source": "schemas/tasknotes-task.schema.json",
"target": "_schemas/tasknotes/tasknotes-task.schema.json",
- "digest": "sha256:8319707179c2f8243126cdb71930cf5c997ef219e9a1353fe4f0039f3dc5d51f"
+ "digest": "sha256:3d01df62fc230dfa1cd5c290d685e285594370e17fa176bc06e48c49200d0dc8"
},
{
"kind": "schema",
"source": "schemas/tasknotes-task-binding.schema.json",
"target": "_schemas/tasknotes/tasknotes-task-binding.schema.json",
- "digest": "sha256:90d8216feb7bc05de0d15fa31efa0531c76a3d3de1d5d90076905d3945f22cf8"
+ "digest": "sha256:ce592fe951504c82441476090901f19b224e9651e2b78d7da1a464ab470719c0"
}
]
},
"resources": [
{
"source": "contracts/tasknotes.task.md",
- "document": "---\nkind: mdbase.contract\ncontract_type: record\nid: tasknotes.task\nversion: 0.3.0-rc.1\nname: TaskNotes task\ndescription: Portable task data and behavior defined by tasknotes-spec 0.3.0-rc.1.\nrecord_schema:\n dialect: json-schema-2020-12\n ref: ../_schemas/tasknotes/tasknotes-task.schema.json\nbinding_schema:\n dialect: json-schema-2020-12\n ref: ../_schemas/tasknotes/tasknotes-task-binding.schema.json\n---\n\n# TaskNotes task contract\n\nTypes implement this contract through `implements`; applications consume\nthe normalized contract view rather than assuming frontmatter names.\n"
+ "document": "---\nkind: mdbase.contract\ncontract_type: record\nid: tasknotes.task\nversion: 0.3.0-rc.3\nname: TaskNotes task\ndescription: Portable task data and behavior defined by tasknotes-spec 0.3.0-rc.3.\nrecord_schema:\n dialect: json-schema-2020-12\n ref: ../_schemas/tasknotes/tasknotes-task.schema.json\nbinding_schema:\n dialect: json-schema-2020-12\n ref: ../_schemas/tasknotes/tasknotes-task-binding.schema.json\n---\n\n# TaskNotes task contract\n\nTypes implement this contract through `implements`; applications consume\nthe normalized contract view rather than assuming frontmatter names.\n"
},
{
"source": "types/task.md",
- "document": "---\nkind: mdbase.type\nname: task\nversion: 1\ndescription: A task managed by TaskNotes.\nmatch:\n where:\n tags:\n contains: task\nschema:\n dialect: json-schema-2020-12\n value:\n $schema: https://json-schema.org/draft/2020-12/schema\n type: object\n additionalProperties: true\n properties:\n id:\n type: string\n minLength: 1\n title:\n type: string\n minLength: 1\n status:\n enum: &a2\n - none\n - open\n - in-progress\n - done\n - cancelled\n default: open\n priority:\n enum: &a4\n - none\n - low\n - normal\n - high\n default: normal\n due: &a1\n anyOf:\n - type: string\n format: date\n - type: string\n format: date-time\n scheduled: *a1\n contexts:\n type: array\n items:\n type: string\n projects:\n type: array\n items:\n type: string\n timeEstimate:\n type: integer\n minimum: 0\n completedDate:\n type: string\n format: date\n dateCreated:\n type: string\n format: date-time\n dateModified:\n type: string\n format: date-time\n recurrence:\n type: string\n recurrence_anchor:\n enum:\n - scheduled\n - completion\n default: scheduled\n occurrence_materialization:\n enum:\n - manual\n - on_completion\n - rolling\n default: manual\n occurrence_next_trigger:\n enum:\n - completion\n - completion_or_skip\n default: completion\n occurrence_template:\n type: string\n occurrence_past_horizon:\n type: string\n occurrence_future_horizon:\n type: string\n recurrence_parent:\n type: string\n occurrence_date:\n type: string\n format: date\n tags:\n type: array\n items:\n type: string\n timeEntries:\n type: array\n items:\n type: object\n additionalProperties: false\n properties:\n startTime:\n type: string\n format: date-time\n endTime:\n type: string\n format: date-time\n description:\n type: string\n duration:\n type: integer\n reminders:\n type: array\n items:\n oneOf:\n - type: object\n required:\n - id\n - type\n - absoluteTime\n additionalProperties: false\n properties:\n id:\n type: string\n type:\n const: absolute\n description:\n type: string\n absoluteTime:\n type: string\n format: date-time\n - type: object\n required:\n - id\n - type\n - relatedTo\n - offset\n additionalProperties: false\n properties:\n id:\n type: string\n type:\n const: relative\n description:\n type: string\n relatedTo:\n enum:\n - due\n - scheduled\n offset:\n type: string\n blockedBy:\n type: array\n items:\n type: object\n additionalProperties: false\n properties:\n uid:\n type: string\n reltype:\n type: string\n gap:\n type: string\n required:\n - uid\n complete_instances:\n type: array\n items:\n type: string\n format: date\n skipped_instances:\n type: array\n items:\n type: string\n format: date\n icsEventId:\n type: array\n items:\n type: string\n googleCalendarEventId:\n type: string\n googleCalendarExceptionEventId:\n type: string\n googleCalendarExceptionOriginalScheduled:\n type: string\n format: date\n googleCalendarMovedOriginalDates:\n type: array\n items:\n type: string\n format: date\n tasknotes_manual_order:\n type: string\n allOf:\n - if:\n required:\n - status\n properties:\n status:\n enum: &a3\n - done\n not:\n required:\n - recurrence\n then:\n required:\n - completedDate\n required:\n - title\n - status\n - dateCreated\ncollection:\n read_defaults:\n status: open\n priority: normal\n recurrence_anchor: scheduled\n occurrence_materialization: manual\n occurrence_next_trigger: completion\n links:\n projects[]:\n target_type: any\n validate_exists: false\n occurrence_template:\n target_type: any\n validate_exists: false\n recurrence_parent:\n target_type: task\n validate_exists: false\n blockedBy[].uid:\n target_type: task\n validate_exists: false\n path:\n runtime: tasknotes\n template: \"{{zettel}}\"\n folder: tasks\n generated_by: tasknotes.filename.create\n display:\n name_field: title\n unique:\n - field: id\n scope: type\nlifecycle:\n on_create:\n set:\n id:\n uuid: true\n dateCreated:\n now: true\n dateModified:\n now: true\n on_update:\n set:\n dateModified:\n now: true\nimplements:\n - contract: tasknotes.task\n version: 0.3.0-rc.1\n fields:\n id: id\n title: title\n status: status\n priority: priority\n due: due\n scheduled: scheduled\n contexts: contexts\n projects: projects\n timeEstimate: timeEstimate\n completedDate: completedDate\n dateCreated: dateCreated\n dateModified: dateModified\n recurrence: recurrence\n recurrenceAnchor: recurrence_anchor\n occurrenceMaterialization: occurrence_materialization\n occurrenceNextTrigger: occurrence_next_trigger\n occurrenceTemplate: occurrence_template\n occurrencePastHorizon: occurrence_past_horizon\n occurrenceFutureHorizon: occurrence_future_horizon\n recurrenceParent: recurrence_parent\n occurrenceDate: occurrence_date\n tags: tags\n timeEntries: timeEntries\n reminders: reminders\n blockedBy: blockedBy\n completeInstances: complete_instances\n skippedInstances: skipped_instances\n icsEventId: icsEventId\n googleCalendarEventId: googleCalendarEventId\n googleCalendarExceptionEventId: googleCalendarExceptionEventId\n googleCalendarExceptionOriginalScheduled: googleCalendarExceptionOriginalScheduled\n googleCalendarMovedOriginalDates: googleCalendarMovedOriginalDates\n sortOrder: tasknotes_manual_order\n binding:\n profiles:\n - core-lite\n - recurrence\n - materialized-occurrences\n capabilities:\n - dependencies\n - reminders\n - links\n - time-tracking\n - materialized-occurrences\n - archive\n - templating\n title:\n storage: frontmatter\n filename_format: zettel\n status:\n values: *a2\n default: open\n completed_values: *a3\n skipped_values:\n - cancelled\n default_skipped: cancelled\n definitions:\n - value: none\n label: None\n color: \"#cccccc\"\n is_completed: false\n is_skipped: false\n exclude_from_cycle: false\n order: 0\n auto_archive: false\n auto_archive_delay_minutes: 5\n - value: open\n label: Open\n color: \"#808080\"\n is_completed: false\n is_skipped: false\n exclude_from_cycle: false\n order: 1\n auto_archive: false\n auto_archive_delay_minutes: 5\n - value: in-progress\n label: In progress\n color: \"#0066cc\"\n is_completed: false\n is_skipped: false\n exclude_from_cycle: false\n order: 2\n auto_archive: false\n auto_archive_delay_minutes: 5\n - value: done\n label: Done\n color: \"#00aa00\"\n is_completed: true\n is_skipped: false\n exclude_from_cycle: false\n order: 3\n auto_archive: false\n auto_archive_delay_minutes: 5\n - value: cancelled\n label: Cancelled\n color: \"#808080\"\n is_completed: false\n is_skipped: true\n exclude_from_cycle: true\n order: 4\n auto_archive: false\n auto_archive_delay_minutes: 5\n priority:\n values: *a4\n default: normal\n definitions:\n - value: none\n label: None\n color: \"#cccccc\"\n weight: 0\n - value: low\n label: Low\n color: \"#00aa00\"\n weight: 1\n - value: normal\n label: Normal\n color: \"#ffaa00\"\n weight: 2\n - value: high\n label: High\n color: \"#ff0000\"\n weight: 3\n recurrence:\n syntax: tasknotes\n maintain_due_date_offset: true\n reset_body_checkboxes: false\n occurrences:\n identity_roles:\n - recurrenceParent\n - occurrenceDate\n default_materialization: manual\n default_next_trigger: completion\n past_horizon: P0D\n future_horizon: P14D\n links:\n accepted_formats:\n - wikilink\n - markdown\n write_format: wikilink\n archive:\n archived_tag: archived\n move_on_archive: false\n time_tracking:\n auto_stop_on_complete: false\n templating:\n enabled: false\n occurrence_enabled: false\nx-tasknotes-generator:\n managed_fields:\n - blockedBy\n - complete_instances\n - completedDate\n - contexts\n - dateCreated\n - dateModified\n - due\n - googleCalendarEventId\n - googleCalendarExceptionEventId\n - googleCalendarExceptionOriginalScheduled\n - googleCalendarMovedOriginalDates\n - icsEventId\n - id\n - occurrence_date\n - occurrence_future_horizon\n - occurrence_materialization\n - occurrence_next_trigger\n - occurrence_past_horizon\n - occurrence_template\n - priority\n - projects\n - recurrence\n - recurrence_anchor\n - recurrence_parent\n - reminders\n - scheduled\n - skipped_instances\n - status\n - tags\n - tasknotes_manual_order\n - timeEntries\n - timeEstimate\n - title\n---\n# Task\n\nTask records live under `tasks/`.\n"
+ "document": "---\nkind: mdbase.type\nname: task\nversion: 1\ndescription: A task managed by TaskNotes.\nmatch:\n where:\n tags:\n contains: task\nschema:\n dialect: json-schema-2020-12\n value:\n $schema: https://json-schema.org/draft/2020-12/schema\n type: object\n additionalProperties: true\n properties:\n id:\n type: string\n minLength: 1\n title:\n type: string\n minLength: 1\n status:\n enum: &a2\n - none\n - open\n - in-progress\n - done\n - cancelled\n default: open\n priority:\n enum: &a4\n - none\n - low\n - normal\n - high\n default: normal\n due: &a1\n anyOf:\n - type: string\n format: date\n - type: string\n format: date-time\n scheduled: *a1\n contexts:\n type: array\n items:\n type: string\n projects:\n type: array\n items:\n type: string\n attachments:\n type: array\n items:\n type: string\n minLength: 1\n uniqueItems: true\n timeEstimate:\n type: integer\n minimum: 0\n completedDate:\n type: string\n format: date\n dateCreated:\n type: string\n format: date-time\n dateModified:\n type: string\n format: date-time\n recurrence:\n type: string\n recurrence_anchor:\n enum:\n - scheduled\n - completion\n default: scheduled\n occurrence_materialization:\n enum:\n - manual\n - on_completion\n - rolling\n default: manual\n occurrence_next_trigger:\n enum:\n - completion\n - completion_or_skip\n default: completion\n occurrence_template:\n type: string\n occurrence_past_horizon:\n type: string\n occurrence_future_horizon:\n type: string\n recurrence_parent:\n type: string\n occurrence_date:\n type: string\n format: date\n tags:\n type: array\n items:\n type: string\n timeEntries:\n type: array\n items:\n type: object\n additionalProperties: false\n properties:\n startTime:\n type: string\n format: date-time\n endTime:\n type: string\n format: date-time\n description:\n type: string\n duration:\n type: integer\n reminders:\n type: array\n items:\n oneOf:\n - type: object\n required:\n - id\n - type\n - absoluteTime\n additionalProperties: false\n properties:\n id:\n type: string\n type:\n const: absolute\n description:\n type: string\n absoluteTime:\n type: string\n format: date-time\n - type: object\n required:\n - id\n - type\n - relatedTo\n - offset\n additionalProperties: false\n properties:\n id:\n type: string\n type:\n const: relative\n description:\n type: string\n relatedTo:\n enum:\n - due\n - scheduled\n offset:\n type: string\n blockedBy:\n type: array\n items:\n type: object\n additionalProperties: false\n properties:\n uid:\n type: string\n reltype:\n type: string\n gap:\n type: string\n required:\n - uid\n complete_instances:\n type: array\n items:\n type: string\n format: date\n skipped_instances:\n type: array\n items:\n type: string\n format: date\n icsEventId:\n type: array\n items:\n type: string\n googleCalendarEventId:\n type: string\n googleCalendarExceptionEventId:\n type: string\n googleCalendarExceptionOriginalScheduled:\n type: string\n format: date\n googleCalendarMovedOriginalDates:\n type: array\n items:\n type: string\n format: date\n tasknotes_manual_order:\n type: string\n allOf:\n - if:\n required:\n - status\n properties:\n status:\n enum: &a3\n - done\n not:\n required:\n - recurrence\n then:\n required:\n - completedDate\n required:\n - title\n - status\n - dateCreated\ncollection:\n read_defaults:\n status: open\n priority: normal\n recurrence_anchor: scheduled\n occurrence_materialization: manual\n occurrence_next_trigger: completion\n links:\n projects[]:\n target_type: any\n validate_exists: false\n attachments[]:\n validate_exists: false\n occurrence_template:\n target_type: any\n validate_exists: false\n recurrence_parent:\n target_type: task\n validate_exists: false\n blockedBy[].uid:\n target_type: task\n validate_exists: false\n path:\n runtime: tasknotes\n template: \"{{zettel}}\"\n folder: tasks\n generated_by: tasknotes.filename.create\n display:\n name_field: title\n unique:\n - field: id\n scope: type\nlifecycle:\n on_create:\n set:\n id:\n uuid: true\n dateCreated:\n now: true\n dateModified:\n now: true\n on_update:\n set:\n dateModified:\n now: true\nimplements:\n - contract: tasknotes.task\n version: 0.3.0-rc.3\n fields:\n id: id\n title: title\n status: status\n priority: priority\n due: due\n scheduled: scheduled\n contexts: contexts\n projects: projects\n attachments: attachments\n timeEstimate: timeEstimate\n completedDate: completedDate\n dateCreated: dateCreated\n dateModified: dateModified\n recurrence: recurrence\n recurrenceAnchor: recurrence_anchor\n occurrenceMaterialization: occurrence_materialization\n occurrenceNextTrigger: occurrence_next_trigger\n occurrenceTemplate: occurrence_template\n occurrencePastHorizon: occurrence_past_horizon\n occurrenceFutureHorizon: occurrence_future_horizon\n recurrenceParent: recurrence_parent\n occurrenceDate: occurrence_date\n tags: tags\n timeEntries: timeEntries\n reminders: reminders\n blockedBy: blockedBy\n completeInstances: complete_instances\n skippedInstances: skipped_instances\n icsEventId: icsEventId\n googleCalendarEventId: googleCalendarEventId\n googleCalendarExceptionEventId: googleCalendarExceptionEventId\n googleCalendarExceptionOriginalScheduled: googleCalendarExceptionOriginalScheduled\n googleCalendarMovedOriginalDates: googleCalendarMovedOriginalDates\n sortOrder: tasknotes_manual_order\n binding:\n profiles:\n - core-lite\n - recurrence\n - materialized-occurrences\n capabilities:\n - dependencies\n - reminders\n - attachments\n - links\n - time-tracking\n - materialized-occurrences\n - archive\n - templating\n title:\n storage: frontmatter\n filename_format: zettel\n status:\n values: *a2\n default: open\n completed_values: *a3\n skipped_values:\n - cancelled\n default_skipped: cancelled\n definitions:\n - value: none\n label: None\n color: \"#cccccc\"\n is_completed: false\n is_skipped: false\n exclude_from_cycle: false\n order: 0\n auto_archive: false\n auto_archive_delay_minutes: 5\n - value: open\n label: Open\n color: \"#808080\"\n is_completed: false\n is_skipped: false\n exclude_from_cycle: false\n order: 1\n auto_archive: false\n auto_archive_delay_minutes: 5\n - value: in-progress\n label: In progress\n color: \"#0066cc\"\n is_completed: false\n is_skipped: false\n exclude_from_cycle: false\n order: 2\n auto_archive: false\n auto_archive_delay_minutes: 5\n - value: done\n label: Done\n color: \"#00aa00\"\n is_completed: true\n is_skipped: false\n exclude_from_cycle: false\n order: 3\n auto_archive: false\n auto_archive_delay_minutes: 5\n - value: cancelled\n label: Cancelled\n color: \"#808080\"\n is_completed: false\n is_skipped: true\n exclude_from_cycle: true\n order: 4\n auto_archive: false\n auto_archive_delay_minutes: 5\n priority:\n values: *a4\n default: normal\n definitions:\n - value: none\n label: None\n color: \"#cccccc\"\n weight: 0\n - value: low\n label: Low\n color: \"#00aa00\"\n weight: 1\n - value: normal\n label: Normal\n color: \"#ffaa00\"\n weight: 2\n - value: high\n label: High\n color: \"#ff0000\"\n weight: 3\n recurrence:\n syntax: tasknotes\n maintain_due_date_offset: true\n reset_body_checkboxes: false\n occurrences:\n identity_roles:\n - recurrenceParent\n - occurrenceDate\n default_materialization: manual\n default_next_trigger: completion\n past_horizon: P0D\n future_horizon: P14D\n links:\n accepted_formats:\n - wikilink\n - markdown\n write_format: wikilink\n archive:\n archived_tag: archived\n move_on_archive: false\n time_tracking:\n auto_stop_on_complete: false\n templating:\n enabled: false\n occurrence_enabled: false\nx-tasknotes-generator:\n managed_fields:\n - attachments\n - blockedBy\n - complete_instances\n - completedDate\n - contexts\n - dateCreated\n - dateModified\n - due\n - googleCalendarEventId\n - googleCalendarExceptionEventId\n - googleCalendarExceptionOriginalScheduled\n - googleCalendarMovedOriginalDates\n - icsEventId\n - id\n - occurrence_date\n - occurrence_future_horizon\n - occurrence_materialization\n - occurrence_next_trigger\n - occurrence_past_horizon\n - occurrence_template\n - priority\n - projects\n - recurrence\n - recurrence_anchor\n - recurrence_parent\n - reminders\n - scheduled\n - skipped_instances\n - status\n - tags\n - tasknotes_manual_order\n - timeEntries\n - timeEstimate\n - title\n---\n# Task\n\nTask records live under `tasks/`.\n"
},
{
"source": "schemas/tasknotes-task.schema.json",
- "document": "{\n \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n \"$id\": \"https://tasknotes.dev/schemas/tasknotes-task.schema.json\",\n \"title\": \"TaskNotes portable task view\",\n \"description\": \"The storage-neutral record view exposed by the tasknotes.task 0.3.0-rc.1 record contract.\",\n \"type\": \"object\",\n \"required\": [\n \"status\",\n \"dateCreated\"\n ],\n \"additionalProperties\": false,\n \"properties\": {\n \"id\": {\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"title\": {\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"status\": {\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"priority\": {\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"due\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"date\"\n },\n {\n \"type\": \"string\",\n \"format\": \"date-time\"\n }\n ]\n },\n \"scheduled\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"date\"\n },\n {\n \"type\": \"string\",\n \"format\": \"date-time\"\n }\n ]\n },\n \"contexts\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"projects\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"timeEstimate\": {\n \"type\": \"integer\",\n \"minimum\": 0\n },\n \"completedDate\": {\n \"type\": \"string\",\n \"format\": \"date\"\n },\n \"dateCreated\": {\n \"type\": \"string\",\n \"format\": \"date-time\"\n },\n \"dateModified\": {\n \"type\": \"string\",\n \"format\": \"date-time\"\n },\n \"recurrence\": {\n \"type\": \"string\"\n },\n \"recurrenceAnchor\": {\n \"enum\": [\n \"scheduled\",\n \"completion\"\n ]\n },\n \"recurrenceParent\": {\n \"type\": \"string\"\n },\n \"occurrenceDate\": {\n \"type\": \"string\",\n \"format\": \"date\"\n },\n \"occurrenceMaterialization\": {\n \"enum\": [\n \"manual\",\n \"on_completion\",\n \"rolling\"\n ]\n },\n \"occurrenceNextTrigger\": {\n \"enum\": [\n \"completion\",\n \"completion_or_skip\"\n ]\n },\n \"occurrenceTemplate\": {\n \"type\": \"string\"\n },\n \"occurrencePastHorizon\": {\n \"type\": \"string\"\n },\n \"occurrenceFutureHorizon\": {\n \"type\": \"string\"\n },\n \"tags\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"timeEntries\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\"\n }\n },\n \"reminders\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\"\n }\n },\n \"blockedBy\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\"\n }\n },\n \"completeInstances\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\",\n \"format\": \"date\"\n }\n },\n \"skippedInstances\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\",\n \"format\": \"date\"\n }\n },\n \"icsEventId\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"googleCalendarEventId\": {\n \"type\": \"string\"\n },\n \"googleCalendarExceptionEventId\": {\n \"type\": \"string\"\n },\n \"googleCalendarExceptionOriginalScheduled\": {\n \"type\": \"string\",\n \"format\": \"date\"\n },\n \"googleCalendarMovedOriginalDates\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\",\n \"format\": \"date\"\n }\n },\n \"sortOrder\": {\n \"type\": \"string\"\n }\n }\n}\n"
+ "document": "{\n \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n \"$id\": \"https://tasknotes.dev/schemas/tasknotes-task.schema.json\",\n \"title\": \"TaskNotes portable task view\",\n \"description\": \"The storage-neutral record view exposed by the tasknotes.task 0.3.0-rc.3 record contract.\",\n \"type\": \"object\",\n \"required\": [\n \"status\",\n \"dateCreated\"\n ],\n \"additionalProperties\": false,\n \"properties\": {\n \"id\": {\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"title\": {\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"status\": {\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"priority\": {\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"due\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"date\"\n },\n {\n \"type\": \"string\",\n \"format\": \"date-time\"\n }\n ]\n },\n \"scheduled\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"date\"\n },\n {\n \"type\": \"string\",\n \"format\": \"date-time\"\n }\n ]\n },\n \"contexts\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"projects\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"attachments\": {\n \"type\": \"array\",\n \"uniqueItems\": true,\n \"items\": {\n \"type\": \"string\",\n \"minLength\": 1\n }\n },\n \"timeEstimate\": {\n \"type\": \"integer\",\n \"minimum\": 0\n },\n \"completedDate\": {\n \"type\": \"string\",\n \"format\": \"date\"\n },\n \"dateCreated\": {\n \"type\": \"string\",\n \"format\": \"date-time\"\n },\n \"dateModified\": {\n \"type\": \"string\",\n \"format\": \"date-time\"\n },\n \"recurrence\": {\n \"type\": \"string\"\n },\n \"recurrenceAnchor\": {\n \"enum\": [\n \"scheduled\",\n \"completion\"\n ]\n },\n \"recurrenceParent\": {\n \"type\": \"string\"\n },\n \"occurrenceDate\": {\n \"type\": \"string\",\n \"format\": \"date\"\n },\n \"occurrenceMaterialization\": {\n \"enum\": [\n \"manual\",\n \"on_completion\",\n \"rolling\"\n ]\n },\n \"occurrenceNextTrigger\": {\n \"enum\": [\n \"completion\",\n \"completion_or_skip\"\n ]\n },\n \"occurrenceTemplate\": {\n \"type\": \"string\"\n },\n \"occurrencePastHorizon\": {\n \"type\": \"string\"\n },\n \"occurrenceFutureHorizon\": {\n \"type\": \"string\"\n },\n \"tags\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"timeEntries\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\"\n }\n },\n \"reminders\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\"\n }\n },\n \"blockedBy\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\"\n }\n },\n \"completeInstances\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\",\n \"format\": \"date\"\n }\n },\n \"skippedInstances\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\",\n \"format\": \"date\"\n }\n },\n \"icsEventId\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"googleCalendarEventId\": {\n \"type\": \"string\"\n },\n \"googleCalendarExceptionEventId\": {\n \"type\": \"string\"\n },\n \"googleCalendarExceptionOriginalScheduled\": {\n \"type\": \"string\",\n \"format\": \"date\"\n },\n \"googleCalendarMovedOriginalDates\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\",\n \"format\": \"date\"\n }\n },\n \"sortOrder\": {\n \"type\": \"string\"\n }\n }\n}\n"
},
{
"source": "schemas/tasknotes-task-binding.schema.json",
- "document": "{\n \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n \"$id\": \"https://tasknotes.dev/schemas/tasknotes-task-binding.schema.json\",\n \"title\": \"TaskNotes task data-contract binding\",\n \"description\": \"Semantic configuration supplied by an mdbase type that implements tasknotes.task 0.3.0-rc.1.\",\n \"type\": \"object\",\n \"required\": [\n \"profiles\",\n \"capabilities\",\n \"title\",\n \"status\",\n \"priority\",\n \"recurrence\",\n \"occurrences\",\n \"links\",\n \"archive\",\n \"time_tracking\",\n \"templating\"\n ],\n \"properties\": {\n \"profiles\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"uniqueItems\": true,\n \"items\": {\n \"enum\": [\n \"core-lite\",\n \"recurrence\",\n \"templating\",\n \"materialized-occurrences\",\n \"extended\"\n ]\n }\n },\n \"capabilities\": {\n \"type\": \"array\",\n \"uniqueItems\": true,\n \"items\": {\n \"enum\": [\n \"dependencies\",\n \"reminders\",\n \"links\",\n \"time-tracking\",\n \"materialized-occurrences\",\n \"rename\",\n \"archive\",\n \"batch\",\n \"concurrency\",\n \"dry-run\",\n \"migration\",\n \"templating\"\n ]\n }\n },\n \"title\": {\n \"$ref\": \"#/$defs/titlePolicy\"\n },\n \"status\": {\n \"$ref\": \"#/$defs/statusPolicy\"\n },\n \"priority\": {\n \"$ref\": \"#/$defs/priorityPolicy\"\n },\n \"runtime_timezone\": {\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"recurrence\": {\n \"$ref\": \"#/$defs/recurrencePolicy\"\n },\n \"occurrences\": {\n \"$ref\": \"#/$defs/occurrencePolicy\"\n },\n \"links\": {\n \"$ref\": \"#/$defs/linkPolicy\"\n },\n \"archive\": {\n \"$ref\": \"#/$defs/archivePolicy\"\n },\n \"time_tracking\": {\n \"$ref\": \"#/$defs/timeTrackingPolicy\"\n },\n \"templating\": {\n \"$ref\": \"#/$defs/templatingPolicy\"\n },\n \"nlp\": {\n \"$ref\": \"#/$defs/nlpPolicy\"\n }\n },\n \"additionalProperties\": false,\n \"$defs\": {\n \"nonEmptyString\": {\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"stringSet\": {\n \"type\": \"array\",\n \"uniqueItems\": true,\n \"items\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n }\n },\n \"titlePolicy\": {\n \"type\": \"object\",\n \"required\": [\n \"storage\"\n ],\n \"properties\": {\n \"storage\": {\n \"enum\": [\n \"filename\",\n \"frontmatter\"\n ]\n },\n \"filename_format\": {\n \"enum\": [\n \"title\",\n \"zettel\",\n \"timestamp\",\n \"uuid\",\n \"custom\"\n ]\n },\n \"custom_filename_template\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n }\n },\n \"allOf\": [\n {\n \"if\": {\n \"properties\": {\n \"filename_format\": {\n \"const\": \"custom\"\n }\n },\n \"required\": [\n \"filename_format\"\n ]\n },\n \"then\": {\n \"properties\": {\n \"custom_filename_template\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n }\n },\n \"required\": [\n \"custom_filename_template\"\n ]\n }\n }\n ],\n \"additionalProperties\": true\n },\n \"statusDefinition\": {\n \"type\": \"object\",\n \"required\": [\n \"value\",\n \"label\",\n \"color\",\n \"is_completed\",\n \"is_skipped\",\n \"exclude_from_cycle\",\n \"order\",\n \"auto_archive\",\n \"auto_archive_delay_minutes\"\n ],\n \"properties\": {\n \"value\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n },\n \"label\": {\n \"type\": \"string\"\n },\n \"color\": {\n \"type\": \"string\"\n },\n \"icon\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n },\n \"is_completed\": {\n \"type\": \"boolean\"\n },\n \"is_skipped\": {\n \"type\": \"boolean\"\n },\n \"exclude_from_cycle\": {\n \"type\": \"boolean\"\n },\n \"next_status\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n },\n \"order\": {\n \"type\": \"number\"\n },\n \"auto_archive\": {\n \"type\": \"boolean\"\n },\n \"auto_archive_delay_minutes\": {\n \"type\": \"number\",\n \"minimum\": 0\n }\n },\n \"additionalProperties\": true\n },\n \"statusPolicy\": {\n \"type\": \"object\",\n \"required\": [\n \"values\",\n \"default\",\n \"completed_values\",\n \"definitions\"\n ],\n \"properties\": {\n \"values\": {\n \"$ref\": \"#/$defs/stringSet\"\n },\n \"default\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n },\n \"completed_values\": {\n \"$ref\": \"#/$defs/stringSet\"\n },\n \"skipped_values\": {\n \"$ref\": \"#/$defs/stringSet\"\n },\n \"default_skipped\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n },\n \"definitions\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/$defs/statusDefinition\"\n }\n }\n },\n \"additionalProperties\": false\n },\n \"priorityDefinition\": {\n \"type\": \"object\",\n \"required\": [\n \"value\",\n \"label\",\n \"color\",\n \"weight\"\n ],\n \"properties\": {\n \"value\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n },\n \"label\": {\n \"type\": \"string\"\n },\n \"color\": {\n \"type\": \"string\"\n },\n \"icon\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n },\n \"weight\": {\n \"type\": \"number\"\n }\n },\n \"additionalProperties\": true\n },\n \"priorityPolicy\": {\n \"type\": \"object\",\n \"required\": [\n \"values\",\n \"default\",\n \"definitions\"\n ],\n \"properties\": {\n \"values\": {\n \"$ref\": \"#/$defs/stringSet\"\n },\n \"default\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n },\n \"definitions\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/$defs/priorityDefinition\"\n }\n }\n },\n \"additionalProperties\": false\n },\n \"recurrencePolicy\": {\n \"type\": \"object\",\n \"required\": [\n \"syntax\",\n \"maintain_due_date_offset\",\n \"reset_body_checkboxes\"\n ],\n \"properties\": {\n \"syntax\": {\n \"const\": \"tasknotes\"\n },\n \"maintain_due_date_offset\": {\n \"type\": \"boolean\"\n },\n \"reset_body_checkboxes\": {\n \"type\": \"boolean\"\n }\n },\n \"additionalProperties\": false\n },\n \"occurrencePolicy\": {\n \"type\": \"object\",\n \"required\": [\n \"identity_roles\",\n \"default_materialization\",\n \"default_next_trigger\"\n ],\n \"properties\": {\n \"identity_roles\": {\n \"const\": [\n \"recurrenceParent\",\n \"occurrenceDate\"\n ]\n },\n \"default_materialization\": {\n \"enum\": [\n \"manual\",\n \"on_completion\",\n \"rolling\"\n ]\n },\n \"default_next_trigger\": {\n \"enum\": [\n \"completion\",\n \"completion_or_skip\"\n ]\n },\n \"past_horizon\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n },\n \"future_horizon\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n }\n },\n \"additionalProperties\": false\n },\n \"linkPolicy\": {\n \"type\": \"object\",\n \"required\": [\n \"accepted_formats\",\n \"write_format\"\n ],\n \"properties\": {\n \"accepted_formats\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"uniqueItems\": true,\n \"items\": {\n \"enum\": [\n \"wikilink\",\n \"markdown\"\n ]\n }\n },\n \"write_format\": {\n \"enum\": [\n \"wikilink\",\n \"markdown\"\n ]\n }\n },\n \"additionalProperties\": false\n },\n \"archivePolicy\": {\n \"type\": \"object\",\n \"required\": [\n \"archived_tag\",\n \"move_on_archive\"\n ],\n \"properties\": {\n \"archived_tag\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n },\n \"move_on_archive\": {\n \"type\": \"boolean\"\n },\n \"folder\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n }\n },\n \"additionalProperties\": false\n },\n \"timeTrackingPolicy\": {\n \"type\": \"object\",\n \"required\": [\n \"auto_stop_on_complete\"\n ],\n \"properties\": {\n \"auto_stop_on_complete\": {\n \"type\": \"boolean\"\n }\n },\n \"additionalProperties\": false\n },\n \"templatingPolicy\": {\n \"type\": \"object\",\n \"required\": [\n \"enabled\",\n \"occurrence_enabled\"\n ],\n \"properties\": {\n \"enabled\": {\n \"type\": \"boolean\"\n },\n \"template_path\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n },\n \"occurrence_enabled\": {\n \"type\": \"boolean\"\n },\n \"occurrence_template_path\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n },\n \"failure_mode\": {\n \"enum\": [\n \"error\",\n \"warning_fallback\"\n ]\n },\n \"unknown_variable_policy\": {\n \"enum\": [\n \"preserve\",\n \"empty\"\n ]\n }\n },\n \"additionalProperties\": false\n },\n \"nlpTrigger\": {\n \"type\": \"object\",\n \"required\": [\n \"property_id\",\n \"trigger\",\n \"enabled\"\n ],\n \"properties\": {\n \"property_id\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n },\n \"trigger\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n },\n \"enabled\": {\n \"type\": \"boolean\"\n }\n },\n \"additionalProperties\": false\n },\n \"nlpPolicy\": {\n \"type\": \"object\",\n \"required\": [\n \"triggers\"\n ],\n \"properties\": {\n \"triggers\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/$defs/nlpTrigger\"\n }\n }\n },\n \"additionalProperties\": false\n }\n }\n}\n"
+ "document": "{\n \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n \"$id\": \"https://tasknotes.dev/schemas/tasknotes-task-binding.schema.json\",\n \"title\": \"TaskNotes task data-contract binding\",\n \"description\": \"Semantic configuration supplied by an mdbase type that implements tasknotes.task 0.3.0-rc.3.\",\n \"type\": \"object\",\n \"required\": [\n \"profiles\",\n \"capabilities\",\n \"title\",\n \"status\",\n \"priority\",\n \"recurrence\",\n \"occurrences\",\n \"links\",\n \"archive\",\n \"time_tracking\",\n \"templating\"\n ],\n \"properties\": {\n \"profiles\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"uniqueItems\": true,\n \"items\": {\n \"enum\": [\n \"core-lite\",\n \"recurrence\",\n \"templating\",\n \"materialized-occurrences\",\n \"extended\"\n ]\n }\n },\n \"capabilities\": {\n \"type\": \"array\",\n \"uniqueItems\": true,\n \"items\": {\n \"enum\": [\n \"dependencies\",\n \"reminders\",\n \"attachments\",\n \"links\",\n \"time-tracking\",\n \"materialized-occurrences\",\n \"rename\",\n \"archive\",\n \"batch\",\n \"concurrency\",\n \"dry-run\",\n \"migration\",\n \"templating\"\n ]\n }\n },\n \"title\": {\n \"$ref\": \"#/$defs/titlePolicy\"\n },\n \"status\": {\n \"$ref\": \"#/$defs/statusPolicy\"\n },\n \"priority\": {\n \"$ref\": \"#/$defs/priorityPolicy\"\n },\n \"runtime_timezone\": {\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"recurrence\": {\n \"$ref\": \"#/$defs/recurrencePolicy\"\n },\n \"occurrences\": {\n \"$ref\": \"#/$defs/occurrencePolicy\"\n },\n \"links\": {\n \"$ref\": \"#/$defs/linkPolicy\"\n },\n \"archive\": {\n \"$ref\": \"#/$defs/archivePolicy\"\n },\n \"time_tracking\": {\n \"$ref\": \"#/$defs/timeTrackingPolicy\"\n },\n \"templating\": {\n \"$ref\": \"#/$defs/templatingPolicy\"\n },\n \"nlp\": {\n \"$ref\": \"#/$defs/nlpPolicy\"\n }\n },\n \"additionalProperties\": false,\n \"$defs\": {\n \"nonEmptyString\": {\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"stringSet\": {\n \"type\": \"array\",\n \"uniqueItems\": true,\n \"items\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n }\n },\n \"titlePolicy\": {\n \"type\": \"object\",\n \"required\": [\n \"storage\"\n ],\n \"properties\": {\n \"storage\": {\n \"enum\": [\n \"filename\",\n \"frontmatter\"\n ]\n },\n \"filename_format\": {\n \"enum\": [\n \"title\",\n \"zettel\",\n \"timestamp\",\n \"uuid\",\n \"custom\"\n ]\n },\n \"custom_filename_template\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n }\n },\n \"allOf\": [\n {\n \"if\": {\n \"properties\": {\n \"filename_format\": {\n \"const\": \"custom\"\n }\n },\n \"required\": [\n \"filename_format\"\n ]\n },\n \"then\": {\n \"properties\": {\n \"custom_filename_template\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n }\n },\n \"required\": [\n \"custom_filename_template\"\n ]\n }\n }\n ],\n \"additionalProperties\": true\n },\n \"statusDefinition\": {\n \"type\": \"object\",\n \"required\": [\n \"value\",\n \"label\",\n \"color\",\n \"is_completed\",\n \"is_skipped\",\n \"exclude_from_cycle\",\n \"order\",\n \"auto_archive\",\n \"auto_archive_delay_minutes\"\n ],\n \"properties\": {\n \"value\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n },\n \"label\": {\n \"type\": \"string\"\n },\n \"color\": {\n \"type\": \"string\"\n },\n \"icon\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n },\n \"is_completed\": {\n \"type\": \"boolean\"\n },\n \"is_skipped\": {\n \"type\": \"boolean\"\n },\n \"exclude_from_cycle\": {\n \"type\": \"boolean\"\n },\n \"next_status\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n },\n \"order\": {\n \"type\": \"number\"\n },\n \"auto_archive\": {\n \"type\": \"boolean\"\n },\n \"auto_archive_delay_minutes\": {\n \"type\": \"number\",\n \"minimum\": 0\n }\n },\n \"additionalProperties\": true\n },\n \"statusPolicy\": {\n \"type\": \"object\",\n \"required\": [\n \"values\",\n \"default\",\n \"completed_values\",\n \"definitions\"\n ],\n \"properties\": {\n \"values\": {\n \"$ref\": \"#/$defs/stringSet\"\n },\n \"default\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n },\n \"completed_values\": {\n \"$ref\": \"#/$defs/stringSet\"\n },\n \"skipped_values\": {\n \"$ref\": \"#/$defs/stringSet\"\n },\n \"default_skipped\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n },\n \"definitions\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/$defs/statusDefinition\"\n }\n }\n },\n \"additionalProperties\": false\n },\n \"priorityDefinition\": {\n \"type\": \"object\",\n \"required\": [\n \"value\",\n \"label\",\n \"color\",\n \"weight\"\n ],\n \"properties\": {\n \"value\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n },\n \"label\": {\n \"type\": \"string\"\n },\n \"color\": {\n \"type\": \"string\"\n },\n \"icon\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n },\n \"weight\": {\n \"type\": \"number\"\n }\n },\n \"additionalProperties\": true\n },\n \"priorityPolicy\": {\n \"type\": \"object\",\n \"required\": [\n \"values\",\n \"default\",\n \"definitions\"\n ],\n \"properties\": {\n \"values\": {\n \"$ref\": \"#/$defs/stringSet\"\n },\n \"default\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n },\n \"definitions\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/$defs/priorityDefinition\"\n }\n }\n },\n \"additionalProperties\": false\n },\n \"recurrencePolicy\": {\n \"type\": \"object\",\n \"required\": [\n \"syntax\",\n \"maintain_due_date_offset\",\n \"reset_body_checkboxes\"\n ],\n \"properties\": {\n \"syntax\": {\n \"const\": \"tasknotes\"\n },\n \"maintain_due_date_offset\": {\n \"type\": \"boolean\"\n },\n \"reset_body_checkboxes\": {\n \"type\": \"boolean\"\n }\n },\n \"additionalProperties\": false\n },\n \"occurrencePolicy\": {\n \"type\": \"object\",\n \"required\": [\n \"identity_roles\",\n \"default_materialization\",\n \"default_next_trigger\"\n ],\n \"properties\": {\n \"identity_roles\": {\n \"const\": [\n \"recurrenceParent\",\n \"occurrenceDate\"\n ]\n },\n \"default_materialization\": {\n \"enum\": [\n \"manual\",\n \"on_completion\",\n \"rolling\"\n ]\n },\n \"default_next_trigger\": {\n \"enum\": [\n \"completion\",\n \"completion_or_skip\"\n ]\n },\n \"past_horizon\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n },\n \"future_horizon\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n }\n },\n \"additionalProperties\": false\n },\n \"linkPolicy\": {\n \"type\": \"object\",\n \"required\": [\n \"accepted_formats\",\n \"write_format\"\n ],\n \"properties\": {\n \"accepted_formats\": {\n \"type\": \"array\",\n \"minItems\": 1,\n \"uniqueItems\": true,\n \"items\": {\n \"enum\": [\n \"wikilink\",\n \"markdown\"\n ]\n }\n },\n \"write_format\": {\n \"enum\": [\n \"wikilink\",\n \"markdown\"\n ]\n }\n },\n \"additionalProperties\": false\n },\n \"archivePolicy\": {\n \"type\": \"object\",\n \"required\": [\n \"archived_tag\",\n \"move_on_archive\"\n ],\n \"properties\": {\n \"archived_tag\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n },\n \"move_on_archive\": {\n \"type\": \"boolean\"\n },\n \"folder\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n }\n },\n \"additionalProperties\": false\n },\n \"timeTrackingPolicy\": {\n \"type\": \"object\",\n \"required\": [\n \"auto_stop_on_complete\"\n ],\n \"properties\": {\n \"auto_stop_on_complete\": {\n \"type\": \"boolean\"\n }\n },\n \"additionalProperties\": false\n },\n \"templatingPolicy\": {\n \"type\": \"object\",\n \"required\": [\n \"enabled\",\n \"occurrence_enabled\"\n ],\n \"properties\": {\n \"enabled\": {\n \"type\": \"boolean\"\n },\n \"template_path\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n },\n \"occurrence_enabled\": {\n \"type\": \"boolean\"\n },\n \"occurrence_template_path\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n },\n \"failure_mode\": {\n \"enum\": [\n \"error\",\n \"warning_fallback\"\n ]\n },\n \"unknown_variable_policy\": {\n \"enum\": [\n \"preserve\",\n \"empty\"\n ]\n }\n },\n \"additionalProperties\": false\n },\n \"nlpTrigger\": {\n \"type\": \"object\",\n \"required\": [\n \"property_id\",\n \"trigger\",\n \"enabled\"\n ],\n \"properties\": {\n \"property_id\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n },\n \"trigger\": {\n \"$ref\": \"#/$defs/nonEmptyString\"\n },\n \"enabled\": {\n \"type\": \"boolean\"\n }\n },\n \"additionalProperties\": false\n },\n \"nlpPolicy\": {\n \"type\": \"object\",\n \"required\": [\n \"triggers\"\n ],\n \"properties\": {\n \"triggers\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/$defs/nlpTrigger\"\n }\n }\n },\n \"additionalProperties\": false\n }\n }\n}\n"
}
],
"provides": [
{
"id": "tasknotes.task",
- "version": "0.3.0-rc.1"
+ "version": "0.3.0-rc.3"
}
]
}
diff --git a/src/native/folder-access.ts b/src/native/folder-access.ts
index 07947a3..00ad560 100644
--- a/src/native/folder-access.ts
+++ b/src/native/folder-access.ts
@@ -9,6 +9,7 @@ export interface NativeFolderEntry {
path: string;
lastModified: number;
size: number;
+ mediaType?: string;
}
interface FolderAccessPlugin {
@@ -32,11 +33,20 @@ interface FolderAccessPlugin {
selectionId: string;
path: string;
}): Promise<{ data: string }>;
+ readBinary(options: {
+ selectionId: string;
+ path: string;
+ }): Promise<{ data: string }>;
writeText(options: {
selectionId: string;
path: string;
data: string;
}): Promise<{ entry: NativeFolderEntry }>;
+ writeBinary(options: {
+ selectionId: string;
+ path: string;
+ data: string;
+ }): Promise<{ entry: NativeFolderEntry }>;
rename(options: {
selectionId: string;
from: string;
diff --git a/src/storage/cloud-repository.test.ts b/src/storage/cloud-repository.test.ts
index af37058..d2a332e 100644
--- a/src/storage/cloud-repository.test.ts
+++ b/src/storage/cloud-repository.test.ts
@@ -32,7 +32,7 @@ function resources(): SyncCollectionResources {
const implementation = type.implements.find(
(candidate) =>
candidate.contract === "tasknotes.task" &&
- candidate.version === "0.3.0-rc.1",
+ candidate.version === "0.3.0-rc.3",
)!;
return {
revision: crypto.randomUUID(),
@@ -51,7 +51,7 @@ function resources(): SyncCollectionResources {
{
id: "tasknotes.task",
contract_type: "record",
- version: "0.3.0-rc.1",
+ version: "0.3.0-rc.3",
digest: `sha256:${"0".repeat(64)}`,
schema: generated.taskSchema,
binding_schema: generated.bindingSchema,
diff --git a/src/storage/cloud-repository.ts b/src/storage/cloud-repository.ts
index 0506df1..0e6e06f 100644
--- a/src/storage/cloud-repository.ts
+++ b/src/storage/cloud-repository.ts
@@ -29,6 +29,7 @@ import {
} from "../domain/task-occurrence";
import { runMdbaseMutation } from "./mdbase-mutation-coordinator";
import { MdbaseCollectionFileStore } from "./mdbase-files";
+import { LocalFirstMdbaseFileStore } from "./local-first-mdbase-files";
import {
connectedTaskRelationships,
connectedTaskSignature as signature,
@@ -89,7 +90,7 @@ interface CachedCloudTask {
}
export class CloudTaskRepository implements TaskRepository {
- readonly files: MdbaseCollectionFileStore;
+ readonly files: LocalFirstMdbaseFileStore;
private replica: OfflineReplica | null = null;
private model = new TaskNotesTaskModel();
private resources: SyncCollectionResources | null = null;
@@ -119,7 +120,10 @@ export class CloudTaskRepository implements TaskRepository {
private syncInFlight: Promise | null = null;
constructor(private readonly connect: MdbaseConnection) {
- this.files = new MdbaseCollectionFileStore(connect);
+ this.files = new LocalFirstMdbaseFileStore(
+ new MdbaseCollectionFileStore(connect),
+ connect.collectionId,
+ );
}
initialize(): Promise {
@@ -128,6 +132,7 @@ export class CloudTaskRepository implements TaskRepository {
}
private async initializeUnlocked(): Promise {
+ await this.files.sync().catch(() => undefined);
const sync = this.connect.sync();
if (!sync) {
throw new Error(
diff --git a/src/storage/collection-migration.test.ts b/src/storage/collection-migration.test.ts
index b5d93fa..e2c487d 100644
--- a/src/storage/collection-migration.test.ts
+++ b/src/storage/collection-migration.test.ts
@@ -19,7 +19,7 @@ describe("managed TaskNotes type upgrades", () => {
).find(
(candidate) =>
candidate.contract === "tasknotes.task" &&
- candidate.version === "0.3.0-rc.1",
+ candidate.version === "0.3.0-rc.3",
)!;
const extension = implementation.binding as Record;
extension.profiles = ["core-lite"];
@@ -42,7 +42,7 @@ describe("managed TaskNotes type upgrades", () => {
).find(
(candidate) =>
candidate.contract === "tasknotes.task" &&
- candidate.version === "0.3.0-rc.1",
+ candidate.version === "0.3.0-rc.3",
)!;
const upgradedExtension = upgradedImplementation.binding as Record<
string,
diff --git a/src/storage/collection-migration.ts b/src/storage/collection-migration.ts
index 59addf1..ffd3521 100644
--- a/src/storage/collection-migration.ts
+++ b/src/storage/collection-migration.ts
@@ -1,3 +1,5 @@
+import { TASKNOTES_SPEC_VERSION } from "@tasknotes/model/types";
+
export interface ManagedTypeUpgrade {
changed: boolean;
frontmatter: Record;
@@ -21,7 +23,7 @@ export function upgradeManagedTaskType(
source.description === "A TaskNotes-compatible task." &&
record(properties.mobileRevision).type === "integer" &&
implementation.contract === "tasknotes.task" &&
- implementation.version === "0.3.0-rc.1";
+ implementation.version === TASKNOTES_SPEC_VERSION;
if (!managed) {
return { changed: false, frontmatter: source, completedField };
}
@@ -162,7 +164,7 @@ function taskNotesImplementation(
.find(
(implementation) =>
implementation.contract === "tasknotes.task" &&
- implementation.version === "0.3.0-rc.1",
+ implementation.version === TASKNOTES_SPEC_VERSION,
) ?? {}
);
}
@@ -176,7 +178,7 @@ function replaceTaskNotesImplementation(
const next = implementations.map((implementation) => {
if (
implementation.contract !== "tasknotes.task" ||
- implementation.version !== "0.3.0-rc.1"
+ implementation.version !== TASKNOTES_SPEC_VERSION
)
return implementation;
replaced = true;
diff --git a/src/storage/collection-transfer.test.ts b/src/storage/collection-transfer.test.ts
index 6600829..7b16706 100644
--- a/src/storage/collection-transfer.test.ts
+++ b/src/storage/collection-transfer.test.ts
@@ -6,6 +6,7 @@ import {
type AuthorityAdoptionSession,
type AuthorityAdoptionView,
type PreparedAuthorityAdoption,
+ type UploadAuthoritySnapshotOptions,
} from "@mdbase-dev/connect-sync/adoption";
import type { AuthorityImportSnapshot } from "@mdbase-dev/connect-protocol";
import { beforeEach, describe, expect, it } from "vitest";
@@ -23,6 +24,7 @@ describe("local to hosted collection adoption", () => {
it("uploads a replacement final snapshot after a late edit and archives the local authority", async () => {
const source = await localCollection();
const snapshots: AuthorityImportSnapshot[] = [];
+ const uploadedFileBytes: Uint8Array[][] = [];
const client = clientDouble({
afterFirstUpload: async () => {
await source.vault.writeText(
@@ -30,7 +32,18 @@ describe("local to hosted collection adoption", () => {
"---\ntitle: Late\n---\nArrived during staging.\n",
);
},
- upload: (snapshot) => snapshots.push(snapshot),
+ upload: async (snapshot, uploadOptions) => {
+ snapshots.push(snapshot);
+ uploadedFileBytes.push(
+ await Promise.all(
+ snapshot.files.map(async (file) =>
+ Uint8Array.from(
+ await bytesFromSource(await uploadOptions?.fileSource?.(file)),
+ ),
+ ),
+ ),
+ );
+ },
});
const result = await transferLocalCollectionToHosted({
@@ -42,6 +55,17 @@ describe("local to hosted collection adoption", () => {
});
expect(snapshots).toHaveLength(2);
+ expect(snapshots[0].files).toEqual([
+ expect.objectContaining({
+ path: "Attachments/source.png",
+ media_type: "image/png",
+ media_class: "image",
+ }),
+ ]);
+ expect(uploadedFileBytes).toEqual([
+ [Uint8Array.of(137, 80, 78, 71, 1)],
+ [Uint8Array.of(137, 80, 78, 71, 1)],
+ ]);
expect(snapshots[0].records.map((record) => record.path)).toEqual([
"notes/context.md",
"tasks/source.md",
@@ -314,13 +338,20 @@ async function localCollection() {
"views/tasks.base",
"views:\n - type: table\n name: Tasks\n",
);
+ await vault.writeBinary(
+ "Attachments/source.png",
+ Uint8Array.of(137, 80, 78, 71, 1),
+ );
return { collection, taskId, vault };
}
function clientDouble(
options: {
afterFirstUpload?: () => Promise;
- upload?: (snapshot: AuthorityImportSnapshot) => void;
+ upload?: (
+ snapshot: AuthorityImportSnapshot,
+ options?: UploadAuthoritySnapshotOptions,
+ ) => void | Promise;
complete?: () => Promise;
exchange?:
| PreparedAuthorityAdoption
@@ -399,9 +430,10 @@ function clientDouble(
_session: AuthorityAdoptionSession,
_prepared: PreparedAuthorityAdoption,
snapshot: AuthorityImportSnapshot,
+ uploadOptions?: UploadAuthoritySnapshotOptions,
) => {
uploads += 1;
- options.upload?.(snapshot);
+ await options.upload?.(snapshot, uploadOptions);
if (uploads === 1) await options.afterFirstUpload?.();
},
complete: async (
@@ -417,6 +449,17 @@ function clientDouble(
} as unknown as AuthorityAdoptionClient;
}
+async function bytesFromSource(
+ source: Blob | ArrayBuffer | ArrayBufferView | undefined,
+): Promise {
+ if (!source)
+ throw new Error("The adoption did not request attachment bytes.");
+ if (source instanceof Blob) return new Uint8Array(await source.arrayBuffer());
+ if (ArrayBuffer.isView(source))
+ return new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
+ return new Uint8Array(source);
+}
+
function adoptionView(
snapshot: AuthorityImportSnapshot,
state: "activating" | "completed",
diff --git a/src/storage/collection-transfer.ts b/src/storage/collection-transfer.ts
index e8cfb2c..31ad02e 100644
--- a/src/storage/collection-transfer.ts
+++ b/src/storage/collection-transfer.ts
@@ -4,12 +4,17 @@ import {
AuthorityAdoptionOutcomeUnknownError,
buildPortableAuthoritySnapshot,
type AuthorityAdoptionSession,
+ type AuthorityImportFileSource,
type AuthorityAdoptionVerification,
type PreparedAuthorityAdoption,
} from "@mdbase-dev/connect-sync/adoption";
-import type { AuthorityImportSnapshot } from "@mdbase-dev/connect-protocol";
+import type {
+ AuthorityImportSnapshot,
+ CollectionFileDescriptor,
+} from "@mdbase-dev/connect-protocol";
import type { MarkdownCollection } from "./collection";
+import { isBinaryVault } from "./vault-contract";
export type CollectionTransferPhase =
| "reading"
@@ -157,13 +162,15 @@ export async function transferLocalCollectionToHosted({
onProgress?.({
phase: "uploading",
completed: 0,
- total: initial.records.length + initial.resources.documents!.length,
+ total: snapshotItemCount(initial.snapshot),
+ });
+ await client.uploadSnapshot(session, prepared, initial.snapshot, {
+ fileSource: initial.fileSource,
});
- await client.uploadSnapshot(session, prepared, initial);
onProgress?.({
phase: "uploading",
- completed: initial.records.length + initial.resources.documents!.length,
- total: initial.records.length + initial.resources.documents!.length,
+ completed: snapshotItemCount(initial.snapshot),
+ total: snapshotItemCount(initial.snapshot),
});
onProgress?.({ phase: "fencing", completed: 0, total: 1 });
@@ -171,26 +178,31 @@ export async function transferLocalCollectionToHosted({
const final = await capture(source);
let activationAttempted = false;
try {
- await source.persistAuthorityAdoptionSnapshot(session.adoptionId, final);
+ await source.persistAuthorityAdoptionSnapshot(
+ session.adoptionId,
+ final.snapshot,
+ );
onCheckpoint?.({
session,
snapshot: {
- sourceRevision: final.source_revision,
- manifestDigest: final.manifest_digest,
- sourceHead: final.source_head,
+ sourceRevision: final.snapshot.source_revision,
+ manifestDigest: final.snapshot.manifest_digest,
+ sourceHead: final.snapshot.source_head,
},
});
- if (!sameSnapshot(initial, final)) {
+ if (!sameSnapshot(initial.snapshot, final.snapshot)) {
prepared = await requirePrepared(client, session);
- await client.uploadSnapshot(session, prepared, final);
+ await client.uploadSnapshot(session, prepared, final.snapshot, {
+ fileSource: final.fileSource,
+ });
}
onProgress?.({ phase: "fencing", completed: 1, total: 1 });
onProgress?.({ phase: "activating", completed: 0, total: 1 });
activationAttempted = true;
- await client.complete(session, final);
+ await client.complete(session, final.snapshot);
await fence.markHosted();
onProgress?.({ phase: "activating", completed: 1, total: 1 });
- return resultFor(final);
+ return resultFor(final.snapshot);
} catch (reason) {
if (!activationAttempted) {
await fence.release();
@@ -212,16 +224,37 @@ async function requirePrepared(
return resumed;
}
-async function capture(
- source: MarkdownCollection,
-): Promise {
+async function capture(source: MarkdownCollection): Promise<{
+ snapshot: AuthorityImportSnapshot;
+ fileSource: (
+ file: CollectionFileDescriptor,
+ ) => Promise;
+}> {
const snapshot = await source.authoritySnapshot();
- return buildPortableAuthoritySnapshot({
- collectionId: snapshot.collectionId,
- specVersion: snapshot.specVersion,
- resources: snapshot.resources,
- records: snapshot.records,
- });
+ if (snapshot.files.length && !isBinaryVault(source.vault))
+ throw new Error("This local collection cannot read its attachment bytes.");
+ return {
+ snapshot: buildPortableAuthoritySnapshot({
+ collectionId: snapshot.collectionId,
+ specVersion: snapshot.specVersion,
+ resources: snapshot.resources,
+ records: snapshot.records,
+ files: snapshot.files,
+ }),
+ fileSource: async (file) => {
+ if (!isBinaryVault(source.vault))
+ throw new Error("This local collection cannot read attachment bytes.");
+ return source.vault.readBinary(file.path);
+ },
+ };
+}
+
+function snapshotItemCount(snapshot: AuthorityImportSnapshot): number {
+ return (
+ snapshot.records.length +
+ (snapshot.resources.documents?.length ?? 0) +
+ snapshot.files.length
+ );
}
function sameSnapshot(
diff --git a/src/storage/collection.ts b/src/storage/collection.ts
index b2e28c9..28b8368 100644
--- a/src/storage/collection.ts
+++ b/src/storage/collection.ts
@@ -3,6 +3,7 @@ import {
serializeMarkdownDocument,
} from "@tasknotes/model/frontmatter";
import { buildTaskNotesMdbaseResources } from "@tasknotes/model/mdbase";
+import { TASKNOTES_SPEC_VERSION } from "@tasknotes/model/types";
import { patchTaskNotesMdbaseTypeSettings } from "@tasknotes/model/mdbase";
import { parseDocument } from "yaml";
import picomatch from "picomatch";
@@ -10,7 +11,11 @@ import type {
PortableAuthorityRecord,
PortableAuthorityResource,
} from "@mdbase-dev/connect-sync/adoption";
-import type { AuthorityImportSnapshot } from "@mdbase-dev/connect-protocol";
+import { portableRecordId } from "@mdbase-dev/connect-sync/adoption";
+import type {
+ AuthorityImportSnapshot,
+ CollectionFileDescriptor,
+} from "@mdbase-dev/connect-protocol";
import { TaskNotesTaskModel } from "../domain/tasknotes-model";
import { viewSourceRevision } from "./local-views";
@@ -37,6 +42,8 @@ import type {
UpdateTaskViewSourceInput,
} from "../domain/view";
import type { Vault, VaultEntry } from "./vault";
+import { isBinaryVault } from "./vault-contract";
+import { VaultCollectionFileStore } from "./vault-files";
const UUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -53,6 +60,7 @@ export interface LocalAuthoritySnapshot {
specVersion: string;
resources: PortableAuthorityResource[];
records: PortableAuthorityRecord[];
+ files: CollectionFileDescriptor[];
}
export interface LocalAuthorityFence {
@@ -81,7 +89,7 @@ export class MarkdownCollection {
private authorityState: StoredAuthorityState | null = null;
constructor(
- private readonly vault: Vault,
+ readonly vault: Vault,
private readonly options: {
approveManagedTypeUpgrade?: (
request: ManagedTypeUpgradeRequest,
@@ -194,7 +202,7 @@ export class MarkdownCollection {
).flat();
if (!matches.length)
throw new Error(
- `No type implementing tasknotes.task 0.3.0-rc.1 was found in ${nextTypesFolder}/.`,
+ `No type implementing tasknotes.task ${TASKNOTES_SPEC_VERSION} was found in ${nextTypesFolder}/.`,
);
if (matches.length > 1)
throw new Error(
@@ -360,11 +368,24 @@ export class MarkdownCollection {
};
}),
);
+ const files = isBinaryVault(this.vault)
+ ? (await new VaultCollectionFileStore(this.vault).list()).map((file) => ({
+ file_id: portableRecordId(collectionId, `file:${file.path}`),
+ path: file.path,
+ revision: file.revision,
+ content_digest: file.contentDigest,
+ size: file.size,
+ ...(file.mediaType ? { media_type: file.mediaType } : {}),
+ media_class: file.mediaClass,
+ modified_at: file.modifiedAt,
+ }))
+ : [];
return {
collectionId,
specVersion,
resources,
records,
+ files,
};
}
@@ -1096,12 +1117,13 @@ function taskNotesImplementation(
!Array.isArray(candidate) &&
(candidate as Record).contract ===
"tasknotes.task" &&
- (candidate as Record).version === "0.3.0-rc.1",
+ (candidate as Record).version ===
+ TASKNOTES_SPEC_VERSION,
)
: undefined;
if (!implementation)
throw new Error(
- "The generated type does not implement tasknotes.task 0.3.0-rc.1.",
+ `The generated type does not implement tasknotes.task ${TASKNOTES_SPEC_VERSION}.`,
);
return implementation as Record;
}
diff --git a/src/storage/connected-task-cache.test.ts b/src/storage/connected-task-cache.test.ts
index 557e8d5..ab5bb2d 100644
--- a/src/storage/connected-task-cache.test.ts
+++ b/src/storage/connected-task-cache.test.ts
@@ -10,7 +10,12 @@ import type { TaskView } from "../domain/view";
it("applies shared search, state, archive, and limit projection rules", () => {
const cached = [
- { task: fixtureTask("open", "Open release", { tags: ["laptop"] }) },
+ {
+ task: fixtureTask("open", "Open release", {
+ tags: ["laptop"],
+ attachments: ["[[Attachments/receipt.jpg]]"],
+ }),
+ },
{
task: fixtureTask("done", "Completed release", {
completed: true,
@@ -25,6 +30,9 @@ it("applies shared search, state, archive, and limit projection rules", () => {
({ id }) => id,
),
).toEqual(["open"]);
+ expect(
+ listConnectedTasks(cached, { search: "receipt" }).map(({ id }) => id),
+ ).toEqual(["open"]);
expect(
listConnectedTasks(cached, { status: "completed" }).map(({ id }) => id),
).toEqual(["done"]);
@@ -85,5 +93,6 @@ function fixtureTask(
revision: 1,
frontmatter: { id, title },
...overrides,
+ attachments: overrides.attachments ?? [],
};
}
diff --git a/src/storage/connected-task-cache.ts b/src/storage/connected-task-cache.ts
index 8d9a473..ad2cbc6 100644
--- a/src/storage/connected-task-cache.ts
+++ b/src/storage/connected-task-cache.ts
@@ -32,6 +32,7 @@ export function listConnectedTasks(
...task.tags,
...task.contexts,
...task.projects,
+ ...task.attachments,
]
.join("\n")
.toLocaleLowerCase();
diff --git a/src/storage/index.ts b/src/storage/index.ts
index 9021a3d..58c6577 100644
--- a/src/storage/index.ts
+++ b/src/storage/index.ts
@@ -57,6 +57,7 @@ export function indexTask(
...task.tags,
...task.contexts,
...task.projects,
+ ...task.attachments,
]
.join("\n")
.toLocaleLowerCase(),
@@ -76,6 +77,7 @@ export function indexedTaskNeedsNormalization(task: IndexedTask): boolean {
!Array.isArray(task.tags) ||
!Array.isArray(task.contexts) ||
!Array.isArray(task.projects) ||
+ !Array.isArray(task.attachments) ||
!Array.isArray(task.blockedBy) ||
!Array.isArray(task.completeInstances) ||
!Array.isArray(task.skippedInstances) ||
@@ -94,6 +96,7 @@ export function normalizeIndexedTask(task: IndexedTask): IndexedTask {
tags: stringArray(task.tags),
contexts: stringArray(task.contexts),
projects: stringArray(task.projects),
+ attachments: stringArray(task.attachments),
blockedBy: Array.isArray(task.blockedBy) ? task.blockedBy : [],
completeInstances: stringArray(task.completeInstances),
skippedInstances: stringArray(task.skippedInstances),
diff --git a/src/storage/local-first-mdbase-files.test.ts b/src/storage/local-first-mdbase-files.test.ts
new file mode 100644
index 0000000..4bc49db
--- /dev/null
+++ b/src/storage/local-first-mdbase-files.test.ts
@@ -0,0 +1,223 @@
+import { describe, expect, it } from "vitest";
+
+import type {
+ CollectionFile,
+ CollectionFileAction,
+ CollectionFileStore,
+} from "../application/ports/collection-file-store";
+import { LocalFirstMdbaseFileStore } from "./local-first-mdbase-files";
+
+describe("LocalFirstMdbaseFileStore", () => {
+ it("durably accepts bytes offline and resumes the same upload after restart", async () => {
+ const remote = new MemoryRemote();
+ remote.online = false;
+ const collectionId = crypto.randomUUID();
+ const first = new LocalFirstMdbaseFileStore(remote, collectionId);
+ const bytes = Uint8Array.from([137, 80, 78, 71, 4, 2]);
+
+ const pending = await first.upload("Attachments/offline.png", bytes, {
+ mediaType: "image/png",
+ });
+ expect(pending).toMatchObject({
+ pending: "upload",
+ availability: "local",
+ });
+ expect(await first.list()).toHaveLength(1);
+ expect(
+ new Uint8Array(await (await first.download(pending)).arrayBuffer()),
+ ).toEqual(bytes);
+
+ remote.online = true;
+ const restarted = new LocalFirstMdbaseFileStore(remote, collectionId);
+ await restarted.sync();
+ const [synced] = await restarted.list();
+ expect(synced).toMatchObject({
+ path: "Attachments/offline.png",
+ availability: "local-and-remote",
+ });
+ expect(synced?.pending).toBeUndefined();
+ expect(remote.uploadIds).toHaveLength(1);
+ expect(
+ new Uint8Array(await (await remote.download(synced!)).arrayBuffer()),
+ ).toEqual(bytes);
+ });
+
+ it("hides an offline deletion immediately and finishes it on reconnect", async () => {
+ const remote = new MemoryRemote();
+ const store = new LocalFirstMdbaseFileStore(remote, crypto.randomUUID());
+ const uploaded = await store.upload(
+ "Attachments/remove.jpg",
+ Uint8Array.of(1, 2, 3),
+ { mediaType: "image/jpeg" },
+ );
+ remote.online = false;
+
+ await store.delete(uploaded);
+ expect(await store.list()).toEqual([]);
+ expect(remote.files).toHaveLength(1);
+
+ remote.online = true;
+ await store.sync();
+ expect(remote.files).toEqual([]);
+ expect(await store.list()).toEqual([]);
+ });
+
+ it("reconciles files removed directly from the hosted authority", async () => {
+ const remote = new MemoryRemote();
+ const store = new LocalFirstMdbaseFileStore(remote, crypto.randomUUID());
+ const uploaded = await store.upload(
+ "Attachments/removed-elsewhere.jpg",
+ Uint8Array.of(1),
+ { mediaType: "image/jpeg" },
+ );
+ await store.download(uploaded);
+ remote.files = [];
+ remote.bytes.clear();
+
+ expect(await store.list()).toEqual([]);
+ });
+
+ it("discards cached bytes when another client replaces the remote file", async () => {
+ const remote = new MemoryRemote();
+ const store = new LocalFirstMdbaseFileStore(remote, crypto.randomUUID());
+ const original = await store.upload(
+ "Attachments/replaced.png",
+ Uint8Array.of(1, 2, 3),
+ { mediaType: "image/png" },
+ );
+ await store.download(original);
+
+ const replacement = await remote.upload(
+ original.path,
+ Uint8Array.of(9, 8, 7, 6),
+ { mediaType: "image/png" },
+ );
+ const [listed] = await store.list();
+
+ expect(listed?.contentDigest).toBe(replacement.contentDigest);
+ expect(listed?.availability).toBe("remote");
+ expect(
+ new Uint8Array(await (await store.download(listed!)).arrayBuffer()),
+ ).toEqual(Uint8Array.of(9, 8, 7, 6));
+ });
+
+ it("preserves operation order when an offline move is followed by delete", async () => {
+ const remote = new MemoryRemote();
+ const store = new LocalFirstMdbaseFileStore(remote, crypto.randomUUID());
+ const uploaded = await store.upload(
+ "Attachments/original.jpg",
+ Uint8Array.of(1, 2),
+ { mediaType: "image/jpeg" },
+ );
+ remote.online = false;
+
+ const moved = await store.move(uploaded, "Attachments/moved.jpg");
+ await store.delete(moved);
+ expect(await store.list()).toEqual([]);
+
+ remote.online = true;
+ remote.failDelete = true;
+ await expect(store.sync()).rejects.toThrow("Delete unavailable");
+ expect(remote.files.map(({ path }) => path)).toEqual([
+ "Attachments/moved.jpg",
+ ]);
+ expect(await store.list()).toEqual([]);
+
+ remote.failDelete = false;
+ await store.sync();
+ expect(remote.files).toEqual([]);
+ expect(await store.list()).toEqual([]);
+ });
+});
+
+class MemoryRemote implements CollectionFileStore {
+ online = true;
+ failDelete = false;
+ files: CollectionFile[] = [];
+ readonly bytes = new Map();
+ readonly uploadIds: string[] = [];
+
+ authorizedActions(): ReadonlySet {
+ return new Set(["list", "read", "add", "replace", "move", "delete"]);
+ }
+
+ async list(): Promise {
+ this.requireOnline();
+ return structuredClone(this.files);
+ }
+
+ async upload(
+ path: string,
+ source: Blob | ArrayBuffer | ArrayBufferView,
+ options: { mediaType?: string; transferId?: string } = {},
+ ): Promise {
+ this.requireOnline();
+ const blob = source instanceof Blob ? source : new Blob([owned(source)]);
+ const existing = this.files.find((candidate) => candidate.path === path);
+ const file: CollectionFile = {
+ fileId: existing?.fileId ?? crypto.randomUUID(),
+ path,
+ revision: crypto.randomUUID(),
+ contentDigest: await digest(blob),
+ size: blob.size,
+ mediaType: options.mediaType,
+ mediaClass: options.mediaType?.startsWith("image/") ? "image" : "other",
+ modifiedAt: new Date().toISOString(),
+ };
+ this.files = [
+ ...this.files.filter((candidate) => candidate.path !== path),
+ file,
+ ];
+ this.bytes.set(file.fileId, blob);
+ if (options.transferId) this.uploadIds.push(options.transferId);
+ return file;
+ }
+
+ async download(file: CollectionFile): Promise {
+ this.requireOnline();
+ return this.bytes.get(file.fileId)!;
+ }
+
+ async downloadStream(
+ file: CollectionFile,
+ ): Promise> {
+ return (await this.download(file)).stream();
+ }
+
+ async move(file: CollectionFile, path: string): Promise {
+ this.requireOnline();
+ const moved = { ...file, path, revision: crypto.randomUUID() };
+ this.files = [
+ ...this.files.filter(({ fileId }) => fileId !== file.fileId),
+ moved,
+ ];
+ return moved;
+ }
+
+ async delete(file: CollectionFile): Promise {
+ this.requireOnline();
+ if (this.failDelete) throw new Error("Delete unavailable");
+ this.files = this.files.filter(({ fileId }) => fileId !== file.fileId);
+ this.bytes.delete(file.fileId);
+ }
+
+ private requireOnline(): void {
+ if (!this.online) throw new TypeError("Network unavailable");
+ }
+}
+
+function owned(source: ArrayBuffer | ArrayBufferView): ArrayBuffer {
+ const bytes = ArrayBuffer.isView(source)
+ ? new Uint8Array(source.buffer, source.byteOffset, source.byteLength)
+ : new Uint8Array(source);
+ const copy = new Uint8Array(bytes.byteLength);
+ copy.set(bytes);
+ return copy.buffer;
+}
+
+async function digest(blob: Blob): Promise<`sha256:${string}`> {
+ const value = await crypto.subtle.digest("SHA-256", await blob.arrayBuffer());
+ return `sha256:${[...new Uint8Array(value)]
+ .map((byte) => byte.toString(16).padStart(2, "0"))
+ .join("")}`;
+}
diff --git a/src/storage/local-first-mdbase-files.ts b/src/storage/local-first-mdbase-files.ts
new file mode 100644
index 0000000..8c94e56
--- /dev/null
+++ b/src/storage/local-first-mdbase-files.ts
@@ -0,0 +1,534 @@
+import Dexie, { type EntityTable } from "dexie";
+
+import type {
+ CollectionFile,
+ CollectionFileAction,
+ CollectionFileProgress,
+ CollectionFileStore,
+} from "../application/ports/collection-file-store";
+
+interface CachedFile {
+ path: string;
+ file: CollectionFile;
+ bytes?: ArrayBuffer;
+ tombstone: boolean;
+}
+
+type FileMutation =
+ | {
+ id: string;
+ kind: "upload";
+ path: string;
+ file: CollectionFile;
+ bytes: ArrayBuffer;
+ mediaType?: string;
+ ifRevision?: string;
+ enqueuedAt: number;
+ }
+ | {
+ id: string;
+ kind: "move";
+ after?: string;
+ path: string;
+ targetPath: string;
+ file: CollectionFile;
+ enqueuedAt: number;
+ }
+ | {
+ id: string;
+ kind: "delete";
+ after?: string;
+ path: string;
+ file: CollectionFile;
+ enqueuedAt: number;
+ };
+
+class FileReplica extends Dexie {
+ files!: EntityTable;
+ mutations!: EntityTable;
+
+ constructor(collectionId: string) {
+ super(`tasknotes-files-${collectionId}`);
+ this.version(1).stores({
+ files: "&path",
+ mutations: "&id,enqueuedAt,path",
+ });
+ }
+}
+
+/**
+ * Durable cache and mutation outbox for mdbase files. Binary bytes land here
+ * before the network is attempted, making an accepted attachment recoverable
+ * across offline restarts and ambiguous upload failures.
+ */
+export class LocalFirstMdbaseFileStore implements CollectionFileStore {
+ private readonly replica: FileReplica;
+ private syncInFlight: Promise | null = null;
+
+ constructor(
+ private readonly remote: CollectionFileStore,
+ collectionId: string,
+ ) {
+ this.replica = new FileReplica(collectionId);
+ }
+
+ authorizedActions(): ReadonlySet {
+ return this.remote.authorizedActions();
+ }
+
+ async list(
+ options: { folder?: string; signal?: AbortSignal } = {},
+ ): Promise {
+ throwIfAborted(options.signal);
+ await this.trySync();
+ try {
+ const remoteFiles = await this.remote.list(options);
+ const remotePaths = new Set(remoteFiles.map(({ path }) => path));
+ const folderPrefix = options.folder
+ ? `${options.folder.replace(/\/$/, "")}/`
+ : "";
+ await this.replica.transaction("rw", this.replica.files, async () => {
+ const cachedFiles = await this.replica.files.toArray();
+ for (const cached of cachedFiles) {
+ if (
+ !cached.tombstone &&
+ !cached.file.pending &&
+ (!folderPrefix || cached.path.startsWith(folderPrefix)) &&
+ !remotePaths.has(cached.path)
+ )
+ await this.replica.files.delete(cached.path);
+ }
+ for (const file of remoteFiles) {
+ const cached = await this.replica.files.get(file.path);
+ if (cached?.tombstone || cached?.file.pending) continue;
+ const bytes =
+ cached?.file.contentDigest === file.contentDigest
+ ? cached.bytes
+ : undefined;
+ await this.replica.files.put({
+ path: file.path,
+ file: {
+ ...file,
+ availability: bytes ? "local-and-remote" : "remote",
+ },
+ ...(bytes ? { bytes } : {}),
+ tombstone: false,
+ });
+ }
+ });
+ } catch {
+ // A complete cached listing remains useful when the authority is offline.
+ }
+ const folderPrefix = options.folder
+ ? `${options.folder.replace(/\/$/, "")}/`
+ : "";
+ return (await this.replica.files.toArray())
+ .filter(({ tombstone }) => !tombstone)
+ .filter(({ path }) => !folderPrefix || path.startsWith(folderPrefix))
+ .map(({ file, bytes }) => ({
+ ...file,
+ availability: (bytes
+ ? file.pending
+ ? "local"
+ : "local-and-remote"
+ : "remote") as CollectionFile["availability"],
+ }))
+ .sort((left, right) => left.path.localeCompare(right.path));
+ }
+
+ async upload(
+ path: string,
+ source: Blob | ArrayBuffer | ArrayBufferView,
+ options: {
+ mediaType?: string;
+ ifRevision?: string;
+ transferId?: string;
+ signal?: AbortSignal;
+ onProgress?: (progress: CollectionFileProgress) => void;
+ } = {},
+ ): Promise {
+ requireAction(this.remote, options.ifRevision ? "replace" : "add");
+ throwIfAborted(options.signal);
+ const blob = await toBlob(source, options.mediaType);
+ const bytes = await blob.arrayBuffer();
+ const transferId = options.transferId ?? crypto.randomUUID();
+ const digest = await sha256(blob);
+ const existing = await this.replica.files.get(path);
+ if (
+ options.ifRevision !== undefined &&
+ existing?.file.revision !== options.ifRevision
+ )
+ throw new Error(`Attachment revision conflict at ${path}.`);
+ const file: CollectionFile = {
+ fileId: existing?.file.fileId ?? `pending:${transferId}`,
+ path,
+ revision: `pending:${transferId}`,
+ contentDigest: digest,
+ size: blob.size,
+ ...(options.mediaType || blob.type
+ ? { mediaType: options.mediaType || blob.type }
+ : {}),
+ mediaClass: (options.mediaType || blob.type).startsWith("image/")
+ ? "image"
+ : "other",
+ modifiedAt: new Date().toISOString(),
+ availability: "local",
+ pending: "upload",
+ };
+ const mutation: FileMutation = {
+ id: transferId,
+ kind: "upload",
+ path,
+ file,
+ bytes,
+ ...(options.mediaType ? { mediaType: options.mediaType } : {}),
+ ...(options.ifRevision ? { ifRevision: options.ifRevision } : {}),
+ enqueuedAt: Date.now(),
+ };
+ await this.replica.transaction(
+ "rw",
+ this.replica.files,
+ this.replica.mutations,
+ async () => {
+ await this.replica.files.put({ path, file, bytes, tombstone: false });
+ await this.replica.mutations.put(mutation);
+ },
+ );
+ options.onProgress?.({
+ phase: "uploading",
+ transferredBytes: blob.size,
+ totalBytes: blob.size,
+ });
+ await this.trySync();
+ return (await this.replica.files.get(path))?.file ?? file;
+ }
+
+ async download(
+ file: CollectionFile,
+ options: {
+ signal?: AbortSignal;
+ onProgress?: (progress: CollectionFileProgress) => void;
+ } = {},
+ ): Promise {
+ throwIfAborted(options.signal);
+ const cached = await this.replica.files.get(file.path);
+ if (
+ cached?.bytes &&
+ !cached.tombstone &&
+ cached.file.contentDigest === file.contentDigest
+ ) {
+ options.onProgress?.({
+ phase: "downloading",
+ transferredBytes: cached.bytes.byteLength,
+ totalBytes: cached.bytes.byteLength,
+ });
+ return new Blob([cached.bytes], {
+ type: cached.file.mediaType ?? "application/octet-stream",
+ });
+ }
+ const blob = await this.remote.download(file, options);
+ if (blob.size !== file.size || (await sha256(blob)) !== file.contentDigest)
+ throw new Error(
+ `Downloaded attachment failed integrity checks at ${file.path}.`,
+ );
+ const bytes = await blob.arrayBuffer();
+ await this.replica.files.put({
+ path: file.path,
+ file: { ...file, availability: "local-and-remote" },
+ bytes,
+ tombstone: false,
+ });
+ return blob;
+ }
+
+ async downloadStream(
+ file: CollectionFile,
+ options: {
+ signal?: AbortSignal;
+ onProgress?: (progress: CollectionFileProgress) => void;
+ } = {},
+ ): Promise> {
+ return (await this.download(file, options)).stream();
+ }
+
+ async move(
+ file: CollectionFile,
+ path: string,
+ options: { mutationId?: string; signal?: AbortSignal } = {},
+ ): Promise {
+ requireAction(this.remote, "move");
+ throwIfAborted(options.signal);
+ const id = options.mutationId ?? crypto.randomUUID();
+ if (file.pending === "delete")
+ throw new Error("An attachment pending deletion cannot be moved.");
+ const cached = await this.replica.files.get(file.path);
+ if (file.pending === "upload") {
+ const transferId = file.revision.replace(/^pending:/, "");
+ const upload = await this.replica.mutations.get(transferId);
+ if (upload?.kind === "upload") {
+ const moved: CollectionFile = { ...file, path };
+ await this.replica.transaction(
+ "rw",
+ this.replica.files,
+ this.replica.mutations,
+ async () => {
+ await this.replica.files.delete(file.path);
+ await this.replica.files.put({
+ path,
+ file: moved,
+ bytes: upload.bytes,
+ tombstone: false,
+ });
+ await this.replica.mutations.put({
+ ...upload,
+ path,
+ file: moved,
+ });
+ },
+ );
+ await this.trySync();
+ return (await this.replica.files.get(path))?.file ?? moved;
+ }
+ }
+ const moved: CollectionFile = {
+ ...file,
+ path,
+ revision: `pending:${id}`,
+ availability: cached?.bytes ? "local" : file.availability,
+ pending: "move",
+ };
+ await this.replica.transaction(
+ "rw",
+ this.replica.files,
+ this.replica.mutations,
+ async () => {
+ await this.replica.files.delete(file.path);
+ await this.replica.files.put({
+ path,
+ file: moved,
+ ...(cached?.bytes ? { bytes: cached.bytes } : {}),
+ tombstone: false,
+ });
+ const mutation: FileMutation = {
+ id,
+ kind: "move",
+ ...(file.pending === "move"
+ ? { after: file.revision.replace(/^pending:/, "") }
+ : {}),
+ path: file.path,
+ targetPath: path,
+ file,
+ enqueuedAt: Date.now(),
+ };
+ await this.replica.mutations.put(mutation);
+ },
+ );
+ await this.trySync();
+ return (await this.replica.files.get(path))?.file ?? moved;
+ }
+
+ async delete(
+ file: CollectionFile,
+ options: { mutationId?: string; signal?: AbortSignal } = {},
+ ): Promise {
+ requireAction(this.remote, "delete");
+ throwIfAborted(options.signal);
+ if (file.pending === "delete") return;
+ if (file.pending === "upload") {
+ const transferId = file.revision.replace(/^pending:/, "");
+ await this.replica.transaction(
+ "rw",
+ this.replica.files,
+ this.replica.mutations,
+ async () => {
+ await this.replica.files.delete(file.path);
+ await this.replica.mutations.delete(transferId);
+ },
+ );
+ return;
+ }
+ const id = options.mutationId ?? crypto.randomUUID();
+ const cached = await this.replica.files.get(file.path);
+ await this.replica.transaction(
+ "rw",
+ this.replica.files,
+ this.replica.mutations,
+ async () => {
+ await this.replica.files.put({
+ path: file.path,
+ file: { ...file, pending: "delete" },
+ ...(cached?.bytes ? { bytes: cached.bytes } : {}),
+ tombstone: true,
+ });
+ await this.replica.mutations.put({
+ id,
+ kind: "delete",
+ ...(file.pending === "move"
+ ? { after: file.revision.replace(/^pending:/, "") }
+ : {}),
+ path: file.path,
+ file,
+ enqueuedAt: Date.now(),
+ });
+ },
+ );
+ await this.trySync();
+ }
+
+ sync(): Promise {
+ if (this.syncInFlight) return this.syncInFlight;
+ this.syncInFlight = this.flush().finally(() => {
+ this.syncInFlight = null;
+ });
+ return this.syncInFlight;
+ }
+
+ private async trySync(): Promise {
+ await this.sync().catch(() => undefined);
+ }
+
+ private async flush(): Promise {
+ while (true) {
+ const queued = await this.replica.mutations
+ .orderBy("enqueuedAt")
+ .toArray();
+ if (!queued.length) return;
+ const queuedIds = new Set(queued.map(({ id }) => id));
+ const mutation = queued.find(
+ (candidate) =>
+ candidate.kind === "upload" ||
+ !candidate.after ||
+ !queuedIds.has(candidate.after),
+ );
+ if (!mutation)
+ throw new Error("Attachment mutation journal contains a cycle.");
+ if (mutation.kind === "upload") {
+ const uploaded = await this.remote.upload(
+ mutation.path,
+ mutation.bytes,
+ {
+ mediaType: mutation.mediaType,
+ transferId: mutation.id,
+ ...(mutation.ifRevision ? { ifRevision: mutation.ifRevision } : {}),
+ },
+ );
+ await this.replica.transaction(
+ "rw",
+ this.replica.files,
+ this.replica.mutations,
+ async () => {
+ await this.replica.files.put({
+ path: mutation.path,
+ file: { ...uploaded, availability: "local-and-remote" },
+ bytes: mutation.bytes,
+ tombstone: false,
+ });
+ await this.replica.mutations.delete(mutation.id);
+ },
+ );
+ continue;
+ }
+ if (mutation.kind === "move") {
+ const moved = await this.remote.move(
+ mutation.file,
+ mutation.targetPath,
+ {
+ mutationId: mutation.id,
+ },
+ );
+ await this.replica.transaction(
+ "rw",
+ this.replica.files,
+ this.replica.mutations,
+ async () => {
+ const dependents = (await this.replica.mutations.toArray()).filter(
+ (candidate) =>
+ candidate.id !== mutation.id &&
+ candidate.kind !== "upload" &&
+ candidate.after === mutation.id,
+ );
+ for (const dependent of dependents)
+ await this.replica.mutations.put({
+ ...dependent,
+ file: moved,
+ });
+ const pendingDelete = dependents.find(
+ (candidate) => candidate.kind === "delete",
+ );
+ if (pendingDelete) {
+ const cached = await this.replica.files.get(pendingDelete.path);
+ await this.replica.files.put({
+ path: pendingDelete.path,
+ file: { ...moved, pending: "delete" },
+ ...(cached?.bytes ? { bytes: cached.bytes } : {}),
+ tombstone: true,
+ });
+ } else if (!dependents.length) {
+ const cached = await this.replica.files.get(mutation.targetPath);
+ await this.replica.files.put({
+ path: mutation.targetPath,
+ file: {
+ ...moved,
+ availability: cached?.bytes ? "local-and-remote" : "remote",
+ },
+ ...(cached?.bytes ? { bytes: cached.bytes } : {}),
+ tombstone: false,
+ });
+ }
+ await this.replica.mutations.delete(mutation.id);
+ },
+ );
+ continue;
+ }
+ await this.remote.delete(mutation.file, { mutationId: mutation.id });
+ await this.replica.transaction(
+ "rw",
+ this.replica.files,
+ this.replica.mutations,
+ async () => {
+ await this.replica.files.delete(mutation.path);
+ await this.replica.mutations.delete(mutation.id);
+ },
+ );
+ }
+ }
+}
+
+function requireAction(
+ store: CollectionFileStore,
+ action: CollectionFileAction,
+): void {
+ if (!store.authorizedActions().has(action))
+ throw new Error(`This collection has not authorized attachment ${action}.`);
+}
+
+async function toBlob(
+ source: Blob | ArrayBuffer | ArrayBufferView,
+ mediaType?: string,
+): Promise {
+ if (source instanceof Blob)
+ return source.type || !mediaType
+ ? source
+ : new Blob([await source.arrayBuffer()], { type: mediaType });
+ const bytes = ArrayBuffer.isView(source)
+ ? new Uint8Array(source.buffer, source.byteOffset, source.byteLength)
+ : new Uint8Array(source);
+ const copy = new Uint8Array(bytes.byteLength);
+ copy.set(bytes);
+ return new Blob([copy.buffer], mediaType ? { type: mediaType } : {});
+}
+
+async function sha256(blob: Blob): Promise<`sha256:${string}`> {
+ const digest = await crypto.subtle.digest(
+ "SHA-256",
+ await blob.arrayBuffer(),
+ );
+ return `sha256:${[...new Uint8Array(digest)]
+ .map((value) => value.toString(16).padStart(2, "0"))
+ .join("")}`;
+}
+
+function throwIfAborted(signal?: AbortSignal): void {
+ if (signal?.aborted)
+ throw signal.reason ?? new DOMException("Aborted", "AbortError");
+}
diff --git a/src/storage/mdbase-files.ts b/src/storage/mdbase-files.ts
index 5a59d88..661bf8a 100644
--- a/src/storage/mdbase-files.ts
+++ b/src/storage/mdbase-files.ts
@@ -43,6 +43,7 @@ export class MdbaseCollectionFileStore implements CollectionFileStore {
options: {
mediaType?: string;
ifRevision?: string;
+ transferId?: string;
signal?: AbortSignal;
onProgress?: (progress: CollectionFileProgress) => void;
} = {},
@@ -66,14 +67,27 @@ export class MdbaseCollectionFileStore implements CollectionFileStore {
return this.connection.files.downloadStream(toMdbaseFile(file), options);
}
- async move(file: CollectionFile, path: string): Promise {
+ async move(
+ file: CollectionFile,
+ path: string,
+ options: { mutationId?: string; signal?: AbortSignal } = {},
+ ): Promise {
return fromMdbaseFile(
- await this.connection.files.move(toMdbaseFile(file), path),
+ await this.connection.files.move(toMdbaseFile(file), path, {
+ ifRevision: file.revision,
+ ...options,
+ }),
);
}
- async delete(file: CollectionFile): Promise {
- await this.connection.files.delete(toMdbaseFile(file));
+ async delete(
+ file: CollectionFile,
+ options: { mutationId?: string; signal?: AbortSignal } = {},
+ ): Promise {
+ await this.connection.files.delete(toMdbaseFile(file), {
+ ifRevision: file.revision,
+ ...options,
+ });
}
}
diff --git a/src/storage/relay-repository.test.ts b/src/storage/relay-repository.test.ts
index 5d12bc4..b4d5b76 100644
--- a/src/storage/relay-repository.test.ts
+++ b/src/storage/relay-repository.test.ts
@@ -343,7 +343,7 @@ describe("relay task repository", () => {
implements: [
{
contract: "tasknotes.task",
- version: "0.3.0-rc.1",
+ version: "0.3.0-rc.3",
fields: next.contracts[0]!.implementations[0].fields,
binding: configuration,
},
@@ -1063,7 +1063,7 @@ function description(
const implementation = type.implements.find(
(candidate) =>
candidate.contract === "tasknotes.task" &&
- candidate.version === "0.3.0-rc.1",
+ candidate.version === "0.3.0-rc.3",
)!;
const configuration = structuredClone(implementation.binding);
if (templating)
@@ -1113,7 +1113,7 @@ function description(
{
id: "tasknotes.task",
contract_type: "record",
- version: "0.3.0-rc.1",
+ version: "0.3.0-rc.3",
digest: `sha256:${"0".repeat(64)}`,
schema: generated.taskSchema,
binding_schema: generated.bindingSchema,
diff --git a/src/storage/relay-repository.ts b/src/storage/relay-repository.ts
index ac3ddd5..a645ba2 100644
--- a/src/storage/relay-repository.ts
+++ b/src/storage/relay-repository.ts
@@ -30,6 +30,7 @@ import {
} from "./connected-task-cache";
import { runMdbaseMutation } from "./mdbase-mutation-coordinator";
import { MdbaseCollectionFileStore } from "./mdbase-files";
+import { LocalFirstMdbaseFileStore } from "./local-first-mdbase-files";
import { resolveTaskCollection } from "./tasknotes-collection";
import { TaskViewCache } from "./view-cache";
import {
@@ -98,7 +99,7 @@ const PAGE_SIZE = 1_000;
* authority to be reachable and use revisions whenever one has been observed.
*/
export class RelayTaskRepository implements TaskRepository {
- readonly files: MdbaseCollectionFileStore;
+ readonly files: LocalFirstMdbaseFileStore;
private model = new TaskNotesTaskModel();
private taskTypeName = "task";
private taskProviders = new Map([
@@ -141,7 +142,10 @@ export class RelayTaskRepository implements TaskRepository {
};
constructor(private readonly connect: MdbaseConnection) {
- this.files = new MdbaseCollectionFileStore(connect);
+ this.files = new LocalFirstMdbaseFileStore(
+ new MdbaseCollectionFileStore(connect),
+ connect.collectionId,
+ );
}
initialize(): Promise {
@@ -150,6 +154,7 @@ export class RelayTaskRepository implements TaskRepository {
}
private async initializeUnlocked(): Promise {
+ await this.files.sync().catch(() => undefined);
const description = validResult(await this.connect.describe());
this.configureDescription(description);
this.collectionId = description.collection_id;
diff --git a/src/storage/repository.test.ts b/src/storage/repository.test.ts
index f7771dc..b772dc5 100644
--- a/src/storage/repository.test.ts
+++ b/src/storage/repository.test.ts
@@ -325,14 +325,14 @@ describe("IndexedMarkdownRepository", () => {
expect(await index.metadata.get("projection")).toMatchObject({
complete: false,
needsReindex: true,
- taskShapeVersion: 1,
+ taskShapeVersion: 2,
});
expect(await reopened.refresh()).toMatchObject({ changed: 1 });
expect(await index.metadata.get("projection")).toMatchObject({
complete: true,
needsReindex: false,
- taskShapeVersion: 1,
+ taskShapeVersion: 2,
});
});
@@ -613,7 +613,7 @@ describe("IndexedMarkdownRepository", () => {
).find(
(candidate) =>
candidate.contract === "tasknotes.task" &&
- candidate.version === "0.3.0-rc.1",
+ candidate.version === "0.3.0-rc.3",
)!;
expect(
(
@@ -1099,7 +1099,7 @@ describe("IndexedMarkdownRepository", () => {
).find(
(candidate) =>
candidate.contract === "tasknotes.task" &&
- candidate.version === "0.3.0-rc.1",
+ candidate.version === "0.3.0-rc.3",
)!;
const extension = implementation.binding as Record;
extension.archive = { move_on_archive: true, folder: "archive" };
diff --git a/src/storage/repository.ts b/src/storage/repository.ts
index abedbf4..468485c 100644
--- a/src/storage/repository.ts
+++ b/src/storage/repository.ts
@@ -9,6 +9,8 @@ import {
import { batches, MarkdownCollection } from "./collection";
import { createLocalVault } from "./vault";
import { LocalViewExecutor } from "./local-views";
+import { VaultCollectionFileStore } from "./vault-files";
+import { isBinaryVault } from "./vault-contract";
import { completeRecords, completeTaskValues } from "./completions";
import { archiveMoveWarning } from "../domain/task-archive";
import {
@@ -63,6 +65,7 @@ import type {
RepositorySyncStatus,
TaskRepository,
} from "../application/ports/task-repository";
+import type { CollectionFileStore } from "../application/ports/collection-file-store";
export type {
CollectionInfo,
@@ -74,9 +77,10 @@ export type {
} from "../application/ports/task-repository";
const PROJECTION_CONSISTENCY_VERSION = 1;
-const TASK_PROJECTION_SHAPE_VERSION = 1;
+const TASK_PROJECTION_SHAPE_VERSION = 2;
export class IndexedMarkdownRepository implements TaskRepository {
+ readonly files?: CollectionFileStore;
private readonly collection: MarkdownCollection;
private readonly index: TaskIndex;
private readonly cache = new Map();
@@ -119,6 +123,8 @@ export class IndexedMarkdownRepository implements TaskRepository {
});
this.index =
options.index ?? new TaskIndex(indexName(this.collection.identifier()));
+ if (isBinaryVault(this.collection.vault))
+ this.files = new VaultCollectionFileStore(this.collection.vault);
this.views = new LocalViewExecutor(
this.collection,
() => [...this.cache.values()],
diff --git a/src/storage/tasknotes-collection.ts b/src/storage/tasknotes-collection.ts
index 13b7195..135cf09 100644
--- a/src/storage/tasknotes-collection.ts
+++ b/src/storage/tasknotes-collection.ts
@@ -3,6 +3,7 @@ import type {
CollectionTypeDescriptor,
JsonObject,
} from "@mdbase-dev/connect-protocol";
+import { TASKNOTES_SPEC_VERSION } from "@tasknotes/model/types";
import { TaskNotesTaskModel } from "../domain/tasknotes-model";
import { resolveTaskCollectionConfiguration } from "../domain/task-configuration";
@@ -27,11 +28,12 @@ export function resolveTaskCollection(
): ResolvedTaskCollection {
const contract = resources.contracts.find(
(candidate) =>
- candidate.id === "tasknotes.task" && candidate.version === "0.3.0-rc.1",
+ candidate.id === "tasknotes.task" &&
+ candidate.version === TASKNOTES_SPEC_VERSION,
);
if (!contract)
throw new Error(
- "This collection does not provide tasknotes.task 0.3.0-rc.1.",
+ `This collection does not provide tasknotes.task ${TASKNOTES_SPEC_VERSION}.`,
);
const providers = contract.implementations.map((implementation) => {
const type = resources.types.find(
@@ -70,7 +72,7 @@ export function resolveTaskTypeDefinition(
overrides.fields ?? taskNotesImplementation(definition)?.fields;
const implementation = {
contract: "tasknotes.task",
- version: "0.3.0-rc.1",
+ version: TASKNOTES_SPEC_VERSION,
fields: fields ?? {},
binding: configuration ?? {},
};
@@ -130,7 +132,7 @@ function taskNotesImplementation(
typeof candidate === "object" &&
!Array.isArray(candidate) &&
(candidate as Record).contract === "tasknotes.task" &&
- (candidate as Record).version === "0.3.0-rc.1",
+ (candidate as Record).version === TASKNOTES_SPEC_VERSION,
);
}
diff --git a/src/storage/vault-capacitor.test.ts b/src/storage/vault-capacitor.test.ts
new file mode 100644
index 0000000..1a4d3d4
--- /dev/null
+++ b/src/storage/vault-capacitor.test.ts
@@ -0,0 +1,115 @@
+const filesystem = vi.hoisted(() => ({
+ deleteFile: vi.fn(),
+ mkdir: vi.fn(),
+ rename: vi.fn(),
+ stat: vi.fn(),
+ writeFile: vi.fn(),
+}));
+
+vi.mock("@capacitor/filesystem", () => ({
+ Directory: { Documents: "DOCUMENTS" },
+ Encoding: { UTF8: "utf8" },
+ Filesystem: filesystem,
+}));
+
+import { CapacitorVault } from "./vault-capacitor";
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ filesystem.mkdir.mockResolvedValue(undefined);
+ filesystem.deleteFile.mockResolvedValue(undefined);
+ filesystem.rename.mockResolvedValue(undefined);
+ filesystem.stat.mockRejectedValue(new Error("File does not exist"));
+ filesystem.writeFile.mockResolvedValue(undefined);
+});
+
+it("stages binary writes and refuses to replace an existing destination", async () => {
+ filesystem.stat.mockImplementation(({ path }: { path: string }) => {
+ if (path.endsWith("Attachments/photo.png"))
+ return Promise.resolve({ mtime: 1, size: 3 });
+ if (path.includes(".tasknotes-write-"))
+ return Promise.resolve({ mtime: 2, size: 3 });
+ return Promise.resolve({ mtime: 1, size: 0 });
+ });
+ const vault = new CapacitorVault();
+
+ await expect(
+ vault.writeBinary("Attachments/photo.png", Uint8Array.of(1, 2, 3)),
+ ).rejects.toThrow("already exists");
+
+ expect(filesystem.writeFile).toHaveBeenCalledOnce();
+ expect(filesystem.writeFile).toHaveBeenCalledWith(
+ expect.objectContaining({
+ path: expect.stringMatching(
+ /^TaskNotes\/Attachments\/photo\.png\.tasknotes-write-[0-9a-f-]+\.tmp$/,
+ ),
+ data: "AQID",
+ }),
+ );
+ expect(filesystem.rename).not.toHaveBeenCalled();
+ expect(filesystem.deleteFile).toHaveBeenCalledWith(
+ expect.objectContaining({
+ path: expect.stringContaining("photo.png.tasknotes-write-"),
+ }),
+ );
+});
+
+it("deduplicates concurrent parent creation before nested writes", async () => {
+ filesystem.stat.mockImplementation(({ path }: { path: string }) =>
+ path.endsWith(".json")
+ ? Promise.resolve({ mtime: 3, size: 12 })
+ : Promise.reject(new Error("File does not exist")),
+ );
+ const vault = new CapacitorVault();
+
+ await Promise.all([
+ vault.writeText("_schemas/tasknotes/one.json", "one"),
+ vault.writeText("_schemas/tasknotes/two.json", "two"),
+ ]);
+
+ expect(filesystem.mkdir).toHaveBeenCalledTimes(1);
+ expect(filesystem.mkdir).toHaveBeenCalledWith({
+ path: "TaskNotes/_schemas/tasknotes",
+ directory: "DOCUMENTS",
+ recursive: true,
+ });
+ expect(filesystem.writeFile).toHaveBeenCalledTimes(2);
+ expect(filesystem.writeFile).toHaveBeenCalledWith(
+ expect.objectContaining({ recursive: false }),
+ );
+});
+
+it("creates the collection root before its standard directories", async () => {
+ await new CapacitorVault().initialize();
+
+ expect(filesystem.mkdir.mock.calls).toEqual([
+ [
+ {
+ path: "TaskNotes",
+ directory: "DOCUMENTS",
+ recursive: true,
+ },
+ ],
+ [
+ {
+ path: "TaskNotes/tasks",
+ directory: "DOCUMENTS",
+ recursive: true,
+ },
+ ],
+ [
+ {
+ path: "TaskNotes/_types",
+ directory: "DOCUMENTS",
+ recursive: true,
+ },
+ ],
+ [
+ {
+ path: "TaskNotes/views",
+ directory: "DOCUMENTS",
+ recursive: true,
+ },
+ ],
+ ]);
+});
diff --git a/src/storage/vault-capacitor.ts b/src/storage/vault-capacitor.ts
index eb0be0b..2c8e32b 100644
--- a/src/storage/vault-capacitor.ts
+++ b/src/storage/vault-capacitor.ts
@@ -8,21 +8,23 @@ import {
import {
isExcludedCollectionComponent,
safePath,
- type Vault,
+ type BinaryVault,
type VaultEntry,
} from "./vault-contract";
import { isMissingFileError } from "./vault-errors";
const ROOT = "TaskNotes";
-export class CapacitorVault implements Vault {
+export class CapacitorVault implements BinaryVault {
readonly kind = "native" as const;
+ private readonly directoryOperations = new Map>();
identifier(): string {
return "native-default";
}
async initialize(): Promise {
+ await this.ensureRootDirectory();
await this.ensureDirectory("tasks");
await this.ensureDirectory("_types");
await this.ensureDirectory("views");
@@ -88,12 +90,13 @@ export class CapacitorVault implements Vault {
async writeText(path: string, contents: string): Promise {
const relativePath = safePath(path);
+ await this.ensureParentDirectory(relativePath);
await Filesystem.writeFile({
path: this.path(relativePath),
directory: Directory.Documents,
encoding: Encoding.UTF8,
data: contents,
- recursive: true,
+ recursive: false,
});
const info = await Filesystem.stat({
path: this.path(relativePath),
@@ -102,6 +105,40 @@ export class CapacitorVault implements Vault {
return toEntry(relativePath, info);
}
+ async readBinary(path: string): Promise {
+ const result = await Filesystem.readFile({
+ path: this.path(path),
+ directory: Directory.Documents,
+ });
+ if (typeof result.data !== "string")
+ return new Uint8Array(await result.data.arrayBuffer());
+ return bytesFromBase64(result.data);
+ }
+
+ async writeBinary(path: string, contents: Uint8Array): Promise {
+ const relativePath = safePath(path);
+ const temporaryPath = `${relativePath}.tasknotes-write-${crypto.randomUUID()}.tmp`;
+ await this.ensureParentDirectory(relativePath);
+ try {
+ await Filesystem.writeFile({
+ path: this.path(temporaryPath),
+ directory: Directory.Documents,
+ data: base64FromBytes(contents),
+ recursive: false,
+ });
+ const staged = await Filesystem.stat({
+ path: this.path(temporaryPath),
+ directory: Directory.Documents,
+ });
+ if (staged.size !== contents.byteLength)
+ throw new Error(`The provider wrote only part of ${relativePath}.`);
+ return await this.rename(temporaryPath, relativePath);
+ } catch (error) {
+ await this.delete(temporaryPath);
+ throw error;
+ }
+ }
+
async delete(path: string): Promise {
await Filesystem.deleteFile({
path: this.path(path),
@@ -149,14 +186,63 @@ export class CapacitorVault implements Vault {
return `${ROOT}/${safePath(path)}`;
}
- private async ensureDirectory(path: string): Promise {
- if (await this.exists(path)) return;
+ private async ensureRootDirectory(): Promise {
+ try {
+ await Filesystem.stat({
+ path: ROOT,
+ directory: Directory.Documents,
+ });
+ return;
+ } catch (error) {
+ if (!isMissingFileError(error)) throw error;
+ }
await Filesystem.mkdir({
- path: this.path(path),
+ path: ROOT,
directory: Directory.Documents,
recursive: true,
});
}
+
+ private ensureDirectory(path: string): Promise {
+ const relativePath = safePath(path);
+ const active = this.directoryOperations.get(relativePath);
+ if (active) return active;
+ const operation = this.createDirectory(relativePath).finally(() => {
+ this.directoryOperations.delete(relativePath);
+ });
+ this.directoryOperations.set(relativePath, operation);
+ return operation;
+ }
+
+ private async createDirectory(path: string): Promise {
+ if (await this.exists(path)) return;
+ try {
+ await Filesystem.mkdir({
+ path: this.path(path),
+ directory: Directory.Documents,
+ recursive: true,
+ });
+ } catch (error) {
+ if (!(await this.exists(path))) throw error;
+ }
+ }
+
+ private async ensureParentDirectory(path: string): Promise {
+ const separator = path.lastIndexOf("/");
+ if (separator > 0) await this.ensureDirectory(path.slice(0, separator));
+ }
+}
+
+function base64FromBytes(bytes: Uint8Array): string {
+ let binary = "";
+ for (let offset = 0; offset < bytes.length; offset += 0x8000)
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
+ return btoa(binary);
+}
+
+function bytesFromBase64(value: string): Uint8Array {
+ const binary = atob(value);
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
}
function toEntry(path: string, info: FileInfo): VaultEntry {
diff --git a/src/storage/vault-contract.ts b/src/storage/vault-contract.ts
index 159cc1c..154f241 100644
--- a/src/storage/vault-contract.ts
+++ b/src/storage/vault-contract.ts
@@ -20,6 +20,18 @@ export interface Vault {
location(): string;
}
+export interface BinaryVault extends Vault {
+ readBinary(path: string): Promise;
+ writeBinary(path: string, contents: Uint8Array): Promise;
+}
+
+export function isBinaryVault(vault: Vault): vault is BinaryVault {
+ return (
+ typeof (vault as Partial).readBinary === "function" &&
+ typeof (vault as Partial).writeBinary === "function"
+ );
+}
+
export function safePath(value: string): string {
const normalized = value.replaceAll("\\", "/");
const segments = normalized.split("/");
diff --git a/src/storage/vault-files.test.ts b/src/storage/vault-files.test.ts
new file mode 100644
index 0000000..b38d90f
--- /dev/null
+++ b/src/storage/vault-files.test.ts
@@ -0,0 +1,104 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { MemoryVault } from "../test/memory-vault";
+import { VaultCollectionFileStore } from "./vault-files";
+
+describe("VaultCollectionFileStore", () => {
+ it("round-trips an image without changing its bytes", async () => {
+ const vault = new MemoryVault();
+ const store = new VaultCollectionFileStore(vault);
+ const bytes = Uint8Array.from([0, 255, 12, 99, 1]);
+ const progress = vi.fn();
+
+ const uploaded = await store.upload("Attachments/photo.jpg", bytes, {
+ mediaType: "image/jpeg",
+ onProgress: progress,
+ });
+
+ expect(uploaded).toMatchObject({
+ path: "Attachments/photo.jpg",
+ size: 5,
+ mediaType: "image/jpeg",
+ mediaClass: "image",
+ });
+ expect(uploaded.contentDigest).toMatch(/^sha256:[0-9a-f]{64}$/);
+ expect(
+ new Uint8Array(await (await store.download(uploaded)).arrayBuffer()),
+ ).toEqual(bytes);
+ expect(await store.list({ folder: "Attachments" })).toEqual([uploaded]);
+ expect(progress).toHaveBeenLastCalledWith({
+ phase: "uploading",
+ transferredBytes: 5,
+ totalBytes: 5,
+ });
+ });
+
+ it("refuses non-atomic replacement without touching the original bytes", async () => {
+ const vault = new MemoryVault();
+ const writeBinary = vi.spyOn(vault, "writeBinary");
+ const store = new VaultCollectionFileStore(vault);
+ const uploaded = await store.upload(
+ "Attachments/photo.png",
+ Uint8Array.of(1),
+ );
+ writeBinary.mockClear();
+
+ await expect(
+ store.upload("Attachments/photo.png", Uint8Array.of(2), {
+ ifRevision: uploaded.revision,
+ }),
+ ).rejects.toThrow("replacement is unavailable");
+ await expect(
+ store.upload("Attachments/photo.png", Uint8Array.of(3)),
+ ).rejects.toThrow("already exists");
+ expect(store.authorizedActions().has("replace")).toBe(false);
+ expect(writeBinary).not.toHaveBeenCalled();
+ expect(
+ new Uint8Array(await (await store.download(uploaded)).arrayBuffer()),
+ ).toEqual(Uint8Array.of(1));
+ });
+
+ it("rejects unsafe paths and mismatched image formats", async () => {
+ const store = new VaultCollectionFileStore(new MemoryVault());
+ await expect(
+ store.upload("../photo.png", Uint8Array.of(1)),
+ ).rejects.toThrow("Unsafe collection path");
+ await expect(
+ store.upload("Attachments/file.txt", Uint8Array.of(1)),
+ ).rejects.toThrow("supported image format");
+ await expect(
+ store.upload("Attachments/disguised.png", Uint8Array.of(1), {
+ mediaType: "image/svg+xml",
+ }),
+ ).rejects.toThrow("does not match");
+ });
+
+ it("moves and deletes files", async () => {
+ const store = new VaultCollectionFileStore(new MemoryVault());
+ const uploaded = await store.upload(
+ "Attachments/one.webp",
+ Uint8Array.of(1, 2),
+ );
+ const moved = await store.move(uploaded, "Attachments/two.webp");
+
+ expect(moved.path).toBe("Attachments/two.webp");
+ expect((await store.list()).map(({ path }) => path)).toEqual([
+ "Attachments/two.webp",
+ ]);
+ await store.delete(moved);
+ expect(await store.list()).toEqual([]);
+ });
+
+ it("does not reread unchanged image bytes when listing descriptors", async () => {
+ const vault = new MemoryVault();
+ await vault.writeBinary("Attachments/photo.png", Uint8Array.of(1, 2, 3));
+ const readBinary = vi.spyOn(vault, "readBinary");
+ const store = new VaultCollectionFileStore(vault);
+
+ const first = await store.list();
+ const second = await store.list();
+
+ expect(second).toEqual(first);
+ expect(readBinary).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/storage/vault-files.ts b/src/storage/vault-files.ts
new file mode 100644
index 0000000..1d38ef6
--- /dev/null
+++ b/src/storage/vault-files.ts
@@ -0,0 +1,265 @@
+import type {
+ CollectionFile,
+ CollectionFileAction,
+ CollectionFileProgress,
+ CollectionFileStore,
+} from "../application/ports/collection-file-store";
+import { safePath, type BinaryVault, type VaultEntry } from "./vault-contract";
+
+export const TASKNOTES_IMAGE_EXTENSIONS = [
+ ".avif",
+ ".gif",
+ ".heic",
+ ".heif",
+ ".jpeg",
+ ".jpg",
+ ".png",
+ ".webp",
+] as const;
+
+const ALL_ACTIONS = new Set([
+ "list",
+ "read",
+ "add",
+ "move",
+ "delete",
+]);
+
+/** Binary file storage for a native Markdown collection. */
+export class VaultCollectionFileStore implements CollectionFileStore {
+ private readonly digestCache = new Map<
+ string,
+ { revision: string; digest: `sha256:${string}` }
+ >();
+
+ constructor(private readonly vault: BinaryVault) {}
+
+ authorizedActions(): ReadonlySet {
+ return ALL_ACTIONS;
+ }
+
+ async list(
+ options: { folder?: string; signal?: AbortSignal } = {},
+ ): Promise {
+ throwIfAborted(options.signal);
+ const folder = options.folder?.replace(/\/$/, "");
+ const entries = folder
+ ? await this.vault.listFiles(safePath(folder), [
+ ...TASKNOTES_IMAGE_EXTENSIONS,
+ ])
+ : await this.vault.listCollectionFiles([...TASKNOTES_IMAGE_EXTENSIONS]);
+ const present = new Set(entries.map(({ path }) => path));
+ for (const path of this.digestCache.keys())
+ if (!present.has(path)) this.digestCache.delete(path);
+ return Promise.all(
+ entries.map(async (entry) => {
+ throwIfAborted(options.signal);
+ const cached = this.digestCache.get(entry.path);
+ if (cached?.revision === revision(entry))
+ return descriptorFrom(entry, cached.digest);
+ const digest = await sha256(await this.vault.readBinary(entry.path));
+ this.remember(entry, digest);
+ return descriptorFrom(entry, digest);
+ }),
+ );
+ }
+
+ async upload(
+ path: string,
+ source: Blob | ArrayBuffer | ArrayBufferView,
+ options: {
+ mediaType?: string;
+ ifRevision?: string;
+ signal?: AbortSignal;
+ onProgress?: (progress: CollectionFileProgress) => void;
+ } = {},
+ ): Promise {
+ const target = imagePath(path);
+ const resolvedMediaType = validatedMediaType(target, options.mediaType);
+ throwIfAborted(options.signal);
+ const existing = await this.entry(target);
+ if (options.ifRevision !== undefined)
+ throw new Error(
+ "Native attachment replacement is unavailable until the folder provider can commit it atomically.",
+ );
+ if (existing) throw new Error(`An attachment already exists at ${target}.`);
+ const bytes = await bytesFrom(source);
+ options.onProgress?.({
+ phase: "hashing",
+ transferredBytes: 0,
+ totalBytes: bytes.byteLength,
+ });
+ throwIfAborted(options.signal);
+ const digest = await sha256(bytes);
+ options.onProgress?.({
+ phase: "hashing",
+ transferredBytes: bytes.byteLength,
+ totalBytes: bytes.byteLength,
+ });
+ throwIfAborted(options.signal);
+ const written = await this.vault.writeBinary(target, bytes);
+ this.remember(written, digest);
+ options.onProgress?.({
+ phase: "uploading",
+ transferredBytes: bytes.byteLength,
+ totalBytes: bytes.byteLength,
+ });
+ return descriptorFrom(written, digest, resolvedMediaType);
+ }
+
+ async download(
+ file: CollectionFile,
+ options: {
+ signal?: AbortSignal;
+ onProgress?: (progress: CollectionFileProgress) => void;
+ } = {},
+ ): Promise {
+ throwIfAborted(options.signal);
+ const bytes = await this.vault.readBinary(imagePath(file.path));
+ throwIfAborted(options.signal);
+ options.onProgress?.({
+ phase: "downloading",
+ transferredBytes: bytes.byteLength,
+ totalBytes: bytes.byteLength,
+ });
+ return new Blob([ownedBuffer(bytes)], {
+ type: file.mediaType ?? mediaType(file.path),
+ });
+ }
+
+ async downloadStream(
+ file: CollectionFile,
+ options: {
+ signal?: AbortSignal;
+ onProgress?: (progress: CollectionFileProgress) => void;
+ } = {},
+ ): Promise> {
+ const blob = await this.download(file, options);
+ return blob.stream();
+ }
+
+ async move(file: CollectionFile, path: string): Promise {
+ const target = imagePath(path);
+ const entry = await this.vault.rename(imagePath(file.path), target);
+ this.digestCache.delete(file.path);
+ this.remember(entry, file.contentDigest);
+ return {
+ ...file,
+ fileId: fileId(target),
+ path: target,
+ revision: revision(entry),
+ modifiedAt: modifiedAt(entry),
+ mediaType: mediaType(target),
+ };
+ }
+
+ async delete(file: CollectionFile): Promise {
+ await this.vault.delete(imagePath(file.path));
+ this.digestCache.delete(file.path);
+ }
+
+ private async entry(path: string): Promise {
+ if (!(await this.vault.exists(path))) return undefined;
+ return (
+ await this.vault.listCollectionFiles([...TASKNOTES_IMAGE_EXTENSIONS])
+ ).find((entry) => entry.path === path);
+ }
+
+ private remember(entry: VaultEntry, digest: `sha256:${string}`): void {
+ this.digestCache.set(entry.path, { revision: revision(entry), digest });
+ }
+}
+
+function descriptorFrom(
+ entry: VaultEntry,
+ digest: `sha256:${string}`,
+ explicitMediaType?: string,
+): CollectionFile {
+ return {
+ fileId: fileId(entry.path),
+ path: entry.path,
+ revision: revision(entry),
+ contentDigest: digest,
+ size: entry.size,
+ mediaType: explicitMediaType || mediaType(entry.path),
+ mediaClass: "image",
+ modifiedAt: modifiedAt(entry),
+ };
+}
+
+function imagePath(path: string): string {
+ const target = safePath(path);
+ if (
+ !TASKNOTES_IMAGE_EXTENSIONS.some((extension) =>
+ target.toLowerCase().endsWith(extension),
+ )
+ )
+ throw new Error("TaskNotes attachments must use a supported image format.");
+ return target;
+}
+
+function fileId(path: string): string {
+ return `vault:${path}`;
+}
+
+function revision(entry: VaultEntry): string {
+ return `${entry.lastModified}:${entry.size}`;
+}
+
+function modifiedAt(entry: VaultEntry): string {
+ return new Date(entry.lastModified).toISOString();
+}
+
+function mediaType(path: string): string {
+ const extension = path.slice(path.lastIndexOf(".")).toLowerCase();
+ if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg";
+ if (extension === ".heic" || extension === ".heif") return "image/heic";
+ return `image/${extension.slice(1)}`;
+}
+
+function validatedMediaType(path: string, declared?: string): string {
+ const inferred = mediaType(path);
+ if (!declared) return inferred;
+ const normalized = declared.toLowerCase().split(";", 1)[0].trim();
+ const accepted =
+ inferred === "image/heic"
+ ? new Set(["image/heic", "image/heif"])
+ : new Set([inferred]);
+ if (!accepted.has(normalized))
+ throw new Error(
+ "Attachment media type does not match its image extension.",
+ );
+ return normalized;
+}
+
+async function bytesFrom(
+ source: Blob | ArrayBuffer | ArrayBufferView,
+): Promise {
+ if (source instanceof Blob) return new Uint8Array(await source.arrayBuffer());
+ if (ArrayBuffer.isView(source))
+ return new Uint8Array(
+ source.buffer.slice(
+ source.byteOffset,
+ source.byteOffset + source.byteLength,
+ ),
+ );
+ return new Uint8Array(source.slice(0));
+}
+
+async function sha256(bytes: Uint8Array): Promise<`sha256:${string}`> {
+ const digest = await crypto.subtle.digest("SHA-256", ownedBuffer(bytes));
+ return `sha256:${[...new Uint8Array(digest)]
+ .map((value) => value.toString(16).padStart(2, "0"))
+ .join("")}`;
+}
+
+function ownedBuffer(bytes: Uint8Array): ArrayBuffer {
+ const copy = new Uint8Array(bytes.byteLength);
+ copy.set(bytes);
+ return copy.buffer;
+}
+
+function throwIfAborted(signal?: AbortSignal): void {
+ if (signal?.aborted)
+ throw signal.reason ?? new DOMException("Aborted", "AbortError");
+}
diff --git a/src/storage/vault-native-folder.test.ts b/src/storage/vault-native-folder.test.ts
index 7aed616..8869872 100644
--- a/src/storage/vault-native-folder.test.ts
+++ b/src/storage/vault-native-folder.test.ts
@@ -4,8 +4,10 @@ const folderAccess = vi.hoisted(() => ({
ensureDirectory: vi.fn(),
exists: vi.fn(),
listFiles: vi.fn(),
+ readBinary: vi.fn(),
readText: vi.fn(),
rename: vi.fn(),
+ writeBinary: vi.fn(),
writeText: vi.fn(),
}));
@@ -91,3 +93,27 @@ it("filters every path containing an excluded component", async () => {
{ path: "visible.md", lastModified: 1, size: 1 },
]);
});
+
+it("round-trips binary bytes through the retained-folder bridge", async () => {
+ folderAccess.readBinary.mockResolvedValue({ data: "AP8MYw==" });
+ folderAccess.writeBinary.mockResolvedValue({
+ entry: { path: "Attachments/photo.jpg", lastModified: 3, size: 4 },
+ });
+ const vault = new NativeFolderVault(selection);
+
+ await expect(vault.readBinary("Attachments/photo.jpg")).resolves.toEqual(
+ Uint8Array.of(0, 255, 12, 99),
+ );
+ await expect(
+ vault.writeBinary("Attachments/photo.jpg", Uint8Array.of(0, 255, 12, 99)),
+ ).resolves.toEqual({
+ path: "Attachments/photo.jpg",
+ lastModified: 3,
+ size: 4,
+ });
+ expect(folderAccess.writeBinary).toHaveBeenCalledWith({
+ selectionId: selection.id,
+ path: "Attachments/photo.jpg",
+ data: "AP8MYw==",
+ });
+});
diff --git a/src/storage/vault-native-folder.ts b/src/storage/vault-native-folder.ts
index 376d689..8118ec1 100644
--- a/src/storage/vault-native-folder.ts
+++ b/src/storage/vault-native-folder.ts
@@ -2,13 +2,13 @@ import { FolderAccess } from "../native/folder-access";
import {
isExcludedCollectionPath,
safePath,
- type Vault,
+ type BinaryVault,
type VaultEntry,
} from "./vault-contract";
import type { LocalCollectionLocation } from "./local-collection-location";
-export class NativeFolderVault implements Vault {
+export class NativeFolderVault implements BinaryVault {
readonly kind = "native" as const;
private readonly selection: Extract<
LocalCollectionLocation,
@@ -70,6 +70,23 @@ export class NativeFolderVault implements Vault {
return result.entry;
}
+ async readBinary(path: string): Promise {
+ const result = await FolderAccess.readBinary({
+ selectionId: this.selection.id,
+ path: safePath(path),
+ });
+ return bytesFromBase64(result.data);
+ }
+
+ async writeBinary(path: string, contents: Uint8Array): Promise {
+ const result = await FolderAccess.writeBinary({
+ selectionId: this.selection.id,
+ path: safePath(path),
+ data: base64FromBytes(contents),
+ });
+ return result.entry;
+ }
+
async rename(from: string, to: string): Promise {
const result = await FolderAccess.rename({
selectionId: this.selection.id,
@@ -121,3 +138,15 @@ export class NativeFolderVault implements Vault {
.sort((left, right) => left.path.localeCompare(right.path));
}
}
+
+function base64FromBytes(bytes: Uint8Array): string {
+ let binary = "";
+ for (let offset = 0; offset < bytes.length; offset += 0x8000)
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
+ return btoa(binary);
+}
+
+function bytesFromBase64(value: string): Uint8Array {
+ const binary = atob(value);
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
+}
diff --git a/src/styles.css b/src/styles.css
index dac8f15..059fa65 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -2948,6 +2948,192 @@ button:disabled {
outline: 0;
}
+.task-attachments {
+ padding: 25px 0 22px;
+ border-bottom: 1px solid var(--line);
+}
+
+.task-attachments-heading {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 18px;
+ margin-bottom: 14px;
+}
+
+.task-attachments-heading h2,
+.task-attachments-heading p {
+ margin: 0;
+}
+
+.task-attachments-heading h2 {
+ color: var(--ink-muted);
+ font-size: 1rem;
+ font-weight: 400;
+}
+
+.task-attachments-heading p {
+ max-width: 48ch;
+ margin-top: 4px;
+ color: var(--ink-muted);
+ font-size: 0.78rem;
+ line-height: 1.45;
+}
+
+.task-attachment-add-actions {
+ display: flex;
+ flex: 0 0 auto;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 2px 12px;
+}
+
+.task-attachment-add-actions input {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.task-attachment-add-actions label {
+ display: inline-flex;
+ min-height: var(--control-min-size);
+ align-items: center;
+ gap: 7px;
+ cursor: pointer;
+}
+
+.task-attachment-add-actions input:focus-visible + label {
+ outline: 2px solid color-mix(in srgb, var(--accent), transparent 28%);
+ outline-offset: 2px;
+}
+
+.task-attachment-add-actions input:disabled + label {
+ cursor: wait;
+ opacity: 0.55;
+}
+
+.attachment-status,
+.attachment-empty,
+.attachment-error {
+ min-height: 44px;
+ margin: 0;
+ color: var(--ink-muted);
+ font-size: 0.84rem;
+ line-height: 1.45;
+}
+
+.attachment-status {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.attachment-error {
+ color: var(--danger);
+}
+
+.attachment-list {
+ padding: 0;
+ margin: 0;
+ list-style: none;
+ border-top: 1px solid var(--line);
+}
+
+.attachment-list > li {
+ display: grid;
+ grid-template-columns: 44px minmax(0, 1fr) auto;
+ min-height: 68px;
+ align-items: center;
+ gap: 12px;
+ padding: 11px 0;
+ border-bottom: 1px solid var(--line);
+}
+
+.attachment-thumbnail {
+ display: grid;
+ width: 44px;
+ height: 44px;
+ object-fit: cover;
+ place-items: center;
+ color: var(--ink-muted);
+ background: var(--paper-soft);
+ border-radius: 8px;
+}
+
+.attachment-identity {
+ min-width: 0;
+}
+
+.attachment-identity strong,
+.attachment-identity small {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.attachment-identity strong {
+ font-size: 0.9rem;
+ font-weight: 560;
+}
+
+.attachment-identity small {
+ margin-top: 3px;
+ color: var(--ink-muted);
+ font-size: 0.74rem;
+}
+
+.attachment-actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 2px;
+}
+
+.attachment-actions button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 5px;
+ min-width: var(--control-min-size);
+ min-height: var(--control-min-size);
+ padding: 0 9px;
+ color: var(--ink-muted);
+ background: transparent;
+ border: 0;
+ border-radius: 6px;
+ cursor: pointer;
+}
+
+.attachment-policy {
+ margin: 0;
+ color: var(--ink-muted);
+ font-size: 0.8rem;
+ line-height: 1.45;
+}
+
+.attachment-actions button:hover,
+.attachment-actions button:focus-visible {
+ color: var(--ink);
+ background: var(--paper-soft);
+}
+
+.attachment-actions button:focus-visible {
+ outline: 2px solid color-mix(in srgb, var(--accent), transparent 28%);
+ outline-offset: 1px;
+}
+
+.attachment-actions button:disabled {
+ cursor: not-allowed;
+ opacity: 0.45;
+}
+
.markdown-preview {
min-height: 150px;
overflow-wrap: anywhere;
@@ -2955,6 +3141,27 @@ button:disabled {
line-height: 1.6;
}
+.markdown-preview img {
+ display: block;
+ width: auto;
+ max-width: 100%;
+ max-height: min(52vh, 520px);
+ margin: 14px 0;
+ object-fit: contain;
+ border-radius: 10px;
+}
+
+.markdown-image-placeholder {
+ display: grid;
+ min-height: 96px;
+ margin: 14px 0;
+ place-items: center;
+ color: var(--ink-muted);
+ font-size: 0.82rem;
+ background: var(--paper-soft);
+ border-radius: 10px;
+}
+
.markdown-preview > :first-child {
margin-top: 0;
}
@@ -6084,6 +6291,16 @@ button:disabled {
padding-left: 14px;
}
+ .detail-inspector .task-attachments-heading {
+ align-items: stretch;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ .detail-inspector .task-attachment-add-actions {
+ justify-content: flex-start;
+ }
+
.view-detail {
width: min(100%, 1440px);
}
@@ -6496,6 +6713,26 @@ button:disabled {
align-items: flex-start;
}
+ .task-attachments-heading {
+ align-items: stretch;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ .task-attachment-add-actions {
+ justify-content: flex-start;
+ }
+
+ .attachment-list > li {
+ grid-template-columns: 44px minmax(0, 1fr);
+ }
+
+ .attachment-actions {
+ grid-column: 1 / -1;
+ justify-content: flex-start;
+ padding-left: 56px;
+ }
+
.delete-confirmation {
align-items: flex-start;
flex-direction: column;
diff --git a/src/test/memory-vault.ts b/src/test/memory-vault.ts
index b836c85..88c8726 100644
--- a/src/test/memory-vault.ts
+++ b/src/test/memory-vault.ts
@@ -5,7 +5,7 @@ import {
} from "../storage/vault-contract";
interface StoredFile {
- contents: string;
+ contents: Uint8Array;
lastModified: number;
}
@@ -49,7 +49,7 @@ export class MemoryVault implements Vault {
.map(([name, file]) => ({
path: name,
lastModified: file.lastModified,
- size: new TextEncoder().encode(file.contents).byteLength,
+ size: file.contents.byteLength,
}))
.sort((left, right) => left.path.localeCompare(right.path));
}
@@ -57,17 +57,37 @@ export class MemoryVault implements Vault {
async readText(path: string): Promise {
const file = this.files.get(safePath(path));
if (!file) throw new DOMException("File not found", "NotFoundError");
- return file.contents;
+ return new TextDecoder().decode(file.contents);
}
async writeText(path: string, contents: string): Promise {
const safe = safePath(path);
- const file = { contents, lastModified: this.clock++ };
+ const file = {
+ contents: new TextEncoder().encode(contents),
+ lastModified: this.clock++,
+ };
+ this.files.set(safe, file);
+ return {
+ path: safe,
+ lastModified: file.lastModified,
+ size: file.contents.byteLength,
+ };
+ }
+
+ async readBinary(path: string): Promise {
+ const file = this.files.get(safePath(path));
+ if (!file) throw new DOMException("File not found", "NotFoundError");
+ return file.contents.slice();
+ }
+
+ async writeBinary(path: string, contents: Uint8Array): Promise {
+ const safe = safePath(path);
+ const file = { contents: contents.slice(), lastModified: this.clock++ };
this.files.set(safe, file);
return {
path: safe,
lastModified: file.lastModified,
- size: new TextEncoder().encode(contents).byteLength,
+ size: file.contents.byteLength,
};
}
@@ -88,7 +108,7 @@ export class MemoryVault implements Vault {
return {
path: destination,
lastModified: moved.lastModified,
- size: new TextEncoder().encode(moved.contents).byteLength,
+ size: moved.contents.byteLength,
};
}
diff --git a/vendor/tasknotes-model-0.3.0-rc.9.tgz b/vendor/tasknotes-model-0.3.0-rc.9.tgz
new file mode 100644
index 0000000..600bdad
Binary files /dev/null and b/vendor/tasknotes-model-0.3.0-rc.9.tgz differ
diff --git a/vendor/tasknotes-spec-0.3.0-rc.3.tgz b/vendor/tasknotes-spec-0.3.0-rc.3.tgz
new file mode 100644
index 0000000..57b57c4
Binary files /dev/null and b/vendor/tasknotes-spec-0.3.0-rc.3.tgz differ