diff --git a/e2e/cloud-connection.spec.ts b/e2e/cloud-connection.spec.ts index efb38a2..efe9a8a 100644 --- a/e2e/cloud-connection.spec.ts +++ b/e2e/cloud-connection.spec.ts @@ -1,5 +1,8 @@ import type { JsonObject } from "@mdbase-dev/connect"; -import { buildTaskNotesMdbaseResources } from "@tasknotes/model/mdbase"; +import { + buildTaskNotesMdbaseResources, + TASKNOTES_CONTRACT_DIGEST, +} from "@tasknotes/model/mdbase"; import { TASKNOTES_SPEC_VERSION } from "@tasknotes/model/types"; import { expect, test, type Route } from "@playwright/test"; @@ -685,7 +688,7 @@ function collectionDescription() { contract_type: "record" as const, id: "tasknotes.task", version: TASKNOTES_SPEC_VERSION, - digest: `sha256:${"0".repeat(64)}`, + digest: TASKNOTES_CONTRACT_DIGEST, schema: generated.taskSchema, binding_schema: generated.bindingSchema, implementations: [ diff --git a/package.json b/package.json index 11f3b41..fb61b9f 100644 --- a/package.json +++ b/package.json @@ -52,9 +52,9 @@ "@fullcalendar/list": "6.1.17", "@fullcalendar/react": "6.1.17", "@fullcalendar/timegrid": "6.1.17", - "@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", + "@mdbase-dev/connect": "file:vendor/mdbase-dev-connect-0.1.0-beta.27-33890fcd1346.tgz", + "@mdbase-dev/connect-protocol": "file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.27-33890fcd1346.tgz", + "@mdbase-dev/connect-sync": "file:vendor/mdbase-dev-connect-sync-0.1.0-beta.27-33890fcd1346.tgz", "@tasknotes/model": "file:vendor/tasknotes-model-0.3.0-rc.9.tgz", "dexie": "4.4.4", "firebase": "12.16.0", @@ -70,7 +70,7 @@ }, "devDependencies": { "@axe-core/playwright": "^4.12.1", - "@callumalpass/mdbase": "file:vendor/callumalpass-mdbase-0.3.0-rc.4.tgz", + "@callumalpass/mdbase": "file:vendor/callumalpass-mdbase-0.3.0-rc.4-4f28ea27a15a.tgz", "@capacitor/android": "8.4.2", "@capacitor/assets": "3.0.5", "@capacitor/cli": "8.4.2", @@ -99,7 +99,9 @@ }, "pnpm": { "overrides": { - "@mdbase-dev/connect-protocol": "0.1.0-beta.23" + "@mdbase-dev/connect-protocol": "file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.27-33890fcd1346.tgz", + "@mdbase-dev/connect": "file:vendor/mdbase-dev-connect-0.1.0-beta.27-33890fcd1346.tgz", + "@mdbase-dev/connect-sync": "file:vendor/mdbase-dev-connect-sync-0.1.0-beta.27-33890fcd1346.tgz" } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d34c38f..fb27e5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,9 @@ settings: excludeLinksFromLockfile: false overrides: - "@mdbase-dev/connect-protocol": 0.1.0-beta.23 + "@mdbase-dev/connect-protocol": file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.27-33890fcd1346.tgz + "@mdbase-dev/connect": file:vendor/mdbase-dev-connect-0.1.0-beta.27-33890fcd1346.tgz + "@mdbase-dev/connect-sync": file:vendor/mdbase-dev-connect-sync-0.1.0-beta.27-33890fcd1346.tgz importers: .: @@ -56,14 +58,14 @@ importers: specifier: 6.1.17 version: 6.1.17(@fullcalendar/core@6.1.17) "@mdbase-dev/connect": - specifier: 0.1.0-beta.23 - version: 0.1.0-beta.23 + specifier: file:vendor/mdbase-dev-connect-0.1.0-beta.27-33890fcd1346.tgz + version: file:vendor/mdbase-dev-connect-0.1.0-beta.27-33890fcd1346.tgz "@mdbase-dev/connect-protocol": - specifier: 0.1.0-beta.23 - version: 0.1.0-beta.23 + specifier: file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.27-33890fcd1346.tgz + version: file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.27-33890fcd1346.tgz "@mdbase-dev/connect-sync": - specifier: 0.1.0-beta.23 - version: 0.1.0-beta.23 + specifier: file:vendor/mdbase-dev-connect-sync-0.1.0-beta.27-33890fcd1346.tgz + version: file:vendor/mdbase-dev-connect-sync-0.1.0-beta.27-33890fcd1346.tgz "@tasknotes/model": specifier: file:vendor/tasknotes-model-0.3.0-rc.9.tgz version: file:vendor/tasknotes-model-0.3.0-rc.9.tgz @@ -105,8 +107,8 @@ importers: specifier: ^4.12.1 version: 4.12.1(playwright-core@1.61.1) "@callumalpass/mdbase": - specifier: file:vendor/callumalpass-mdbase-0.3.0-rc.4.tgz - version: file:vendor/callumalpass-mdbase-0.3.0-rc.4.tgz + specifier: file:vendor/callumalpass-mdbase-0.3.0-rc.4-4f28ea27a15a.tgz + version: file:vendor/callumalpass-mdbase-0.3.0-rc.4-4f28ea27a15a.tgz "@capacitor/android": specifier: 8.4.2 version: 8.4.2(@capacitor/core@8.4.2) @@ -389,11 +391,11 @@ packages: integrity: sha512-HrSjnBjk71LpKDlUnbxdN9DD24Hr5O92kCxRVHA0sEa35z6tEUjBS7YQVYBMS9npbllLtPW7tGSAWwfUKBs1Hw==, } - "@callumalpass/mdbase@file:vendor/callumalpass-mdbase-0.3.0-rc.4.tgz": + "@callumalpass/mdbase@file:vendor/callumalpass-mdbase-0.3.0-rc.4-4f28ea27a15a.tgz": resolution: { - integrity: sha512-jsN4p87I6XS5WxxyeW0moKV836sA+HiFK2JOUZ/Mcjt/qN6Q4ttBP4elu/HxXKFr4JXgwPRcpvRjxn9aZ8kHfQ==, - tarball: file:vendor/callumalpass-mdbase-0.3.0-rc.4.tgz, + integrity: sha512-OulWXSTmB9A0JtUnJhAculVKBWbef4zNPmH9TmE/sPdCPuJfb8VoKAO0ktxvmchhaCP2LfNJKjLG6cX5sCoxYw==, + tarball: file:vendor/callumalpass-mdbase-0.3.0-rc.4-4f28ea27a15a.tgz, } version: 0.3.0-rc.4 hasBin: true @@ -1243,24 +1245,30 @@ packages: integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==, } - "@mdbase-dev/connect-protocol@0.1.0-beta.23": + "@mdbase-dev/connect-protocol@file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.27-33890fcd1346.tgz": resolution: { - integrity: sha512-IHbuwIBSh1d+Xys7KVnKctqz2c4SuE7g78A1K7ZGgUjWpqo+li99k4cNYcpgjPNOTlGr9hs19HPLqLFhVxZ9UA==, + integrity: sha512-WcqdnFW3ozKRUxFTLypyv6Db1UIcbNJyAyuiR/vA3bokOLLmTkdcxyA+IuY6X9IM0rt+PLztFT4ZFYZQfHqxeA==, + tarball: file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.27-33890fcd1346.tgz, } + version: 0.1.0-beta.27 - "@mdbase-dev/connect-sync@0.1.0-beta.23": + "@mdbase-dev/connect-sync@file:vendor/mdbase-dev-connect-sync-0.1.0-beta.27-33890fcd1346.tgz": resolution: { - integrity: sha512-hEfXJwJkaSNRLljNayF13pOLDDQTnHx1uf3elCJuARP/8kjHFBgQAzB7uDjM0j/W8YFrTca8yF9XJAHw4vk+CQ==, + integrity: sha512-Ti0DcCQvjPI7V715u5wJXrdKsvnmDqFKUBquPZqgOJINJTPo54p5I60PjduL7gYOmtR57KBQm2tsmTGSozrs0A==, + tarball: file:vendor/mdbase-dev-connect-sync-0.1.0-beta.27-33890fcd1346.tgz, } + version: 0.1.0-beta.27 hasBin: true - "@mdbase-dev/connect@0.1.0-beta.23": + "@mdbase-dev/connect@file:vendor/mdbase-dev-connect-0.1.0-beta.27-33890fcd1346.tgz": resolution: { - integrity: sha512-2YXIiSxAo/n4lY04lVM4OvAKWY5zu/18hwhJ1acZjDWbYZRnO6PL4VstdtlPJUVEiHrmv4pwgW7O8OMMokO03g==, + integrity: sha512-tRUifjtTupf3P96TSqJfPnXNS4YxLLQ/5blhJ/vz2ERqG/Fmrb/FhgMZgvWGiiK71MB/mmjNX64BowkmwAzLKA==, + tarball: file:vendor/mdbase-dev-connect-0.1.0-beta.27-33890fcd1346.tgz, } + version: 0.1.0-beta.27 "@napi-rs/wasm-runtime@1.1.6": resolution: @@ -1501,7 +1509,7 @@ packages: "@tasknotes/model@file:vendor/tasknotes-model-0.3.0-rc.9.tgz": resolution: { - integrity: sha512-wC0zwIhOnaxEJ3VuVnIoZqiKkQtY1YZqzGYDK8KONZf/aqegpy2cglyago0dlkkYttcm2yoHHHx7oca90HwxtA==, + integrity: sha512-vZw4o4f1MtXZGK3KPGr1GkrbO4Vg3pdPOuABB0x55WzfrDseB23C9iSAPqmGjUOBdWNIVHELeMn1p4LKmSjB6w==, tarball: file:vendor/tasknotes-model-0.3.0-rc.9.tgz, } version: 0.3.0-rc.9 @@ -6411,7 +6419,7 @@ snapshots: "@bufbuild/re2@0.6.0": {} - "@callumalpass/mdbase@file:vendor/callumalpass-mdbase-0.3.0-rc.4.tgz": + "@callumalpass/mdbase@file:vendor/callumalpass-mdbase-0.3.0-rc.4-4f28ea27a15a.tgz": dependencies: "@bufbuild/cel": 0.6.0(@bufbuild/protobuf@2.13.0) "@bufbuild/protobuf": 2.13.0 @@ -7148,17 +7156,18 @@ snapshots: "@jridgewell/resolve-uri": 3.1.2 "@jridgewell/sourcemap-codec": 1.5.5 - "@mdbase-dev/connect-protocol@0.1.0-beta.23": {} + "@mdbase-dev/connect-protocol@file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.27-33890fcd1346.tgz": + {} - "@mdbase-dev/connect-sync@0.1.0-beta.23": + "@mdbase-dev/connect-sync@file:vendor/mdbase-dev-connect-sync-0.1.0-beta.27-33890fcd1346.tgz": dependencies: - "@mdbase-dev/connect-protocol": 0.1.0-beta.23 + "@mdbase-dev/connect-protocol": file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.27-33890fcd1346.tgz "@noble/hashes": 2.2.0 yaml: 2.9.0 - "@mdbase-dev/connect@0.1.0-beta.23": + "@mdbase-dev/connect@file:vendor/mdbase-dev-connect-0.1.0-beta.27-33890fcd1346.tgz": dependencies: - "@mdbase-dev/connect-protocol": 0.1.0-beta.23 + "@mdbase-dev/connect-protocol": file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.27-33890fcd1346.tgz "@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)": dependencies: diff --git a/scripts/cloud-e2e.mjs b/scripts/cloud-e2e.mjs index f06ddc4..dd6ec4b 100644 --- a/scripts/cloud-e2e.mjs +++ b/scripts/cloud-e2e.mjs @@ -6,7 +6,11 @@ import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { promisify } from "node:util"; -import { Collection, installTypePack } from "@callumalpass/mdbase"; +import { + Collection, + applyTypePack, + assessTypePack, +} from "@callumalpass/mdbase"; import { chromium, expect } from "@playwright/test"; import { parse } from "yaml"; @@ -1482,11 +1486,19 @@ async function provisionedResources(resources, provisions) { await writeFile(target, document.document); } for (const provision of provisions) { - const installed = await installTypePack( - root, - provision.manifest, - provision.resources, - ); + const installedBy = "dev.tasknotes.app.cloud-e2e"; + const assessment = await assessTypePack(root, provision, { installedBy }); + if (!assessment.valid) { + throw new Error( + assessment.diagnostics + .map((diagnostic) => diagnostic.message) + .join("; "), + ); + } + const installed = await applyTypePack(root, provision, { + installedBy, + expectedAssessmentDigest: assessment.result.assessment_digest, + }); if (!installed.valid) { throw new Error( installed.diagnostics diff --git a/scripts/tasknotes-manifest.mjs b/scripts/tasknotes-manifest.mjs index da2e65a..86069d1 100644 --- a/scripts/tasknotes-manifest.mjs +++ b/scripts/tasknotes-manifest.mjs @@ -1,5 +1,5 @@ import { buildTaskNotesMdbaseTypePack } from "@tasknotes/model/mdbase"; -import { TASKNOTES_SPEC_VERSION } from "@tasknotes/model/types"; +import { MDBASE_TIMER_FIRED_CONTRACT } from "@mdbase-dev/connect-protocol"; export async function buildTaskNotesManifest({ appUrl, @@ -8,6 +8,11 @@ export async function buildTaskNotesManifest({ resources, }) { const typePack = await buildTaskNotesMdbaseTypePack(resources); + const taskContract = typePack.provides.find( + (contract) => contract.id === "tasknotes.task", + ); + if (!taskContract) + throw new Error("TaskNotes pack provides no task contract."); return { manifest_version: 1, id: "dev.tasknotes.app", @@ -19,7 +24,7 @@ export async function buildTaskNotesManifest({ ...(!webOnly ? ["dev.tasknotes.app://auth/mdbase/callback"] : []), ], requirements: { - contracts: [{ id: "tasknotes.task", version: TASKNOTES_SPEC_VERSION }], + contracts: [taskContract], access: "full_collection", files: { actions: ["list", "read", "add", "replace", "move", "delete"], @@ -33,7 +38,7 @@ export async function buildTaskNotesManifest({ criteria: [ { id: "task.reminder", - event: { id: "mdbase.runtime.timer.fired", version: "1.0.0" }, + event: MDBASE_TIMER_FIRED_CONTRACT, presentation: { title: "Task reminder", body: "Open TaskNotes to view your task.", diff --git a/scripts/tasknotes-manifest.test.mjs b/scripts/tasknotes-manifest.test.mjs index 8e4b574..2287d63 100644 --- a/scripts/tasknotes-manifest.test.mjs +++ b/scripts/tasknotes-manifest.test.mjs @@ -17,7 +17,12 @@ describe("TaskNotes mdbase manifest", () => { expect(manifest.notifications.criteria).toEqual([ { id: "task.reminder", - event: { id: "mdbase.runtime.timer.fired", version: "1.0.0" }, + event: { + id: "mdbase.runtime.timer.fired", + version: "1.0.0", + digest: + "sha256:41105be7a7abf33b31ced47e1e1965242236e40ccaea286b959b0a8c591f5642", + }, presentation: { title: "Task reminder", body: "Open TaskNotes to view your task.", @@ -28,7 +33,11 @@ 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.3" }, + expect.objectContaining({ + id: "tasknotes.task", + version: "0.3.0-rc.3", + digest: expect.stringMatching(/^sha256:[0-9a-f]{64}$/), + }), ]); expect(manifest.requirements.files).toEqual({ actions: ["list", "read", "add", "replace", "move", "delete"], diff --git a/scripts/write-manifest.mjs b/scripts/write-manifest.mjs index f301195..403d376 100644 --- a/scripts/write-manifest.mjs +++ b/scripts/write-manifest.mjs @@ -1,5 +1,6 @@ import { mkdir, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; +import { format } from "prettier"; import { buildTaskNotesManifest } from "./tasknotes-manifest.mjs"; import { buildAppTaskNotesResources } from "./tasknotes-resources.mjs"; @@ -31,7 +32,7 @@ const manifest = await buildTaskNotesManifest({ resources, }); -const serialized = `${JSON.stringify(manifest, null, 2)}\n`; +const serialized = await format(JSON.stringify(manifest), { parser: "json" }); await Promise.all( targets.map(async (target) => { await mkdir(resolve(target, ".."), { recursive: true }); diff --git a/src/app/definition-review-dialog.test.tsx b/src/app/definition-review-dialog.test.tsx new file mode 100644 index 0000000..b0d2ece --- /dev/null +++ b/src/app/definition-review-dialog.test.tsx @@ -0,0 +1,60 @@ +import { fireEvent, render, screen } from "@testing-library/react"; + +import { DefinitionReviewDialog } from "./definition-review-dialog"; + +describe("DefinitionReviewDialog", () => { + it("names the managed files and explains the portable receipt before adoption", () => { + const decide = vi.fn(); + render( + , + ); + + expect( + screen.getByRole("alertdialog", { + name: "Let TaskNotes manage these definitions?", + }), + ).toBeVisible(); + expect(screen.getByText("_contracts/tasknotes.task.md")).toBeVisible(); + fireEvent.click(screen.getByText("What “manage” means")); + expect(screen.getByText(/mdbase\.lock\.yaml/)).toBeVisible(); + fireEvent.click(screen.getByRole("button", { name: "Adopt and update" })); + expect(decide).toHaveBeenCalledWith(true); + }); + + it("keeps cancellation focused by default and supports Escape", () => { + const decide = vi.fn(); + render( + , + ); + + expect(screen.getByRole("button", { name: "Not now" })).toHaveFocus(); + fireEvent.keyDown(document, { key: "Escape" }); + expect(decide).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/app/definition-review-dialog.tsx b/src/app/definition-review-dialog.tsx new file mode 100644 index 0000000..bc01b51 --- /dev/null +++ b/src/app/definition-review-dialog.tsx @@ -0,0 +1,129 @@ +import { FileKey2, ShieldCheck } from "lucide-react"; +import { useEffect, useRef } from "react"; + +import type { ManagedTypeUpgradeRequest } from "../storage/collection"; +import type { DefinitionAdoptionRequest } from "../storage/local-type-pack"; + +export type DefinitionReview = + | { kind: "adoption"; request: DefinitionAdoptionRequest } + | { kind: "managed-upgrade"; request: ManagedTypeUpgradeRequest }; + +export function DefinitionReviewDialog({ + review, + decide, +}: { + review: DefinitionReview; + decide(approved: boolean): void; +}) { + const dialogRef = useRef(null); + const adoption = review.kind === "adoption" ? review.request : null; + const files = + review.kind === "adoption" + ? review.request.resources.map(({ path }) => path) + : [review.request.typePath]; + + useEffect(() => { + const dialog = dialogRef.current; + const initial = + dialog?.querySelector("[data-safe-action]"); + initial?.focus(); + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + decide(false); + return; + } + if (event.key !== "Tab" || !dialog) return; + const controls = [ + ...dialog.querySelectorAll( + "button:not([disabled]), summary, [href], [tabindex]:not([tabindex='-1'])", + ), + ]; + if (!controls.length) return; + const first = controls[0]!; + const last = controls.at(-1)!; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [decide]); + + const title = adoption + ? "Let TaskNotes manage these definitions?" + : "Update the TaskNotes task definition?"; + const action = adoption ? "Adopt and update" : "Update definition"; + + return ( +
+ + +
+ + + ); +} diff --git a/src/app/local-collection.tsx b/src/app/local-collection.tsx index 455f211..f9cb30b 100644 --- a/src/app/local-collection.tsx +++ b/src/app/local-collection.tsx @@ -1,8 +1,12 @@ -import { useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; import { IndexedMarkdownRepository } from "../storage/repository"; import type { CollectionChoice } from "./collection-context"; import { OpenedCollection } from "./opened-collection"; +import { + DefinitionReviewDialog, + type DefinitionReview, +} from "./definition-review-dialog"; export default function LocalCollection({ authorizeAnotherCloudCollection, @@ -19,17 +23,45 @@ export default function LocalCollection({ openCollectionPicker(): void; reauthorizeCurrentCloudCollection(): void; }) { - const repository = useMemo(() => new IndexedMarkdownRepository(), []); + const [review, setReview] = useState< + (DefinitionReview & { resolve(approved: boolean): void }) | null + >(null); + const requestReview = useCallback( + (next: DefinitionReview) => + new Promise((resolve) => setReview({ ...next, resolve })), + [], + ); + const repository = useMemo( + () => + new IndexedMarkdownRepository({ + approveDefinitionAdoption: (request) => + requestReview({ kind: "adoption", request }), + approveManagedTypeUpgrade: (request) => + requestReview({ kind: "managed-upgrade", request }), + }), + [requestReview], + ); + const decide = useCallback((approved: boolean) => { + setReview((current) => { + current?.resolve(approved); + return null; + }); + }, []); return ( - + <> + + {review ? ( + + ) : null} + ); } diff --git a/src/generated/mdbase-app.json b/src/generated/mdbase-app.json index 83721fc..aec5361 100644 --- a/src/generated/mdbase-app.json +++ b/src/generated/mdbase-app.json @@ -12,15 +12,14 @@ "contracts": [ { "id": "tasknotes.task", - "version": "0.3.0-rc.3" + "version": "0.3.0-rc.3", + "digest": "sha256:ef411577c2817b06ab2c8fcfe1241e005b7cd4355723b22ac14346462b5d0049" } ], "access": "full_collection", "files": { "actions": ["list", "read", "add", "replace", "move", "delete"], - "scope": { - "kind": "collection" - } + "scope": { "kind": "collection" } } }, "provisions": { @@ -29,30 +28,34 @@ "manifest": { "kind": "mdbase.type-pack", "id": "tasknotes.task", - "version": "0.3.0-rc.3", + "version": "0.3.0-rc.9", "name": "TaskNotes task", "description": "TaskNotes task contract, implementation, and referenced JSON Schemas.", "resources": [ { "kind": "contract", + "mode": "managed", "source": "contracts/tasknotes.task.md", "target": "_contracts/tasknotes.task.md", - "digest": "sha256:7f00349e4128b85067d715ad11e54b18244f9ee15d6dcfbc89989f334b095b93" + "digest": "sha256:1aa5c9f0f58ff5f220dd5bde02a07bf4ca1cb9ca5306647d8bcdc4bb2386632f" }, { "kind": "type", + "mode": "seed", "source": "types/task.md", "target": "_types/task.md", "digest": "sha256:dbfe2535df3e9bc4a3cfce0ee0e4ea3d01f7e66dd869f793f10db485f144f19e" }, { "kind": "schema", + "mode": "managed", "source": "schemas/tasknotes-task.schema.json", "target": "_schemas/tasknotes/tasknotes-task.schema.json", "digest": "sha256:3d01df62fc230dfa1cd5c290d685e285594370e17fa176bc06e48c49200d0dc8" }, { "kind": "schema", + "mode": "managed", "source": "schemas/tasknotes-task-binding.schema.json", "target": "_schemas/tasknotes/tasknotes-task-binding.schema.json", "digest": "sha256:ce592fe951504c82441476090901f19b224e9651e2b78d7da1a464ab470719c0" @@ -62,7 +65,7 @@ "resources": [ { "source": "contracts/tasknotes.task.md", - "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" + "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 value:\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 additionalProperties: false\n properties:\n id:\n type: string\n minLength: 1\n title:\n type: string\n minLength: 1\n status:\n type: string\n minLength: 1\n priority:\n type: string\n minLength: 1\n due:\n anyOf:\n - type: string\n format: date\n - type: string\n format: date-time\n scheduled:\n anyOf:\n - type: string\n format: date\n - type: string\n format: date-time\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 uniqueItems: true\n items:\n type: string\n minLength: 1\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 recurrenceAnchor:\n enum:\n - scheduled\n - completion\n recurrenceParent:\n type: string\n occurrenceDate:\n type: string\n format: date\n occurrenceMaterialization:\n enum:\n - manual\n - on_completion\n - rolling\n occurrenceNextTrigger:\n enum:\n - completion\n - completion_or_skip\n occurrenceTemplate:\n type: string\n occurrencePastHorizon:\n type: string\n occurrenceFutureHorizon:\n type: string\n tags:\n type: array\n items:\n type: string\n timeEntries:\n type: array\n items:\n type: object\n reminders:\n type: array\n items:\n type: object\n blockedBy:\n type: array\n items:\n type: object\n completeInstances:\n type: array\n items:\n type: string\n format: date\n skippedInstances:\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 sortOrder:\n type: string\nbinding_schema:\n dialect: json-schema-2020-12\n value:\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 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 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 title:\n $ref: \"#/$defs/titlePolicy\"\n status:\n $ref: \"#/$defs/statusPolicy\"\n priority:\n $ref: \"#/$defs/priorityPolicy\"\n runtime_timezone:\n type: string\n minLength: 1\n recurrence:\n $ref: \"#/$defs/recurrencePolicy\"\n occurrences:\n $ref: \"#/$defs/occurrencePolicy\"\n links:\n $ref: \"#/$defs/linkPolicy\"\n archive:\n $ref: \"#/$defs/archivePolicy\"\n time_tracking:\n $ref: \"#/$defs/timeTrackingPolicy\"\n templating:\n $ref: \"#/$defs/templatingPolicy\"\n nlp:\n $ref: \"#/$defs/nlpPolicy\"\n additionalProperties: false\n $defs:\n nonEmptyString:\n type: string\n minLength: 1\n stringSet:\n type: array\n uniqueItems: true\n items:\n $ref: \"#/$defs/nonEmptyString\"\n titlePolicy:\n type: object\n required:\n - storage\n properties:\n storage:\n enum:\n - filename\n - frontmatter\n filename_format:\n enum:\n - title\n - zettel\n - timestamp\n - uuid\n - custom\n custom_filename_template:\n $ref: \"#/$defs/nonEmptyString\"\n allOf:\n - if:\n properties:\n filename_format:\n const: custom\n required:\n - filename_format\n then:\n properties:\n custom_filename_template:\n $ref: \"#/$defs/nonEmptyString\"\n required:\n - custom_filename_template\n additionalProperties: true\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 properties:\n value:\n $ref: \"#/$defs/nonEmptyString\"\n label:\n type: string\n color:\n type: string\n icon:\n $ref: \"#/$defs/nonEmptyString\"\n is_completed:\n type: boolean\n is_skipped:\n type: boolean\n exclude_from_cycle:\n type: boolean\n next_status:\n $ref: \"#/$defs/nonEmptyString\"\n order:\n type: number\n auto_archive:\n type: boolean\n auto_archive_delay_minutes:\n type: number\n minimum: 0\n additionalProperties: true\n statusPolicy:\n type: object\n required:\n - values\n - default\n - completed_values\n - definitions\n properties:\n values:\n $ref: \"#/$defs/stringSet\"\n default:\n $ref: \"#/$defs/nonEmptyString\"\n completed_values:\n $ref: \"#/$defs/stringSet\"\n skipped_values:\n $ref: \"#/$defs/stringSet\"\n default_skipped:\n $ref: \"#/$defs/nonEmptyString\"\n definitions:\n type: array\n items:\n $ref: \"#/$defs/statusDefinition\"\n additionalProperties: false\n priorityDefinition:\n type: object\n required:\n - value\n - label\n - color\n - weight\n properties:\n value:\n $ref: \"#/$defs/nonEmptyString\"\n label:\n type: string\n color:\n type: string\n icon:\n $ref: \"#/$defs/nonEmptyString\"\n weight:\n type: number\n additionalProperties: true\n priorityPolicy:\n type: object\n required:\n - values\n - default\n - definitions\n properties:\n values:\n $ref: \"#/$defs/stringSet\"\n default:\n $ref: \"#/$defs/nonEmptyString\"\n definitions:\n type: array\n items:\n $ref: \"#/$defs/priorityDefinition\"\n additionalProperties: false\n recurrencePolicy:\n type: object\n required:\n - syntax\n - maintain_due_date_offset\n - reset_body_checkboxes\n properties:\n syntax:\n const: tasknotes\n maintain_due_date_offset:\n type: boolean\n reset_body_checkboxes:\n type: boolean\n additionalProperties: false\n occurrencePolicy:\n type: object\n required:\n - identity_roles\n - default_materialization\n - default_next_trigger\n properties:\n identity_roles:\n const:\n - recurrenceParent\n - occurrenceDate\n default_materialization:\n enum:\n - manual\n - on_completion\n - rolling\n default_next_trigger:\n enum:\n - completion\n - completion_or_skip\n past_horizon:\n $ref: \"#/$defs/nonEmptyString\"\n future_horizon:\n $ref: \"#/$defs/nonEmptyString\"\n additionalProperties: false\n linkPolicy:\n type: object\n required:\n - accepted_formats\n - write_format\n properties:\n accepted_formats:\n type: array\n minItems: 1\n uniqueItems: true\n items:\n enum:\n - wikilink\n - markdown\n write_format:\n enum:\n - wikilink\n - markdown\n additionalProperties: false\n archivePolicy:\n type: object\n required:\n - archived_tag\n - move_on_archive\n properties:\n archived_tag:\n $ref: \"#/$defs/nonEmptyString\"\n move_on_archive:\n type: boolean\n folder:\n $ref: \"#/$defs/nonEmptyString\"\n additionalProperties: false\n timeTrackingPolicy:\n type: object\n required:\n - auto_stop_on_complete\n properties:\n auto_stop_on_complete:\n type: boolean\n additionalProperties: false\n templatingPolicy:\n type: object\n required:\n - enabled\n - occurrence_enabled\n properties:\n enabled:\n type: boolean\n template_path:\n $ref: \"#/$defs/nonEmptyString\"\n occurrence_enabled:\n type: boolean\n occurrence_template_path:\n $ref: \"#/$defs/nonEmptyString\"\n failure_mode:\n enum:\n - error\n - warning_fallback\n unknown_variable_policy:\n enum:\n - preserve\n - empty\n additionalProperties: false\n nlpTrigger:\n type: object\n required:\n - property_id\n - trigger\n - enabled\n properties:\n property_id:\n $ref: \"#/$defs/nonEmptyString\"\n trigger:\n $ref: \"#/$defs/nonEmptyString\"\n enabled:\n type: boolean\n additionalProperties: false\n nlpPolicy:\n type: object\n required:\n - triggers\n properties:\n triggers:\n type: array\n items:\n $ref: \"#/$defs/nlpTrigger\"\n additionalProperties: false\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", @@ -80,7 +83,8 @@ "provides": [ { "id": "tasknotes.task", - "version": "0.3.0-rc.3" + "version": "0.3.0-rc.3", + "digest": "sha256:ef411577c2817b06ab2c8fcfe1241e005b7cd4355723b22ac14346462b5d0049" } ] } @@ -92,7 +96,8 @@ "id": "task.reminder", "event": { "id": "mdbase.runtime.timer.fired", - "version": "1.0.0" + "version": "1.0.0", + "digest": "sha256:41105be7a7abf33b31ced47e1e1965242236e40ccaea286b959b0a8c591f5642" }, "presentation": { "title": "Task reminder", diff --git a/src/storage/cloud-repository.test.ts b/src/storage/cloud-repository.test.ts index d2a332e..9846b85 100644 --- a/src/storage/cloud-repository.test.ts +++ b/src/storage/cloud-repository.test.ts @@ -7,7 +7,10 @@ import type { SyncCollectionResources, } from "@mdbase-dev/connect-protocol"; import { MemoryAuthority, SyncError } from "@mdbase-dev/connect-sync"; -import { buildTaskNotesMdbaseResources } from "@tasknotes/model/mdbase"; +import { + buildTaskNotesMdbaseResources, + TASKNOTES_CONTRACT_DIGEST, +} from "@tasknotes/model/mdbase"; import { describe, expect, it, vi } from "vitest"; import { todayString } from "../domain/task"; @@ -52,7 +55,7 @@ function resources(): SyncCollectionResources { id: "tasknotes.task", contract_type: "record", version: "0.3.0-rc.3", - digest: `sha256:${"0".repeat(64)}`, + digest: TASKNOTES_CONTRACT_DIGEST, schema: generated.taskSchema, binding_schema: generated.bindingSchema, implementations: [ diff --git a/src/storage/collection.ts b/src/storage/collection.ts index 28b8368..391b967 100644 --- a/src/storage/collection.ts +++ b/src/storage/collection.ts @@ -2,7 +2,11 @@ import { parseFrontmatter, serializeMarkdownDocument, } from "@tasknotes/model/frontmatter"; -import { buildTaskNotesMdbaseResources } from "@tasknotes/model/mdbase"; +import { + buildTaskNotesMdbaseResources, + buildTaskNotesMdbaseTypePack, + type TaskNotesMdbaseOptions, +} from "@tasknotes/model/mdbase"; import { TASKNOTES_SPEC_VERSION } from "@tasknotes/model/types"; import { patchTaskNotesMdbaseTypeSettings } from "@tasknotes/model/mdbase"; import { parseDocument } from "yaml"; @@ -44,6 +48,10 @@ import type { import type { Vault, VaultEntry } from "./vault"; import { isBinaryVault } from "./vault-contract"; import { VaultCollectionFileStore } from "./vault-files"; +import { + applyLocalTypePack, + type DefinitionAdoptionRequest, +} from "./local-type-pack"; 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; @@ -94,6 +102,9 @@ export class MarkdownCollection { approveManagedTypeUpgrade?: ( request: ManagedTypeUpgradeRequest, ) => boolean | Promise; + approveDefinitionAdoption?: ( + request: DefinitionAdoptionRequest, + ) => boolean | Promise; } = {}, ) {} @@ -104,7 +115,7 @@ export class MarkdownCollection { await this.hydrateAuthorityState(); this.assertAuthorityWritable(options.authorityAdoptionId); const defaults = defaultTaskCollectionConfiguration(); - const resources = buildTaskNotesMdbaseResources({ + const resourceOptions: TaskNotesMdbaseOptions = { profiles: ["core-lite", "recurrence", "materialized-occurrences"], modelConfig: { ...defaults, @@ -124,9 +135,39 @@ export class MarkdownCollection { }, ], }, - }); + }; + const initialResources = buildTaskNotesMdbaseResources(resourceOptions); + await this.vault.ensureText( + initialResources.paths.config, + initialResources.configDocument, + ); + await this.ensureViewConfiguration(initialResources.paths.config); + const definitionFolders = await this.readDefinitionFolders( + initialResources.paths.config, + ); + this.typesFolder = definitionFolders.types; + this.contractsFolder = definitionFolders.contracts; + const resources = buildTaskNotesMdbaseResources(resourceOptions); const generatedType = structuredClone(resources.type); + const taskSchema = structuredClone(resources.taskSchema); const implementation = taskNotesImplementation(generatedType); + const taskDateSchema = { + anyOf: [ + { type: "string", format: "date" }, + { type: "string", format: "date-time" }, + ], + }; + const fields = implementation.fields as Record; + const generatedSchema = ( + generatedType.schema as { + value: { properties: Record }; + } + ).value; + generatedSchema.properties[fields.due] = taskDateSchema; + generatedSchema.properties[fields.scheduled] = taskDateSchema; + (taskSchema.properties as Record).due = taskDateSchema; + (taskSchema.properties as Record).scheduled = + taskDateSchema; const extension = implementation.binding as Record; extension.status = { ...(extension.status as Record), @@ -140,37 +181,25 @@ export class MarkdownCollection { past_horizon: defaults.occurrences.pastHorizon, future_horizon: defaults.occurrences.futureHorizon, }; - const generated = parseFrontmatter(resources.typeDocument); - await this.vault.ensureText( - resources.paths.config, - resources.configDocument, - ); - await Promise.all([ - this.vault.ensureText( - resources.paths.contract, - resources.contractDocument, - ), - this.vault.ensureText( - resources.paths.taskSchema, - resources.taskSchemaDocument, - ), - this.vault.ensureText( - resources.paths.bindingSchema, - resources.bindingSchemaDocument, - ), - ]); - await this.ensureViewConfiguration(resources.paths.config); - const definitionFolders = await this.readDefinitionFolders( - resources.paths.config, + const typeDocument = serializeMarkdownDocument( + generatedType, + `# Task\n\nTask records live under \`${resources.paths.records}/\`.\n`, ); - this.typesFolder = definitionFolders.types; - this.contractsFolder = definitionFolders.contracts; - const existingTypes = await this.listTypeFiles(this.typesFolder); - if (!existingTypes.length) - await this.vault.ensureText( - `${this.typesFolder}/task.md`, - serializeMarkdownDocument(generatedType, generated.body), - ); + const pack = await buildTaskNotesMdbaseTypePack({ + ...resources, + type: generatedType, + typeDocument, + taskSchema, + taskSchemaDocument: `${JSON.stringify(taskSchema, null, 2)}\n`, + }); + await applyLocalTypePack(this.vault, pack, { + installedBy: "dev.tasknotes.app", + approveAdoption: this.options.approveDefinitionAdoption, + targetOverrides: { + [resources.paths.type]: `${this.typesFolder}/task.md`, + [resources.paths.contract]: `${this.contractsFolder}/tasknotes.task.md`, + }, + }); await this.refreshConfiguration(); } diff --git a/src/storage/local-type-pack.test.ts b/src/storage/local-type-pack.test.ts new file mode 100644 index 0000000..0e16028 --- /dev/null +++ b/src/storage/local-type-pack.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildTaskNotesMdbaseResources, + buildTaskNotesMdbaseTypePack, + type TaskNotesMdbaseTypePack, +} from "@tasknotes/model/mdbase"; + +import { MemoryVault } from "../test/memory-vault"; +import { + applyLocalTypePack, + type DefinitionAdoptionRequest, +} from "./local-type-pack"; + +describe("local type-pack lifecycle", () => { + it("treats the browser's OPFS NotFoundError as an absent definition", async () => { + const vault = new MemoryVault(); + const readText = vault.readText.bind(vault); + vi.spyOn(vault, "readText").mockImplementation(async (path) => { + try { + return await readText(path); + } catch { + throw new DOMException( + "A requested file or directory could not be found at the time an operation was processed.", + "NotFoundError", + ); + } + }); + + await expect( + applyLocalTypePack(vault, await taskPack(), { + installedBy: "dev.tasknotes.app", + }), + ).resolves.toBeUndefined(); + expect(await vault.readText("_contracts/tasknotes.task.md")).toContain( + "kind: mdbase.contract", + ); + }); + + it("installs managed definitions, seeds the user-owned type, and records provenance", async () => { + const vault = new MemoryVault(); + const pack = await taskPack(); + + await applyLocalTypePack(vault, pack, { + installedBy: "dev.tasknotes.app", + }); + + expect(await vault.readText("_contracts/tasknotes.task.md")).toBe( + resource(pack, "_contracts/tasknotes.task.md"), + ); + expect(await vault.readText("_types/task.md")).toBe( + resource(pack, "_types/task.md"), + ); + const lock = JSON.parse(await vault.readText("mdbase.lock.yaml")); + expect(lock.packs[0]).toMatchObject({ + id: "tasknotes.task", + installed_by: "dev.tasknotes.app", + }); + expect( + lock.packs[0].resources.find( + ({ target }: { target: string }) => target === "_types/task.md", + ), + ).toMatchObject({ + mode: "seed", + source: "types/task.md", + }); + }); + + it("keeps the canonical pack identity while resolving collection-specific definition folders", async () => { + const vault = new MemoryVault(); + const pack = await taskPack(); + + await applyLocalTypePack(vault, pack, { + installedBy: "dev.tasknotes.app", + targetOverrides: { + "_contracts/tasknotes.task.md": + "definitions/contracts/tasknotes.task.md", + "_types/task.md": "definitions/types/task.md", + }, + }); + + expect( + await vault.readText("definitions/contracts/tasknotes.task.md"), + ).toBe(resource(pack, "_contracts/tasknotes.task.md")); + expect(await vault.readText("definitions/types/task.md")).toBe( + resource(pack, "_types/task.md"), + ); + const lock = JSON.parse(await vault.readText("mdbase.lock.yaml")); + expect(lock.packs[0].digest).toBe( + await sha256(canonicalJson(pack.manifest)), + ); + expect(lock.packs[0].resources).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + source: "contracts/tasknotes.task.md", + target: "definitions/contracts/tasknotes.task.md", + }), + ]), + ); + }); + + it("upgrades clean managed resources while preserving a customized seed", async () => { + const vault = new MemoryVault(); + const first = await taskPack(); + await applyLocalTypePack(vault, first, { + installedBy: "dev.tasknotes.app", + }); + await vault.writeText("_types/task.md", "A user-owned task type.\n"); + const next = await revisedPack(first); + + await applyLocalTypePack(vault, next, { + installedBy: "dev.tasknotes.app", + }); + + expect(await vault.readText("_types/task.md")).toBe( + "A user-owned task type.\n", + ); + expect(await vault.readText("_contracts/tasknotes.task.md")).toBe( + resource(next, "_contracts/tasknotes.task.md"), + ); + }); + + it("never overwrites a managed definition changed after installation", async () => { + const vault = new MemoryVault(); + const first = await taskPack(); + await applyLocalTypePack(vault, first, { + installedBy: "dev.tasknotes.app", + }); + const changed = `${await vault.readText("_contracts/tasknotes.task.md")}User edit.\n`; + await vault.writeText("_contracts/tasknotes.task.md", changed); + + await expect( + applyLocalTypePack(vault, await revisedPack(first), { + installedBy: "dev.tasknotes.app", + approveAdoption: () => true, + }), + ).rejects.toThrow("changed after"); + expect(await vault.readText("_contracts/tasknotes.task.md")).toBe(changed); + }); + + it("requires explicit review to adopt differing unmanaged definitions", async () => { + const vault = new MemoryVault(); + const pack = await taskPack(); + await vault.writeText("_contracts/tasknotes.task.md", "Older contract.\n"); + const approve = vi.fn( + (request: DefinitionAdoptionRequest) => request.resources.length > 0, + ); + + await applyLocalTypePack(vault, pack, { + installedBy: "dev.tasknotes.app", + approveAdoption: approve, + }); + + expect(approve).toHaveBeenCalledOnce(); + expect(approve.mock.calls[0]?.[0].resources).toEqual([ + expect.objectContaining({ path: "_contracts/tasknotes.task.md" }), + ]); + expect(await vault.readText("_contracts/tasknotes.task.md")).toBe( + resource(pack, "_contracts/tasknotes.task.md"), + ); + }); +}); + +async function taskPack(): Promise { + return buildTaskNotesMdbaseTypePack(buildTaskNotesMdbaseResources()); +} + +async function revisedPack( + source: TaskNotesMdbaseTypePack, +): Promise { + const pack = structuredClone(source); + pack.manifest.version = "0.3.0-rc.10"; + const contract = pack.resources.find( + (candidate) => candidate.source === "contracts/tasknotes.task.md", + ); + if (!contract) throw new Error("Missing test contract."); + contract.document += "\nUpdated package documentation.\n"; + const declaration = pack.manifest.resources.find( + (candidate) => candidate.source === contract.source, + ); + if (!declaration) throw new Error("Missing test declaration."); + declaration.digest = await sha256(contract.document); + return pack; +} + +function resource(pack: TaskNotesMdbaseTypePack, target: string): string { + const declaration = pack.manifest.resources.find( + (candidate) => candidate.target === target, + ); + const value = pack.resources.find( + (candidate) => candidate.source === declaration?.source, + ); + if (!value) throw new Error(`Missing test resource ${target}.`); + return value.document; +} + +async function sha256(value: string): Promise { + const bytes = new TextEncoder().encode(value); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return `sha256:${Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join("")}`; +} + +function canonicalJson(value: unknown): string { + if (value === null || typeof value === "boolean" || typeof value === "string") + return JSON.stringify(value); + if (typeof value === "number") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + const object = value as Record; + return `{${Object.keys(object) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`) + .join(",")}}`; +} diff --git a/src/storage/local-type-pack.ts b/src/storage/local-type-pack.ts new file mode 100644 index 0000000..d62d1be --- /dev/null +++ b/src/storage/local-type-pack.ts @@ -0,0 +1,342 @@ +import type { TaskNotesMdbaseTypePack } from "@tasknotes/model/mdbase"; +import { parse as parseYaml } from "yaml"; + +import type { Vault } from "./vault"; + +const LOCK_PATH = "mdbase.lock.yaml"; +const JOURNAL_PATH = ".mdbase/type-pack-transaction.json"; + +interface ReceiptResource { + kind: "contract" | "type" | "schema"; + mode: "managed" | "seed"; + source: string; + target: string; + digest: string; +} + +interface Receipt { + id: string; + version: string; + digest: string; + installed_by: string; + resources: ReceiptResource[]; +} + +interface LockFile { + kind: "mdbase.type-pack-lock"; + lock_version: 1; + packs: Receipt[]; +} + +interface Mutation { + target: string; + before: string | null; + after: string | null; +} + +interface Journal { + kind: "tasknotes.type-pack-transaction"; + version: 1; + mutations: Mutation[]; +} + +export interface DefinitionAdoptionResource { + path: string; + currentDigest: string; + desiredDigest: string; +} + +export interface DefinitionAdoptionRequest { + packId: string; + desiredVersion: string; + resources: DefinitionAdoptionResource[]; + message: string; +} + +/** + * Apply the same managed/seed ownership model used by mdbase authorities to a + * mobile Vault. The portable lock is committed last through a roll-forward + * journal, so an interrupted native write can be completed safely on restart. + */ +export async function applyLocalTypePack( + vault: Vault, + provision: TaskNotesMdbaseTypePack, + options: { + installedBy: string; + approveAdoption?: ( + request: DefinitionAdoptionRequest, + ) => boolean | Promise; + targetOverrides?: Record; + }, +): Promise { + await recoverJournal(vault); + const lockSource = await readOptional(vault, LOCK_PATH); + const lock = parseLock(lockSource); + const current = lock.packs.find( + (receipt) => receipt.id === provision.manifest.id, + ); + const overrides = options.targetOverrides ?? {}; + for (const target of Object.keys(overrides)) { + if ( + !provision.manifest.resources.some( + (resource) => resource.target === target, + ) + ) + throw new Error(`${target} is not a target in ${provision.manifest.id}.`); + } + const definitions = provision.manifest.resources.map((resource) => ({ + ...resource, + target: overrides[resource.target] ?? resource.target, + })); + if ( + new Set(definitions.map((resource) => resource.target)).size !== + definitions.length + ) + throw new Error( + "TaskNotes definition target overrides resolve to the same path.", + ); + const desired: Receipt = { + id: provision.manifest.id, + version: provision.manifest.version, + digest: await digest(canonicalJson(provision.manifest)), + installed_by: current?.installed_by ?? options.installedBy, + resources: definitions.map( + ({ kind, mode, source, target, digest: resourceDigest }) => ({ + kind, + mode, + source, + target, + digest: resourceDigest, + }), + ), + }; + if ( + current && + current.version === desired.version && + current.digest !== desired.digest + ) { + throw new Error( + `${desired.id} ${desired.version} changed without a pack version change. ` + + "Publish a new pack version before updating this collection.", + ); + } + + const documents = new Map( + provision.resources.map((resource) => [resource.source, resource.document]), + ); + const currentResources = new Map( + current?.resources.map((resource) => [resource.source, resource]) ?? [], + ); + const otherOwners = new Map( + lock.packs + .filter((receipt) => receipt.id !== desired.id) + .flatMap((receipt) => + receipt.resources + .filter((resource) => resource.mode === "managed") + .map((resource) => [resource.target, receipt.id] as const), + ), + ); + const mutations: Mutation[] = []; + const adoption: DefinitionAdoptionResource[] = []; + + for (const definition of definitions) { + const document = documents.get(definition.source); + if (document === undefined) + throw new Error(`The type pack is missing ${definition.source}.`); + if ((await digest(document)) !== definition.digest) + throw new Error( + `The type pack digest for ${definition.source} is invalid.`, + ); + const before = await readOptional(vault, definition.target); + const beforeDigest = before === null ? null : await digest(before); + const prior = currentResources.get(definition.source); + const installed = prior?.target === definition.target ? prior : undefined; + const otherOwner = otherOwners.get(definition.target); + if (otherOwner) + throw new Error( + `${definition.target} is already managed by ${otherOwner}.`, + ); + if (definition.mode === "seed") { + if (before === null && !prior) + mutations.push({ target: definition.target, before, after: document }); + continue; + } + if (installed) { + if (installed.mode !== "managed") + throw new Error( + `${definition.target} was installed as a seed and cannot be claimed automatically.`, + ); + if (beforeDigest !== installed.digest) + throw new Error( + `${definition.target} changed after ${current?.id} ${current?.version} was applied. ` + + "TaskNotes left it untouched; restore it or move your customization to the task type.", + ); + if (beforeDigest !== definition.digest) + mutations.push({ target: definition.target, before, after: document }); + continue; + } + if (before === null) { + mutations.push({ target: definition.target, before, after: document }); + } else if (beforeDigest && beforeDigest !== definition.digest) { + adoption.push({ + path: definition.target, + currentDigest: beforeDigest, + desiredDigest: definition.digest, + }); + mutations.push({ target: definition.target, before, after: document }); + } + } + + const desiredBySource = new Map( + definitions.map((definition) => [definition.source, definition]), + ); + for (const installed of current?.resources ?? []) { + if (desiredBySource.get(installed.source)?.target === installed.target) + continue; + if (installed.mode === "seed") continue; + const before = await readOptional(vault, installed.target); + const beforeDigest = before === null ? null : await digest(before); + if (beforeDigest !== installed.digest) + throw new Error( + `${installed.target} changed and cannot be relocated safely. ` + + "TaskNotes left it untouched; restore the managed definition before changing definition folders.", + ); + mutations.push({ target: installed.target, before, after: null }); + } + + if (adoption.length) { + const approved = await options.approveAdoption?.({ + packId: desired.id, + desiredVersion: desired.version, + resources: adoption, + message: + "TaskNotes found older unmanaged contract definitions. Review and adopt them so future definition upgrades remain safe and automatic.", + }); + if (!approved) + throw new Error( + "TaskNotes needs approval to adopt and update the collection's older contract definitions.", + ); + } + + const nextLock: LockFile = { + kind: "mdbase.type-pack-lock", + lock_version: 1, + packs: [ + ...lock.packs.filter((receipt) => receipt.id !== desired.id), + desired, + ].sort((left, right) => left.id.localeCompare(right.id)), + }; + const nextLockSource = `${JSON.stringify(nextLock, null, 2)}\n`; + if (lockSource !== nextLockSource) + mutations.push({ + target: LOCK_PATH, + before: lockSource, + after: nextLockSource, + }); + if (!mutations.length) return; + await commit(vault, mutations); +} + +async function commit(vault: Vault, mutations: Mutation[]): Promise { + const journal: Journal = { + kind: "tasknotes.type-pack-transaction", + version: 1, + mutations, + }; + await vault.writeText(JOURNAL_PATH, `${JSON.stringify(journal, null, 2)}\n`); + await rollForward(vault, journal); + await vault.delete(JOURNAL_PATH); +} + +async function recoverJournal(vault: Vault): Promise { + const source = await readOptional(vault, JOURNAL_PATH); + if (source === null) return; + const journal = JSON.parse(source) as Partial; + if ( + journal.kind !== "tasknotes.type-pack-transaction" || + journal.version !== 1 || + !Array.isArray(journal.mutations) + ) + throw new Error( + "The interrupted type-pack transaction journal is invalid.", + ); + await rollForward(vault, journal as Journal); + await vault.delete(JOURNAL_PATH); +} + +async function rollForward(vault: Vault, journal: Journal): Promise { + for (const mutation of journal.mutations) { + const current = await readOptional(vault, mutation.target); + if (current !== mutation.before && current !== mutation.after) + throw new Error( + `${mutation.target} changed during an interrupted definition update. ` + + "TaskNotes stopped before overwriting the unexpected content.", + ); + } + for (const mutation of journal.mutations) { + if (mutation.after === null) await vault.delete(mutation.target); + else await vault.writeText(mutation.target, mutation.after); + } +} + +function parseLock(source: string | null): LockFile { + if (source === null) + return { kind: "mdbase.type-pack-lock", lock_version: 1, packs: [] }; + const value = parseYaml(source) as Partial | null; + if ( + !value || + value.kind !== "mdbase.type-pack-lock" || + value.lock_version !== 1 || + !Array.isArray(value.packs) + ) + throw new Error(`${LOCK_PATH} is not a valid type-pack lock.`); + return value as LockFile; +} + +async function readOptional( + vault: Vault, + path: string, +): Promise { + try { + return await vault.readText(path); + } catch (error) { + if (isMissingPath(error)) return null; + throw error; + } +} + +function isMissingPath(error: unknown): boolean { + if (error instanceof DOMException && error.name === "NotFoundError") + return true; + const message = error instanceof Error ? error.message : String(error); + return /not found|could not be found|does not exist|missing|enoent/i.test( + message, + ); +} + +async function digest(value: string): Promise { + const bytes = new TextEncoder().encode(value); + const hash = await globalThis.crypto.subtle.digest("SHA-256", bytes); + return `sha256:${Array.from(new Uint8Array(hash), (byte) => + byte.toString(16).padStart(2, "0"), + ).join("")}`; +} + +function canonicalJson(value: unknown): string { + if (value === null || typeof value === "boolean" || typeof value === "string") + return JSON.stringify(value); + if (typeof value === "number") { + if (!Number.isFinite(value)) + throw new Error("A type pack contains a non-finite number."); + return JSON.stringify(value); + } + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (typeof value === "object") { + const object = value as Record; + return `{${Object.keys(object) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`) + .join(",")}}`; + } + throw new Error("A type pack contains a non-JSON value."); +} diff --git a/src/storage/relay-repository.test.ts b/src/storage/relay-repository.test.ts index b4d5b76..7d91263 100644 --- a/src/storage/relay-repository.test.ts +++ b/src/storage/relay-repository.test.ts @@ -8,7 +8,10 @@ import { type QueryRecord, type QueryResult, } from "@mdbase-dev/connect"; -import { buildTaskNotesMdbaseResources } from "@tasknotes/model/mdbase"; +import { + buildTaskNotesMdbaseResources, + TASKNOTES_CONTRACT_DIGEST, +} from "@tasknotes/model/mdbase"; import { describe, expect, it, vi } from "vitest"; import { todayString } from "../domain/task"; @@ -1114,7 +1117,7 @@ function description( id: "tasknotes.task", contract_type: "record", version: "0.3.0-rc.3", - digest: `sha256:${"0".repeat(64)}`, + digest: TASKNOTES_CONTRACT_DIGEST, schema: generated.taskSchema, binding_schema: generated.bindingSchema, implementations: [ diff --git a/src/storage/repository.ts b/src/storage/repository.ts index 468485c..aec00b6 100644 --- a/src/storage/repository.ts +++ b/src/storage/repository.ts @@ -6,7 +6,12 @@ import { withoutIndexFields, type IndexedTask, } from "./index"; -import { batches, MarkdownCollection } from "./collection"; +import { + batches, + MarkdownCollection, + type ManagedTypeUpgradeRequest, +} from "./collection"; +import type { DefinitionAdoptionRequest } from "./local-type-pack"; import { createLocalVault } from "./vault"; import { LocalViewExecutor } from "./local-views"; import { VaultCollectionFileStore } from "./vault-files"; @@ -111,15 +116,22 @@ export class IndexedMarkdownRepository implements TaskRepository { private forceProjectionReindex = false; constructor( - options: { collection?: MarkdownCollection; index?: TaskIndex } = {}, + options: { + collection?: MarkdownCollection; + index?: TaskIndex; + approveManagedTypeUpgrade?: ( + request: ManagedTypeUpgradeRequest, + ) => boolean | Promise; + approveDefinitionAdoption?: ( + request: DefinitionAdoptionRequest, + ) => boolean | Promise; + } = {}, ) { this.collection = options.collection ?? new MarkdownCollection(createLocalVault(), { - approveManagedTypeUpgrade: ({ message }) => - typeof globalThis.confirm === "function" - ? globalThis.confirm(message) - : false, + approveManagedTypeUpgrade: options.approveManagedTypeUpgrade, + approveDefinitionAdoption: options.approveDefinitionAdoption, }); this.index = options.index ?? new TaskIndex(indexName(this.collection.identifier())); diff --git a/src/storage/tasknotes-collection.ts b/src/storage/tasknotes-collection.ts index 135cf09..a26d8c7 100644 --- a/src/storage/tasknotes-collection.ts +++ b/src/storage/tasknotes-collection.ts @@ -4,6 +4,7 @@ import type { JsonObject, } from "@mdbase-dev/connect-protocol"; import { TASKNOTES_SPEC_VERSION } from "@tasknotes/model/types"; +import { TASKNOTES_CONTRACT_DIGEST } from "@tasknotes/model/mdbase"; import { TaskNotesTaskModel } from "../domain/tasknotes-model"; import { resolveTaskCollectionConfiguration } from "../domain/task-configuration"; @@ -29,7 +30,8 @@ export function resolveTaskCollection( const contract = resources.contracts.find( (candidate) => candidate.id === "tasknotes.task" && - candidate.version === TASKNOTES_SPEC_VERSION, + candidate.version === TASKNOTES_SPEC_VERSION && + candidate.digest === TASKNOTES_CONTRACT_DIGEST, ); if (!contract) throw new Error( diff --git a/src/storage/vault-opfs.ts b/src/storage/vault-opfs.ts index 543087a..9abd8eb 100644 --- a/src/storage/vault-opfs.ts +++ b/src/storage/vault-opfs.ts @@ -116,8 +116,12 @@ export class OpfsVault implements Vault { async delete(path: string): Promise { const segments = safePath(path).split("/"); const name = segments.pop()!; - const parent = await this.directory(segments.join("/"), false); - await parent.removeEntry(name).catch(ignoreMissing); + try { + const parent = await this.directory(segments.join("/"), false); + await parent.removeEntry(name); + } catch (error) { + ignoreMissing(error); + } } async rename(from: string, to: string): Promise { diff --git a/src/styles.css b/src/styles.css index 2b70a8a..ff6873f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -155,7 +155,7 @@ button:disabled { padding: 8px 12px; color: var(--ink); background: var(--paper-raised); - border: 1px solid var(--line-strong); + border: 0; border-radius: 6px; transform: translateY(-160%); } @@ -604,7 +604,7 @@ button:disabled { color: var(--ink); background: var(--paper-raised); border: 1px solid var(--line-strong); - border-radius: 10px; + border-radius: 6px; box-shadow: 0 16px 36px var(--color-shadow); } @@ -3361,6 +3361,181 @@ button:disabled { overflow-wrap: anywhere; } +.task-delete-layer, +.task-delete-scrim { + position: fixed; + z-index: 60; + inset: 0; +} + +.task-delete-layer { + display: grid; + place-items: center; + padding: 20px; +} + +.task-delete-scrim { + z-index: 0; + width: 100%; + padding: 0; + background: var(--color-scrim); + border: 0; +} + +.task-delete-dialog { + position: relative; + z-index: 1; + width: min(100%, 390px); + padding: 24px; + background: var(--paper-raised); + border: 1px solid var(--line-strong); + border-radius: 9px; + box-shadow: 0 18px 52px var(--color-shadow); +} + +.task-delete-dialog h2, +.task-delete-dialog p { + margin: 0; +} + +.task-delete-dialog h2 { + font-size: 1.08rem; +} + +.task-delete-dialog p { + margin-top: 7px; + color: var(--ink-soft); + line-height: 1.45; +} + +.task-delete-dialog > div { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 22px; +} + +.definition-review-layer, +.definition-review-scrim { + position: fixed; + z-index: 70; + inset: 0; +} + +.definition-review-layer { + display: grid; + place-items: center; + padding: 20px; +} + +.definition-review-scrim { + z-index: 0; + width: 100%; + padding: 0; + background: var(--color-scrim); + border: 0; +} + +.definition-review-dialog { + position: relative; + z-index: 1; + width: min(100%, 540px); + max-height: min(720px, calc(100dvh - 40px)); + overflow: auto; + padding: 26px; + background: var(--paper-raised); + border: 0; + border-radius: 12px; + box-shadow: 0 20px 58px var(--color-shadow); +} + +.definition-review-heading { + display: flex; + align-items: center; + gap: 11px; +} + +.definition-review-heading > span { + display: grid; + flex: 0 0 34px; + width: 34px; + height: 34px; + place-items: center; + color: var(--accent); + background: var(--accent-soft); + border-radius: 6px; +} + +.definition-review-dialog h2, +.definition-review-dialog p { + margin: 0; +} + +.definition-review-dialog h2 { + font-size: 20px; + letter-spacing: -0.015em; +} + +.definition-review-dialog > p { + max-width: 68ch; + margin-top: 12px; + color: var(--ink-soft); + line-height: 1.5; +} + +.definition-review-files { + display: grid; + gap: 1px; + margin-top: 20px; + overflow: hidden; + background: var(--line); + border: 1px solid var(--line); + border-radius: 12px; +} + +.definition-review-files > div { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + padding: 11px 13px; + color: var(--ink-soft); + background: var(--paper); +} + +.definition-review-files code { + overflow: hidden; + color: var(--ink); + font-size: 14px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.definition-review-explanation { + margin-top: 18px; + color: var(--ink-soft); + font-size: 14px; +} + +.definition-review-explanation summary { + width: fit-content; + color: var(--ink); + cursor: pointer; + font-weight: 650; +} + +.definition-review-explanation p { + margin-top: 9px; + line-height: 1.5; +} + +.definition-review-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 24px; +} + .occurrence-banner { display: flex; align-items: center; diff --git a/vendor/README.md b/vendor/README.md index 542185f..ca7fc6b 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -1,18 +1,25 @@ # Vendored packages -These remaining tarballs keep unreleased TaskNotes and mdbase packages -installable without sibling repositories. Published packages, including the -Connect SDK and `obsidian-bases-expression`, are installed from npm instead. +These tarballs keep coordinated prerelease packages installable without sibling +repositories or an unpublished registry release. The revision-stamped mdbase +and Connect SDK artifacts have adjacent JSON provenance manifests containing +their source commit, byte size, and SHA-512 digest. Current snapshots were packed from sibling checkouts after their full test suites passed: -- `../tasknotes-model` (`0.3.0-rc.6`, with string-ranked manual ordering) +- `../tasknotes-model` (`0.3.0-rc.9`) - `../tasknotes-nlp-core` (`62a0d9d`, including wikilink-safe parsing) - `../tasknotes-spec` - `../mdbase` +- `../mdbase-connect` -Refresh a snapshot with `pnpm pack --pack-destination -/path/to/tasknotes-app/vendor` from the relevant package directory, update its -`file:` reference, and run `pnpm install`. Replace each remaining snapshot with -an exact registry version once that release is published. +Refresh the core or Connect SDK snapshots from their source worktrees with: + +```sh +npm run package:consumer -- --destination /path/to/tasknotes-app/vendor +pnpm package:consumer --destination /path/to/tasknotes-app/vendor +``` + +Update the corresponding `file:` references and run `pnpm install`. Replace +each snapshot with an exact registry version once that release is published. diff --git a/vendor/callumalpass-mdbase-0.3.0-rc.4-4f28ea27a15a.tgz b/vendor/callumalpass-mdbase-0.3.0-rc.4-4f28ea27a15a.tgz new file mode 100644 index 0000000..c985acc Binary files /dev/null and b/vendor/callumalpass-mdbase-0.3.0-rc.4-4f28ea27a15a.tgz differ diff --git a/vendor/callumalpass-mdbase-0.3.0-rc.4.tgz b/vendor/callumalpass-mdbase-0.3.0-rc.4.tgz deleted file mode 100644 index 5d421e9..0000000 Binary files a/vendor/callumalpass-mdbase-0.3.0-rc.4.tgz and /dev/null differ diff --git a/vendor/mdbase-connect-sdk.json b/vendor/mdbase-connect-sdk.json new file mode 100644 index 0000000..9a211fd --- /dev/null +++ b/vendor/mdbase-connect-sdk.json @@ -0,0 +1,25 @@ +{ + "schema_version": 1, + "repository": "https://github.com/mdbase-dev/mdbase-connect", + "revision": "33890fcd13469f7e8458c9371d18a612fecf7317", + "artifacts": [ + { + "package": "connect", + "file": "mdbase-dev-connect-0.1.0-beta.27-33890fcd1346.tgz", + "bytes": 113795, + "sha512": "tRUifjtTupf3P96TSqJfPnXNS4YxLLQ/5blhJ/vz2ERqG/Fmrb/FhgMZgvWGiiK71MB/mmjNX64BowkmwAzLKA==" + }, + { + "package": "protocol", + "file": "mdbase-dev-connect-protocol-0.1.0-beta.27-33890fcd1346.tgz", + "bytes": 39064, + "sha512": "WcqdnFW3ozKRUxFTLypyv6Db1UIcbNJyAyuiR/vA3bokOLLmTkdcxyA+IuY6X9IM0rt+PLztFT4ZFYZQfHqxeA==" + }, + { + "package": "sync", + "file": "mdbase-dev-connect-sync-0.1.0-beta.27-33890fcd1346.tgz", + "bytes": 81010, + "sha512": "Ti0DcCQvjPI7V715u5wJXrdKsvnmDqFKUBquPZqgOJINJTPo54p5I60PjduL7gYOmtR57KBQm2tsmTGSozrs0A==" + } + ] +} diff --git a/vendor/mdbase-dev-connect-0.1.0-beta.27-33890fcd1346.tgz b/vendor/mdbase-dev-connect-0.1.0-beta.27-33890fcd1346.tgz new file mode 100644 index 0000000..edd4054 Binary files /dev/null and b/vendor/mdbase-dev-connect-0.1.0-beta.27-33890fcd1346.tgz differ diff --git a/vendor/mdbase-dev-connect-protocol-0.1.0-beta.27-33890fcd1346.tgz b/vendor/mdbase-dev-connect-protocol-0.1.0-beta.27-33890fcd1346.tgz new file mode 100644 index 0000000..767db7a Binary files /dev/null and b/vendor/mdbase-dev-connect-protocol-0.1.0-beta.27-33890fcd1346.tgz differ diff --git a/vendor/mdbase-dev-connect-sync-0.1.0-beta.27-33890fcd1346.tgz b/vendor/mdbase-dev-connect-sync-0.1.0-beta.27-33890fcd1346.tgz new file mode 100644 index 0000000..8ccea07 Binary files /dev/null and b/vendor/mdbase-dev-connect-sync-0.1.0-beta.27-33890fcd1346.tgz differ diff --git a/vendor/mdbase-sdk.json b/vendor/mdbase-sdk.json new file mode 100644 index 0000000..21aaaa2 --- /dev/null +++ b/vendor/mdbase-sdk.json @@ -0,0 +1,10 @@ +{ + "schema_version": 1, + "repository": "https://github.com/callumalpass/mdbase", + "revision": "4f28ea27a15a61a1f925fa5b0814ac1ae04307b2", + "artifact": { + "file": "callumalpass-mdbase-0.3.0-rc.4-4f28ea27a15a.tgz", + "bytes": 253904, + "sha512": "OulWXSTmB9A0JtUnJhAculVKBWbef4zNPmH9TmE/sPdCPuJfb8VoKAO0ktxvmchhaCP2LfNJKjLG6cX5sCoxYw==" + } +} diff --git a/vendor/tasknotes-model-0.3.0-rc.9.tgz b/vendor/tasknotes-model-0.3.0-rc.9.tgz index 600bdad..bd88d15 100644 Binary files a/vendor/tasknotes-model-0.3.0-rc.9.tgz and b/vendor/tasknotes-model-0.3.0-rc.9.tgz differ