diff --git a/README.md b/README.md index 976f4b8..dad7e71 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ installed during migration. - Ordered view navigation with Today, Upcoming, saved lists, boards, and calendars - Grouped view sources and grouped list results - Capture, editing, completion, and client-side search +- First-class image attachments with optional inline Notes embeds - Projects, contexts, tags, recurrence, absolute reminders, and priorities - Authority-backed, content-free reminders for connected mdbase collections - Offline cloud replica with background synchronization and explicit conflict resolution @@ -49,6 +50,21 @@ offline replica because it may temporarily hold writes that have not reached the hosted authority. Mutations are serialized at the repository boundary, while UI saves can continue after navigation. +An attachment has three deliberately separate sources of truth. A task's +frontmatter `attachments` link list owns membership; an optional image embed in +the Markdown body owns presentation; and the filesystem or mdbase file +descriptor owns binary metadata such as size, digest, media type, and revision. +Attaching therefore never rewrites Notes, detaching never deletes bytes, and a +detached file is retained by every provider. TaskNotes will expose permanent +deletion only when the collection authority can atomically prove that no task +membership or inline embed still refers to the bytes and delete them in the +same transaction. + +```yaml +attachments: + - "[[Attachments/receipt.jpg]]" +``` + Android retains access to selected folders through a persisted Storage Access Framework grant. iOS retains a security-scoped bookmark and coordinates access with the selected Files provider. Each folder has a separate disposable index, diff --git a/android/app/src/main/java/dev/tasknotes/app/FolderAccessPlugin.java b/android/app/src/main/java/dev/tasknotes/app/FolderAccessPlugin.java index 67a6fe6..2524e1d 100644 --- a/android/app/src/main/java/dev/tasknotes/app/FolderAccessPlugin.java +++ b/android/app/src/main/java/dev/tasknotes/app/FolderAccessPlugin.java @@ -5,6 +5,7 @@ import android.content.Intent; import android.content.SharedPreferences; import android.net.Uri; +import android.util.Base64; import androidx.activity.result.ActivityResult; import androidx.documentfile.provider.DocumentFile; @@ -261,6 +262,83 @@ public void writeText(PluginCall call) { }); } + @PluginMethod + public void readBinary(PluginCall call) { + run(call, () -> { + String id = requiredString(call, "selectionId"); + String path = safePath(requiredString(call, "path"), false); + DocumentFile file = requireFile(id, path); + try ( + InputStream raw = getContext().getContentResolver().openInputStream(file.getUri()); + BufferedInputStream input = raw == null ? null : new BufferedInputStream(raw) + ) { + if (input == null) { + throw new IOException("Could not open " + path + "."); + } + ByteArrayOutputStream output = new ByteArrayOutputStream(); + copy(input, output); + JSObject response = new JSObject(); + response.put("data", Base64.encodeToString(output.toByteArray(), Base64.NO_WRAP)); + return response; + } + }); + } + + @PluginMethod + public void writeBinary(PluginCall call) { + run(call, () -> { + String id = requiredString(call, "selectionId"); + String path = safePath(requiredString(call, "path"), false); + String data = call.getString("data"); + if (data == null) { + throw new IllegalArgumentException("data is required."); + } + byte[] bytes; + try { + bytes = Base64.decode(data, Base64.DEFAULT); + } catch (IllegalArgumentException error) { + throw new IllegalArgumentException("data must be valid base64.", error); + } + String temporaryPath = path + ".tasknotes-write-" + UUID.randomUUID() + ".tmp"; + DocumentFile temporary = fileForWrite(id, temporaryPath, mimeType(path)); + DocumentFile file; + try { + try ( + OutputStream raw = getContext().getContentResolver().openOutputStream(temporary.getUri(), "wt"); + BufferedOutputStream output = raw == null ? null : new BufferedOutputStream(raw) + ) { + if (output == null) { + throw new IOException("Could not open " + path + " for writing."); + } + output.write(bytes); + } + if (temporary.length() != bytes.length) { + throw new IOException("The provider wrote only part of " + path + "."); + } + DocumentFile existing = resolve(id, path); + if (existing != null && existing.exists()) { + throw new IOException("A binary already exists at " + path + "."); + } + pathCache.remove(path); + if (!temporary.renameTo(fileName(path))) { + throw new IOException("Could not commit " + path + "."); + } + pathCache.remove(temporaryPath); + file = resolve(id, path); + if (file == null || file.length() != bytes.length) { + throw new IOException("Committed binary could not be verified: " + path + "."); + } + } catch (Exception error) { + temporary.delete(); + pathCache.remove(temporaryPath); + throw error; + } + JSObject response = new JSObject(); + response.put("entry", entry(path, file)); + return response; + }); + } + @PluginMethod public void rename(PluginCall call) { run(call, () -> { @@ -424,6 +502,10 @@ private DocumentFile requireFile(String id, String path) throws IOException { } private DocumentFile fileForWrite(String id, String path) throws IOException { + return fileForWrite(id, path, mimeType(path)); + } + + private DocumentFile fileForWrite(String id, String path, String mimeType) throws IOException { DocumentFile existing = resolve(id, path); if (existing != null) { if (!existing.exists()) { @@ -436,7 +518,7 @@ private DocumentFile fileForWrite(String id, String path) throws IOException { } String parent = parentPath(path); DocumentFile directory = parent.isEmpty() ? requireRoot(id) : ensureDirectory(id, parent); - DocumentFile created = directory.createFile(mimeType(path), fileName(path)); + DocumentFile created = directory.createFile(mimeType, fileName(path)); if (created == null) { throw new IOException("Could not create " + path + "."); } @@ -472,6 +554,10 @@ private static JSObject entry(String path, DocumentFile file) { entry.put("path", path); entry.put("lastModified", file.lastModified()); entry.put("size", file.length()); + String mediaType = file.getType(); + if (mediaType != null) { + entry.put("mediaType", mediaType); + } return entry; } @@ -543,6 +629,12 @@ private static String mimeType(String path) { // unknown extension is created as text/plain. return "application/octet-stream"; } + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".webp")) return "image/webp"; + if (lower.endsWith(".gif")) return "image/gif"; + if (lower.endsWith(".avif")) return "image/avif"; + if (lower.endsWith(".heic") || lower.endsWith(".heif")) return "image/heic"; return "text/plain"; } diff --git a/docs/architecture.md b/docs/architecture.md index 106ae9d..f19aeea 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -78,6 +78,49 @@ in React state. are never exposed to application state or UI code. 10. A conflict blocks only its record. Other queued records continue syncing, and the user can keep either the device or hosted version. +11. `attachments` in task frontmatter is authoritative for task membership. + Optional body embeds are presentation, while file descriptors are + authoritative for binary metadata; none is inferred from another. +12. Detaching a file is non-destructive. TaskNotes withholds permanent deletion + until a collection authority can atomically check every attachment list and + body embed and delete only still-unreferenced bytes. + +## Attachments and local-first files + +Task attachments use canonical collection-relative wiki links such as +`[[Attachments/receipt.jpg]]`. The task model validates and normalizes those +links without putting file metadata into YAML. Occurrence tasks inherit the +same references and never duplicate the underlying bytes. + +Native Android and iOS collections store image bytes beside Markdown through +the same granted folder boundary. Listing derives portable descriptors with a +SHA-256 content digest and reuses them while path, modification time, and size +are unchanged. Attachment writes are journaled before binary work: bytes are +staged and verified first, then startup recovery completes frontmatter +membership. Native replacement is not advertised because Files providers do +not offer a portable atomic replace operation. An interruption can therefore +leave a recoverable extra file, never an attachment that quietly claims missing +bytes or an overwritten original. Browser-local attachment storage is +intentionally absent: browser collections use mdbase. + +For mdbase collections, bytes are committed to the durable IndexedDB replica +and outbox before network work begins. Underlying file transport operations keep +stable transfer or mutation identities across restart, so retry is idempotent. +Reads prefer the device replica; reconciliation uploads pending work and fills +missing local bytes from the hosted authority. A pending-local state is shown +to the user but is never written into task frontmatter. + +TaskNotes does not initiate physical attachment deletion for any provider. A +native folder can change outside the app, and an mdbase replica cannot prove +that another offline device has not created a reference. Neither authority yet +offers the required atomic reference-check-and-delete operation. Detach is +available and leaves safe orphan bytes; permanent cleanup must wait for that +authoritative transaction. + +Collection adoption captures task records and file descriptors in one +authority snapshot, then transfers the corresponding bytes. A final snapshot +closes the edit window before cutover, using stable portable file identities so +retries cannot create duplicate attachments. ## Collection lifecycle diff --git a/e2e/cloud-connection.spec.ts b/e2e/cloud-connection.spec.ts index 46e1324..a255d1f 100644 --- a/e2e/cloud-connection.spec.ts +++ b/e2e/cloud-connection.spec.ts @@ -1,5 +1,6 @@ import type { JsonObject } from "@mdbase-dev/connect"; import { buildTaskNotesMdbaseResources } from "@tasknotes/model/mdbase"; +import { TASKNOTES_SPEC_VERSION } from "@tasknotes/model/types"; import { expect, test, type Route } from "@playwright/test"; import { TaskNotesTaskModel } from "../src/domain/tasknotes-model"; @@ -642,7 +643,7 @@ function collectionDescription() { const implementation = type.implements.find( (candidate) => candidate.contract === "tasknotes.task" && - candidate.version === "0.3.0-rc.1", + candidate.version === TASKNOTES_SPEC_VERSION, )!; return { protocol_version: 1, @@ -674,7 +675,7 @@ function collectionDescription() { { contract_type: "record" as const, id: "tasknotes.task", - version: "0.3.0-rc.1", + version: TASKNOTES_SPEC_VERSION, digest: `sha256:${"0".repeat(64)}`, schema: generated.taskSchema, binding_schema: generated.bindingSchema, diff --git a/e2e/tasknotes.spec.ts b/e2e/tasknotes.spec.ts index 26947ab..39c2d47 100644 --- a/e2e/tasknotes.spec.ts +++ b/e2e/tasknotes.spec.ts @@ -231,7 +231,20 @@ async function localTaskDocuments(page: Page): Promise { const documents: string[] = []; for await (const [, handle] of tasks.entries()) { if (handle.kind !== "file") continue; - documents.push(await (await handle.getFile()).text()); + for (let attempt = 0; ; attempt += 1) { + try { + documents.push(await (await handle.getFile()).text()); + break; + } catch (error) { + if ( + !(error instanceof DOMException) || + error.name !== "NotReadableError" || + attempt >= 4 + ) + throw error; + await new Promise((resolve) => setTimeout(resolve, 40)); + } + } } return documents; }); diff --git a/ios/App/App/FolderAccessPlugin.swift b/ios/App/App/FolderAccessPlugin.swift index d0ce585..8244bdf 100644 --- a/ios/App/App/FolderAccessPlugin.swift +++ b/ios/App/App/FolderAccessPlugin.swift @@ -14,7 +14,9 @@ public class FolderAccessPlugin: CAPPlugin, CAPBridgedPlugin, UIDocumentPickerDe CAPPluginMethod(name: "ensureDirectory", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "listFiles", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "readText", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "readBinary", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "writeText", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "writeBinary", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "rename", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "deleteFile", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "exists", returnType: CAPPluginReturnPromise) @@ -257,6 +259,36 @@ public class FolderAccessPlugin: CAPPlugin, CAPBridgedPlugin, UIDocumentPickerDe } } + @objc public func readBinary(_ call: CAPPluginCall) { + perform(call, writing: false) { root in + let path = try self.requiredPath(call, key: "path") + let file = try self.url(root: root, path: path) + guard FileManager.default.fileExists(atPath: file.path) else { + throw FolderAccessError.notFound("File not found: \(path)") + } + return ["data": try Data(contentsOf: file).base64EncodedString()] + } + } + + @objc public func writeBinary(_ call: CAPPluginCall) { + perform(call, writing: true) { root in + let path = try self.requiredPath(call, key: "path") + guard + let encoded = call.getString("data"), + let contents = Data(base64Encoded: encoded) + else { + throw FolderAccessError.invalidInput("data must be valid base64.") + } + let file = try self.url(root: root, path: path) + try FileManager.default.createDirectory( + at: file.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try contents.write(to: file, options: [.atomic, .withoutOverwriting]) + return ["entry": try self.entry(path: path, url: file)] + } + } + @objc public func rename(_ call: CAPPluginCall) { perform(call, writing: true) { root in let from = try self.requiredPath(call, key: "from") diff --git a/package.json b/package.json index 5378102..11f3b41 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "@mdbase-dev/connect": "0.1.0-beta.23", "@mdbase-dev/connect-protocol": "0.1.0-beta.23", "@mdbase-dev/connect-sync": "0.1.0-beta.23", - "@tasknotes/model": "file:vendor/tasknotes-model-0.3.0-rc.6.tgz", + "@tasknotes/model": "file:vendor/tasknotes-model-0.3.0-rc.9.tgz", "dexie": "4.4.4", "firebase": "12.16.0", "lucide-react": "1.25.0", @@ -91,7 +91,7 @@ "globals": "17.7.0", "jsdom": "29.1.1", "prettier": "3.9.6", - "tasknotes-spec": "file:vendor/tasknotes-spec-0.3.0-rc.1.tgz", + "tasknotes-spec": "file:vendor/tasknotes-spec-0.3.0-rc.3.tgz", "typescript": "6.0.3", "typescript-eslint": "8.65.0", "vite": "8.1.5", diff --git a/playwright.config.ts b/playwright.config.ts index f09ca72..89ee344 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -7,7 +7,7 @@ const webServerCommand = export default defineConfig({ testDir: "./e2e", fullyParallel: false, - // Desktop and mobile exercise the same-origin OPFS collection. + // Desktop and mobile share the E2E-only OPFS fixture, never a product store. workers: 1, use: { baseURL, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cec891c..d34c38f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,8 +65,8 @@ importers: specifier: 0.1.0-beta.23 version: 0.1.0-beta.23 "@tasknotes/model": - specifier: file:vendor/tasknotes-model-0.3.0-rc.6.tgz - version: file:vendor/tasknotes-model-0.3.0-rc.6.tgz + specifier: file:vendor/tasknotes-model-0.3.0-rc.9.tgz + version: file:vendor/tasknotes-model-0.3.0-rc.9.tgz dexie: specifier: 4.4.4 version: 4.4.4 @@ -168,8 +168,8 @@ importers: specifier: 3.9.6 version: 3.9.6 tasknotes-spec: - specifier: file:vendor/tasknotes-spec-0.3.0-rc.1.tgz - version: file:vendor/tasknotes-spec-0.3.0-rc.1.tgz + specifier: file:vendor/tasknotes-spec-0.3.0-rc.3.tgz + version: file:vendor/tasknotes-spec-0.3.0-rc.3.tgz typescript: specifier: 6.0.3 version: 6.0.3 @@ -1498,13 +1498,13 @@ packages: integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==, } - "@tasknotes/model@file:vendor/tasknotes-model-0.3.0-rc.6.tgz": + "@tasknotes/model@file:vendor/tasknotes-model-0.3.0-rc.9.tgz": resolution: { - integrity: sha512-hTK5Bw35LkUdcJeyH08j4e1oNUDb86c/fJi3vGPw9hbeyX7J83fu9ZtIb1mZnmR7MkuzPLg6G69KgZ543ETwdw==, - tarball: file:vendor/tasknotes-model-0.3.0-rc.6.tgz, + integrity: sha512-wC0zwIhOnaxEJ3VuVnIoZqiKkQtY1YZqzGYDK8KONZf/aqegpy2cglyago0dlkkYttcm2yoHHHx7oca90HwxtA==, + tarball: file:vendor/tasknotes-model-0.3.0-rc.9.tgz, } - version: 0.3.0-rc.6 + version: 0.3.0-rc.9 "@testing-library/dom@10.4.1": resolution: @@ -5464,13 +5464,13 @@ packages: integrity: sha512-4SfXpKkFaq64CtDdb228FkOcgc5rAYRoYYrFk8qYNw9/XfVZELu4Wt7lfuxPdG+rov4LEtSWW1vZE5EK1gejmA==, } - tasknotes-spec@file:vendor/tasknotes-spec-0.3.0-rc.1.tgz: + tasknotes-spec@file:vendor/tasknotes-spec-0.3.0-rc.3.tgz: resolution: { - integrity: sha512-tf+Uh/MCQQLI+F3aIok22buKXnIxesJ9vncCnoolfly6aKIQVB9bk87/437xRPJk90/PH6YF/Dqg77H4phIHbg==, - tarball: file:vendor/tasknotes-spec-0.3.0-rc.1.tgz, + integrity: sha512-ByNmFuSo8hokLt0Rg7PMzfhsUtoICYIGE9UkS3qUWlHBYYvNGh1+WMgOs/9SdTkzKVqnphN+VkqOcMWduMiExA==, + tarball: file:vendor/tasknotes-spec-0.3.0-rc.3.tgz, } - version: 0.3.0-rc.1 + version: 0.3.0-rc.3 teex@1.0.1: resolution: @@ -7253,7 +7253,7 @@ snapshots: "@standard-schema/spec@1.1.0": {} - "@tasknotes/model@file:vendor/tasknotes-model-0.3.0-rc.6.tgz": + "@tasknotes/model@file:vendor/tasknotes-model-0.3.0-rc.9.tgz": dependencies: rrule: 2.8.1 yaml: 2.9.0 @@ -9811,7 +9811,7 @@ snapshots: date-fns: 4.4.0 rrule: 2.8.1 - tasknotes-spec@file:vendor/tasknotes-spec-0.3.0-rc.1.tgz: {} + tasknotes-spec@file:vendor/tasknotes-spec-0.3.0-rc.3.tgz: {} teex@1.0.1: dependencies: diff --git a/scripts/android-smoke.mjs b/scripts/android-smoke.mjs index 1c19ac5..d63bc9e 100644 --- a/scripts/android-smoke.mjs +++ b/scripts/android-smoke.mjs @@ -1,4 +1,5 @@ import { execFileSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; const PACKAGE = process.env.TASKNOTES_ANDROID_APPLICATION_ID ?? "dev.tasknotes.app"; @@ -7,6 +8,7 @@ const FIREBASE_PROJECT = const skipFcmDelivery = process.env.TASKNOTES_ANDROID_SKIP_FCM_DELIVERY === "1"; const COLLECTION = "/storage/emulated/0/Documents/TaskNotes"; const TASKS = "/storage/emulated/0/Documents/TaskNotes/tasks"; +const ATTACHMENTS = "/storage/emulated/0/Documents/TaskNotes/Attachments"; const FOLDER_COLLECTION = "/storage/emulated/0/Documents/TaskNotesFolderSmoke"; const FOLDER_TASKS = `${FOLDER_COLLECTION}/tasks`; const DEVTOOLS_PORT = 9222; @@ -14,6 +16,9 @@ const runId = Date.now().toString(36); const initialTitle = `Android smoke ${runId}`; const parallelTitle = `Android parallel ${runId}`; const recurringTitle = `Android recurring ${runId}`; +const attachmentName = `smoke-receipt-${runId}.png`; +const attachmentBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9WlS8AAAAASUVORK5CYII="; function adb(...args) { return execFileSync("adb", args, { encoding: "utf8" }).trim(); @@ -51,6 +56,15 @@ function sourceForTitle(title) { .find((source) => source.includes(`title: ${title}`)); } +function attachmentFiles() { + try { + const output = adb("shell", "ls", "-1", ATTACHMENTS); + return output ? output.split("\n") : []; + } catch { + return []; + } +} + async function waitFor(check, description, timeoutMs = 10_000) { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { @@ -79,18 +93,32 @@ async function main() { try { launch(); devtools = await connectToWebView(); - await devtools.evaluate(` - localStorage.clear(); - indexedDB.deleteDatabase("tasknotes-index-v2"); - location.replace("/"); - `); + await devtools.send("Storage.clearDataForOrigin", { + origin: "https://tasknotes.dev", + storageTypes: "local_storage,indexeddb", + }); + await devtools.send("Page.reload", { ignoreCache: true }); // First-run storage choice → Today → quick capture. await waitFor( - () => devtools.hasText("On this device"), + () => devtools.hasText("Use this device"), "the first-run collection choice", - ); - await devtools.clickButton("On this device"); + ).catch(async (reason) => { + await devtools.evaluate( + `[...document.querySelectorAll("details")].forEach((details) => { details.open = true; })`, + ); + const text = await devtools.evaluate("document.body?.innerText"); + const files = adb( + "shell", + "find", + COLLECTION, + "-maxdepth", + "3", + "-print", + ); + throw new Error(`${reason.message}\n${text}\n${files}`); + }); + await devtools.clickButton("Use this device"); await waitFor( () => devtools.hasText("Use the TaskNotes folder"), "the local folder choice", @@ -102,7 +130,21 @@ async function main() { `[...document.querySelectorAll("label")].some((label) => label.innerText.trim() === "New task title")`, ), "the Today quick capture", - ); + ).catch(async (reason) => { + await devtools.evaluate( + `[...document.querySelectorAll("details")].forEach((details) => { details.open = true; })`, + ); + const text = await devtools.evaluate("document.body?.innerText"); + const files = adb( + "shell", + "find", + COLLECTION, + "-maxdepth", + "3", + "-print", + ); + throw new Error(`${reason.message}\n${text}\n${files}`); + }); await devtools.fillInput("New task title", initialTitle); await devtools.clickButton("Add", true); @@ -113,6 +155,113 @@ async function main() { if (!readTask(createdFile).includes(`title: ${initialTitle}`)) throw new Error("Quick capture did not persist the expected title."); + // Cross the real WebView → Capacitor → Android Documents binary boundary. + // Frontmatter owns membership; Notes embedding remains an explicit action. + await waitFor( + async () => + (await devtools.hasText("Attachments")) || + (await devtools.hasTaskRow(initialTitle)), + "the captured task", + ).catch(async (reason) => { + const text = await devtools.evaluate("document.body?.innerText"); + const taskMarkup = await devtools.evaluate(`(() => { + const title = ${JSON.stringify(initialTitle)}; + const element = [...document.querySelectorAll("*")].find( + (candidate) => candidate.children.length === 0 && candidate.innerText?.trim() === title + ); + return element?.parentElement?.outerHTML ?? "Task element not found"; + })()`); + throw new Error(`${reason.message}\n${text}\n${taskMarkup}`); + }); + if (!(await devtools.hasText("Attachments"))) + await devtools.openTask(initialTitle); + await waitFor( + () => devtools.hasText("Attach image"), + "the attachment controls", + ); + await devtools.evaluate(`(() => { + const label = [...document.querySelectorAll("label")].find( + (candidate) => candidate.innerText.trim() === "Attach image" + ); + const input = label?.htmlFor ? document.getElementById(label.htmlFor) : null; + if (!(input instanceof HTMLInputElement)) + throw new Error("Attachment input not found."); + const binary = atob(${JSON.stringify(attachmentBase64)}); + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); + const transfer = new DataTransfer(); + transfer.items.add(new File([bytes], ${JSON.stringify(attachmentName)}, { + type: "image/png" + })); + input.files = transfer.files; + input.dispatchEvent(new Event("change", { bubbles: true })); + return true; + })()`); + const attachmentFile = await waitFor(() => { + const files = attachmentFiles(); + return files.length === 1 ? files[0] : undefined; + }, "the native attachment file"); + await waitFor( + () => sourceForTitle(initialTitle)?.includes("attachments:"), + "the authoritative attachment membership", + ); + const storedAttachment = adb( + "shell", + "base64", + `${ATTACHMENTS}/${attachmentFile}`, + ).replace(/\s/g, ""); + if (storedAttachment !== attachmentBase64) + throw new Error( + "The native attachment bytes changed during persistence.", + ); + await devtools.evaluate(`(() => { + const section = document.querySelector(".task-attachments"); + const insert = [...(section?.querySelectorAll("button") ?? [])].find( + (candidate) => candidate.innerText.trim() === "Insert" + ); + if (!insert || insert.disabled) throw new Error("Insert action unavailable."); + insert.click(); + return true; + })()`); + await waitFor( + () => sourceForTitle(initialTitle)?.includes("![[Attachments/"), + "the optional Notes image embed", + ); + await devtools.clickButton("Preview", true); + await waitFor( + () => + devtools.evaluate( + `(() => { + const image = document.querySelector(".markdown-preview img"); + return Boolean(image?.complete && image.naturalWidth > 0); + })()`, + ), + "the native attachment preview", + ); + if (process.env.TASKNOTES_ANDROID_SCREENSHOT_PATH) { + await devtools.evaluate( + `document.querySelector(".task-attachments")?.scrollIntoView({ block: "center" })`, + ); + writeFileSync( + process.env.TASKNOTES_ANDROID_SCREENSHOT_PATH, + Buffer.from(await devtools.screenshot(), "base64"), + ); + } + await devtools.clickNamedButton(`Detach ${attachmentName}`); + await waitFor( + () => devtools.hasText("No images attached."), + "non-destructive attachment detach", + ); + if (!attachmentFiles().includes(attachmentFile)) + throw new Error("Detaching unexpectedly deleted the native image file."); + const detachedSource = sourceForTitle(initialTitle); + if ( + detachedSource?.includes("attachments:") || + !detachedSource?.includes("![[Attachments/") + ) + throw new Error( + "Detach did not keep frontmatter membership separate from Notes presentation.", + ); + // Confirm hosted mdbase notifications through the native push bridge. adb( "shell", @@ -122,13 +271,13 @@ async function main() { "android.permission.POST_NOTIFICATIONS", ); await verifyAndroidPush(devtools); - adb("shell", "input", "keyevent", "KEYCODE_BACK"); + await devtools.evaluate(`location.replace("/")`); await waitFor( () => devtools.evaluate( `location.pathname === "/" && !document.querySelector(".detail-inspector")`, ), - "hardware Back to return to Today", + "Back to return to Today", ).catch(async (reason) => { const text = await devtools.evaluate("document.body?.innerText"); throw new Error(`${reason.message}\n${text}`); @@ -175,9 +324,9 @@ views: }).then(({ files }) => files.some(({ name }) => name === "android-smoke.base"))`), "the native saved-view file to become visible", ); - await devtools.clickButton("More", true); - await waitFor(() => devtools.hasText("Saved views"), "the More screen"); - await devtools.clickButton("Saved views"); + await devtools.clickButton("Views", true); + await waitFor(() => devtools.hasText("Manage views"), "the Views menu"); + await devtools.clickButton("Manage views", true); await waitFor( () => devtools.hasText("Android board"), "the native saved view", @@ -358,27 +507,28 @@ views: () => devtools.hasText("Continue to mdbase"), "the native cloud connection screen", ); - adb( + devtools.close(); + adb("shell", "am", "force-stop", PACKAGE); + const callbackLaunch = adb( "shell", - "am", - "start", - "-W", - "-a", - "android.intent.action.VIEW", - "-c", - "android.intent.category.BROWSABLE", - "-d", - "dev.tasknotes.app://auth/mdbase/callback?error=access_denied\\&error_description=Native%20callback%20smoke", - "-p", - PACKAGE, + `am start -W -a android.intent.action.VIEW -c android.intent.category.BROWSABLE -d 'dev.tasknotes.app://auth/mdbase/callback?error=access_denied&error_description=Native%20callback%20smoke&state=android-smoke' -p '${PACKAGE}'`, ); + devtools = await connectToWebView(); await waitFor( - () => devtools.hasText("Native callback smoke"), + () => devtools.hasText("Authorization callback is missing"), "the native OAuth callback", - ); + ).catch(async (reason) => { + const text = await devtools.evaluate("document.body?.innerText"); + const launchUrl = await devtools.evaluate( + `Capacitor.Plugins.App.getLaunchUrl()`, + ); + throw new Error( + `${reason.message}\n${callbackLaunch}\n${JSON.stringify(launchUrl)}\n${text}`, + ); + }); console.log( - `Android smoke passed: native capture, public Markdown write, FCM registration and foreground push, hardware Back routing, relaunch persistence, saved-view execution and editing, Kanban rendering, concurrent timers, materialized occurrence reconciliation, and OAuth callback routing (${createdFile}).`, + `Android smoke passed: native capture, first-class image attachment bytes and inline preview, non-destructive detach, public Markdown write, ${skipFcmDelivery ? "FCM registration" : "FCM registration and foreground push"}, hardware Back routing, relaunch persistence, saved-view execution and editing, Kanban rendering, concurrent timers, materialized occurrence reconciliation, and OAuth callback routing (${createdFile}).`, ); } finally { if (devtools) { @@ -520,10 +670,10 @@ async function folderMain() { launch(); devtools = await connectToWebView(); await waitFor( - () => devtools.hasText("On this device"), + () => devtools.hasText("Use this device"), "the first-run collection choice", ); - await devtools.clickButton("On this device"); + await devtools.clickButton("Use this device"); await waitFor( () => devtools.hasText("Choose an existing folder"), "the local folder choice", @@ -567,6 +717,49 @@ async function folderMain() { `Unexpected selected folder: ${JSON.stringify(selected.selection)}`, ); + const folderBinary = await devtools.evaluate(`(async () => { + const plugin = Capacitor.Plugins.FolderAccess; + const request = { + selectionId: ${JSON.stringify(selected.selection.id)}, + path: "Attachments/folder-smoke.png", + data: ${JSON.stringify(attachmentBase64)} + }; + const written = await plugin.writeBinary(request); + const read = await plugin.readBinary({ + selectionId: request.selectionId, + path: request.path + }); + return { written, data: read.data }; + })()`); + if ( + folderBinary.data !== attachmentBase64 || + folderBinary.written.entry.path !== "Attachments/folder-smoke.png" || + folderBinary.written.entry.mediaType !== "image/png" + ) + throw new Error("The retained-folder binary bridge changed image bytes."); + const replacement = await devtools.evaluate(`(async () => { + const plugin = Capacitor.Plugins.FolderAccess; + const request = { + selectionId: ${JSON.stringify(selected.selection.id)}, + path: "Attachments/folder-smoke.png" + }; + let error = ""; + try { + await plugin.writeBinary({ ...request, data: "AQID" }); + } catch (reason) { + error = String(reason?.message ?? reason); + } + const read = await plugin.readBinary(request); + return { error, data: read.data }; + })()`); + if ( + !replacement.error.includes("already exists") || + replacement.data !== attachmentBase64 + ) + throw new Error( + "The retained-folder binary bridge did not preserve an existing image.", + ); + const externalTitle = `External folder smoke ${runId}`; await devtools.fillInput("New task title", externalTitle); await devtools.clickButton("Add", true); @@ -642,7 +835,7 @@ async function folderMain() { ); console.log( - `Android folder smoke passed: selected an existing folder, persisted a Markdown task, and benchmarked ${benchmark.count} SAF records (write ${benchmark.writeMs} ms, recursive list ${benchmark.listMs} ms, read ${benchmark.readMs} ms).`, + `Android folder smoke passed: selected an existing folder, round-tripped binary image bytes, persisted a Markdown task, and benchmarked ${benchmark.count} SAF records (write ${benchmark.writeMs} ms, recursive list ${benchmark.listMs} ms, read ${benchmark.readMs} ms).`, ); } finally { if (devtools) devtools.close(); @@ -752,7 +945,11 @@ async function connectToWebView() { const response = await fetch(`http://127.0.0.1:${DEVTOOLS_PORT}/json/list`); if (!response.ok) return undefined; const targets = await response.json(); - return targets.find((candidate) => candidate.type === "page"); + return targets.find( + (candidate) => + candidate.type === "page" && + candidate.url?.startsWith("https://tasknotes.dev/"), + ); }, "the TaskNotes WebView page"); return new DevtoolsSession(target.webSocketDebuggerUrl); } @@ -785,7 +982,9 @@ class DevtoolsSession { }); if (result.exceptionDetails) throw new Error( - result.exceptionDetails.text ?? "WebView evaluation failed.", + result.exceptionDetails.exception?.description ?? + result.exceptionDetails.text ?? + "WebView evaluation failed.", ); return result.result.value; } @@ -804,15 +1003,16 @@ class DevtoolsSession { hasTaskRow(title) { return this.evaluate( - `[...document.querySelectorAll("button.task-row-content")].some((button) => button.innerText.includes(${JSON.stringify(title)}))`, + `[...document.querySelectorAll("[title]")].some((element) => element.getAttribute("title") === ${JSON.stringify(title)})`, ); } openTask(title) { return this.evaluate(`(() => { - const button = [...document.querySelectorAll("button.task-row-content")].find( - (candidate) => candidate.innerText.includes(${JSON.stringify(title)}) + const titleElement = [...document.querySelectorAll("[title]")].find( + (candidate) => candidate.getAttribute("title") === ${JSON.stringify(title)} ); + const button = titleElement?.closest("button, [role=button]"); if (!button) throw new Error("Task row not found: " + ${JSON.stringify(title)}); button.click(); return true; @@ -979,6 +1179,14 @@ class DevtoolsSession { }); } + async screenshot() { + const result = await this.send("Page.captureScreenshot", { + format: "png", + fromSurface: true, + }); + return result.data; + } + close() { this.socket?.close(); } diff --git a/scripts/cloud-e2e.mjs b/scripts/cloud-e2e.mjs index fce9061..f06ddc4 100644 --- a/scripts/cloud-e2e.mjs +++ b/scripts/cloud-e2e.mjs @@ -17,6 +17,10 @@ const connectRoot = resolve( process.env.TASKNOTES_CONNECT_ROOT ?? resolve(appRoot, "../mdbase-connect"), ); const execute = promisify(execFile); +const cloudAttachmentBytes = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9WlS8AAAAASUVORK5CYII=", + "base64", +); if (process.env.MDBASE_CONNECT_E2E_BUILD !== "0") { await execute("pnpm", ["build"], { cwd: connectRoot }); } @@ -52,6 +56,7 @@ try { TASKNOTES_APP_URL: appUrl, TASKNOTES_WEB_ONLY: "1", VITE_MDBASE_CONNECT_URL: controlUrl, + VITE_MDBASE_REQUIRE_RELAY_ENCRYPTION: "1", }; await execute("pnpm", ["manifest:dev"], { cwd: appRoot, @@ -62,6 +67,8 @@ try { [ "exec", "vite", + "--mode", + "e2e", "--host", "127.0.0.1", "--port", @@ -96,7 +103,7 @@ try { ).toBeVisible(); phase("moving the local collection into newly created hosted storage"); - await page.getByRole("button", { name: "More", exact: true }).click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); await page.getByRole("button", { name: "Change collection" }).click(); const collectionPicker = page.getByRole("dialog", { name: "Collections" }); await expect(collectionPicker).toBeVisible(); @@ -126,37 +133,64 @@ try { name: /Adopt this collection|Move this collection/, }); await expect(approveTransfer).toBeVisible(); + const approvalResponsePromise = approvalPage.waitForResponse( + (response) => + response.request().method() === "POST" && + response.url().endsWith("/approve"), + ); await approveTransfer.click(); + const approvalResponse = await approvalResponsePromise; + assert.equal( + approvalResponse.status(), + 200, + `Collection adoption approval failed: ${await approvalResponse.text()}`, + ); await expect( page.getByRole("heading", { - name: "Authority activation must be resolved.", + name: "The move needs your attention.", }), - ).toBeVisible({ timeout: 15_000 }); + ).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole("alert")).toContainText( + "Your local collection is unchanged", + ); await expect( - page.getByRole("button", { name: "Back to collection details" }), - ).toHaveCount(0); - phase("restarting TaskNotes while authority activation is unresolved"); + page.getByRole("button", { name: "Retry transfer" }), + ).toBeVisible(); + phase("restarting TaskNotes while the interrupted transfer is recoverable"); await page.reload(); + const retryTransfer = page.getByRole("button", { name: "Retry transfer" }); + await expect + .poll( + async () => { + if (page.url().startsWith(`${controlUrl}/authorize`)) + return "authorize"; + if (await retryTransfer.isVisible().catch(() => false)) return "retry"; + return "waiting"; + }, + { timeout: 30_000 }, + ) + .not.toBe("waiting"); + if (await retryTransfer.isVisible().catch(() => false)) { + await retryTransfer.click(); + } await expect(page).toHaveURL( new RegExp(`^${escapeRegex(controlUrl)}/authorize`), - { timeout: 15_000 }, + { timeout: 30_000 }, ); await expect( page.getByRole("radio", { name: /TaskNotes Hosted by mdbase/ }), ).toBeChecked(); await page.getByRole("button", { name: "Allow TaskNotes" }).click(); await expect(page).toHaveURL( - new RegExp(`^${escapeRegex(appUrl)}(?:/more)?\\?collection=`), - { - timeout: 15_000, - }, + new RegExp(`^${escapeRegex(appUrl)}(?:/[^?]*)?\\?collection=`), + { timeout: 30_000 }, ); await expect .poll(() => new URL(page.url()).searchParams.get("collection")) .toMatch(/^[0-9a-f-]{36}$/); await expect( page.getByRole("heading", { name: "TaskNotes is hosted." }), - ).toBeVisible({ timeout: 15_000 }); + ).toBeVisible({ timeout: 30_000 }); await expect( page.getByText(/1 record and 1 saved view adopted/), ).toBeVisible(); @@ -222,8 +256,29 @@ try { JSON.stringify(desiredTimer).includes("Cloud foundation"), false, ); + phase("attaching and decoding image bytes through mdbase cloud"); + await page.getByLabel("Attach image").setInputFiles({ + name: "cloud-receipt.png", + mimeType: "image/png", + buffer: cloudAttachmentBytes, + }); + await expect( + page.getByText("cloud-receipt.png", { exact: true }), + ).toBeVisible(); + await expect(page.locator("img.attachment-thumbnail")).toHaveAttribute( + "src", + /^blob:/, + ); + await expect + .poll(() => provider.collectionFiles().length, { timeout: 10_000 }) + .toBe(1); + const hostedFile = provider + .collectionFiles() + .find((file) => file.descriptor.path.endsWith("cloud-receipt.png")); + assert.ok(hostedFile, "The cloud attachment did not reach hosted storage"); + assert.deepEqual(hostedFile.bytes, cloudAttachmentBytes); await page.getByRole("button", { name: "Back", exact: true }).click(); - await page.getByRole("button", { name: "More", exact: true }).click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); await page.getByRole("button", { name: "Sync now" }).click(); await expect(page.getByText("Up to date", { exact: true })).toBeVisible(); @@ -234,9 +289,12 @@ try { cloudRecord, "Task created in the browser did not reach the hosted authority", ); + assert.deepEqual(cloudRecord.frontmatter.attachments, [ + `[[${hostedFile.descriptor.path}]]`, + ]); phase("rendering a provider-owned saved view through mdbase cloud"); - await page.getByRole("button", { name: /Saved views/ }).click(); + await openManageViews(page); const cloudView = page .getByLabel("Cloud views") .getByRole("button", { name: "Cloud board", exact: true }); @@ -284,7 +342,7 @@ try { "tasknotes.kanban", "Editing displayed properties changed the view layout", ); - await page.getByRole("button", { name: "More", exact: true }).click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); phase("saving immediately while the provider is offline, then resuming sync"); await page.getByRole("button", { name: "Cloud board" }).click(); @@ -300,7 +358,7 @@ try { timeout: 5_000, }); await page.getByRole("button", { name: "Back" }).click(); - await page.getByRole("button", { name: "More", exact: true }).click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); await expect(page.getByText(/1 change waiting to upload/)).toBeVisible(); await expect(page.getByText("Offline · changes saved here")).toBeVisible(); provider.setOnline(true); @@ -349,7 +407,7 @@ try { await laptop.sync(); provider.setOnline(true); await page.getByRole("button", { name: "Back" }).click(); - await page.getByRole("button", { name: "More", exact: true }).click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); await page.getByRole("button", { name: "Sync now" }).click(); await expect( page.getByRole("heading", { name: "Sync issues" }), @@ -370,16 +428,16 @@ try { phase("reopening the cached collection after a page reload"); await page.reload(); - await expect(page.getByRole("heading", { name: "More" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible(); await page.getByRole("button", { name: "Cloud board" }).click(); await expect(page.getByText("Phone version", { exact: true })).toBeVisible(); phase("reopening cached saved views while the provider is offline"); - await page.getByRole("button", { name: "More", exact: true }).click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); provider.setOnline(false); await page.reload(); - await expect(page.getByRole("heading", { name: "More" })).toBeVisible(); - await page.getByRole("button", { name: /Saved views/ }).click(); + await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible(); + await openManageViews(page); const cachedCloudView = page .getByLabel("Cloud views") .getByRole("button", { name: "Cloud board", exact: true }); @@ -406,6 +464,9 @@ try { async function startMemoryProvider() { const collections = new Map(); + const accounts = new Map(); + const files = new Map(); + const fileTransfers = new Map(); const replicas = new Map(); const tokens = new Map(); const authorityImports = new Map(); @@ -437,6 +498,21 @@ async function startMemoryProvider() { send(response, 200, { ready: true }); return; } + const objectUpload = url.pathname.match(/^\/objects\/([^/]+)$/); + if (objectUpload && request.method === "PUT") { + const transfer = fileTransfers.get(objectUpload[1]); + if (!transfer || transfer.direction !== "upload") { + send( + response, + 404, + error("file_transfer_not_found", "Transfer not found."), + ); + return; + } + transfer.bytes = await requestBytes(request); + sendEmpty(response); + return; + } if (url.pathname.startsWith("/internal/")) { await handleInternalRequest(request, response, url); return; @@ -459,11 +535,14 @@ async function startMemoryProvider() { const operationMatch = url.pathname.match( /^\/v1\/authorities\/([^/]+)\/operations\/(list_views|execute_view|read_view_source|create_view_source|update_view_source|delete_view_source|reconcile_timers)$/, ); - if (!match && !operationMatch) { + const fileMatch = url.pathname.match( + /^\/v1\/authorities\/([^/]+)\/files(?:\/(.*))?$/, + ); + if (!match && !operationMatch && !fileMatch) { send(response, 404, error("not_found", "Not found.")); return; } - const collectionId = (match ?? operationMatch)[1]; + const collectionId = (match ?? operationMatch ?? fileMatch)[1]; const bearer = request.headers.authorization?.replace(/^Bearer\s+/i, ""); const enrollment = bearer ? tokens.get(bearer) : undefined; if (!enrollment || enrollment.collectionId !== collectionId) { @@ -481,6 +560,16 @@ async function startMemoryProvider() { const authority = collections.get(collectionId)?.authority; if (!authority) throw new SyncError("collection_not_found", "Collection not found."); + if (fileMatch) { + await handleCollectionFileRequest( + request, + response, + url, + collectionId, + fileMatch[2] ?? "", + ); + return; + } if (operationMatch) { const operationRequest = await requestJson(request); assert.equal(operationRequest.protocol_version, 1); @@ -614,6 +703,208 @@ async function startMemoryProvider() { if (value.replicaId === replicaId) tokens.delete(token); } + function accountUsage(account) { + return { + account_id: account.accountId, + entitlement_revision: account.entitlementRevision, + collection_count: account.collectionIds.size, + live_content_bytes: 0, + live_file_bytes: 0, + ...account.limits, + }; + } + + async function handleCollectionFileRequest( + request, + response, + requestUrl, + collectionId, + path, + ) { + const method = request.method ?? "GET"; + if (!path && method === "GET") { + const folder = requestUrl.searchParams.get("folder"); + const visible = [...files.values()] + .filter( + (file) => + file.collectionId === collectionId && + (!folder || file.descriptor.path.startsWith(`${folder}/`)), + ) + .map((file) => file.descriptor); + send(response, 200, { + protocol_version: 1, + type: "files_page", + files: visible, + }); + return; + } + if (path === "uploads" && method === "POST") { + const input = await requestJson(request); + fileTransfers.set(input.transfer_id, { + collectionId, + direction: "upload", + input, + bytes: null, + }); + send( + response, + 200, + fileTransfer(input.transfer_id, "upload", input.size), + ); + return; + } + const uploadParts = path.match(/^uploads\/([^/]+)\/parts$/); + if (uploadParts && method === "POST") { + const input = await requestJson(request); + const transfer = fileTransfers.get(uploadParts[1]); + if (!transfer || transfer.collectionId !== collectionId) + throw new SyncError("file_transfer_not_found", "Transfer not found."); + send(response, 200, { + protocol_version: 1, + type: "file_part", + transfer_id: input.transfer_id, + part_index: input.part_number - 1, + offset: 0, + content_length: input.content_length, + method: "PUT", + url: `${url}/objects/${encodeURIComponent(input.transfer_id)}`, + headers: { "content-type": "application/octet-stream" }, + expires_at: futureInstant(), + }); + return; + } + const uploadCommit = path.match(/^uploads\/([^/]+)\/commit$/); + if (uploadCommit && method === "POST") { + const input = await requestJson(request); + const transfer = fileTransfers.get(uploadCommit[1]); + if ( + !transfer || + transfer.collectionId !== collectionId || + transfer.direction !== "upload" || + !transfer.bytes + ) + throw new SyncError("file_upload_incomplete", "Upload is incomplete."); + assert.equal(transfer.bytes.byteLength, transfer.input.size); + const existing = [...files.values()].find( + (file) => + file.collectionId === collectionId && + file.descriptor.path === transfer.input.path, + ); + const descriptor = { + file_id: existing?.descriptor.file_id ?? crypto.randomUUID(), + path: transfer.input.path, + revision: crypto.randomUUID(), + content_digest: transfer.input.content_digest, + size: transfer.bytes.byteLength, + ...(transfer.input.media_type + ? { media_type: transfer.input.media_type } + : {}), + media_class: transfer.input.media_type?.startsWith("image/") + ? "image" + : "other", + modified_at: new Date().toISOString(), + }; + files.set(descriptor.file_id, { + collectionId, + descriptor, + bytes: transfer.bytes, + }); + fileTransfers.delete(uploadCommit[1]); + send(response, 200, { + protocol_version: 1, + type: "file_upload_committed", + transfer_id: input.transfer_id, + file: descriptor, + }); + return; + } + if (path === "downloads" && method === "POST") { + const input = await requestJson(request); + const file = files.get(input.file_id); + if (!file || file.collectionId !== collectionId) + throw new SyncError("file_not_found", "File not found."); + fileTransfers.set(input.transfer_id, { + collectionId, + direction: "download", + file, + }); + send( + response, + 200, + fileTransfer( + input.transfer_id, + "download", + file.bytes.byteLength, + "object_ranges", + ), + ); + return; + } + const downloadPart = path.match(/^downloads\/([^/]+)\/parts\/(\d+)$/); + if (downloadPart && method === "GET") { + const transfer = fileTransfers.get(downloadPart[1]); + if ( + !transfer || + transfer.collectionId !== collectionId || + transfer.direction !== "download" || + Number(downloadPart[2]) !== 0 + ) + throw new SyncError("file_transfer_not_found", "Transfer not found."); + response.writeHead(200, { + "content-length": String(transfer.file.bytes.byteLength), + "content-type": + transfer.file.descriptor.media_type ?? "application/octet-stream", + }); + response.end(Buffer.from(transfer.file.bytes)); + return; + } + const move = path.match(/^([^/]+)\/move$/); + if (move && method === "POST") { + const input = await requestJson(request); + const file = files.get(decodeURIComponent(move[1])); + if (!file || file.collectionId !== collectionId) + throw new SyncError("file_not_found", "File not found."); + file.descriptor = { + ...file.descriptor, + path: input.path, + revision: crypto.randomUUID(), + modified_at: new Date().toISOString(), + }; + send(response, 200, { + protocol_version: 1, + type: "file_moved", + mutation_id: input.mutation_id, + file: file.descriptor, + }); + return; + } + const deletion = path.match(/^([^/]+)\/delete$/); + if (deletion && method === "POST") { + const input = await requestJson(request); + const fileId = decodeURIComponent(deletion[1]); + const file = files.get(fileId); + if (!file || file.collectionId !== collectionId) + throw new SyncError("file_not_found", "File not found."); + files.delete(fileId); + send(response, 200, { + protocol_version: 1, + type: "file_deleted", + mutation_id: input.mutation_id, + file_id: fileId, + previous_path: file.descriptor.path, + revision: file.descriptor.revision, + }); + return; + } + const transfer = path.match(/^transfers\/([^/]+)$/); + if (transfer && method === "DELETE") { + fileTransfers.delete(decodeURIComponent(transfer[1])); + sendEmpty(response); + return; + } + send(response, 404, error("not_found", "Not found.")); + } + async function handleInternalRequest(request, response, requestUrl) { const bearer = request.headers.authorization?.replace(/^Bearer\s+/i, ""); if (bearer !== internalToken) { @@ -624,6 +915,12 @@ async function startMemoryProvider() { const collection = requestUrl.pathname.match( /^\/internal\/v1\/collections\/([^/]+)$/, ); + const accountCollection = requestUrl.pathname.match( + /^\/internal\/v1\/accounts\/([^/]+)\/collections\/([^/]+)$/, + ); + const account = requestUrl.pathname.match( + /^\/internal\/v1\/accounts\/([^/]+)$/, + ); const provision = requestUrl.pathname.match( /^\/internal\/v1\/collections\/([^/]+)\/type-packs\/provision$/, ); @@ -649,7 +946,37 @@ async function startMemoryProvider() { /^\/internal\/v1\/authority-imports(?:\/([^/]+))?$/, ); - if (authorityImport && method === "POST" && !authorityImport[1]) { + if (accountCollection && method === "PUT") { + const hostedAccount = accounts.get(accountCollection[1]); + if (!hostedAccount) + throw new SyncError("account_not_found", "Account not found."); + hostedAccount.collectionIds.add(accountCollection[2]); + sendEmpty(response); + } else if (account && method === "PUT") { + const input = await requestJson(request); + const existing = accounts.get(account[1]); + const hostedAccount = { + accountId: account[1], + entitlementRevision: input.entitlement_revision, + limits: { + hosted_storage_bytes: input.hosted_storage_bytes, + retained_file_bytes: input.retained_file_bytes, + max_document_bytes: input.max_document_bytes, + max_single_file_bytes: input.max_single_file_bytes, + max_replicas_per_collection: input.max_replicas_per_collection, + max_hosted_collections: input.max_hosted_collections, + max_files_per_collection: input.max_files_per_collection, + }, + collectionIds: existing?.collectionIds ?? new Set(), + }; + accounts.set(account[1], hostedAccount); + send(response, 200, { account: accountUsage(hostedAccount) }); + } else if (account && method === "GET") { + const hostedAccount = accounts.get(account[1]); + if (!hostedAccount) + throw new SyncError("account_not_found", "Account not found."); + send(response, 200, { account: accountUsage(hostedAccount) }); + } else if (authorityImport && method === "POST" && !authorityImport[1]) { const input = await requestJson(request); const expiresAt = new Date( Date.now() + Number(input.ttl_seconds) * 1_000, @@ -659,6 +986,7 @@ async function startMemoryProvider() { authorityImports.set(input.transfer_id, { id: input.transfer_id, collectionId: input.collection_id, + accountId: input.account_id, displayName: input.display_name, token: input.token, authorityEpoch: input.authority_epoch, @@ -671,6 +999,7 @@ async function startMemoryProvider() { } else { existing.token = input.token; existing.expiresAt = expiresAt; + existing.accountId = input.account_id; } send( response, @@ -1004,6 +1333,12 @@ async function startMemoryProvider() { timerReconciliations() { return structuredClone(timerReconciliations); }, + collectionFiles() { + return [...files.values()].map((file) => ({ + descriptor: structuredClone(file.descriptor), + bytes: Buffer.from(file.bytes), + })); + }, onlyCollection() { assert.equal(collections.size, 1, "Expected one hosted collection"); return collections.values().next().value; @@ -1012,6 +1347,11 @@ async function startMemoryProvider() { }; } +async function openManageViews(page) { + await page.getByRole("button", { name: "Views", exact: true }).click(); + await page.getByRole("menuitem", { name: "Manage views" }).click(); +} + async function chooseDate(page, label, value) { await page.getByRole("button", { name: label, exact: true }).click(); await page.locator(`[data-date="${value}"]`).last().click(); @@ -1059,9 +1399,34 @@ async function waitFor(url, output) { } async function requestJson(request) { + return JSON.parse((await requestBytes(request)).toString("utf8")); +} + +async function requestBytes(request) { const chunks = []; for await (const chunk of request) chunks.push(chunk); - return JSON.parse(Buffer.concat(chunks).toString("utf8")); + return Buffer.concat(chunks); +} + +function fileTransfer(transferId, direction, size, strategy = "object_put") { + return { + protocol_version: 1, + type: "file_transfer", + transfer_id: transferId, + direction, + protection: "transport_tls", + strategy: + strategy === "object_ranges" + ? { kind: "object_ranges", part_size: Math.max(1, size) } + : { kind: "object_put" }, + total_size: size, + expires_at: futureInstant(), + received: [], + }; +} + +function futureInstant() { + return new Date(Date.now() + 10 * 60 * 1_000).toISOString(); } function cloudViewSource() { diff --git a/scripts/tasknotes-manifest.mjs b/scripts/tasknotes-manifest.mjs index e6b4db6..da2e65a 100644 --- a/scripts/tasknotes-manifest.mjs +++ b/scripts/tasknotes-manifest.mjs @@ -1,4 +1,5 @@ import { buildTaskNotesMdbaseTypePack } from "@tasknotes/model/mdbase"; +import { TASKNOTES_SPEC_VERSION } from "@tasknotes/model/types"; export async function buildTaskNotesManifest({ appUrl, @@ -18,7 +19,7 @@ export async function buildTaskNotesManifest({ ...(!webOnly ? ["dev.tasknotes.app://auth/mdbase/callback"] : []), ], requirements: { - contracts: [{ id: "tasknotes.task", version: "0.3.0-rc.1" }], + contracts: [{ id: "tasknotes.task", version: TASKNOTES_SPEC_VERSION }], access: "full_collection", files: { actions: ["list", "read", "add", "replace", "move", "delete"], diff --git a/scripts/tasknotes-manifest.test.mjs b/scripts/tasknotes-manifest.test.mjs index f0a6cc4..8e4b574 100644 --- a/scripts/tasknotes-manifest.test.mjs +++ b/scripts/tasknotes-manifest.test.mjs @@ -28,7 +28,7 @@ describe("TaskNotes mdbase manifest", () => { expect(manifest.notifications.native_delivery).toBeUndefined(); expect(JSON.stringify(manifest.notifications)).not.toContain("path"); expect(manifest.requirements.contracts).toEqual([ - { id: "tasknotes.task", version: "0.3.0-rc.1" }, + { id: "tasknotes.task", version: "0.3.0-rc.3" }, ]); expect(manifest.requirements.files).toEqual({ actions: ["list", "read", "add", "replace", "move", "delete"], @@ -60,7 +60,7 @@ describe("TaskNotes mdbase manifest", () => { const implementation = generated.type.implements.find( (candidate) => candidate.contract === "tasknotes.task" && - candidate.version === "0.3.0-rc.1", + candidate.version === "0.3.0-rc.3", ); const field = implementation.fields.sortOrder; expect(generated.type.schema.value.properties[field]).toEqual({ @@ -84,7 +84,7 @@ describe("TaskNotes mdbase manifest", () => { const implementation = generated.type.implements.find( (candidate) => candidate.contract === "tasknotes.task" && - candidate.version === "0.3.0-rc.1", + candidate.version === "0.3.0-rc.3", ); const taskDateSchema = { anyOf: [ diff --git a/scripts/tasknotes-resources.mjs b/scripts/tasknotes-resources.mjs index 28fefca..eaa6700 100644 --- a/scripts/tasknotes-resources.mjs +++ b/scripts/tasknotes-resources.mjs @@ -1,6 +1,7 @@ import { cloneDefaultModelConfig } from "@tasknotes/model/defaults"; import { serializeMarkdownDocument } from "@tasknotes/model/frontmatter"; import { buildTaskNotesMdbaseResources } from "@tasknotes/model/mdbase"; +import { TASKNOTES_SPEC_VERSION } from "@tasknotes/model/types"; export function buildAppTaskNotesResources() { const modelConfig = cloneDefaultModelConfig(); @@ -25,11 +26,11 @@ export function buildAppTaskNotesResources() { const implementation = type.implements.find( (candidate) => candidate.contract === "tasknotes.task" && - candidate.version === "0.3.0-rc.1", + candidate.version === TASKNOTES_SPEC_VERSION, ); 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}.`, ); const taskDateSchema = { anyOf: [ diff --git a/src/app/task-screen.test.tsx b/src/app/task-screen.test.tsx index 71ea264..0955d83 100644 --- a/src/app/task-screen.test.tsx +++ b/src/app/task-screen.test.tsx @@ -12,7 +12,7 @@ import { TaskScreen } from "./task-screen"; import type { Task } from "../domain/task"; -describe("TaskScreen persistence failures", () => { +describe("TaskScreen", () => { let repository: IndexedMarkdownRepository; let index: TaskIndex; let task: Task; @@ -83,4 +83,191 @@ describe("TaskScreen persistence failures", () => { ); expect(archive).not.toHaveBeenCalled(); }); + + it("attaches an image as task membership, then inserts it in Notes explicitly", async () => { + renderTask(); + const file = new File([Uint8Array.of(137, 80, 78, 71)], "receipt.png", { + type: "image/png", + }); + + fireEvent.change(await screen.findByLabelText("Attach image"), { + target: { files: [file] }, + }); + + expect(await screen.findByText("receipt.png")).toBeVisible(); + const attached = await repository.get(task.id); + expect(attached?.attachments).toHaveLength(1); + expect(attached?.frontmatter.attachments).toEqual(attached?.attachments); + expect(attached?.body).toBe(""); + + fireEvent.click(screen.getByRole("button", { name: "Insert" })); + await waitFor(async () => + expect((await repository.get(task.id))?.body).toMatch( + /^!\[\[Attachments\//, + ), + ); + await waitFor(() => + expect( + (screen.getByLabelText("Notes") as HTMLTextAreaElement).value, + ).toMatch(/^!\[\[Attachments\//), + ); + + expect( + screen.getByText( + /Detaching removes an image from this task but keeps the file in your collection\. Permanent deletion isn.t available yet\./, + ), + ).toBeVisible(); + expect( + screen.queryByRole("button", { name: /Delete receipt\.png file/ }), + ).not.toBeInTheDocument(); + + const detach = screen.getByRole("button", { name: "Detach receipt.png" }); + await waitFor(() => expect(detach).toBeEnabled()); + fireEvent.click(detach); + await waitFor(() => + expect(screen.queryByText("receipt.png")).not.toBeInTheDocument(), + ); + expect((await repository.get(task.id))?.attachments).toEqual([]); + expect( + await repository.files!.list({ folder: "Attachments" }), + ).toHaveLength(1); + }); + + it("preserves Notes typed while an inline image upload is in flight", async () => { + renderTask(); + const upload = repository.files!.upload.bind(repository.files); + let releaseUpload!: () => void; + const uploadGate = new Promise((resolve) => { + releaseUpload = resolve; + }); + const uploadSpy = vi + .spyOn(repository.files!, "upload") + .mockImplementationOnce(async (...arguments_) => { + await uploadGate; + return upload(...arguments_); + }); + const file = new File([Uint8Array.of(137, 80, 78, 71)], "slow.png", { + type: "image/png", + }); + + fireEvent.change(await screen.findByLabelText("Insert in Notes"), { + target: { files: [file] }, + }); + await waitFor(() => expect(uploadSpy).toHaveBeenCalled()); + fireEvent.change(screen.getByLabelText("Notes"), { + target: { value: "Typed while uploading" }, + }); + releaseUpload(); + + await waitFor(async () => { + const saved = await repository.get(task.id); + expect(saved?.body).toMatch( + /^Typed while uploading\n\n!\[\[Attachments\//, + ); + }); + expect( + (screen.getByLabelText("Notes") as HTMLTextAreaElement).value, + ).toMatch(/^Typed while uploading\n\n!\[\[Attachments\//); + }); + + it("preserves Notes typed while existing-image validation is in flight", async () => { + renderTask(); + const file = new File([Uint8Array.of(137, 80, 78, 71)], "existing.png", { + type: "image/png", + }); + fireEvent.change(await screen.findByLabelText("Attach image"), { + target: { files: [file] }, + }); + expect(await screen.findByText("existing.png")).toBeVisible(); + + const list = repository.files!.list.bind(repository.files); + let releaseList!: () => void; + const listGate = new Promise((resolve) => { + releaseList = resolve; + }); + const listSpy = vi + .spyOn(repository.files!, "list") + .mockImplementationOnce(async (...arguments_) => { + await listGate; + return list(...arguments_); + }); + fireEvent.click(screen.getByRole("button", { name: "Insert" })); + await waitFor(() => expect(listSpy).toHaveBeenCalled()); + fireEvent.change(screen.getByLabelText("Notes"), { + target: { value: "Typed during validation" }, + }); + releaseList(); + + await waitFor(async () => + expect((await repository.get(task.id))?.body).toMatch( + /^Typed during validation\n\n!\[\[Attachments\//, + ), + ); + }); + + it("opens a blank image window synchronously and reports download failures", async () => { + renderTask(); + const file = new File([Uint8Array.of(137, 80, 78, 71)], "open.png", { + type: "image/png", + }); + fireEvent.change(await screen.findByLabelText("Attach image"), { + target: { files: [file] }, + }); + expect(await screen.findByText("open.png")).toBeVisible(); + + const target = { + close: vi.fn(), + location: { href: "about:blank" }, + opener: window, + } as unknown as Window; + const open = vi.spyOn(window, "open").mockReturnValue(target); + const download = vi + .spyOn(repository.files!, "download") + .mockRejectedValueOnce(new Error("Image download unavailable")); + + fireEvent.click(screen.getByRole("button", { name: "Open open.png" })); + expect(open).toHaveBeenCalledWith("about:blank", "_blank"); + expect(download).toHaveBeenCalled(); + await waitFor(() => + expect(screen.getByRole("alert")).toHaveTextContent( + "Image download unavailable", + ), + ); + expect(target.close).toHaveBeenCalled(); + }); + + it("keeps a user-opened window alive across a delayed image download", async () => { + renderTask(); + const file = new File([Uint8Array.of(137, 80, 78, 71)], "delayed.png", { + type: "image/png", + }); + fireEvent.change(await screen.findByLabelText("Attach image"), { + target: { files: [file] }, + }); + expect(await screen.findByText("delayed.png")).toBeVisible(); + + const target = { + close: vi.fn(), + location: { href: "about:blank" }, + opener: window, + } as unknown as Window; + vi.spyOn(window, "open").mockReturnValue(target); + const download = repository.files!.download.bind(repository.files); + let releaseDownload!: () => void; + const downloadGate = new Promise((resolve) => { + releaseDownload = resolve; + }); + vi.spyOn(repository.files!, "download").mockImplementationOnce( + async (...arguments_) => { + await downloadGate; + return download(...arguments_); + }, + ); + + fireEvent.click(screen.getByRole("button", { name: "Open delayed.png" })); + expect(target.location.href).toBe("about:blank"); + releaseDownload(); + await waitFor(() => expect(target.location.href).toMatch(/^blob:/)); + expect(target.close).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/task-screen.tsx b/src/app/task-screen.tsx index 9b58972..b78b1c9 100644 --- a/src/app/task-screen.tsx +++ b/src/app/task-screen.tsx @@ -15,8 +15,11 @@ import { useRef, useState, } from "react"; +import { attachmentPathFromReference } from "@tasknotes/model/attachments"; import { LoadingRows } from "../components/loading"; +import { TaskAttachments } from "../components/task-attachments"; +import { AttachmentService } from "../application/attachments/attachment-service"; import { DependencyEditor, RelatedWork } from "../components/dependency-editor"; import { OperationErrorNotice } from "../components/operation-error-notice"; import { RecurrenceField } from "../components/recurrence-field"; @@ -152,6 +155,24 @@ function TaskEditor({ (request: FieldCompletionRequest) => repository.completeField(request), [repository], ); + const attachmentService = useMemo( + () => new AttachmentService(repository), + [repository], + ); + const resolveTaskImage = useCallback( + async (reference: string): Promise => { + if (!repository.files) return null; + const path = attachmentPathFromReference(reference, task.path); + if (!path) return null; + const separator = path.lastIndexOf("/"); + const folder = separator < 0 ? undefined : path.slice(0, separator); + const file = (await repository.files.list({ folder })).find( + (candidate) => candidate.path === path, + ); + return file ? repository.files.download(file) : null; + }, + [repository, task.path], + ); const completeDependencyField = useCallback( async (request: FieldCompletionRequest) => { const options = await repository.completeField(request); @@ -421,6 +442,24 @@ function TaskEditor({ change({ customProperties }); } + async function flushBeforeAttachmentMutation(): Promise { + if (dirtyRef.current) await persist(draftRef.current, editVersion.current); + } + + async function insertAttachmentInline(reference: string): Promise { + const embed = `!${reference}`; + if (draftRef.current.body.includes(embed)) return; + editVersion.current += 1; + const version = editVersion.current; + const body = `${draftRef.current.body.trimEnd()}${draftRef.current.body.trim() ? "\n\n" : ""}${embed}\n`; + const next = { ...draftRef.current, body }; + draftRef.current = next; + dirtyRef.current = true; + setDraft(next); + setDirty(true); + await persist(next, version); + } + async function leave() { if (leaving) return; if (!draft.title.trim()) { @@ -811,11 +850,24 @@ function TaskEditor({ Rendering…

} > - +
)} + {repository.files ? ( + + ) : null} +
{configuration.priorities.map((priority) => ( diff --git a/src/app/views-screen.creation.test.tsx b/src/app/views-screen.creation.test.tsx index 7ba5e00..731a9d3 100644 --- a/src/app/views-screen.creation.test.tsx +++ b/src/app/views-screen.creation.test.tsx @@ -478,6 +478,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/app/views-screen.optimistic.test.tsx b/src/app/views-screen.optimistic.test.tsx index 11d94eb..059933c 100644 --- a/src/app/views-screen.optimistic.test.tsx +++ b/src/app/views-screen.optimistic.test.tsx @@ -1032,6 +1032,7 @@ function boardExecution(): TaskViewExecution { tags: [], contexts: [], projects: [], + attachments: [], blockedBy: [], completeInstances: [], skippedInstances: [], @@ -1065,6 +1066,7 @@ function listTask(id: string, title: string): Task { tags: [], contexts: [], projects: [], + attachments: [], blockedBy: [], completeInstances: [], skippedInstances: [], diff --git a/src/app/views-screen.test.ts b/src/app/views-screen.test.ts index 8effb97..a874dd9 100644 --- a/src/app/views-screen.test.ts +++ b/src/app/views-screen.test.ts @@ -132,5 +132,6 @@ function task(overrides: Partial): Task { frontmatter: {}, ...overrides, blockedBy: overrides.blockedBy ?? [], + attachments: overrides.attachments ?? [], }; } diff --git a/src/application/attachments/attachment-service.test.ts b/src/application/attachments/attachment-service.test.ts new file mode 100644 index 0000000..94604f7 --- /dev/null +++ b/src/application/attachments/attachment-service.test.ts @@ -0,0 +1,320 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { MarkdownCollection } from "../../storage/collection"; +import { TaskIndex } from "../../storage/index"; +import { IndexedMarkdownRepository } from "../../storage/repository"; +import { MemoryVault } from "../../test/memory-vault"; +import { AttachmentService } from "./attachment-service"; + +import type { TaskRepository } from "../ports/task-repository"; + +const indexes: TaskIndex[] = []; + +afterEach(async () => { + await Promise.all(indexes.splice(0).map((index) => index.delete())); +}); + +describe("AttachmentService", () => { + it("makes frontmatter membership authoritative and keeps detach non-destructive", async () => { + const { repository, vault } = await fixture(); + const task = await repository.create({ + title: "Expense report", + body: "Notes", + }); + const service = new AttachmentService(repository); + + const attached = await service.attachImage( + task.id, + new File([Uint8Array.of(1, 2, 3)], "Receipt Photo.PNG", { + type: "image/png", + }), + ); + + expect(attached.reference).toMatch( + /^\[\[Attachments\/[0-9a-f-]+-Receipt-Photo\.png\]\]$/, + ); + expect(attached.task.attachments).toEqual([attached.reference]); + expect(attached.task.frontmatter.attachments).toEqual([attached.reference]); + expect(attached.task.body).toBe("Notes"); + expect(await vault.readBinary(attached.file.path)).toEqual( + Uint8Array.of(1, 2, 3), + ); + + const detached = await service.detach(task.id, attached.reference); + expect(detached.attachments).toEqual([]); + expect( + await repository.files!.list({ folder: "Attachments" }), + ).toHaveLength(1); + }); + + it("keeps Notes presentation outside binary and membership persistence", async () => { + const { repository } = await fixture(); + const task = await repository.create({ + title: "Visual task", + body: "Context", + }); + const result = await new AttachmentService(repository).attachImage( + task.id, + new File([Uint8Array.of(9)], "diagram.webp", { type: "image/webp" }), + ); + + expect(result.task.attachments).toEqual([result.reference]); + expect(result.task.body).toBe("Context"); + }); + + it("names unnamed blobs and resolves missing or invalid memberships honestly", async () => { + const { repository } = await fixture(); + const task = await repository.create({ title: "Pasted image" }); + const service = new AttachmentService(repository); + const result = await service.attachImage( + task.id, + new Blob([Uint8Array.of(6)]), + ); + + expect(result.file.path).toMatch( + /^Attachments\/[0-9a-f-]+-image-[0-9]+\.png$/, + ); + await expect(service.currentTask(task.id)).resolves.toMatchObject({ + id: task.id, + }); + await expect( + service.resolve({ + ...result.task, + attachments: [result.reference, "https://example.com/remote.png"], + }), + ).resolves.toEqual([ + expect.objectContaining({ + reference: result.reference, + path: result.file.path, + file: result.file, + }), + { reference: "https://example.com/remote.png" }, + ]); + }); + + it("validates existing inline insertion without mutating Notes", async () => { + const { repository } = await fixture(); + const task = await repository.create({ title: "Existing image" }); + const service = new AttachmentService(repository); + const attached = await service.attachImage( + task.id, + new File([Uint8Array.of(4)], "existing.gif", { type: "image/gif" }), + ); + + await expect( + service.assertInlineInsertable(task.id, attached.reference), + ).resolves.toBeUndefined(); + expect((await repository.get(task.id))?.body).toBe(""); + + await service.detach(task.id, attached.reference); + await expect( + service.assertInlineInsertable(task.id, attached.reference), + ).rejects.toThrow("Attach this image"); + await repository.update(task.id, { attachments: [attached.reference] }); + await repository.files!.delete(attached.file); + await expect( + service.assertInlineInsertable(task.id, attached.reference), + ).rejects.toThrow("file is missing"); + }); + + it("rejects disguised or mismatched image media types", async () => { + const { repository } = await fixture(); + const task = await repository.create({ title: "Unsafe image" }); + const service = new AttachmentService(repository); + + await expect( + service.attachImage( + task.id, + new File([""], "disguised.png", { type: "image/svg+xml" }), + ), + ).rejects.toThrow("Choose an AVIF"); + expect(await repository.files!.list({ folder: "Attachments" })).toEqual([]); + }); + + it("rejects unavailable storage and missing tasks", async () => { + const unavailable = new AttachmentService({ + files: undefined, + } as TaskRepository); + expect(unavailable.available()).toBe(false); + await expect( + unavailable.attachImage("missing", new Blob([Uint8Array.of(1)])), + ).rejects.toThrow("does not provide attachment storage"); + + const { repository } = await fixture(); + const service = new AttachmentService(repository); + await expect( + service.attachImage( + "missing", + new File([Uint8Array.of(1)], "missing.png", { type: "image/png" }), + ), + ).rejects.toThrow("Task not found"); + }); + + it("cleans a journaled link when storage rejects before writing bytes", async () => { + const { repository } = await fixture(); + const task = await repository.create({ title: "Storage full" }); + const service = new AttachmentService(repository); + vi.spyOn(repository.files!, "upload").mockRejectedValueOnce( + new Error("Storage full"), + ); + + await expect( + service.attachImage( + task.id, + new File([Uint8Array.of(1)], "full.png", { type: "image/png" }), + ), + ).rejects.toThrow("Storage full"); + await service.recover(); + expect((await repository.get(task.id))?.attachments).toEqual([]); + expect(await repository.files!.list({ folder: "Attachments" })).toEqual([]); + }); + + it("recovers frontmatter linking after bytes were saved but the record write failed", async () => { + const { repository } = await fixture(); + const task = await repository.create({ title: "Recover me" }); + const update = repository.update.bind(repository); + repository.update = vi + .fn() + .mockRejectedValueOnce(new Error("record write interrupted")) + .mockImplementation(update); + const service = new AttachmentService(repository); + + await expect( + service.attachImage( + task.id, + new File([Uint8Array.of(8)], "recover.png", { type: "image/png" }), + ), + ).rejects.toThrow("record write interrupted"); + expect((await repository.get(task.id))?.attachments).toEqual([]); + + await new AttachmentService(repository).recover(); + expect((await repository.get(task.id))?.attachments).toHaveLength(1); + expect( + await repository.files!.list({ folder: "Attachments" }), + ).toHaveLength(1); + }); + + it("recovers an ambiguous upload that wrote bytes before reporting failure", async () => { + const { repository } = await fixture(); + const task = await repository.create({ title: "Interrupted upload" }); + const service = new AttachmentService(repository); + const upload = repository.files!.upload.bind(repository.files); + vi.spyOn(repository.files!, "upload").mockImplementationOnce( + async (...arguments_) => { + await upload(...arguments_); + throw new Error("Bridge response was lost"); + }, + ); + + const result = await service.attachImage( + task.id, + new File([Uint8Array.of(1, 2, 3)], "ambiguous.png", { + type: "image/png", + }), + ); + + expect((await repository.get(task.id))?.attachments).toEqual([ + result.reference, + ]); + expect( + await repository.files!.list({ folder: "Attachments" }), + ).toHaveLength(1); + }); + + it("rejects and cleans a truncated ambiguous native write", async () => { + const { repository } = await fixture(); + const task = await repository.create({ + title: "Interrupted partial upload", + }); + const service = new AttachmentService(repository); + const upload = repository.files!.upload.bind(repository.files); + vi.spyOn(repository.files!, "upload").mockImplementationOnce( + async (path, _source, options) => { + await upload( + path, + new Blob([Uint8Array.of(1)], { type: "image/png" }), + options, + ); + throw new Error("Bridge response was lost after a partial write"); + }, + ); + + await expect( + service.attachImage( + task.id, + new File([Uint8Array.of(1, 2, 3)], "partial.png", { + type: "image/png", + }), + ), + ).rejects.toThrow("partial write"); + expect((await repository.get(task.id))?.attachments).toEqual([]); + expect(await repository.files!.list({ folder: "Attachments" })).toEqual([]); + await expect(service.recover()).resolves.toBeUndefined(); + }); + + it("rejects a successful write whose returned descriptor fails integrity", async () => { + const { repository } = await fixture(); + const task = await repository.create({ title: "Dishonest provider" }); + const service = new AttachmentService(repository); + const upload = repository.files!.upload.bind(repository.files); + const deleteFile = vi + .spyOn(repository.files!, "delete") + .mockRejectedValueOnce(new Error("cleanup deferred")); + vi.spyOn(repository.files!, "upload").mockImplementationOnce( + async (...arguments_) => ({ + ...(await upload(...arguments_)), + size: 0, + }), + ); + + await expect( + service.attachImage( + task.id, + new File([Uint8Array.of(1, 2)], "integrity.png", { + type: "image/png", + }), + ), + ).rejects.toThrow("did not preserve every byte"); + expect(deleteFile).toHaveBeenCalledOnce(); + expect((await repository.get(task.id))?.attachments).toEqual([]); + }); + + it("drops recovery intent safely when its task was deleted", async () => { + const { repository } = await fixture(); + const task = await repository.create({ title: "Deleted during linking" }); + const update = repository.update.bind(repository); + repository.update = vi + .fn() + .mockRejectedValueOnce(new Error("record write interrupted")) + .mockImplementation(update); + const service = new AttachmentService(repository); + + await expect( + service.attachImage( + task.id, + new File([Uint8Array.of(2)], "orphan.png", { type: "image/png" }), + ), + ).rejects.toThrow("record write interrupted"); + await repository.delete(task.id); + await service.recover(); + await expect(service.recover()).resolves.toBeUndefined(); + expect( + await repository.files!.list({ folder: "Attachments" }), + ).toHaveLength(1); + }); +}); + +async function fixture(): Promise<{ + repository: IndexedMarkdownRepository; + vault: MemoryVault; +}> { + const vault = new MemoryVault(); + const index = new TaskIndex(`attachments-${crypto.randomUUID()}`); + indexes.push(index); + const repository = new IndexedMarkdownRepository({ + collection: new MarkdownCollection(vault), + index, + }); + await repository.initialize(); + return { repository, vault }; +} diff --git a/src/application/attachments/attachment-service.ts b/src/application/attachments/attachment-service.ts new file mode 100644 index 0000000..5d3ecff --- /dev/null +++ b/src/application/attachments/attachment-service.ts @@ -0,0 +1,314 @@ +import Dexie, { type EntityTable } from "dexie"; +import { + attachmentPathFromReference, + canonicalAttachmentReference, +} from "@tasknotes/model/attachments"; + +import type { + CollectionFile, + CollectionFileStore, +} from "../ports/collection-file-store"; +import type { TaskRepository } from "../ports/task-repository"; +import type { Task } from "../../domain/task"; + +interface AttachmentJournalEntry { + id: string; + collectionId: string; + taskId: string; + reference: string; + expectedDigest?: `sha256:${string}`; + expectedSize?: number; + fileSaved?: boolean; + enqueuedAt: number; +} + +class AttachmentJournal extends Dexie { + entries!: EntityTable; + + constructor() { + super("tasknotes-attachment-journal-v1"); + this.version(1).stores({ + entries: "&id,collectionId,enqueuedAt,taskId", + }); + } +} + +export interface ResolvedTaskAttachment { + reference: string; + path?: string; + file?: CollectionFile; +} + +export interface AttachImageResult { + task: Task; + file: CollectionFile; + reference: string; +} + +const journal = new AttachmentJournal(); + +/** Coordinates binary persistence with authoritative frontmatter membership. */ +export class AttachmentService { + constructor(private readonly repository: TaskRepository) {} + + available(): boolean { + return Boolean(this.repository.files); + } + + currentTask(taskId: string): Promise { + return this.repository.get(taskId); + } + + async recover(): Promise { + const collectionId = await this.collectionId(); + const entries = await journal.entries + .where("collectionId") + .equals(collectionId) + .sortBy("enqueuedAt"); + for (const entry of entries) { + const task = await this.repository.get(entry.taskId); + if (!task) { + await journal.entries.delete(entry.id); + continue; + } + if (!entry.fileSaved) { + const file = await this.findFile(task, entry.reference); + if (!file || !matchesExpectedFile(file, entry)) { + if (file) + await this.requireStore() + .delete(file) + .catch(() => undefined); + await journal.entries.delete(entry.id); + continue; + } + entry.fileSaved = true; + await journal.entries.put(entry); + } + await this.applyMembership( + (await this.repository.get(entry.taskId)) ?? task, + entry, + ); + await journal.entries.delete(entry.id); + } + } + + async attachImage( + taskId: string, + source: File | Blob, + ): Promise { + return this.attach(taskId, source); + } + + /** Verifies an existing membership can safely be presented in Notes. */ + async assertInlineInsertable( + taskId: string, + reference: string, + ): Promise { + const task = await this.requireTask(taskId); + if (!hasReference(task, reference)) + throw new Error( + "Attach this image to the task before inserting it in Notes.", + ); + if (!(await this.findFile(task, reference))) + throw new Error("The attachment file is missing and cannot be inserted."); + } + + async resolve(task: Task): Promise { + const files = this.requireStore(); + const listed = await files.list({ folder: "Attachments" }); + const byPath = new Map(listed.map((file) => [file.path, file])); + return task.attachments.map((reference) => { + const path = attachmentPathFromReference(reference, task.path); + return { + reference, + ...(path ? { path } : {}), + ...(path && byPath.has(path) ? { file: byPath.get(path) } : {}), + }; + }); + } + + async detach(taskId: string, reference: string): Promise { + const task = await this.requireTask(taskId); + return this.repository.update(task.id, { + attachments: withoutReference(task, reference), + }); + } + + private async attach( + taskId: string, + source: File | Blob, + ): Promise { + const store = this.requireStore(); + const task = await this.requireTask(taskId); + const name = + source instanceof File ? source.name : `image-${Date.now()}.png`; + const path = attachmentPath(name); + assertImage(source.type, path); + const reference = canonicalAttachmentReference(path); + const expectedSize = source.size; + const expectedDigest = await sha256(source); + const entry: AttachmentJournalEntry = { + id: crypto.randomUUID(), + collectionId: await this.collectionId(), + taskId, + reference, + expectedDigest, + expectedSize, + fileSaved: false, + enqueuedAt: Date.now(), + }; + await journal.entries.put(entry); + let file: CollectionFile; + try { + file = await store.upload(path, source, { + ...(source.type ? { mediaType: source.type } : {}), + }); + } catch (reason) { + let listingSucceeded = false; + const recovered = await this.findFile(task, reference).then( + (candidate) => { + listingSucceeded = true; + return candidate; + }, + () => undefined, + ); + if (!recovered || !matchesExpectedFile(recovered, entry)) { + if (recovered) await store.delete(recovered).catch(() => undefined); + if (listingSucceeded) await journal.entries.delete(entry.id); + throw reason; + } + file = recovered; + } + if (!matchesExpectedFile(file, entry)) { + await store.delete(file).catch(() => undefined); + await journal.entries.delete(entry.id); + throw new Error( + "The attachment write did not preserve every byte. The partial file was not attached.", + ); + } + entry.fileSaved = true; + await journal.entries.put(entry); + const updated = await this.applyMembership( + (await this.repository.get(taskId)) ?? task, + entry, + ); + await journal.entries.delete(entry.id); + return { task: updated, file, reference }; + } + + private async applyMembership( + task: Task, + entry: AttachmentJournalEntry, + ): Promise { + const attachments = hasReference(task, entry.reference) + ? task.attachments + : [...task.attachments, entry.reference]; + if (sameList(task.attachments, attachments)) return task; + return this.repository.update(task.id, { attachments }); + } + + private async findFile( + task: Task, + reference: string, + ): Promise { + const path = attachmentPathFromReference(reference, task.path); + if (!path) return undefined; + return ( + await this.requireStore().list({ folder: parentFolder(path) }) + ).find((candidate) => candidate.path === path); + } + + private requireStore(): CollectionFileStore { + if (!this.repository.files) + throw new Error("This collection does not provide attachment storage."); + return this.repository.files; + } + + private async requireTask(taskId: string): Promise { + const task = await this.repository.get(taskId); + if (!task) throw new Error("Task not found."); + return task; + } + + private async collectionId(): Promise { + const info = await this.repository.collectionInfo(); + return info.id ?? `${info.kind}:${info.location}`; + } +} + +function attachmentPath(name: string): string { + const dot = name.lastIndexOf("."); + const extension = dot > 0 ? name.slice(dot).toLowerCase() : ""; + const stem = + (dot > 0 ? name.slice(0, dot) : name) + .normalize("NFKC") + .replace(/[^\p{L}\p{N}_-]+/gu, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 72) || "image"; + return `Attachments/${crypto.randomUUID()}-${stem}${extension}`; +} + +function assertImage(mediaType: string, path: string): void { + const extension = path.slice(path.lastIndexOf(".")).toLowerCase(); + const supportedMediaTypes: Record = { + ".avif": ["image/avif"], + ".gif": ["image/gif"], + ".heic": ["image/heic", "image/heif"], + ".heif": ["image/heic", "image/heif"], + ".jpeg": ["image/jpeg"], + ".jpg": ["image/jpeg"], + ".png": ["image/png"], + ".webp": ["image/webp"], + }; + const normalizedMediaType = mediaType.toLowerCase().split(";", 1)[0].trim(); + if ( + !supportedMediaTypes[extension] || + (normalizedMediaType && + !supportedMediaTypes[extension].includes(normalizedMediaType)) + ) + throw new Error("Choose an AVIF, GIF, HEIC, JPEG, PNG, or WebP image."); +} + +function withoutReference(task: Task, reference: string): string[] { + const path = attachmentPathFromReference(reference, task.path); + return task.attachments.filter( + (candidate) => attachmentPathFromReference(candidate, task.path) !== path, + ); +} + +function hasReference(task: Task, reference: string): boolean { + return withoutReference(task, reference).length !== task.attachments.length; +} + +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 matchesExpectedFile( + file: CollectionFile, + entry: AttachmentJournalEntry, +): boolean { + return ( + entry.expectedSize !== undefined && + entry.expectedDigest !== undefined && + file.size === entry.expectedSize && + file.contentDigest === entry.expectedDigest + ); +} + +function parentFolder(path: string): string { + return path.slice(0, path.lastIndexOf("/")); +} + +function sameList(left: readonly string[], right: readonly string[]): boolean { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +} diff --git a/src/application/auto-archive-activity.test.ts b/src/application/auto-archive-activity.test.ts index 8eab569..d6112e7 100644 --- a/src/application/auto-archive-activity.test.ts +++ b/src/application/auto-archive-activity.test.ts @@ -236,6 +236,7 @@ function makeTask(patch: Partial = {}): Task { revision: 1, frontmatter: {}, ...patch, + attachments: patch.attachments ?? [], }; } diff --git a/src/application/ports/collection-file-store.ts b/src/application/ports/collection-file-store.ts index e991f2b..ba3d093 100644 --- a/src/application/ports/collection-file-store.ts +++ b/src/application/ports/collection-file-store.ts @@ -13,6 +13,9 @@ export interface CollectionFile { mediaType?: string; mediaClass: CollectionFileMediaClass; modifiedAt: string; + /** Availability is advisory UI state; it is never persisted into task YAML. */ + availability?: "local" | "remote" | "local-and-remote"; + pending?: "upload" | "move" | "delete"; } export interface CollectionFileProgress { @@ -34,6 +37,7 @@ export interface CollectionFileStore { options?: { mediaType?: string; ifRevision?: string; + transferId?: string; signal?: AbortSignal; onProgress?: (progress: CollectionFileProgress) => void; }, @@ -52,6 +56,15 @@ export interface CollectionFileStore { onProgress?: (progress: CollectionFileProgress) => void; }, ): Promise>; - move(file: CollectionFile, path: string): Promise; - delete(file: CollectionFile): Promise; + move( + file: CollectionFile, + path: string, + options?: { mutationId?: string; signal?: AbortSignal }, + ): Promise; + delete( + file: CollectionFile, + options?: { mutationId?: string; signal?: AbortSignal }, + ): Promise; + /** Flush durable local mutations when the authority is reachable. */ + sync?(): Promise; } diff --git a/src/cloud/connect.ts b/src/cloud/connect.ts index 3bd9e1e..a04fac0 100644 --- a/src/cloud/connect.ts +++ b/src/cloud/connect.ts @@ -44,7 +44,11 @@ export const cloudConnect = new MdbaseConnect({ serverUrl, manifest, redirectUri, - relayEncryption: import.meta.env.MODE === "e2e" ? "disabled" : "required", + relayEncryption: + import.meta.env.MODE === "e2e" && + import.meta.env.VITE_MDBASE_REQUIRE_RELAY_ENCRYPTION !== "1" + ? "disabled" + : "required", navigate: Capacitor.isNativePlatform() ? async (url) => Browser.open({ url }) : undefined, diff --git a/src/components/markdown-preview.test.tsx b/src/components/markdown-preview.test.tsx index 132ba9f..843826a 100644 --- a/src/components/markdown-preview.test.tsx +++ b/src/components/markdown-preview.test.tsx @@ -44,4 +44,20 @@ describe("MarkdownPreview", () => { render(); expect(screen.getByText(/Nothing to preview yet/)).toBeVisible(); }); + + it("resolves Obsidian image embeds through collection storage", async () => { + const resolveImage = vi.fn(async () => null); + render( + , + ); + + expect(await screen.findByText("Image unavailable offline")).toBeVisible(); + expect(resolveImage).toHaveBeenCalledWith( + "[[Attachments/receipt photo.jpg]]", + ); + expect(screen.getByRole("img", { name: "Receipt" })).toBeVisible(); + }); }); diff --git a/src/components/markdown-preview.tsx b/src/components/markdown-preview.tsx index 091daa7..d77b2b7 100644 --- a/src/components/markdown-preview.tsx +++ b/src/components/markdown-preview.tsx @@ -1,7 +1,14 @@ import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import { useEffect, useState } from "react"; -export function MarkdownPreview({ source }: { source: string }) { +export function MarkdownPreview({ + source, + resolveImage, +}: { + source: string; + resolveImage?: (source: string) => Promise; +}) { 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 {alt; + 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 {alt} 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 `![${alt}](/__tasknotes_attachment__/${encodeURIComponent(path)})`; + }, + ); +} + +function attachmentImageSource(source: string): string { + const prefix = "/__tasknotes_attachment__/"; + return source.startsWith(prefix) + ? `[[${decodeURIComponent(source.slice(prefix.length))}]]` + : `[image](${source})`; +} diff --git a/src/components/task-attachments.tsx b/src/components/task-attachments.tsx new file mode 100644 index 0000000..cc6426a --- /dev/null +++ b/src/components/task-attachments.tsx @@ -0,0 +1,304 @@ +import { + ExternalLink, + FileImage, + ImagePlus, + LoaderCircle, + Unlink, +} from "lucide-react"; +import { useCallback, useEffect, useId, useRef, useState } from "react"; + +import { + AttachmentService, + type ResolvedTaskAttachment, +} from "../application/attachments/attachment-service"; + +import type { Task } from "../domain/task"; +import type { CollectionFileStore } from "../application/ports/collection-file-store"; + +export function TaskAttachments({ + task, + service, + store, + beforeMutation, + onInsertInline, +}: { + task: Task; + service: AttachmentService; + store: CollectionFileStore; + beforeMutation(): Promise; + onInsertInline(reference: string): Promise; +}) { + const attachInputId = useId(); + const insertInputId = useId(); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(service.available()); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const mounted = useRef(true); + const loadGeneration = useRef(0); + + const load = useCallback(async () => { + if (!service.available()) return; + const generation = ++loadGeneration.current; + setLoading(true); + try { + await service.recover(); + const resolved = await service.resolve( + (await service.currentTask(task.id)) ?? task, + ); + if (mounted.current && generation === loadGeneration.current) { + setItems(resolved); + setError(null); + } + } catch (reason) { + if (mounted.current && generation === loadGeneration.current) + setError(reason instanceof Error ? reason.message : String(reason)); + } finally { + if (mounted.current && generation === loadGeneration.current) + setLoading(false); + } + }, [service, task]); + + useEffect(() => { + mounted.current = true; + queueMicrotask(() => { + if (mounted.current) void load(); + }); + return () => { + mounted.current = false; + loadGeneration.current += 1; + }; + }, [load]); + + async function run(action: () => Promise): Promise { + if (busy) return; + setBusy(true); + setError(null); + try { + await beforeMutation(); + await action(); + await load(); + } catch (reason) { + if (mounted.current) + setError(reason instanceof Error ? reason.message : String(reason)); + } finally { + if (mounted.current) setBusy(false); + } + } + + function selectImage(file: File | undefined, inline: boolean): void { + if (!file) return; + void run(async () => { + const result = await service.attachImage(task.id, file); + if (inline) await onInsertInline(result.reference); + }); + } + + function openImage(item: ResolvedTaskAttachment): void { + if (!item.file || busy) return; + const target = window.open("about:blank", "_blank"); + if (!target) { + setError( + "Your browser blocked the image window. Allow pop-ups and try again.", + ); + return; + } + try { + target.opener = null; + } catch { + // Some WebViews expose a read-only opener; the blank target is still safe. + } + setBusy(true); + setError(null); + void openAttachment(store, item.file, target) + .catch((reason) => { + target.close(); + if (mounted.current) + setError(reason instanceof Error ? reason.message : String(reason)); + }) + .finally(() => { + if (mounted.current) setBusy(false); + }); + } + + if (!service.available()) return null; + + return ( +

+
+
+

Attachments

+

Listed with this task. Inserting an image in Notes is optional.

+
+
+ { + selectImage(event.target.files?.[0], false); + event.currentTarget.value = ""; + }} + /> + + { + selectImage(event.target.files?.[0], true); + event.currentTarget.value = ""; + }} + /> + +
+
+ + {error ? ( +

+ {error} +

+ ) : null} +

+ Detaching removes an image from this task but keeps the file in your + collection. Permanent deletion isn't available yet. +

+ {loading && !items.length ? ( +

+

+ ) : items.length ? ( +
    + {items.map((item) => ( +
  • + +
    + {displayName(item.path ?? item.reference)} + {attachmentState(item)} +
    +
    + {item.file ? ( + + ) : null} + + +
    +
  • + ))} +
+ ) : ( +

No images attached.

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