;
+}) {
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..74a4a78
--- /dev/null
+++ b/src/components/task-attachments.tsx
@@ -0,0 +1,323 @@
+import {
+ ExternalLink,
+ FileImage,
+ ImagePlus,
+ LoaderCircle,
+ Trash2,
+ 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,
+ onBodyUpdated,
+}: {
+ task: Task;
+ service: AttachmentService;
+ store: CollectionFileStore;
+ beforeMutation(): Promise;
+ onBodyUpdated(body: string): void;
+}) {
+ 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 [confirming, setConfirming] = useState(null);
+ const mounted = useRef(true);
+ const loadGeneration = useRef(0);
+ const keepButtonRef = useRef(null);
+ const deleteTriggerRef = useRef(null);
+
+ 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]);
+
+ useEffect(() => {
+ if (confirming) keepButtonRef.current?.focus();
+ }, [confirming]);
+
+ async function run(action: () => Promise): Promise {
+ if (busy) return;
+ setBusy(true);
+ setError(null);
+ try {
+ await beforeMutation();
+ const updated = await action();
+ if (updated?.body !== undefined) onBodyUpdated(updated.body);
+ 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 = inline
+ ? await service.insertImageInline(task.id, file)
+ : await service.attachImage(task.id, file);
+ return result.task;
+ });
+ }
+
+ if (!service.available()) return null;
+
+ return (
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+ {loading ? (
+
+ Loading
+ images…
+
+ ) : items.length ? (
+
+ {items.map((item) => (
+ -
+
+
+ {displayName(item.path ?? item.reference)}
+ {attachmentState(item)}
+
+ {confirming === item.reference ? (
+
+ Delete the file?
+
+
+
+ ) : (
+
+ {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,
+): Promise {
+ const url = URL.createObjectURL(await store.download(file));
+ window.open(url, "_blank", "noopener,noreferrer");
+ 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..2f96ecb 100644
--- a/src/generated/mdbase-app.json
+++ b/src/generated/mdbase-app.json
@@ -12,12 +12,19 @@
"contracts": [
{
"id": "tasknotes.task",
- "version": "0.3.0-rc.1"
+ "version": "0.3.0-rc.3"
}
],
"access": "full_collection",
"files": {
- "actions": ["list", "read", "add", "replace", "move", "delete"],
+ "actions": [
+ "list",
+ "read",
+ "add",
+ "replace",
+ "move",
+ "delete"
+ ],
"scope": {
"kind": "collection"
}
@@ -29,7 +36,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 +44,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..8c4aecf 100644
--- a/src/native/folder-access.ts
+++ b/src/native/folder-access.ts
@@ -32,11 +32,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..e1c4e11
--- /dev/null
+++ b/src/storage/local-first-mdbase-files.test.ts
@@ -0,0 +1,192 @@
+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("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: `sha256:${"0".repeat(64)}`,
+ 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;
+}
diff --git a/src/storage/local-first-mdbase-files.ts b/src/storage/local-first-mdbase-files.ts
new file mode 100644
index 0000000..0fab052
--- /dev/null
+++ b/src/storage/local-first-mdbase-files.ts
@@ -0,0 +1,522 @@
+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) continue;
+ await this.replica.files.put({
+ path: file.path,
+ file: {
+ ...file,
+ availability: cached?.bytes ? "local-and-remote" : "remote",
+ },
+ ...(cached?.bytes ? { bytes: cached.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) {
+ 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);
+ 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..ab23048
--- /dev/null
+++ b/src/storage/vault-capacitor.test.ts
@@ -0,0 +1,80 @@
+const filesystem = vi.hoisted(() => ({
+ mkdir: 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.stat.mockRejectedValue(new Error("File does not exist"));
+ filesystem.writeFile.mockResolvedValue(undefined);
+});
+
+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..70dc20c 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,39 @@ 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),
+ directory: Directory.Documents,
+ });
+ 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);
+ await this.ensureParentDirectory(relativePath);
+ await Filesystem.writeFile({
+ path: this.path(relativePath),
+ directory: Directory.Documents,
+ data: base64FromBytes(contents),
+ recursive: false,
});
const info = await Filesystem.stat({
path: this.path(relativePath),
@@ -149,14 +178,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..36de4da
--- /dev/null
+++ b/src/storage/vault-files.test.ts
@@ -0,0 +1,90 @@
+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("honors revision guards and safe image paths", async () => {
+ const store = new VaultCollectionFileStore(new MemoryVault());
+ const uploaded = await store.upload(
+ "Attachments/photo.png",
+ Uint8Array.of(1),
+ );
+
+ await expect(
+ store.upload("Attachments/photo.png", Uint8Array.of(2), {
+ ifRevision: "stale",
+ }),
+ ).rejects.toThrow("revision conflict");
+ 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");
+ expect(await store.download(uploaded)).toBeInstanceOf(Blob);
+ });
+
+ 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..febe0d8
--- /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",
+ "replace",
+ "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) {
+ if (!existing || revision(existing) !== options.ifRevision)
+ throw new Error(`Attachment revision conflict 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..86dd0f7 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -2948,6 +2948,202 @@ 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,
+.attachment-delete-confirm {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 2px;
+}
+
+.attachment-actions button,
+.attachment-delete-confirm button {
+ 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-actions button:hover,
+.attachment-actions button:focus-visible,
+.attachment-delete-confirm button:hover,
+.attachment-delete-confirm button:focus-visible {
+ color: var(--ink);
+ background: var(--paper-soft);
+}
+
+.attachment-actions button:focus-visible,
+.attachment-delete-confirm button:focus-visible {
+ outline: 2px solid color-mix(in srgb, var(--accent), transparent 28%);
+ outline-offset: 1px;
+}
+
+.attachment-actions button.danger,
+.attachment-delete-confirm button.danger {
+ color: var(--danger);
+}
+
+.attachment-actions button:disabled,
+.attachment-delete-confirm button:disabled {
+ cursor: not-allowed;
+ opacity: 0.45;
+}
+
+.attachment-delete-confirm {
+ flex-wrap: wrap;
+}
+
+.attachment-delete-confirm span {
+ margin-right: 4px;
+ color: var(--ink);
+ font-size: 0.78rem;
+}
+
.markdown-preview {
min-height: 150px;
overflow-wrap: anywhere;
@@ -2955,6 +3151,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 +6301,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 +6723,27 @@ 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,
+ .attachment-delete-confirm {
+ 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 0000000000000000000000000000000000000000..600bdad5f1b627639ee9bb1a250ed7ec572e5fad
GIT binary patch
literal 771908
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