diff --git a/docs/decisions/ADR-0008-session-archive-format.md b/docs/decisions/ADR-0008-session-archive-format.md new file mode 100644 index 0000000..136c948 --- /dev/null +++ b/docs/decisions/ADR-0008-session-archive-format.md @@ -0,0 +1,134 @@ +# ADR-0008 — Session archive format (ZIP layout, manifest schema, merge policy) + +- Status: accepted +- Date: 2026-05-25 +- Workplan: CE-WP-0005-T05 (schema), CE-WP-0005-T06 (export), + CE-WP-0005-T07 (import) +- Spec refs: `wiki/ProductRequirementsDocument.md` §20, + `wiki/ArchitectureOverview.md` §3.4, §14.3 + +## Context + +The CE-WP-0005 demo loop ends with a user exporting an entire session +(documents, annotations, evidence, links) into a single `.zip` +archive and importing it back later. The archive needs to be the +**only** persistence mechanism the demo provides beyond a tab close — +no IndexedDB in this workplan — so its shape needs to be locked +before two parallel tasks (T06, T07) and the integration test (T08) +land on top of it. + +Three things need a written contract: + +1. **ZIP layout** — what files live in the archive, named how. +2. **manifest.json shape** — versioned JSON schema, validated on + import. +3. **Conflict policy** — what happens when an imported session's name + already exists in the receiving repository. + +## Decision + +### ZIP layout + +``` +manifest.json +documents/ + .pdf +``` + +- `` is the engine-minted branded id (`doc_`). Using + it as the filename means the manifest's `documentBindings[i]` can + cross-reference the binary file without an additional lookup table. +- Per-representation files (e.g. an extracted-text JSON alongside each + PDF) are intentionally deferred. The canonical text + selectors are + embedded in the engine snapshot inside `manifest.json`, so a + re-import can regenerate everything from the binary. +- Future archive variants (multi-attachment documents, Markdown + documents) extend by adding subdirectories under the archive root. + Importers must ignore unknown top-level entries so older clients + remain compatible with newer archives that add new file types. + +### `manifest.json` shape (schemaVersion 1) + +```ts +interface SessionArchiveManifest { + schemaVersion: 1; + exportedAt: string; // ISO-8601 UTC timestamp + session: { + id: SessionId; // sess_ + name: string; // trimmed display name + createdAt: string; // ISO-8601 + updatedAt: string; // ISO-8601 + }; + engine: EngineSnapshot; // shape from src/engine/persistence.ts + documentBindings: Array<{ + documentId: DocumentId; // matches the engine's record + filename: string; // original filename from upload + fingerprint: string; // SHA-256 — used by the importer for dedup + }>; +} +``` + +The `engine` field is the same shape that `captureSnapshot()` produces +in `src/engine/persistence.ts`. Re-using it verbatim keeps the +in-memory ↔ archive round-trip a one-way conversion (snapshot ↔ +JSON) instead of growing a parallel schema that would drift. + +Unknown fields at the top level **must be preserved** on import (a +future client can write them) but unknown fields inside `session` or +`documentBindings[i]` are dropped — the import constructs typed +domain objects from the validated subset. + +### Merge-on-name-collision policy (T07) + +When an imported manifest's `session.name` matches an existing +session, the existing session is the **target** (`outcome: +"merged-into"`). Otherwise a fresh session is created with the +imported name (`outcome: "created"`). + +Within the target session: + +- **Documents** are deduped by `fingerprint` (SHA-256 over the PDF + bytes). If a document with the same fingerprint already exists, + the import keeps the existing `documentId` and records a remap + from the incoming id. The binary file is **skipped** (we already + have the bytes). Otherwise a fresh `documentId` is minted and the + bytes go into the per-session byte store. +- **Annotations**, **evidence items**, and **evidence links** are + imported **additively**: each gets a freshly minted id, with any + `documentId`/`annotationId`/`evidenceItemId` references rewritten + via the remap. No update-in-place, no overwrite-by-id. + +#### Known limitation: re-importing your own export duplicates annotations + +Because annotations/evidence/links are always added with fresh ids, +re-importing a ZIP you just exported into the same session creates a +second copy of every annotation (the existing PDF bytes dedupe +correctly via fingerprint, but the annotations have nothing to +de-dupe against). + +This is intentional for the demo loop and documented here so it's not +mistaken for a bug. A future workplan can introduce an +`importBundleId` field (a UUID minted at export time, stamped onto +the manifest and on every annotation/evidence-link the import +creates) plus a dedupe pass that skips entities already imported +under the same bundle id. + +## Consequences + +- **One source of truth for the engine snapshot.** Same shape on disk + and in memory; the persistence helpers stay re-usable. +- **Fingerprint-based dedup is byte-stable.** Two users converting + the same PDF end up with identical fingerprints; merging their + archives works as expected. +- **Idempotency is opt-in, not the default.** A user who wants exact + round-trips must use a future `importBundleId` flow, not the basic + T07 import. +- **Forward-compatible additions are cheap.** New top-level keys land + by adding fields; old importers preserve them and new importers + consume them. + +## Status + +Accepted. The TypeScript types + `parseSessionArchiveManifest` in +`src/shared/session-archive.ts` are the executable contract for +schemaVersion 1. diff --git a/package.json b/package.json index 51909a0..2b8048f 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "typecheck": "tsc -b --noEmit" }, "dependencies": { + "jszip": "^3.10.1", "pdfjs-dist": "^4.4.168", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a07aa88..5fe7622 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + jszip: + specifier: ^3.10.1 + version: 3.10.1 pdfjs-dist: specifier: ^4.4.168 version: 4.10.38 @@ -1500,6 +1503,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1892,6 +1898,9 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -1900,6 +1909,9 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -2007,6 +2019,9 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -2043,6 +2058,9 @@ packages: engines: {node: '>=6'} hasBin: true + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2050,6 +2068,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -2231,6 +2252,9 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -2312,6 +2336,9 @@ packages: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -2346,6 +2373,9 @@ packages: resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-push-apply@1.0.0: resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} engines: {node: '>= 0.4'} @@ -2378,6 +2408,9 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2434,6 +2467,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -2568,6 +2604,9 @@ packages: '@types/react': optional: true + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vite-node@2.1.9: resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -4052,6 +4091,8 @@ snapshots: convert-source-map@2.0.0: {} + core-util-is@1.0.3: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -4542,6 +4583,8 @@ snapshots: ignore@7.0.5: {} + immediate@3.0.6: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -4549,6 +4592,8 @@ snapshots: imurmurhash@0.1.4: {} + inherits@2.0.4: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -4667,6 +4712,8 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + isarray@1.0.0: {} + isarray@2.0.5: {} isexe@2.0.0: {} @@ -4691,6 +4738,13 @@ snapshots: json5@2.2.3: {} + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -4700,6 +4754,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lie@3.3.0: + dependencies: + immediate: 3.0.6 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -4876,6 +4934,8 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + process-nextick-args@2.0.1: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -4975,6 +5035,16 @@ snapshots: dependencies: loose-envify: 1.4.0 + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.9 @@ -5054,6 +5124,8 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.1.2: {} + safe-push-apply@1.0.0: dependencies: es-errors: 1.3.0 @@ -5095,6 +5167,8 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.2 + setimmediate@1.0.5: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -5167,6 +5241,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.2 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + strip-bom@3.0.0: {} strip-json-comments@3.1.1: {} @@ -5326,6 +5404,8 @@ snapshots: optionalDependencies: '@types/react': 18.3.29 + util-deprecate@1.0.2: {} + vite-node@2.1.9(@types/node@20.19.41): dependencies: cac: 6.7.14 diff --git a/src/app/App.tsx b/src/app/App.tsx index b907398..b6d9393 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,81 +1,198 @@ /** - * App — the citation-evidence MVP shell. + * App — citation-evidence demo shell (CE-WP-0005). * - * Composes the two top-level layouts: + * Composition: * - * - Review mode (CE-WP-0002): collection list / viewer / evidence sidebar. - * - Forms mode (CE-WP-0003): form renderer / viewer / evidence strip, - * with click-to-link interaction. + * SessionProvider (cross-session) + * └─ AppShell — owns routing + the top bar + * ├─ if no active session → CreateFirstSession (empty state) + * └─ else + * EngineProvider key={sessionId} sessionId={sessionId} + * └─ BinderProvider bus={engine.bus} + * └─ ReviewLayout | FormsApp (per `mode`) * - * Mode selection is driven by `location.hash`: `#/forms/demo` lands in - * Forms mode; anything else (including empty) lands in Review mode. The - * top bar toggles between them. We keep the hash sync so reload + deep - * links work; T08's E2E asserts the `/forms/demo` navigation path. - * - * Engine and binder providers are both mounted at the App root so - * evidence/annotations/links survive switching tabs. + * The hash is the single source of truth for `{sessionId, mode}`. The + * SessionService's active id is kept in sync with the hash via a + * useEffect inside `AppShell`. Deep links to unknown sessions redirect + * to the empty state with a toast. */ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { BinderProvider } from "@binder/index"; import { EngineProvider, + SessionProvider, + useActiveSession, useEngine, + usePdfByteStore, + useSessionByteStoreRegistry, + useSessionService, + useSessionsHydrated, + useSessionVersion, + useSessionVersionBumper, } from "@work/index"; import { FormsApp } from "./forms/FormsApp"; import { ReviewLayout } from "./ReviewLayout"; -type Mode = "review" | "forms"; +import { + CreateFirstSession, + EMPTY_ROUTE, + exportSessionZip, + importSessionZip, + parseRoute, + navigateTo, + SessionMenu, + sessionZipFilename, + Toast, + triggerSessionDownload, + UploadDropzone, + useToast, + type AppMode, + type AppRoute, +} from "./sessions"; -const FORMS_HASH = "#/forms/demo"; - -function readModeFromHash(): Mode { - if (typeof window === "undefined") return "review"; - return window.location.hash === FORMS_HASH ? "forms" : "review"; +function readRoute(): AppRoute { + if (typeof window === "undefined") return EMPTY_ROUTE; + return parseRoute(window.location.hash); } -function writeModeToHash(mode: Mode) { - if (typeof window === "undefined") return; - const target = mode === "forms" ? FORMS_HASH : ""; - if (window.location.hash !== target) { - if (target) { - window.location.hash = target; - } else { - // Clear hash without leaving "#" trailing in the URL bar. - history.replaceState(null, "", window.location.pathname + window.location.search); - } - } -} - -function ModeRouter() { - const [mode, setMode] = useState(() => readModeFromHash()); - +function useHashRoute(): AppRoute { + const [route, setRoute] = useState(() => readRoute()); useEffect(() => { - function onHash() { - setMode(readModeFromHash()); - } - window.addEventListener("hashchange", onHash); - return () => window.removeEventListener("hashchange", onHash); + const handler = () => setRoute(readRoute()); + window.addEventListener("hashchange", handler); + return () => window.removeEventListener("hashchange", handler); }, []); + return route; +} - const handleModeChange = (next: Mode) => { - writeModeToHash(next); - setMode(next); - }; +function AppShell() { + const route = useHashRoute(); + const service = useSessionService(); + const hydrated = useSessionsHydrated(); + const toast = useToast(); + // Guards the "unknown session id → toast + redirect" path against an + // infinite loop: `useToast.show` creates a fresh `toast` object every + // render, which would otherwise re-fire the effect. + const lastHandledSessionIdRef = useRef(null); + // Sync hash → SessionService.setActive. Unknown session ids fall back + // to the empty state with a toast. + useEffect(() => { + if (!hydrated) return; + const key = route.sessionId ?? ""; + if (lastHandledSessionIdRef.current === key) return; + lastHandledSessionIdRef.current = key; + + if (route.sessionId === null) { + service.setActive(null); + return; + } + const exists = service.get(route.sessionId); + if (exists) { + service.setActive(route.sessionId); + } else { + toast.show("Session not found — opened the empty state instead", "error"); + navigateTo(EMPTY_ROUTE); + } + }, [route.sessionId, service, hydrated, toast]); + + if (!hydrated) { + return ( +
+ Loading… +
+ ); + } + + if (route.sessionId === null) { + return ( +
+ +
+ +
+ +
+ ); + } + + return ; +} + +function ActiveAppFrame({ + route, + toast, +}: { + route: AppRoute; + toast: ReturnType; +}) { + // EngineProvider remounts whenever the session id OR the per-session + // version counter changes. Import-into-active-session bumps the version + // so the new state from storage is picked up. + const sessionId = route.sessionId!; + const version = useSessionVersion(sessionId); return (
- -
- {mode === "review" ? : } -
+ + +
+ +
+
+
); } -function TopBar({ mode, onModeChange }: { mode: Mode; onModeChange: (m: Mode) => void }) { +function SessionScopedTree({ mode }: { mode: AppMode }) { + const engine = useEngine(); + return ( + + {mode === "forms" ? : } />} + + ); +} + +function EmptyTopBar() { + const sessionService = useSessionService(); + const registry = useSessionByteStoreRegistry(); + const bumpVersion = useSessionVersionBumper(); + const toast = useToast(); // local toast — empty state has its own + + const handleImport = useCallback(async (file: File) => { + try { + const result = await importSessionZip(file, { + sessionService, + getOrCreateByteStore: registry.getOrCreateByteStore, + bumpSessionVersion: bumpVersion, + }); + navigateTo({ sessionId: result.sessionId, mode: "review" }); + toast.show( + result.outcome === "created" + ? "Imported as a new session" + : "Merged into existing session", + "success", + ); + } catch (err) { + toast.show( + err instanceof Error ? `Import failed: ${err.message}` : "Import failed", + "error", + ); + } + }, [sessionService, registry, bumpVersion, toast]); + return (
}} > citation-evidence - - + pickAndImport(handleImport)} /> + +
+ ); +} + +function pickAndImport(onPicked: (file: File) => void): void { + if (typeof document === "undefined") return; + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".zip,application/zip"; + input.onchange = () => { + const file = input.files?.[0]; + if (file) onPicked(file); + }; + input.click(); +} + +function ActiveTopBar({ + route, + showToast, +}: { + route: AppRoute; + showToast: (msg: string, tone?: "success" | "error" | "info") => void; +}) { + const engine = useEngine(); + const byteStore = usePdfByteStore(); + const session = useActiveSession(); + const sessionService = useSessionService(); + const registry = useSessionByteStoreRegistry(); + const bumpVersion = useSessionVersionBumper(); + + const handleModeChange = useCallback( + (next: AppMode) => { + if (!route.sessionId) return; + navigateTo({ sessionId: route.sessionId, mode: next }); + }, + [route.sessionId], + ); + + const handleExport = useCallback(async () => { + if (!session) return; + try { + const blob = await exportSessionZip(engine, byteStore, session); + triggerSessionDownload(blob, sessionZipFilename(session)); + showToast("Session exported", "success"); + } catch (err) { + showToast( + err instanceof Error ? `Export failed: ${err.message}` : "Export failed", + "error", + ); + } + }, [engine, byteStore, session, showToast]); + + const handleImport = useCallback( + async (file: File) => { + try { + const result = await importSessionZip(file, { + sessionService, + getOrCreateByteStore: registry.getOrCreateByteStore, + bumpSessionVersion: bumpVersion, + }); + navigateTo({ sessionId: result.sessionId, mode: "review" }); + const totals = result.stats; + const summary = + result.outcome === "created" + ? `Imported new session — ${totals.documentsAdded} document${totals.documentsAdded === 1 ? "" : "s"}, ${totals.annotationsAdded} annotation${totals.annotationsAdded === 1 ? "" : "s"}` + : `Merged into existing — ${totals.documentsAdded} new doc${totals.documentsAdded === 1 ? "" : "s"}, ${totals.documentsDeduped} deduped`; + showToast(summary, "success"); + } catch (err) { + showToast( + err instanceof Error ? `Import failed: ${err.message}` : "Import failed", + "error", + ); + } + }, + [sessionService, registry, bumpVersion, showToast], + ); + + const tabs = useMemo( + () => [ + { id: "review" as const, label: "Review" }, + { id: "forms" as const, label: "Forms" }, + ], + [], + ); + + return ( +
+ citation-evidence + void handleExport()} + onImportZip={() => pickAndImport((file) => void handleImport(file))} + /> +
+ {tabs.map((t) => ( + + ))} +
); } @@ -118,19 +337,10 @@ function tabStyle(active: boolean) { }; } -function AppInner() { - const engine = useEngine(); - return ( - - - - ); -} - export function App() { return ( - - - + + + ); } diff --git a/src/app/ReviewLayout.tsx b/src/app/ReviewLayout.tsx index 92cc275..8cb94fa 100644 --- a/src/app/ReviewLayout.tsx +++ b/src/app/ReviewLayout.tsx @@ -5,15 +5,27 @@ * │ Collection │ Document Viewer │ Evidence │ * │ List │ │ Sidebar │ * └────────────┴──────────────────┴────────────┘ + * + * CE-WP-0005 added an `upload` slot for the active session's upload + * dropzone, threaded in by the app composition root so this component + * stays inside the `work` boundary (which cannot import `app`). */ +import type { ReactNode } from "react"; + import { CollectionList, EvidenceSidebar, ViewerShell, + useActiveSession, } from "@work/index"; -export function ReviewLayout() { +export interface ReviewLayoutProps { + readonly upload?: ReactNode; +} + +export function ReviewLayout({ upload }: ReviewLayoutProps) { + const session = useActiveSession(); return (
- +
diff --git a/src/app/sessions/CreateFirstSession.tsx b/src/app/sessions/CreateFirstSession.tsx new file mode 100644 index 0000000..c18b826 --- /dev/null +++ b/src/app/sessions/CreateFirstSession.tsx @@ -0,0 +1,104 @@ +/** + * Empty-state landing — shown when no session is active. + * + * Inline name input + Create button. On success, navigates the hash to + * the new session so the rest of the app mounts. Used both on first + * launch (no sessions yet) and after the last session was deleted. + */ + +import { useCallback, useState } from "react"; + +import { useSessionService } from "@work/index"; + +import { navigateTo } from "./routing"; + +export function CreateFirstSession() { + const service = useSessionService(); + const [name, setName] = useState(""); + const [error, setError] = useState(null); + const hasOthers = service.list().length > 0; + + const handleCreate = useCallback(() => { + setError(null); + try { + const created = service.create(name); + navigateTo({ sessionId: created.id, mode: "review" }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, [name, service]); + + return ( +
+

citation-evidence

+

+ {hasOthers + ? "Pick a session from the menu above, or create a new one." + : "Create your first session to get started."} +

+
+ setName(e.target.value)} + placeholder="Session name (e.g. Lease 2024)" + data-testid="empty-state-input" + style={{ + fontSize: 14, + padding: "6px 10px", + border: "1px solid #888", + borderRadius: 3, + minWidth: 260, + }} + onKeyDown={(e) => { + if (e.key === "Enter") handleCreate(); + }} + /> + +
+ {error && ( +
+ {error} +
+ )} +
+ ); +} diff --git a/src/app/sessions/SampleSessions.tsx b/src/app/sessions/SampleSessions.tsx new file mode 100644 index 0000000..dfb2a79 --- /dev/null +++ b/src/app/sessions/SampleSessions.tsx @@ -0,0 +1,125 @@ +/** + * SampleSessions — optional fixture-driven quick-start. + * + * The MVP collection list (pre-CE-WP-0005) ingested fixture PDFs over + * `fetch`. After the session refactor that workflow is no longer the + * default; it survives here as an optional way to seed the active + * session with a sample document for demo and testing. + * + * Mounted by `SessionMenu` (T04) under a "Sample sessions ▸" entry + * and by the integration tests under CE-WP-0002-T09 / -T05 that need + * a known-good document. + */ + +import { useCallback, useState } from "react"; + +import { ingestPdf } from "@source/index"; +import type { DocumentId } from "@shared/ids"; +import { + useActiveDocumentId, + useEngine, + usePdfByteStore, +} from "@work/index"; + +import manifest from "../../../fixtures/pdfs/manifest.json"; + +interface Fixture { + id: string; + filename: string; + description: string; + page_count: number; +} + +const FIXTURES: readonly Fixture[] = (manifest as { fixtures: Fixture[] }).fixtures; + +export function SampleSessions() { + const engine = useEngine(); + const byteStore = usePdfByteStore(); + const { id: activeId, setId } = useActiveDocumentId(); + const [loadingFixtureId, setLoadingFixtureId] = useState(null); + const [error, setError] = useState(null); + const [byFixture, setByFixture] = useState>({}); + + const handleLoad = useCallback( + async (fixture: Fixture) => { + setError(null); + const existing = byFixture[fixture.id]; + if (existing) { + setId(existing); + return; + } + setLoadingFixtureId(fixture.id); + try { + const url = `/fixtures/pdfs/${encodeURIComponent(fixture.filename)}`; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`fetch ${url} → ${response.status}`); + } + const buffer = await response.arrayBuffer(); + const bytes = new Uint8Array(buffer); + const { document, representation } = await ingestPdf(bytes, { + filename: fixture.filename, + }); + // Push the bytes into the byte store so the viewer can mount them via + // the same blob URL machinery used by the upload path. The document + // record carries the blob URL on `uri` for the viewer adapter. + const record = byteStore.put(document.id, bytes); + engine.documents.register({ + document: { ...document, uri: record.blobUrl }, + representation, + }); + setByFixture((prev) => ({ ...prev, [fixture.id]: document.id })); + setId(document.id); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoadingFixtureId(null); + } + }, + [byFixture, byteStore, engine, setId], + ); + + return ( +
+

+ Load a fixture PDF as a sample document for the active session. +

+ {error && ( +

+ {error} +

+ )} +
    + {FIXTURES.map((f) => { + const isLoading = loadingFixtureId === f.id; + const documentId = byFixture[f.id]; + const isActive = documentId !== undefined && documentId === activeId; + return ( +
  • + +
  • + ); + })} +
+
+ ); +} diff --git a/src/app/sessions/SessionMenu.dom.test.tsx b/src/app/sessions/SessionMenu.dom.test.tsx new file mode 100644 index 0000000..4e7c130 --- /dev/null +++ b/src/app/sessions/SessionMenu.dom.test.tsx @@ -0,0 +1,132 @@ +// @vitest-environment happy-dom + +import { useEffect, useState } from "react"; + +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SessionId } from "@shared/ids"; +import { SessionProvider, useSessionService } from "@work/index"; + +import { SessionMenu } from "./SessionMenu"; +import { parseRoute } from "./routing"; + +function HashSync() { + // Mirrors the production AppShell effect: hash changes drive + // SessionService.setActive so useActiveSession() resolves correctly. + const service = useSessionService(); + const [tick, setTick] = useState(0); + useEffect(() => { + const onHash = () => setTick((t) => t + 1); + window.addEventListener("hashchange", onHash); + return () => window.removeEventListener("hashchange", onHash); + }, []); + useEffect(() => { + const route = parseRoute(window.location.hash); + if (route.sessionId && service.get(route.sessionId as SessionId)) { + service.setActive(route.sessionId as SessionId); + } else { + service.setActive(null); + } + }, [tick, service]); + return null; +} + +function Wrap({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + ); +} + +function CurrentHash() { + return {window.location.hash || "(empty)"}; +} + +function SeedTwo() { + const service = useSessionService(); + if (service.list().length === 0) { + service.create({ name: "Alpha" }); + service.create({ name: "Beta" }); + } + return null; +} + +beforeEach(() => { + globalThis.localStorage?.clear(); + history.replaceState(null, "", window.location.pathname); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe("SessionMenu", () => { + it("creating a new session navigates the hash to /s/", async () => { + render( + + + + , + ); + const user = userEvent.setup(); + await user.click(screen.getByTestId("session-menu-toggle")); + await user.click(screen.getByTestId("session-menu-new")); + await user.type(screen.getByTestId("session-new-input"), "Demo"); + await user.click(screen.getByTestId("session-new-confirm")); + await waitFor(() => { + const route = parseRoute(window.location.hash); + expect(route.sessionId).toMatch(/^sess_/); + expect(route.mode).toBe("review"); + }); + }); + + it("switching sessions writes the chosen id into the hash", async () => { + render( + + + + + , + ); + const user = userEvent.setup(); + await user.click(screen.getByTestId("session-menu-toggle")); + + const alphaBtn = await screen.findByText(/Alpha/); + await user.click(alphaBtn); + await waitFor(() => { + const route = parseRoute(window.location.hash); + expect(route.sessionId).not.toBeNull(); + expect(route.mode).toBe("review"); + }); + }); + + it("rename rejects a duplicate name with an inline error", async () => { + render( + + + + , + ); + const user = userEvent.setup(); + // Switch to Alpha first so it becomes active and rename becomes available. + await user.click(screen.getByTestId("session-menu-toggle")); + const alphaBtn = await screen.findByText(/Alpha/); + await user.click(alphaBtn); + + // Re-open menu (it closed after switch) and try rename → Beta (taken). + await user.click(screen.getByTestId("session-menu-toggle")); + await user.click(screen.getByTestId("session-menu-rename")); + const input = screen.getByTestId("session-rename-input") as HTMLInputElement; + // Clear existing value and type new + await user.clear(input); + await user.type(input, "Beta"); + await user.click(screen.getByTestId("session-rename-confirm")); + const error = await screen.findByTestId("session-menu-error"); + expect(error.textContent).toMatch(/already exists/); + }); +}); diff --git a/src/app/sessions/SessionMenu.tsx b/src/app/sessions/SessionMenu.tsx new file mode 100644 index 0000000..36a6758 --- /dev/null +++ b/src/app/sessions/SessionMenu.tsx @@ -0,0 +1,362 @@ +/** + * SessionMenu — top-bar dropdown that drives the SessionService. + * + * Holds the only place in the UI where sessions get created, renamed, + * deleted, and switched. Export/Import ZIP menu items are slots — + * T06/T07 wire them. + * + * Switching sessions writes the new id into the URL hash; the routing + * layer is the source of truth (see `routing.ts`). That keeps deep + * links + browser back/forward behaving naturally. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { CSSProperties } from "react"; + +import type { Session } from "@shared/session"; +import { useActiveSession, useSessionListTick, useSessionService } from "@work/index"; + +import { navigateTo } from "./routing"; + +interface SessionMenuProps { + readonly onExportZip?: () => void; + readonly onImportZip?: () => void; + readonly onOpenSamples?: () => void; +} + +export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: SessionMenuProps) { + const service = useSessionService(); + const tick = useSessionListTick(); + const active = useActiveSession(); + + const [open, setOpen] = useState(false); + const [newName, setNewName] = useState(""); + const [creating, setCreating] = useState(false); + const [renaming, setRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(""); + const [pendingDelete, setPendingDelete] = useState(false); + const [error, setError] = useState(null); + const wrapperRef = useRef(null); + + const sessions = useMemo(() => { + // sorted by lastOpenedAt desc, then by createdAt desc + void tick; + const list = [...service.list()]; + list.sort((a: Session, b: Session) => { + const aKey = a.lastOpenedAt ?? a.createdAt; + const bKey = b.lastOpenedAt ?? b.createdAt; + return bKey.localeCompare(aKey); + }); + return list; + }, [service, tick]); + + // Click outside closes the menu. + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (!wrapperRef.current) return; + if (!wrapperRef.current.contains(e.target as Node)) { + setOpen(false); + setCreating(false); + setRenaming(false); + setPendingDelete(false); + } + }; + window.addEventListener("mousedown", handler); + return () => window.removeEventListener("mousedown", handler); + }, [open]); + + const switchTo = useCallback( + (sessionId: import("@shared/ids").SessionId) => { + navigateTo({ sessionId, mode: "review" }); + setOpen(false); + }, + [], + ); + + const handleCreate = useCallback(() => { + setError(null); + try { + const created = service.create(newName); + setNewName(""); + setCreating(false); + setOpen(false); + navigateTo({ sessionId: created.id, mode: "review" }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, [newName, service]); + + const handleRename = useCallback(() => { + if (!active) return; + setError(null); + try { + service.rename(active.id, renameValue); + setRenaming(false); + setOpen(false); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, [active, renameValue, service]); + + const handleDelete = useCallback(() => { + if (!active) return; + if (!pendingDelete) { + setPendingDelete(true); + return; + } + service.delete(active.id); + setPendingDelete(false); + setOpen(false); + navigateTo({ sessionId: null, mode: "review" }); + }, [active, pendingDelete, service]); + + return ( +
+ + {open && ( +
+ {sessions.length > 0 && ( + <> +
+ Switch to… +
+ {sessions.map((s) => ( + + ))} +
+ + )} + + {!creating && ( + + )} + {creating && ( +
+ setNewName(e.target.value)} + placeholder="Session name" + data-testid="session-new-input" + style={{ flex: 1, fontSize: 12, padding: 4 }} + onKeyDown={(e) => { + if (e.key === "Enter") handleCreate(); + if (e.key === "Escape") setCreating(false); + }} + /> + +
+ )} + + {active && ( + <> +
+ {!renaming && ( + + )} + {renaming && ( +
+ setRenameValue(e.target.value)} + data-testid="session-rename-input" + style={{ flex: 1, fontSize: 12, padding: 4 }} + onKeyDown={(e) => { + if (e.key === "Enter") handleRename(); + if (e.key === "Escape") setRenaming(false); + }} + /> + +
+ )} + + + )} + + {(onExportZip || onImportZip || onOpenSamples) && ( +
+ )} + {onExportZip && active && ( + + )} + {onImportZip && ( + + )} + {onOpenSamples && ( + + )} + + {error && ( +
+ {error} +
+ )} +
+ )} +
+ ); +} + +const menuItemStyle: CSSProperties = { + display: "block", + width: "100%", + textAlign: "left", + background: "transparent", + border: "none", + padding: "4px 8px", + cursor: "pointer", + fontSize: 12, +}; + +const smallButtonStyle: CSSProperties = { + fontSize: 12, + padding: "2px 8px", + border: "1px solid #888", + background: "white", + cursor: "pointer", +}; + +const dividerStyle: CSSProperties = { + border: "none", + borderTop: "1px solid #eee", + margin: "4px 0", +}; diff --git a/src/app/sessions/Toast.tsx b/src/app/sessions/Toast.tsx new file mode 100644 index 0000000..3b60f81 --- /dev/null +++ b/src/app/sessions/Toast.tsx @@ -0,0 +1,94 @@ +/** + * Small reusable toast for session-scoped messages. + * + * Mirrors the CE-WP-0004 EvidenceSidebar pattern. Used by SessionMenu + * for "no such session" redirects, by T06 for export success/error, + * and by T07 for import results. + */ + +import { useEffect, useState } from "react"; + +export type ToastTone = "success" | "error" | "info"; + +export interface ToastApi { + show(message: string, tone?: ToastTone): void; + dismiss(): void; +} + +export interface ToastProps { + readonly toast: { readonly message: string; readonly tone: ToastTone; readonly key: number } | null; + readonly onDismiss: () => void; + readonly timeoutMs?: number; +} + +export function Toast({ toast, onDismiss, timeoutMs = 3500 }: ToastProps) { + useEffect(() => { + if (!toast) return; + const t = setTimeout(onDismiss, timeoutMs); + return () => clearTimeout(t); + }, [toast, onDismiss, timeoutMs]); + if (!toast) return null; + return ( +
+ {toast.message} +
+ ); +} + +export function useToast(): { + toast: { message: string; tone: ToastTone; key: number } | null; + show(message: string, tone?: ToastTone): void; + dismiss(): void; +} { + const [toast, setToast] = useState<{ message: string; tone: ToastTone; key: number } | null>( + null, + ); + const [, setCounter] = useState(0); + return { + toast, + show(message, tone = "info") { + setCounter((c) => { + const next = c + 1; + setToast({ message, tone, key: next }); + return next; + }); + }, + dismiss() { + setToast(null); + }, + }; +} diff --git a/src/app/sessions/UploadDropzone.dom.test.tsx b/src/app/sessions/UploadDropzone.dom.test.tsx new file mode 100644 index 0000000..7e1c441 --- /dev/null +++ b/src/app/sessions/UploadDropzone.dom.test.tsx @@ -0,0 +1,93 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { Document, DocumentRepresentation } from "@shared/document"; +import type { DocumentId, RepresentationId } from "@shared/ids"; + +import { EngineProvider } from "@work/index"; + +import { UploadDropzone } from "./UploadDropzone"; + +// Bypass PDF.js extraction in this DOM test. Mock `ingestPdfFromFile` +// (the entry point the dropzone calls) so it stamps a synthetic +// document onto the byte store without ever opening pdfjs. +vi.mock("@source/index", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + ingestPdfFromFile: vi.fn( + async (file: File | Blob, store: import("@source/index").PdfByteStore) => { + const filename = + "name" in file && typeof file.name === "string" ? file.name : "uploaded.pdf"; + const documentId = ("doc_test_" + Math.random().toString(36).slice(2, 10)) as DocumentId; + const representationId = ("rep_test_" + + Math.random().toString(36).slice(2, 10)) as RepresentationId; + const bytes = new Uint8Array(await file.arrayBuffer()); + const record = store.put(documentId, bytes); + const document: Document = { + id: documentId, + mediaType: "application/pdf", + title: filename, + uri: record.blobUrl, + fingerprint: `synthetic-${documentId}`, + createdAt: "2026-05-25T00:00:00.000Z", + updatedAt: "2026-05-25T00:00:00.000Z", + }; + const representation: DocumentRepresentation = { + id: representationId, + documentId, + representationType: "pdf-text", + contentHash: `synthetic-${documentId}`, + canonicalText: "synthetic body", + pageMap: [{ page: 1, width: 595, height: 842 }], + offsetMap: [{ page: 1, globalStart: 0, globalEnd: 14, pageLength: 14 }], + generatedAt: "2026-05-25T00:00:00.000Z", + }; + return { document, representation }; + }, + ), + }; +}); + +beforeEach(() => { + globalThis.localStorage?.clear(); + // happy-dom's URL.createObjectURL returns blob:null/...; that's fine for tests. +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe("UploadDropzone", () => { + it( + "ingests a dropped PDF and reports a 'done' progress entry", + { timeout: 10000 }, + async () => { + render( + + + , + ); + + // happy-dom doesn't synthesise drag events well, so go through the + // file input — same processFiles path either way. + const input = screen.getByTestId("upload-file-input") as HTMLInputElement; + const bytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]); + const file = new File([bytes], "demo.pdf", { type: "application/pdf" }); + + const user = userEvent.setup(); + await user.upload(input, file); + + await waitFor(() => { + const items = screen.getByTestId("upload-progress").querySelectorAll("li"); + expect(items.length).toBe(1); + expect(items[0]?.getAttribute("data-status")).toBe("done"); + }); + }, + ); + +}); diff --git a/src/app/sessions/UploadDropzone.tsx b/src/app/sessions/UploadDropzone.tsx new file mode 100644 index 0000000..d3d0d02 --- /dev/null +++ b/src/app/sessions/UploadDropzone.tsx @@ -0,0 +1,189 @@ +/** + * UploadDropzone — drag-drop + file-picker for uploading PDFs into the + * active session. + * + * On every successful drop: + * 1. read each File as bytes, + * 2. run the source-layer `ingestPdfFromFile` (mints the blob URL + * via the session's `PdfByteStore`), + * 3. register the resulting `{document, representation}` with the + * engine, + * 4. activate the most-recently-uploaded document. + * + * Failures (non-PDFs, ingest errors) are surfaced inline above the + * dropzone; the caller doesn't need a separate toast for them. + */ + +import { useCallback, useRef, useState } from "react"; + +import { ingestPdfFromFile } from "@source/index"; +import { + useActiveDocumentId, + useEngine, + usePdfByteStore, +} from "@work/index"; + +interface UploadEntry { + readonly file: File; + status: "queued" | "uploading" | "done" | "error"; + error?: string; +} + +export interface UploadDropzoneProps { + /** Optional callback fired after each successful upload. */ + readonly onUploaded?: (documentId: import("@shared/ids").DocumentId) => void; +} + +export function UploadDropzone({ onUploaded }: UploadDropzoneProps) { + const engine = useEngine(); + const byteStore = usePdfByteStore(); + const { setId } = useActiveDocumentId(); + const [entries, setEntries] = useState([]); + const [isOver, setIsOver] = useState(false); + const fileInputRef = useRef(null); + + const processFiles = useCallback( + async (files: readonly File[]) => { + if (files.length === 0) return; + const initial: UploadEntry[] = files.map((file) => { + const isPdf = + file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf"); + if (isPdf) return { file, status: "queued" }; + return { + file, + status: "error", + error: "Not a PDF (only application/pdf accepted)", + }; + }); + setEntries((prev) => [...prev, ...initial]); + + let lastDocumentId: import("@shared/ids").DocumentId | null = null; + for (const entry of initial) { + if (entry.status === "error") continue; + entry.status = "uploading"; + setEntries((prev) => [...prev]); + try { + const { document, representation } = await ingestPdfFromFile( + entry.file, + byteStore, + ); + engine.documents.register({ document, representation }); + entry.status = "done"; + lastDocumentId = document.id; + onUploaded?.(document.id); + } catch (err) { + entry.status = "error"; + entry.error = err instanceof Error ? err.message : String(err); + } + setEntries((prev) => [...prev]); + } + if (lastDocumentId) setId(lastDocumentId); + }, + [byteStore, engine, onUploaded, setId], + ); + + const onDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsOver(false); + const files = Array.from(e.dataTransfer.files); + void processFiles(files); + }, + [processFiles], + ); + + const onDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsOver(true); + }, []); + + const onDragLeave = useCallback(() => { + setIsOver(false); + }, []); + + const openPicker = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const onPicked = useCallback( + (e: React.ChangeEvent) => { + const files = e.target.files ? Array.from(e.target.files) : []; + void processFiles(files); + // Reset so the same filename can be picked again. + e.target.value = ""; + }, + [processFiles], + ); + + return ( +
+
+
Drop PDF files here
+
or
+ + +
+ {entries.length > 0 && ( +
    + {entries.map((entry, i) => ( +
  • + {entry.file.name} — {entry.status} + {entry.error ? `: ${entry.error}` : ""} +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/app/sessions/exportSessionZip.test.ts b/src/app/sessions/exportSessionZip.test.ts new file mode 100644 index 0000000..17f6cc3 --- /dev/null +++ b/src/app/sessions/exportSessionZip.test.ts @@ -0,0 +1,154 @@ +/** + * Round-trip an exported session through JSZip and assert the + * archive matches ADR-0008 (manifest + per-document PDF bytes). + */ + +import JSZip from "jszip"; +import { describe, expect, it } from "vitest"; + +import { createEngine } from "@engine/index"; +import type { DocumentId, RepresentationId, SessionId } from "@shared/ids"; +import type { Session } from "@shared/session"; +import { parseSessionArchiveManifest } from "@shared/session-archive"; +import { createPdfByteStore } from "@source/index"; + +import { exportSessionZip, sessionZipFilename } from "./exportSessionZip"; + +function makeSession(id: string, name: string): Session { + return { + id: id as SessionId, + name, + createdAt: "2026-05-25T00:00:00.000Z", + updatedAt: "2026-05-25T00:00:00.000Z", + }; +} + +describe("exportSessionZip", () => { + it("produces a ZIP with manifest.json + documents/.pdf for each binding", async () => { + const engine = createEngine(); + const byteStore = createPdfByteStore({ + createObjectURL: () => "blob:test-1", + revokeObjectURL: () => {}, + }); + + const docId = "doc_test" as DocumentId; + const repId = "rep_test" as RepresentationId; + const bytes = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d]); // %PDF- + byteStore.put(docId, bytes); + engine.documents.register({ + document: { + id: docId, + mediaType: "application/pdf", + title: "demo.pdf", + fingerprint: "fingerprint-abc", + createdAt: "2026-05-25T00:00:00.000Z", + updatedAt: "2026-05-25T00:00:00.000Z", + }, + representation: { + id: repId, + documentId: docId, + representationType: "pdf-text", + contentHash: "fingerprint-abc", + canonicalText: "Quoted passage.", + pageMap: [{ page: 1, width: 595, height: 842 }], + offsetMap: [{ page: 1, globalStart: 0, globalEnd: 15, pageLength: 15 }], + generatedAt: "2026-05-25T00:00:00.000Z", + }, + }); + + // Add an annotation + evidence item so the snapshot exercises that path. + const ann = engine.annotations.create({ + documentId: docId, + representationId: repId, + quote: "Quoted", + selectors: [{ type: "TextQuoteSelector", exact: "Quoted" }], + }); + engine.evidence.create({ annotationIds: [ann.id], commentary: "hi" }); + + const session = makeSession("sess_x", "Demo session"); + const blob = await exportSessionZip(engine, byteStore, session, { + exportedAt: "2026-05-25T12:00:00.000Z", + }); + + expect(blob.size).toBeGreaterThan(0); + + const arrayBuffer = await blob.arrayBuffer(); + const zip = await JSZip.loadAsync(arrayBuffer); + expect(zip.file("manifest.json")).not.toBeNull(); + expect(zip.file(`documents/${docId}.pdf`)).not.toBeNull(); + + const manifestText = await zip.file("manifest.json")!.async("string"); + const manifest = parseSessionArchiveManifest(JSON.parse(manifestText)); + expect(manifest.schemaVersion).toBe(1); + expect(manifest.session.id).toBe("sess_x"); + expect(manifest.session.name).toBe("Demo session"); + expect(manifest.documentBindings).toHaveLength(1); + expect(manifest.documentBindings[0]).toMatchObject({ + documentId: docId, + filename: "demo.pdf", + fingerprint: "fingerprint-abc", + }); + expect(manifest.engine.documents).toHaveLength(1); + expect(manifest.engine.representations).toHaveLength(1); + expect(manifest.engine.annotations).toHaveLength(1); + expect(manifest.engine.evidenceItems).toHaveLength(1); + + const storedBytes = await zip.file(`documents/${docId}.pdf`)!.async("uint8array"); + expect(Array.from(storedBytes)).toEqual(Array.from(bytes)); + }); + + it("skips the binary file when the byte store has no bytes for a document", async () => { + const engine = createEngine(); + const byteStore = createPdfByteStore({ + createObjectURL: () => "blob:test-noop", + revokeObjectURL: () => {}, + }); + const docId = "doc_no_bytes" as DocumentId; + engine.documents.register({ + document: { + id: docId, + mediaType: "application/pdf", + title: "ghost.pdf", + fingerprint: "ghost-fp", + createdAt: "2026-05-25T00:00:00.000Z", + updatedAt: "2026-05-25T00:00:00.000Z", + }, + representation: { + id: "rep_no_bytes" as RepresentationId, + documentId: docId, + representationType: "pdf-text", + contentHash: "ghost-fp", + canonicalText: "", + pageMap: [], + offsetMap: [], + generatedAt: "2026-05-25T00:00:00.000Z", + }, + }); + + const blob = await exportSessionZip( + engine, + byteStore, + makeSession("sess_nb", "No Bytes"), + ); + const zip = await JSZip.loadAsync(await blob.arrayBuffer()); + expect(zip.file(`documents/${docId}.pdf`)).toBeNull(); + const manifestText = await zip.file("manifest.json")!.async("string"); + const manifest = parseSessionArchiveManifest(JSON.parse(manifestText)); + expect(manifest.documentBindings).toHaveLength(1); + }); +}); + +describe("sessionZipFilename", () => { + it("slugifies the session name and stamps the date in UTC", () => { + const session = makeSession("sess_a", "Lease — 2024 / München!"); + const fixed = new Date(Date.UTC(2026, 4, 25, 14, 7)); + expect(sessionZipFilename(session, fixed)).toBe("lease-2024-m-nchen-20260525-1407.zip"); + }); + + it("falls back to 'session' when slugification produces empty string", () => { + const session = makeSession("sess_x", "!!!"); + const fixed = new Date(Date.UTC(2026, 0, 1)); + expect(sessionZipFilename(session, fixed)).toBe("session-20260101-0000.zip"); + }); +}); + diff --git a/src/app/sessions/exportSessionZip.ts b/src/app/sessions/exportSessionZip.ts new file mode 100644 index 0000000..8247b9b --- /dev/null +++ b/src/app/sessions/exportSessionZip.ts @@ -0,0 +1,148 @@ +/** + * `exportSessionZip` — pack a session's engine snapshot + uploaded PDF + * bytes into a single `.zip` archive (ADR-0008 layout). + * + * Steps: + * 1. Build the manifest from `captureSnapshot(engine)` + session + * metadata + per-document `{filename, fingerprint}` derived from + * `engine.documents`. + * 2. For each binding, push `bytes` into `documents/.pdf`. + * 3. Push `manifest.json` (pretty-printed JSON). + * 4. `zip.generateAsync({ type: "blob" })`. + * + * `triggerSessionDownload` creates an `` link and clicks + * it. The filename is `-.zip` so two exports of the + * same session don't collide on disk. + */ + +import JSZip from "jszip"; + +import { captureSnapshot, type Engine } from "@engine/index"; +import type { DocumentId } from "@shared/ids"; +import type { Session } from "@shared/session"; +import { + SESSION_ARCHIVE_SCHEMA_VERSION, + type SessionArchiveDocumentBinding, + type SessionArchiveManifest, +} from "@shared/session-archive"; + +import type { PdfByteStore } from "@source/index"; + +export interface ExportSessionZipOptions { + /** Override the timestamp embedded in the manifest. */ + readonly exportedAt?: string; +} + +export async function exportSessionZip( + engine: Engine, + byteStore: PdfByteStore, + session: Session, + options: ExportSessionZipOptions = {}, +): Promise { + const snapshot = captureSnapshot(engine); + const documents = engine.documents.list(); + + const bindings: SessionArchiveDocumentBinding[] = []; + const zip = new JSZip(); + const documentsFolder = zip.folder("documents"); + if (!documentsFolder) { + throw new Error("exportSessionZip: JSZip refused to create 'documents/' folder"); + } + + for (const doc of documents) { + const filename = + doc.title ?? + (typeof doc.metadata?.["filename"] === "string" + ? (doc.metadata["filename"] as string) + : `${doc.id}.pdf`); + const fingerprint = doc.fingerprint ?? ""; + bindings.push({ documentId: doc.id, filename, fingerprint }); + const record = byteStore.get(doc.id); + if (record) { + documentsFolder.file(`${doc.id}.pdf`, record.bytes); + } + // If bytes are missing (e.g. fixture-loaded doc whose bytes weren't + // pushed into the store), the manifest still lists the binding but + // the binary is absent — the importer surfaces this as a warning + // in T07. + } + + const manifest: SessionArchiveManifest = { + schemaVersion: SESSION_ARCHIVE_SCHEMA_VERSION, + exportedAt: options.exportedAt ?? new Date().toISOString(), + session: { + id: session.id, + name: session.name, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + }, + engine: snapshot, + documentBindings: bindings, + }; + + zip.file("manifest.json", JSON.stringify(manifest, null, 2)); + + return zip.generateAsync({ type: "blob" }); +} + +export function sessionZipFilename(session: Session, now: Date = new Date()): string { + const slug = + session.name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "session"; + // YYYYMMDD-HHMM + const pad = (n: number) => String(n).padStart(2, "0"); + const stamp = `${now.getUTCFullYear()}${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())}-${pad(now.getUTCHours())}${pad(now.getUTCMinutes())}`; + return `${slug}-${stamp}.zip`; +} + +export interface TriggerDownloadHooks { + /** Override the `` creation — used by tests to intercept the click. */ + readonly createAnchor?: () => HTMLAnchorElement; + readonly createObjectURL?: (blob: Blob) => string; + readonly revokeObjectURL?: (url: string) => void; +} + +export function triggerSessionDownload( + blob: Blob, + filename: string, + hooks: TriggerDownloadHooks = {}, +): void { + const createObjectURL = + hooks.createObjectURL ?? + ((b: Blob) => { + if (typeof URL === "undefined" || typeof URL.createObjectURL !== "function") { + throw new Error("triggerSessionDownload: URL.createObjectURL unavailable"); + } + return URL.createObjectURL(b); + }); + const revokeObjectURL = + hooks.revokeObjectURL ?? + ((url: string) => { + if (typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") { + URL.revokeObjectURL(url); + } + }); + const createAnchor = + hooks.createAnchor ?? + (() => { + if (typeof document === "undefined") { + throw new Error("triggerSessionDownload: document is not available"); + } + return document.createElement("a"); + }); + + const url = createObjectURL(blob); + const a = createAnchor(); + a.href = url; + a.download = filename; + a.click(); + // Revoke after the click so the browser has a chance to start the download. + setTimeout(() => revokeObjectURL(url), 1_000); +} + +// Re-export the DocumentId type so consumers can write +// `exportSessionZip(...)` without an extra import. Tree-shakeable. +export type { DocumentId }; diff --git a/src/app/sessions/importSessionZip.test.ts b/src/app/sessions/importSessionZip.test.ts new file mode 100644 index 0000000..3cb0881 --- /dev/null +++ b/src/app/sessions/importSessionZip.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, it } from "vitest"; + +import { + createEngine, + createEventBus, + createInMemorySessionRepository, + createSessionService, + engineSnapshotKey, + restoreFromStorage, + type SessionService, +} from "@engine/index"; +import type { + DocumentId, + RepresentationId, + SessionId, +} from "@shared/ids"; +import type { Session } from "@shared/session"; +import { createPdfByteStore, type PdfByteStore } from "@source/index"; + +import { exportSessionZip } from "./exportSessionZip"; +import { + importSessionZip, + SessionImportError, + type ImportSessionServices, +} from "./importSessionZip"; + +function memoryStorage(): Pick { + const map = new Map(); + return { + getItem: (k) => map.get(k) ?? null, + setItem: (k, v) => void map.set(k, v), + removeItem: (k) => void map.delete(k), + }; +} + +function makeService(): SessionService { + const repo = createInMemorySessionRepository(); + const bus = createEventBus(); + return createSessionService(repo, bus); +} + +function freshStores() { + const stores = new Map(); + return { + stores, + get(sessionId: SessionId): PdfByteStore { + let s = stores.get(sessionId); + if (!s) { + s = createPdfByteStore({ + createObjectURL: () => `blob:t-${sessionId}-${Math.random()}`, + revokeObjectURL: () => {}, + }); + stores.set(sessionId, s); + } + return s; + }, + }; +} + +interface Harness { + service: SessionService; + stores: ReturnType["stores"]; + byteStoreFor(sessionId: SessionId): PdfByteStore; + bumps: SessionId[]; + storage: ReturnType; + services: ImportSessionServices; +} + +function harness(): Harness { + const service = makeService(); + const stores = freshStores(); + const bumps: SessionId[] = []; + const storage = memoryStorage(); + return { + service, + stores: stores.stores, + byteStoreFor: stores.get, + bumps, + storage, + services: { + sessionService: service, + getOrCreateByteStore: stores.get, + bumpSessionVersion: (id) => bumps.push(id), + storage, + }, + }; +} + +async function seedAndExport(opts: { + sessionName: string; + storage: Pick; +}): Promise<{ blob: Blob; session: Session; docId: DocumentId }> { + const engine = createEngine(); + const byteStore = createPdfByteStore({ + createObjectURL: () => "blob:src", + revokeObjectURL: () => {}, + }); + const session: Session = { + id: "sess_src" as SessionId, + name: opts.sessionName, + createdAt: "2026-05-25T00:00:00.000Z", + updatedAt: "2026-05-25T00:00:00.000Z", + }; + + const docId = "doc_src" as DocumentId; + const repId = "rep_src" as RepresentationId; + const bytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]); + byteStore.put(docId, bytes); + engine.documents.register({ + document: { + id: docId, + mediaType: "application/pdf", + title: "src.pdf", + fingerprint: "fp-shared", + createdAt: "2026-05-25T00:00:00.000Z", + updatedAt: "2026-05-25T00:00:00.000Z", + }, + representation: { + id: repId, + documentId: docId, + representationType: "pdf-text", + contentHash: "fp-shared", + canonicalText: "The quote.", + pageMap: [{ page: 1, width: 595, height: 842 }], + offsetMap: [{ page: 1, globalStart: 0, globalEnd: 10, pageLength: 10 }], + generatedAt: "2026-05-25T00:00:00.000Z", + }, + }); + const ann = engine.annotations.create({ + documentId: docId, + representationId: repId, + quote: "The quote.", + selectors: [{ type: "TextQuoteSelector", exact: "The quote." }], + }); + engine.evidence.create({ annotationIds: [ann.id], commentary: "important" }); + + const blob = await exportSessionZip(engine, byteStore, session); + // The "blob" JSZip produces inside a node test isn't a real Blob — + // re-pack as a fresh Blob over an ArrayBuffer so JSZip.loadAsync (in + // the importer) can consume it. + const buf = await blob.arrayBuffer(); + const portableBlob = new Blob([buf], { type: "application/zip" }); + // Silence unused-storage lint + void opts.storage; + return { blob: portableBlob, session, docId }; +} + +describe("importSessionZip — create path", () => { + it("imports a fresh session and stamps a new engine snapshot in storage", async () => { + const h = harness(); + const { blob } = await seedAndExport({ + sessionName: "From Export", + storage: h.storage, + }); + + const result = await importSessionZip(blob, h.services); + + expect(result.outcome).toBe("created"); + expect(result.sessionId).toMatch(/^sess_/); + expect(result.stats.documentsAdded).toBe(1); + expect(result.stats.documentsDeduped).toBe(0); + expect(result.stats.annotationsAdded).toBe(1); + expect(result.stats.evidenceAdded).toBe(1); + + // The session record exists in the service. + const created = h.service.get(result.sessionId); + expect(created?.name).toBe("From Export"); + + // The engine snapshot was persisted to localStorage at the per- + // session key. + const raw = h.storage.getItem(engineSnapshotKey(result.sessionId)); + expect(raw).not.toBeNull(); + const restored = createEngine(); + restoreFromStorage(restored, { + key: engineSnapshotKey(result.sessionId), + storage: h.storage, + }); + expect(restored.documents.list()).toHaveLength(1); + expect(restored.annotations.listByDocument(restored.documents.list()[0]!.id)).toHaveLength(1); + + // The byte store registry got the bytes. + const bytesStore = h.byteStoreFor(result.sessionId); + expect(bytesStore.list()).toHaveLength(1); + + // setActive was called + version bumped. + expect(h.service.getActive()).toBe(result.sessionId); + expect(h.bumps).toContain(result.sessionId); + }); +}); + +describe("importSessionZip — merge path", () => { + it("dedupes documents by fingerprint and adds annotations additively", async () => { + const h = harness(); + // Pre-create a session with the same name + same fingerprint + // document so the merge has something to dedupe against. + const targetSession = h.service.create({ name: "Demo" }); + { + const seedEngine = createEngine(); + const seedStore = h.byteStoreFor(targetSession.id); + seedStore.put("doc_pre" as DocumentId, new Uint8Array([1])); + seedEngine.documents.register({ + document: { + id: "doc_pre" as DocumentId, + mediaType: "application/pdf", + title: "pre.pdf", + fingerprint: "fp-shared", + createdAt: "2026-05-25T00:00:00.000Z", + updatedAt: "2026-05-25T00:00:00.000Z", + }, + representation: { + id: "rep_pre" as RepresentationId, + documentId: "doc_pre" as DocumentId, + representationType: "pdf-text", + contentHash: "fp-shared", + canonicalText: "x", + pageMap: [], + offsetMap: [], + generatedAt: "2026-05-25T00:00:00.000Z", + }, + }); + const seedSnap = await import("@engine/index").then((m) => m.captureSnapshot(seedEngine)); + h.storage.setItem(engineSnapshotKey(targetSession.id), JSON.stringify(seedSnap)); + } + + const { blob } = await seedAndExport({ + sessionName: "Demo", + storage: h.storage, + }); + + const result = await importSessionZip(blob, h.services); + + expect(result.outcome).toBe("merged-into"); + expect(result.sessionId).toBe(targetSession.id); + expect(result.stats.documentsAdded).toBe(0); + expect(result.stats.documentsDeduped).toBe(1); + expect(result.stats.annotationsAdded).toBe(1); + expect(result.stats.evidenceAdded).toBe(1); + + // Re-load the snapshot — there should still be ONE document + // (deduped), and the annotation/evidence we added are now visible + // on that existing document. + const restored = createEngine(); + restoreFromStorage(restored, { + key: engineSnapshotKey(targetSession.id), + storage: h.storage, + }); + expect(restored.documents.list()).toHaveLength(1); + expect(restored.documents.list()[0]!.id).toBe("doc_pre" as DocumentId); + const annsOnDoc = restored.annotations.listByDocument("doc_pre" as DocumentId); + expect(annsOnDoc).toHaveLength(1); + expect(annsOnDoc[0]!.quote).toBe("The quote."); + }); +}); + +describe("importSessionZip — error path", () => { + it("rejects an archive with a malformed manifest", async () => { + const h = harness(); + // Build a minimal zip with a malformed manifest. + const { default: JSZip } = await import("jszip"); + const zip = new JSZip(); + zip.file("manifest.json", JSON.stringify({ schemaVersion: 999, exportedAt: "x" })); + const buf = await zip.generateAsync({ type: "arraybuffer" }); + const blob = new Blob([buf], { type: "application/zip" }); + await expect(importSessionZip(blob, h.services)).rejects.toThrow(SessionImportError); + }); + + it("rejects an archive without a manifest", async () => { + const h = harness(); + const { default: JSZip } = await import("jszip"); + const zip = new JSZip(); + zip.file("something-else.txt", "hello"); + const buf = await zip.generateAsync({ type: "arraybuffer" }); + const blob = new Blob([buf], { type: "application/zip" }); + await expect(importSessionZip(blob, h.services)).rejects.toThrow(/manifest\.json missing/); + }); +}); diff --git a/src/app/sessions/importSessionZip.ts b/src/app/sessions/importSessionZip.ts new file mode 100644 index 0000000..bd7e31c --- /dev/null +++ b/src/app/sessions/importSessionZip.ts @@ -0,0 +1,314 @@ +/** + * `importSessionZip` — read a session ZIP archive, dedupe documents by + * fingerprint, additively merge annotations/evidence/links into the + * target session. ADR-0008 is the authoritative spec. + * + * Target session resolution: + * - If a session with the manifest's `session.name` exists (case + * insensitive, matching SessionService rules), that's the target + * and `outcome` is `"merged-into"`. + * - Otherwise a fresh session is created with the imported name and + * `outcome` is `"created"`. + * + * Per-archive document handling: + * - SHA-256 fingerprint match against the target session's existing + * documents → reuse the existing `documentId`, skip the binary, + * record a remap. + * - No match → mint a new branded `documentId`, push the bytes into + * the target's byte store, register with the target's engine, + * record the remap. + * + * Per-archive annotation/evidence/link handling: + * - Always mint fresh ids; rewrite any `documentId` / `annotationId` + * / `evidenceItemId` references via the remap. + * + * Known limitation: re-importing your own export creates duplicate + * annotations (no idempotency). See ADR-0008 §"Known limitation" for + * the planned `importBundleId` follow-up. + * + * The importer works against a *fresh* off-React `Engine` for the + * target session and writes the resulting snapshot directly to + * `localStorage` at `engineSnapshotKey(targetSession.id)`. Callers + * then invoke `bumpSessionVersion(target.id)` to force the React + * EngineProvider to remount + restore the new snapshot. + */ + +import JSZip from "jszip"; + +import type { Annotation } from "@shared/annotation"; +import type { Document, DocumentRepresentation } from "@shared/document"; +import type { EvidenceItem } from "@shared/evidence"; +import { + newId, + type AnnotationId, + type DocumentId, + type RepresentationId, + type SessionId, +} from "@shared/ids"; +import { + parseSessionArchiveManifest, + type SessionArchiveDocumentBinding, + type SessionArchiveManifest, +} from "@shared/session-archive"; + +import { + captureSnapshot, + createEngine, + engineSnapshotKey, + restoreFromStorage, + type SessionService, +} from "@engine/index"; +import type { PdfByteStore } from "@source/index"; + +export interface ImportSessionServices { + readonly sessionService: SessionService; + getOrCreateByteStore(sessionId: SessionId): PdfByteStore; + bumpSessionVersion(sessionId: SessionId): void; + /** Storage shim — defaults to globalThis.localStorage. */ + readonly storage?: Pick; +} + +export type ImportOutcome = "created" | "merged-into"; + +export interface ImportSessionStats { + readonly documentsAdded: number; + readonly documentsDeduped: number; + readonly annotationsAdded: number; + readonly evidenceAdded: number; + readonly linksAdded: number; +} + +export interface ImportSessionResult { + readonly sessionId: SessionId; + readonly outcome: ImportOutcome; + readonly stats: ImportSessionStats; +} + +export class SessionImportError extends Error { + constructor(message: string) { + super(`Session import failed: ${message}`); + this.name = "SessionImportError"; + } +} + +export async function importSessionZip( + file: File | Blob, + services: ImportSessionServices, +): Promise { + const storage = services.storage ?? globalThis.localStorage; + if (!storage) { + throw new SessionImportError("no storage available"); + } + + // 1. Open the zip + parse the manifest. + const zip = await loadZip(file); + const manifestEntry = zip.file("manifest.json"); + if (!manifestEntry) { + throw new SessionImportError("manifest.json missing from archive"); + } + let manifest: SessionArchiveManifest; + try { + const text = await manifestEntry.async("string"); + manifest = parseSessionArchiveManifest(JSON.parse(text)); + } catch (err) { + throw new SessionImportError( + err instanceof Error ? err.message : `manifest parse failed: ${String(err)}`, + ); + } + + // 2. Read all binary files referenced by the manifest. We tolerate + // missing files — they appear as 0 documents added for that binding. + const incomingBytes = new Map(); + for (const binding of manifest.documentBindings) { + const entry = zip.file(`documents/${binding.documentId}.pdf`); + if (entry) { + incomingBytes.set(binding.documentId, await entry.async("uint8array")); + } + } + + // 3. Resolve target session. + const matchingExisting = services.sessionService + .list() + .find((s) => s.name.trim().toLocaleLowerCase() === manifest.session.name.trim().toLocaleLowerCase()); + + let targetSessionId: SessionId; + let outcome: ImportOutcome; + if (matchingExisting) { + targetSessionId = matchingExisting.id; + outcome = "merged-into"; + } else { + const created = services.sessionService.create({ name: manifest.session.name }); + targetSessionId = created.id; + outcome = "created"; + } + + // 4. Build an off-React engine for the target — populated either from + // the target's existing snapshot (merge path) or empty (create path). + const targetEngine = createEngine(); + if (outcome === "merged-into") { + restoreFromStorage(targetEngine, { + key: engineSnapshotKey(targetSessionId), + storage, + }); + } + const targetByteStore = services.getOrCreateByteStore(targetSessionId); + + // 5. Build the document remap. + const docRemap = new Map(); + const existingByFingerprint = new Map(); + for (const doc of targetEngine.documents.list()) { + if (doc.fingerprint) existingByFingerprint.set(doc.fingerprint, doc.id); + } + + let documentsAdded = 0; + let documentsDeduped = 0; + + const incomingDocs = manifest.engine.documents as readonly Document[]; + const incomingReps = manifest.engine.representations as readonly DocumentRepresentation[]; + + for (const binding of manifest.documentBindings) { + const remappedExisting = existingByFingerprint.get(binding.fingerprint); + if (remappedExisting) { + docRemap.set(binding.documentId, remappedExisting); + documentsDeduped += 1; + continue; + } + const incomingDoc = incomingDocs.find((d) => d.id === binding.documentId); + if (!incomingDoc) { + // Manifest pointed to a binding without an engine record for it — + // skip silently, matches the "tolerate missing files" rule. + continue; + } + const newDocId = newId("document"); + const incomingDocReps = incomingReps.filter((r) => r.documentId === binding.documentId); + // Push bytes into the byte store; mint a fresh blob URL on the way. + const bytes = incomingBytes.get(binding.documentId); + const blobUrl = bytes ? targetByteStore.put(newDocId, bytes).blobUrl : undefined; + const newDoc: Document = { + ...incomingDoc, + id: newDocId, + ...(blobUrl !== undefined ? { uri: blobUrl } : {}), + }; + const newReps: DocumentRepresentation[] = incomingDocReps.map((rep) => ({ + ...rep, + id: newId("representation") as RepresentationId, + documentId: newDocId, + })); + const firstRep = newReps[0]; + if (firstRep) { + // Use the service for the first rep so events fire + dedup logic + // in the repos runs. Extra reps go in via the repo directly. + targetEngine.documents.register({ document: newDoc, representation: firstRep }); + for (let i = 1; i < newReps.length; i++) { + targetEngine.repos.representations.create(newReps[i]!); + } + } else { + // Engine snapshot somehow lacks a representation — push the doc + // directly so the snapshot stays self-consistent. + targetEngine.repos.documents.create(newDoc); + } + docRemap.set(binding.documentId, newDocId); + documentsAdded += 1; + } + + // 6. Remap annotations. + const annRemap = new Map(); + let annotationsAdded = 0; + const incomingAnns = manifest.engine.annotations as readonly Annotation[]; + for (const ann of incomingAnns) { + const newDocId = docRemap.get(ann.documentId); + if (!newDocId) continue; // orphan — no doc imported + const newAnnId = newId("annotation"); + const newAnn: Annotation = { + ...ann, + id: newAnnId, + documentId: newDocId, + }; + // Write through the repo + emit AnnotationCreated so any future + // listeners (none in T07 itself) get the event. Mirrors the + // snapshot-restore pattern. + targetEngine.repos.annotations.create(newAnn); + targetEngine.bus.emit({ + type: "AnnotationCreated", + annotationId: newAnnId, + annotation: newAnn, + }); + annRemap.set(ann.id, newAnnId); + annotationsAdded += 1; + } + + // 7. Remap evidence items. + let evidenceAdded = 0; + const incomingEvidence = manifest.engine.evidenceItems as readonly EvidenceItem[]; + for (const item of incomingEvidence) { + const newAnnIds: AnnotationId[] = []; + for (const aid of item.annotationIds) { + const remapped = annRemap.get(aid); + if (remapped) newAnnIds.push(remapped); + } + if (newAnnIds.length === 0) continue; + const newEvId = newId("evidence"); + const newItem: EvidenceItem = { + ...item, + id: newEvId, + annotationIds: newAnnIds, + }; + targetEngine.repos.evidenceItems.create(newItem); + targetEngine.bus.emit({ + type: "EvidenceItemCreated", + evidenceItemId: newEvId, + evidenceItem: newItem, + }); + evidenceAdded += 1; + } + + // 8. EvidenceLinks live on the binder, not the engine snapshot. The + // schema-version-1 manifest does not carry them yet — `linksAdded` + // stays 0 until a future ADR extends the snapshot. + const linksAdded = 0; + + // 9. Persist the merged snapshot directly to the per-session storage + // key. The version bump (below) forces the EngineProvider to remount + // and restore from there. + const snapshot = captureSnapshot(targetEngine); + try { + storage.setItem(engineSnapshotKey(targetSessionId), JSON.stringify(snapshot)); + } catch (err) { + throw new SessionImportError( + `failed to persist target snapshot: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + // 10. Make the target active + bump its version so React picks up the + // new state. + services.sessionService.setActive(targetSessionId); + services.bumpSessionVersion(targetSessionId); + + return { + sessionId: targetSessionId, + outcome, + stats: { + documentsAdded, + documentsDeduped, + annotationsAdded, + evidenceAdded, + linksAdded, + }, + }; +} + +async function loadZip(file: File | Blob): Promise { + try { + // Convert to ArrayBuffer first — JSZip can't always consume a Blob + // in Node (which the test runner uses), but ArrayBuffer is portable. + const buf = await file.arrayBuffer(); + return await JSZip.loadAsync(buf); + } catch (err) { + throw new SessionImportError( + `corrupt ZIP: ${err instanceof Error ? err.message : String(err)}`, + ); + } +} + +// Re-export the binding type for callers that want to inspect manifests. +export type { SessionArchiveDocumentBinding }; diff --git a/src/app/sessions/index.ts b/src/app/sessions/index.ts new file mode 100644 index 0000000..f0d4c05 --- /dev/null +++ b/src/app/sessions/index.ts @@ -0,0 +1,28 @@ +export { UploadDropzone, type UploadDropzoneProps } from "./UploadDropzone"; +export { SampleSessions } from "./SampleSessions"; +export { SessionMenu } from "./SessionMenu"; +export { CreateFirstSession } from "./CreateFirstSession"; +export { Toast, useToast, type ToastTone } from "./Toast"; +export { + EMPTY_ROUTE, + navigateTo, + parseRoute, + serializeRoute, + type AppMode, + type AppRoute, +} from "./routing"; +export { + exportSessionZip, + sessionZipFilename, + triggerSessionDownload, + type ExportSessionZipOptions, + type TriggerDownloadHooks, +} from "./exportSessionZip"; +export { + importSessionZip, + SessionImportError, + type ImportOutcome, + type ImportSessionResult, + type ImportSessionServices, + type ImportSessionStats, +} from "./importSessionZip"; diff --git a/src/app/sessions/routing.test.ts b/src/app/sessions/routing.test.ts new file mode 100644 index 0000000..f73925e --- /dev/null +++ b/src/app/sessions/routing.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import type { SessionId } from "@shared/ids"; + +import { EMPTY_ROUTE, parseRoute, serializeRoute } from "./routing"; + +describe("routing.parseRoute", () => { + it("returns the empty route for an empty hash", () => { + expect(parseRoute("")).toEqual(EMPTY_ROUTE); + expect(parseRoute("#")).toEqual(EMPTY_ROUTE); + expect(parseRoute("#/")).toEqual(EMPTY_ROUTE); + }); + + it("parses #/s/ as review mode for that session", () => { + const route = parseRoute("#/s/sess_abc"); + expect(route.sessionId).toBe("sess_abc"); + expect(route.mode).toBe("review"); + }); + + it("parses #/s//forms/demo as forms mode", () => { + const route = parseRoute("#/s/sess_xyz/forms/demo"); + expect(route.sessionId).toBe("sess_xyz"); + expect(route.mode).toBe("forms"); + }); + + it("treats legacy #/forms/demo as the empty route (session must be chosen first)", () => { + expect(parseRoute("#/forms/demo")).toEqual(EMPTY_ROUTE); + }); + + it("trims trailing slashes", () => { + expect(parseRoute("#/s/sess_abc/")).toMatchObject({ sessionId: "sess_abc" }); + }); +}); + +describe("routing.serializeRoute", () => { + it("returns empty string for the empty route", () => { + expect(serializeRoute(EMPTY_ROUTE)).toBe(""); + }); + + it("round-trips review mode", () => { + const route = { sessionId: "sess_abc" as SessionId, mode: "review" as const }; + expect(serializeRoute(route)).toBe("#/s/sess_abc"); + expect(parseRoute(serializeRoute(route))).toEqual(route); + }); + + it("round-trips forms mode", () => { + const route = { sessionId: "sess_xyz" as SessionId, mode: "forms" as const }; + expect(serializeRoute(route)).toBe("#/s/sess_xyz/forms/demo"); + expect(parseRoute(serializeRoute(route))).toEqual(route); + }); +}); diff --git a/src/app/sessions/routing.ts b/src/app/sessions/routing.ts new file mode 100644 index 0000000..0779dd7 --- /dev/null +++ b/src/app/sessions/routing.ts @@ -0,0 +1,61 @@ +/** + * Hash routing for the demo app. + * + * #/ → empty state ("create your first session") + * #/s/ → review mode, scoped to + * #/s//forms/demo → forms mode, scoped to + * + * The hash is the single source of truth for the active session and the + * active mode. `SessionProvider.setActive(...)` is wired as a side + * effect of hash changes so back/forward and deep links behave + * naturally. + */ + +import type { SessionId } from "@shared/ids"; + +export type AppMode = "review" | "forms"; + +export interface AppRoute { + readonly sessionId: SessionId | null; + readonly mode: AppMode; +} + +export const EMPTY_ROUTE: AppRoute = { sessionId: null, mode: "review" }; + +export function parseRoute(hash: string): AppRoute { + // Normalise: drop leading "#", trim any trailing slashes. + const cleaned = hash.replace(/^#/, "").replace(/^\/+|\/+$/g, ""); + if (cleaned === "") return EMPTY_ROUTE; + const parts = cleaned.split("/"); + if (parts.length >= 2 && parts[0] === "s") { + const sessionId = parts[1]! as SessionId; + const mode: AppMode = + parts[2] === "forms" && parts[3] === "demo" ? "forms" : "review"; + return { sessionId, mode }; + } + // Legacy `#/forms/demo` (pre-CE-WP-0005) maps to the empty state — the + // user has to pick a session first. + return EMPTY_ROUTE; +} + +export function serializeRoute(route: AppRoute): string { + if (!route.sessionId) return ""; + const base = `#/s/${route.sessionId}`; + return route.mode === "forms" ? `${base}/forms/demo` : base; +} + +export function navigateTo(route: AppRoute): void { + if (typeof window === "undefined") return; + const target = serializeRoute(route); + if (target === "") { + // Clear the hash entirely so the URL stays clean. + history.replaceState(null, "", window.location.pathname + window.location.search); + // history.replaceState doesn't fire hashchange — dispatch one so + // subscribers re-read. + window.dispatchEvent(new HashChangeEvent("hashchange")); + return; + } + if (window.location.hash !== target) { + window.location.hash = target; + } +} diff --git a/src/engine/events/types.ts b/src/engine/events/types.ts index eb2ec21..ae85307 100644 --- a/src/engine/events/types.ts +++ b/src/engine/events/types.ts @@ -21,7 +21,9 @@ import type { EvidenceItemId, EvidenceLinkId, RepresentationId, + SessionId, } from "@shared/ids"; +import type { Session } from "@shared/session"; export interface DocumentImportedEvent { readonly type: "DocumentImported"; @@ -36,6 +38,11 @@ export interface DocumentRepresentationGeneratedEvent { readonly representation: DocumentRepresentation; } +export interface DocumentRemovedEvent { + readonly type: "DocumentRemoved"; + readonly documentId: DocumentId; +} + export interface AnnotationCreatedEvent { readonly type: "AnnotationCreated"; readonly annotationId: AnnotationId; @@ -92,9 +99,34 @@ export interface FormFieldActivatedEvent { readonly previousTarget?: EvidenceTarget; } +export interface SessionCreatedEvent { + readonly type: "SessionCreated"; + readonly sessionId: SessionId; + readonly session: Session; +} + +export interface SessionRenamedEvent { + readonly type: "SessionRenamed"; + readonly sessionId: SessionId; + readonly session: Session; + readonly previousName: string; +} + +export interface SessionDeletedEvent { + readonly type: "SessionDeleted"; + readonly sessionId: SessionId; +} + +export interface SessionActivatedEvent { + readonly type: "SessionActivated"; + readonly sessionId: SessionId | null; + readonly previousSessionId: SessionId | null; +} + export type EngineEvent = | DocumentImportedEvent | DocumentRepresentationGeneratedEvent + | DocumentRemovedEvent | AnnotationCreatedEvent | AnnotationResolvedEvent | AnnotationResolutionFailedEvent @@ -103,7 +135,11 @@ export type EngineEvent = | EvidenceItemActivatedEvent | EvidenceLinkCreatedEvent | EvidenceLinkUpdatedEvent - | FormFieldActivatedEvent; + | FormFieldActivatedEvent + | SessionCreatedEvent + | SessionRenamedEvent + | SessionDeletedEvent + | SessionActivatedEvent; export type EngineEventType = EngineEvent["type"]; diff --git a/src/engine/repos/in-memory-sessions.ts b/src/engine/repos/in-memory-sessions.ts new file mode 100644 index 0000000..6975790 --- /dev/null +++ b/src/engine/repos/in-memory-sessions.ts @@ -0,0 +1,47 @@ +/** + * In-memory `Map`-backed `SessionRepository`. + * + * Sister to `in-memory.ts` but for `Session` objects. The session repo + * lives *outside* the per-session `Engine` (one repo for the whole app; + * each session's engine snapshot is keyed by `session.id`). Keeping it + * in the same `engine/repos` directory keeps the storage layer + * conventions together so the eventual ADR-0005 swap touches one + * neighbourhood. + */ + +import type { SessionId } from "@shared/ids"; +import type { Session } from "@shared/session"; + +export interface SessionRepository { + create(session: Session): Session; + get(id: SessionId): Session | null; + list(): readonly Session[]; + update(session: Session): Session; + delete(id: SessionId): boolean; +} + +export function createInMemorySessionRepository(): SessionRepository { + const sessions = new Map(); + return { + create(session) { + sessions.set(session.id, session); + return session; + }, + get(id) { + return sessions.get(id) ?? null; + }, + list() { + return [...sessions.values()]; + }, + update(session) { + if (!sessions.has(session.id)) { + throw new Error(`SessionRepository.update: unknown id ${session.id}`); + } + sessions.set(session.id, session); + return session; + }, + delete(id) { + return sessions.delete(id); + }, + }; +} diff --git a/src/engine/repos/in-memory.ts b/src/engine/repos/in-memory.ts index 1fe6fb5..0dc1331 100644 --- a/src/engine/repos/in-memory.ts +++ b/src/engine/repos/in-memory.ts @@ -25,12 +25,14 @@ export interface DocumentRepository { get(id: DocumentId): Document | null; list(): readonly Document[]; update(document: Document): Document; + delete(id: DocumentId): boolean; } export interface RepresentationRepository { create(representation: DocumentRepresentation): DocumentRepresentation; get(id: RepresentationId): DocumentRepresentation | null; listByDocument(documentId: DocumentId): readonly DocumentRepresentation[]; + deleteByDocument(documentId: DocumentId): number; } export interface AnnotationRepository { @@ -82,6 +84,9 @@ export function createInMemoryRepos(): InMemoryRepos { documents.set(document.id, document); return document; }, + delete(id) { + return documents.delete(id); + }, }, representations: { create(representation) { @@ -98,6 +103,16 @@ export function createInMemoryRepos(): InMemoryRepos { } return out; }, + deleteByDocument(documentId) { + let removed = 0; + for (const [id, rep] of representations) { + if (rep.documentId === documentId) { + representations.delete(id); + removed += 1; + } + } + return removed; + }, }, annotations: { create(annotation) { diff --git a/src/engine/repos/index.ts b/src/engine/repos/index.ts index 9f96c4c..350233c 100644 --- a/src/engine/repos/index.ts +++ b/src/engine/repos/index.ts @@ -6,3 +6,7 @@ export { type AnnotationRepository, type EvidenceItemRepository, } from "./in-memory"; +export { + createInMemorySessionRepository, + type SessionRepository, +} from "./in-memory-sessions"; diff --git a/src/engine/services/documents.ts b/src/engine/services/documents.ts index d1cafc0..307a15e 100644 --- a/src/engine/services/documents.ts +++ b/src/engine/services/documents.ts @@ -23,6 +23,7 @@ export interface DocumentService { list(): readonly Document[]; getRepresentation(id: RepresentationId): DocumentRepresentation | null; listRepresentations(documentId: DocumentId): readonly DocumentRepresentation[]; + remove(id: DocumentId): boolean; } export function createDocumentService( @@ -59,5 +60,15 @@ export function createDocumentService( listRepresentations(documentId) { return representations.listByDocument(documentId); }, + remove(id) { + const existing = documents.get(id); + if (!existing) return false; + representations.deleteByDocument(id); + const removed = documents.delete(id); + if (removed) { + bus.emit({ type: "DocumentRemoved", documentId: id }); + } + return removed; + }, }; } diff --git a/src/engine/services/index.ts b/src/engine/services/index.ts index bdce285..19268a0 100644 --- a/src/engine/services/index.ts +++ b/src/engine/services/index.ts @@ -12,3 +12,16 @@ export { type EvidenceService, type CreateEvidenceItemInput, } from "./evidence"; +export { + ACTIVE_SESSION_KEY, + attachSessionPersister, + createSessionService, + DuplicateSessionNameError, + engineSnapshotKey, + restoreSessionsFromStorage, + SESSIONS_INDEX_KEY, + type CreateSessionInput, + type RestoreSessionsResult, + type SessionPersisterOptions, + type SessionService, +} from "./sessions"; diff --git a/src/engine/services/sessions.test.ts b/src/engine/services/sessions.test.ts new file mode 100644 index 0000000..307ba79 --- /dev/null +++ b/src/engine/services/sessions.test.ts @@ -0,0 +1,204 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import type { SessionId } from "@shared/ids"; +import type { Session } from "@shared/session"; + +import { createEventBus, type EventBus, type EngineEvent } from "../events"; +import { + createInMemorySessionRepository, + type SessionRepository, +} from "../repos"; +import { + ACTIVE_SESSION_KEY, + attachSessionPersister, + createSessionService, + DuplicateSessionNameError, + engineSnapshotKey, + restoreSessionsFromStorage, + SESSIONS_INDEX_KEY, + type SessionService, +} from "./sessions"; + +function memoryStorage(): Pick { + const map = new Map(); + return { + getItem: (k) => map.get(k) ?? null, + setItem: (k, v) => void map.set(k, v), + removeItem: (k) => void map.delete(k), + }; +} + +function freshService(): { + service: SessionService; + repo: SessionRepository; + bus: EventBus; + events: EngineEvent[]; +} { + const repo = createInMemorySessionRepository(); + const bus = createEventBus(); + const events: EngineEvent[] = []; + bus.onAny((e) => events.push(e)); + const service = createSessionService(repo, bus); + return { service, repo, bus, events }; +} + +describe("engineSnapshotKey", () => { + it("formats as citation-evidence:session::engine-snapshot:v1", () => { + expect(engineSnapshotKey("sess_abc" as SessionId)).toBe( + "citation-evidence:session:sess_abc:engine-snapshot:v1", + ); + }); +}); + +describe("SessionService — lifecycle", () => { + let s: ReturnType; + beforeEach(() => { + s = freshService(); + }); + + it("creates a session and emits SessionCreated", () => { + const created = s.service.create("Lease 2024"); + expect(created.name).toBe("Lease 2024"); + expect(created.id).toMatch(/^sess_/); + expect(s.events).toHaveLength(1); + expect(s.events[0]!.type).toBe("SessionCreated"); + }); + + it("trims whitespace in names", () => { + const created = s.service.create(" Trimmed "); + expect(created.name).toBe("Trimmed"); + }); + + it("rejects empty names", () => { + expect(() => s.service.create(" ")).toThrow(/must not be empty/); + }); + + it("rejects case-insensitive duplicates", () => { + s.service.create("Demo"); + expect(() => s.service.create("demo")).toThrow(DuplicateSessionNameError); + expect(() => s.service.create(" Demo ")).toThrow(DuplicateSessionNameError); + }); + + it("rename emits SessionRenamed with previousName", () => { + const created = s.service.create("Old"); + s.events.length = 0; + const renamed = s.service.rename(created.id, "New"); + expect(renamed.name).toBe("New"); + expect(s.events).toHaveLength(1); + const evt = s.events[0]!; + expect(evt.type).toBe("SessionRenamed"); + if (evt.type === "SessionRenamed") { + expect(evt.previousName).toBe("Old"); + } + }); + + it("rename rejects a duplicate (but allows renaming to your own current name)", () => { + const a = s.service.create("Alpha"); + s.service.create("Beta"); + expect(() => s.service.rename(a.id, "Beta")).toThrow(DuplicateSessionNameError); + // self-rename is fine + const same = s.service.rename(a.id, "Alpha"); + expect(same.name).toBe("Alpha"); + }); + + it("delete emits SessionDeleted and clears active if it was the active one", () => { + const created = s.service.create("To Delete"); + s.service.setActive(created.id); + s.events.length = 0; + const ok = s.service.delete(created.id); + expect(ok).toBe(true); + const types = s.events.map((e) => e.type); + expect(types).toContain("SessionActivated"); // active cleared first + expect(types).toContain("SessionDeleted"); + expect(s.service.getActive()).toBeNull(); + }); + + it("delete on an unknown id is a no-op (returns false, no events)", () => { + const ok = s.service.delete("sess_missing" as SessionId); + expect(ok).toBe(false); + expect(s.events).toHaveLength(0); + }); + + it("setActive on an unknown id throws", () => { + expect(() => s.service.setActive("sess_nope" as SessionId)).toThrow(/unknown session/); + }); + + it("setActive emits SessionActivated with previousSessionId", () => { + const a = s.service.create("A"); + const b = s.service.create("B"); + s.events.length = 0; + s.service.setActive(a.id); + s.service.setActive(b.id); + const activated = s.events.filter((e) => e.type === "SessionActivated"); + expect(activated).toHaveLength(2); + if (activated[1]!.type === "SessionActivated") { + expect(activated[1]!.previousSessionId).toBe(a.id); + expect(activated[1]!.sessionId).toBe(b.id); + } + }); + + it("setActive to the same id is a no-op (no event)", () => { + const a = s.service.create("A"); + s.service.setActive(a.id); + s.events.length = 0; + s.service.setActive(a.id); + expect(s.events).toHaveLength(0); + }); + + it("setActive stamps lastOpenedAt", () => { + const a = s.service.create("A"); + expect(a.lastOpenedAt).toBeUndefined(); + s.service.setActive(a.id); + const reread = s.service.get(a.id); + expect(reread?.lastOpenedAt).toBeTruthy(); + }); +}); + +describe("attachSessionPersister + restoreSessionsFromStorage", () => { + it("round-trips a session index through storage", () => { + const storage = memoryStorage(); + const src = freshService(); + attachSessionPersister(src.service, src.bus, { storage }); + + const a = src.service.create("Alpha"); + const b = src.service.create("Beta"); + src.service.setActive(b.id); + + // Read-back into a fresh service. + const dst = freshService(); + const result = restoreSessionsFromStorage(dst.repo, dst.service, { storage }); + expect(result.restored).toBe(true); + expect(result.sessions.map((s: Session) => s.name).sort()).toEqual(["Alpha", "Beta"]); + expect(result.activeSessionId).toBe(b.id); + expect(dst.service.getActive()).toBe(b.id); + // a is still in the repo too + expect(dst.service.get(a.id)?.name).toBe("Alpha"); + }); + + it("returns {restored:false} when storage is empty", () => { + const storage = memoryStorage(); + const dst = freshService(); + const result = restoreSessionsFromStorage(dst.repo, dst.service, { storage }); + expect(result.restored).toBe(false); + expect(result.sessions).toHaveLength(0); + expect(result.activeSessionId).toBeNull(); + }); + + it("delete clears both the index entry and the per-session snapshot key", () => { + const storage = memoryStorage(); + const src = freshService(); + attachSessionPersister(src.service, src.bus, { storage }); + const created = src.service.create("Doomed"); + // Pretend an engine snapshot was written by the per-session persister. + storage.setItem(engineSnapshotKey(created.id), "{}"); + + src.service.delete(created.id); + + expect(storage.getItem(engineSnapshotKey(created.id))).toBeNull(); + const raw = storage.getItem(SESSIONS_INDEX_KEY); + expect(raw).not.toBeNull(); + const parsed = JSON.parse(raw!); + expect(parsed.sessions).toHaveLength(0); + expect(storage.getItem(ACTIVE_SESSION_KEY)).toBeNull(); + }); +}); diff --git a/src/engine/services/sessions.ts b/src/engine/services/sessions.ts new file mode 100644 index 0000000..6919ae5 --- /dev/null +++ b/src/engine/services/sessions.ts @@ -0,0 +1,302 @@ +/** + * SessionService — lifecycle for `Session` records. + * + * Lives *above* the per-session `Engine` (the engine itself is recreated + * each time the active session changes). The service owns its own + * `EventBus` instance — independent of any engine bus — but uses the + * same `EngineEvent` shape so consumers can subscribe with the standard + * `bus.on("SessionCreated", …)` pattern. + * + * Per-session engine snapshot persistence is handled by attaching the + * existing `attachPersister(engine, { key: engineSnapshotKey(sessionId) })` + * inside the app's `EngineProvider`. The helpers in this file own the + * *cross-session* storage: the session index + the active-session + * pointer. + * + * Naming rules: + * - Names are trimmed on input. + * - Case-insensitive uniqueness — two sessions cannot coexist with + * names that differ only in case ("Demo" vs "demo"). This avoids + * surprising the ZIP-merge path in T07, which uses `session.name` + * to find an existing target. + */ + +import { newId } from "@shared/ids"; +import type { SessionId } from "@shared/ids"; +import type { Session } from "@shared/session"; + +import type { EventBus } from "../events"; +import type { SessionRepository } from "../repos"; + +const SESSIONS_INDEX_KEY = "citation-evidence:sessions:v1"; +const ACTIVE_SESSION_KEY = "citation-evidence:active-session-id:v1"; + +export { SESSIONS_INDEX_KEY, ACTIVE_SESSION_KEY }; + +/** + * Build the engine-snapshot storage key for a given session. + * + * Format: `citation-evidence:session::engine-snapshot:v1`. + * The `v1` tail leaves room for a future migration that changes the + * snapshot shape without sweeping the legacy keys. + */ +export function engineSnapshotKey(sessionId: SessionId): string { + return `citation-evidence:session:${sessionId}:engine-snapshot:v1`; +} + +export class DuplicateSessionNameError extends Error { + constructor(name: string) { + super(`Session with name "${name}" already exists`); + this.name = "DuplicateSessionNameError"; + } +} + +export interface CreateSessionInput { + readonly name: string; + /** Override the generated id — primarily for tests and importers. */ + readonly id?: SessionId; + readonly now?: string; +} + +export interface SessionService { + create(input: string | CreateSessionInput): Session; + rename(id: SessionId, name: string): Session; + delete(id: SessionId): boolean; + list(): readonly Session[]; + get(id: SessionId): Session | null; + setActive(id: SessionId | null): void; + getActive(): SessionId | null; + /** Record an "I just opened this" timestamp on the session. */ + touch(id: SessionId): Session | null; +} + +function nowIso(now?: string): string { + return now ?? new Date().toISOString(); +} + +function normalisedName(name: string): { display: string; key: string } { + const display = name.trim(); + return { display, key: display.toLocaleLowerCase() }; +} + +export function createSessionService( + repo: SessionRepository, + bus: EventBus, +): SessionService { + let activeId: SessionId | null = null; + + function assertUniqueName(name: string, exceptId?: SessionId) { + const { key } = normalisedName(name); + for (const existing of repo.list()) { + if (exceptId && existing.id === exceptId) continue; + if (existing.name.trim().toLocaleLowerCase() === key) { + throw new DuplicateSessionNameError(name); + } + } + } + + return { + create(input) { + const { name, id, now } = + typeof input === "string" ? { name: input, id: undefined, now: undefined } : input; + const { display } = normalisedName(name); + if (display.length === 0) { + throw new Error("SessionService.create: name must not be empty"); + } + assertUniqueName(display); + const ts = nowIso(now); + const session: Session = { + id: id ?? newId("session"), + name: display, + createdAt: ts, + updatedAt: ts, + }; + const stored = repo.create(session); + bus.emit({ type: "SessionCreated", sessionId: stored.id, session: stored }); + return stored; + }, + rename(id, name) { + const existing = repo.get(id); + if (!existing) { + throw new Error(`SessionService.rename: unknown session ${id}`); + } + const { display } = normalisedName(name); + if (display.length === 0) { + throw new Error("SessionService.rename: name must not be empty"); + } + assertUniqueName(display, id); + const previousName = existing.name; + const next: Session = { + ...existing, + name: display, + updatedAt: nowIso(), + }; + const stored = repo.update(next); + bus.emit({ + type: "SessionRenamed", + sessionId: stored.id, + session: stored, + previousName, + }); + return stored; + }, + delete(id) { + const removed = repo.delete(id); + if (removed) { + if (activeId === id) { + const previousSessionId = activeId; + activeId = null; + bus.emit({ + type: "SessionActivated", + sessionId: null, + previousSessionId, + }); + } + bus.emit({ type: "SessionDeleted", sessionId: id }); + } + return removed; + }, + list() { + return repo.list(); + }, + get(id) { + return repo.get(id); + }, + setActive(id) { + if (id !== null && !repo.get(id)) { + throw new Error(`SessionService.setActive: unknown session ${id}`); + } + if (id === activeId) return; + const previousSessionId = activeId; + activeId = id; + if (id) { + const existing = repo.get(id); + if (existing) { + repo.update({ ...existing, lastOpenedAt: nowIso() }); + } + } + bus.emit({ + type: "SessionActivated", + sessionId: id, + previousSessionId, + }); + }, + getActive() { + return activeId; + }, + touch(id) { + const existing = repo.get(id); + if (!existing) return null; + return repo.update({ ...existing, lastOpenedAt: nowIso() }); + }, + }; +} + +// --------------------------------------------------------------------------- +// Cross-session persistence (the session index + active-session pointer). +// Per-session engine snapshots are handled by `attachPersister` against +// `engineSnapshotKey(sessionId)`. +// --------------------------------------------------------------------------- + +export interface SessionPersisterOptions { + readonly storage?: Pick; +} + +interface SessionsFile { + readonly version: 1; + readonly sessions: readonly Session[]; + readonly activeSessionId: SessionId | null; +} + +export function attachSessionPersister( + service: SessionService, + bus: EventBus, + options: SessionPersisterOptions = {}, +): () => void { + const storage = options.storage ?? globalThis.localStorage; + const writeIndex = () => { + const file: SessionsFile = { + version: 1, + sessions: service.list(), + activeSessionId: service.getActive(), + }; + try { + storage.setItem(SESSIONS_INDEX_KEY, JSON.stringify(file)); + if (file.activeSessionId) { + storage.setItem(ACTIVE_SESSION_KEY, file.activeSessionId); + } else { + storage.removeItem(ACTIVE_SESSION_KEY); + } + } catch (err) { + console.warn("attachSessionPersister: write failed", err); + } + }; + const writeIndexAndCleanup = () => { + writeIndex(); + }; + const writeOnDelete = (sessionId: SessionId) => { + writeIndex(); + try { + storage.removeItem(engineSnapshotKey(sessionId)); + } catch (err) { + console.warn("attachSessionPersister: snapshot cleanup failed", err); + } + }; + const offs = [ + bus.on("SessionCreated", writeIndexAndCleanup), + bus.on("SessionRenamed", writeIndexAndCleanup), + bus.on("SessionActivated", writeIndexAndCleanup), + bus.on("SessionDeleted", (e) => writeOnDelete(e.sessionId)), + ]; + return () => { + for (const off of offs) off(); + }; +} + +export interface RestoreSessionsResult { + readonly restored: boolean; + readonly sessions: readonly Session[]; + readonly activeSessionId: SessionId | null; +} + +/** + * Hydrate the session repo from storage *without* firing events. Mirrors + * `restoreSnapshot`'s "direct repo write" pattern so the persister + * (which is attached after restore) doesn't immediately re-write what + * we just read. + */ +export function restoreSessionsFromStorage( + repo: SessionRepository, + service: SessionService, + options: SessionPersisterOptions = {}, +): RestoreSessionsResult { + const storage = options.storage ?? globalThis.localStorage; + const raw = storage.getItem(SESSIONS_INDEX_KEY); + if (!raw) return { restored: false, sessions: [], activeSessionId: null }; + try { + const parsed = JSON.parse(raw) as Partial; + if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.sessions)) { + return { restored: false, sessions: [], activeSessionId: null }; + } + for (const s of parsed.sessions) repo.create(s); + const activeRaw = + typeof parsed.activeSessionId === "string" ? parsed.activeSessionId : null; + const fallbackActiveRaw = storage.getItem(ACTIVE_SESSION_KEY); + const candidate = (activeRaw ?? fallbackActiveRaw) as SessionId | null; + let activeSessionId: SessionId | null = null; + if (candidate && repo.get(candidate)) { + activeSessionId = candidate; + // Use service.setActive to keep the in-memory activeId aligned — + // suppress the resulting event so we don't bounce the persister. + // The bus listener attached *after* restore is what does the + // persistence, so emitting here is harmless either way; but + // skipping it keeps restore strictly side-effect-free from the + // listener's point of view. + service.setActive(activeSessionId); + } + return { restored: true, sessions: repo.list(), activeSessionId }; + } catch (err) { + console.warn("restoreSessionsFromStorage: parse failed", err); + return { restored: false, sessions: [], activeSessionId: null }; + } +} diff --git a/src/engine/session-snapshot.test.ts b/src/engine/session-snapshot.test.ts new file mode 100644 index 0000000..0bb388b --- /dev/null +++ b/src/engine/session-snapshot.test.ts @@ -0,0 +1,98 @@ +/** + * Per-session engine snapshot round-trip. + * + * The workplan (CE-WP-0005-T01) requires that two sessions persisted + * under the per-session key scheme can each be restored independently + * — proving the storage layout actually partitions data by session. + */ + +import { describe, expect, it } from "vitest"; + +import type { Document, DocumentRepresentation } from "@shared/document"; +import type { DocumentId, RepresentationId, SessionId } from "@shared/ids"; + +import { + attachPersister, + createEngine, + engineSnapshotKey, + restoreFromStorage, + type Engine, + type EngineSnapshot, +} from "./index"; + +function memoryStorage(): Pick { + const map = new Map(); + return { + getItem: (k) => map.get(k) ?? null, + setItem: (k, v) => void map.set(k, v), + removeItem: (k) => void map.delete(k), + }; +} + +function seedDoc(engine: Engine, label: string): { id: DocumentId } { + const id = `doc_${label}` as DocumentId; + const repId = `rep_${label}` as RepresentationId; + const document: Document = { + id, + mediaType: "application/pdf", + title: `Doc ${label}`, + createdAt: "2026-05-25T00:00:00.000Z", + updatedAt: "2026-05-25T00:00:00.000Z", + }; + const representation: DocumentRepresentation = { + id: repId, + documentId: id, + representationType: "pdf-text", + contentHash: `hash-${label}`, + canonicalText: `text for ${label}`, + pageMap: [{ page: 1, width: 100, height: 100 }], + offsetMap: [{ page: 1, globalStart: 0, globalEnd: 12, pageLength: 12 }], + generatedAt: "2026-05-25T00:00:00.000Z", + }; + engine.documents.register({ document, representation }); + return { id }; +} + +describe("per-session engine snapshot round-trip", () => { + it("keeps two sessions' snapshots isolated under per-session storage keys", () => { + const storage = memoryStorage(); + const sessA = "sess_aaa" as SessionId; + const sessB = "sess_bbb" as SessionId; + + // Author session A. + const engineA = createEngine(); + const offA = attachPersister(engineA, { key: engineSnapshotKey(sessA), storage }); + const a1 = seedDoc(engineA, "a1"); + const a2 = seedDoc(engineA, "a2"); + offA(); + + // Author session B with completely different documents. + const engineB = createEngine(); + const offB = attachPersister(engineB, { key: engineSnapshotKey(sessB), storage }); + const b1 = seedDoc(engineB, "b1"); + offB(); + + // Restore each into its own fresh engine; assert isolation. + const restoredA = createEngine(); + const resA = restoreFromStorage(restoredA, { key: engineSnapshotKey(sessA), storage }); + expect(resA.restored).toBe(true); + const aIds = restoredA.documents.list().map((d) => d.id).sort(); + expect(aIds).toEqual([a1.id, a2.id].sort()); + + const restoredB = createEngine(); + const resB = restoreFromStorage(restoredB, { key: engineSnapshotKey(sessB), storage }); + expect(resB.restored).toBe(true); + const bIds = restoredB.documents.list().map((d) => d.id); + expect(bIds).toEqual([b1.id]); + + // Sanity: each snapshot key really does hold a distinct snapshot. + const rawA = storage.getItem(engineSnapshotKey(sessA)); + const rawB = storage.getItem(engineSnapshotKey(sessB)); + expect(rawA).not.toBeNull(); + expect(rawB).not.toBeNull(); + const snapA = JSON.parse(rawA!) as EngineSnapshot; + const snapB = JSON.parse(rawB!) as EngineSnapshot; + expect(snapA.documents).toHaveLength(2); + expect(snapB.documents).toHaveLength(1); + }); +}); diff --git a/src/shared/ids.ts b/src/shared/ids.ts index 7132149..50d9e86 100644 --- a/src/shared/ids.ts +++ b/src/shared/ids.ts @@ -19,6 +19,7 @@ export type EvidenceSetId = Brand; export type EvidenceLinkId = Brand; export type CitationCardId = Brand; export type CitationRecoveryAttemptId = Brand; +export type SessionId = Brand; export type IdKindMap = { document: DocumentId; @@ -29,6 +30,7 @@ export type IdKindMap = { "evidence-link": EvidenceLinkId; "citation-card": CitationCardId; "citation-recovery": CitationRecoveryAttemptId; + session: SessionId; }; export type IdKind = keyof IdKindMap; @@ -42,6 +44,7 @@ const PREFIXES: Record = { "evidence-link": "evlink", "citation-card": "card", "citation-recovery": "crec", + session: "sess", }; /** diff --git a/src/shared/index.ts b/src/shared/index.ts index 7b711ee..3403604 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -8,4 +8,6 @@ export * from "./evidence-set"; export * from "./citation-card"; export * from "./citation-card-source"; export * from "./open-context-url"; +export * from "./session"; +export * from "./session-archive"; export { normalize, NORMALIZE_VERSION } from "./text/normalize"; diff --git a/src/shared/session-archive.test.ts b/src/shared/session-archive.test.ts new file mode 100644 index 0000000..c4d92a0 --- /dev/null +++ b/src/shared/session-archive.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; + +import type { DocumentId, SessionId } from "./ids"; +import { + parseSessionArchiveManifest, + SESSION_ARCHIVE_SCHEMA_VERSION, + SessionArchiveParseError, + type SessionArchiveManifest, +} from "./session-archive"; + +function validManifest(): SessionArchiveManifest { + return { + schemaVersion: SESSION_ARCHIVE_SCHEMA_VERSION, + exportedAt: "2026-05-25T00:00:00.000Z", + session: { + id: "sess_abc" as SessionId, + name: "Demo", + createdAt: "2026-05-25T00:00:00.000Z", + updatedAt: "2026-05-25T00:00:00.000Z", + }, + engine: { + version: 1, + documents: [], + representations: [], + annotations: [], + evidenceItems: [], + }, + documentBindings: [ + { + documentId: "doc_abc" as DocumentId, + filename: "demo.pdf", + fingerprint: "abc123", + }, + ], + }; +} + +describe("parseSessionArchiveManifest", () => { + it("round-trips a valid manifest through JSON.stringify + parse", () => { + const m = validManifest(); + const round = parseSessionArchiveManifest(JSON.parse(JSON.stringify(m))); + expect(round).toEqual(m); + }); + + it("rejects an unsupported schemaVersion", () => { + const m = { ...validManifest(), schemaVersion: 999 as unknown }; + expect(() => parseSessionArchiveManifest(m)).toThrow(SessionArchiveParseError); + expect(() => parseSessionArchiveManifest(m)).toThrow(/unsupported schemaVersion/); + }); + + it("rejects a missing required top-level field", () => { + const m = validManifest(); + const broken = { ...m, exportedAt: undefined as unknown }; + expect(() => parseSessionArchiveManifest(broken)).toThrow(/exportedAt/); + }); + + it("rejects a malformed session sub-object", () => { + const m = validManifest(); + const broken = { ...m, session: { ...m.session, id: 12345 as unknown } }; + expect(() => parseSessionArchiveManifest(broken)).toThrow(/session\.id/); + }); + + it("rejects a malformed engine snapshot", () => { + const m = validManifest(); + const broken = { ...m, engine: { ...m.engine, version: "1" as unknown } }; + expect(() => parseSessionArchiveManifest(broken)).toThrow(/engine\.version/); + }); + + it("rejects a non-array documentBindings", () => { + const m = validManifest(); + const broken = { ...m, documentBindings: "nope" as unknown }; + expect(() => parseSessionArchiveManifest(broken)).toThrow(/documentBindings/); + }); + + it("rejects a malformed documentBindings entry", () => { + const m = validManifest(); + const broken = { + ...m, + documentBindings: [{ documentId: "doc_x", fingerprint: "abc" }] as unknown[], + }; + expect(() => parseSessionArchiveManifest(broken)).toThrow(/documentBindings\[0\]\.filename/); + }); + + it("rejects a non-object root", () => { + expect(() => parseSessionArchiveManifest("oops")).toThrow(/manifest/); + expect(() => parseSessionArchiveManifest(null)).toThrow(/manifest/); + }); +}); diff --git a/src/shared/session-archive.ts b/src/shared/session-archive.ts new file mode 100644 index 0000000..e4a3a6d --- /dev/null +++ b/src/shared/session-archive.ts @@ -0,0 +1,150 @@ +/** + * SessionArchiveManifest — JSON contract for `manifest.json` inside a + * session ZIP archive. + * + * Schema version 1; see `docs/decisions/ADR-0008-session-archive-format.md` + * for the authoritative spec. This module is the executable contract: + * `parseSessionArchiveManifest` validates an `unknown` JSON value and + * narrows it to `SessionArchiveManifest` or throws with a useful + * message. + * + * The `engine` field re-uses the in-memory `EngineSnapshot` shape so + * the in-memory ↔ archive round-trip stays a one-way conversion. + * Only minimal structural validation runs here; the engine helpers + * (`restoreSnapshot`) handle deeper validation when actually + * hydrating an engine. + */ + +import type { DocumentId, SessionId } from "./ids"; + +export const SESSION_ARCHIVE_SCHEMA_VERSION = 1 as const; + +export interface SessionArchiveSessionRecord { + readonly id: SessionId; + readonly name: string; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface SessionArchiveDocumentBinding { + readonly documentId: DocumentId; + readonly filename: string; + readonly fingerprint: string; +} + +/** + * Mirror of `EngineSnapshot` — kept loose here (record of unknown) to + * avoid the cross-package dependency back into `@engine`. The engine + * persistence layer owns the authoritative shape. + */ +export interface SessionArchiveEngineSnapshot { + readonly version: number; + readonly documents: readonly unknown[]; + readonly representations: readonly unknown[]; + readonly annotations: readonly unknown[]; + readonly evidenceItems: readonly unknown[]; +} + +export interface SessionArchiveManifest { + readonly schemaVersion: typeof SESSION_ARCHIVE_SCHEMA_VERSION; + readonly exportedAt: string; + readonly session: SessionArchiveSessionRecord; + readonly engine: SessionArchiveEngineSnapshot; + readonly documentBindings: readonly SessionArchiveDocumentBinding[]; +} + +export class SessionArchiveParseError extends Error { + constructor(message: string) { + super(`SessionArchiveManifest parse failed: ${message}`); + this.name = "SessionArchiveParseError"; + } +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function assertString(value: unknown, field: string): string { + if (typeof value !== "string") { + throw new SessionArchiveParseError(`field "${field}" must be a string`); + } + return value; +} + +function assertObject(value: unknown, field: string): Record { + if (!isObject(value)) { + throw new SessionArchiveParseError(`field "${field}" must be an object`); + } + return value; +} + +function assertArray(value: unknown, field: string): readonly unknown[] { + if (!Array.isArray(value)) { + throw new SessionArchiveParseError(`field "${field}" must be an array`); + } + return value; +} + +function parseSessionRecord(raw: unknown): SessionArchiveSessionRecord { + const obj = assertObject(raw, "session"); + return { + id: assertString(obj.id, "session.id") as SessionId, + name: assertString(obj.name, "session.name"), + createdAt: assertString(obj.createdAt, "session.createdAt"), + updatedAt: assertString(obj.updatedAt, "session.updatedAt"), + }; +} + +function parseDocumentBinding( + raw: unknown, + index: number, +): SessionArchiveDocumentBinding { + const obj = assertObject(raw, `documentBindings[${index}]`); + return { + documentId: assertString(obj.documentId, `documentBindings[${index}].documentId`) as DocumentId, + filename: assertString(obj.filename, `documentBindings[${index}].filename`), + fingerprint: assertString(obj.fingerprint, `documentBindings[${index}].fingerprint`), + }; +} + +function parseEngineSnapshot(raw: unknown): SessionArchiveEngineSnapshot { + const obj = assertObject(raw, "engine"); + const version = obj.version; + if (typeof version !== "number") { + throw new SessionArchiveParseError(`field "engine.version" must be a number`); + } + const documents = assertArray(obj.documents, "engine.documents"); + const representations = assertArray(obj.representations, "engine.representations"); + const annotations = assertArray(obj.annotations, "engine.annotations"); + const evidenceItems = assertArray(obj.evidenceItems, "engine.evidenceItems"); + return { + version, + documents, + representations, + annotations, + evidenceItems, + }; +} + +export function parseSessionArchiveManifest(raw: unknown): SessionArchiveManifest { + const obj = assertObject(raw, "manifest"); + const schemaVersion = obj.schemaVersion; + if (schemaVersion !== SESSION_ARCHIVE_SCHEMA_VERSION) { + throw new SessionArchiveParseError( + `unsupported schemaVersion ${String(schemaVersion)} — expected ${SESSION_ARCHIVE_SCHEMA_VERSION}`, + ); + } + const exportedAt = assertString(obj.exportedAt, "exportedAt"); + const session = parseSessionRecord(obj.session); + const engine = parseEngineSnapshot(obj.engine); + const documentBindings = assertArray(obj.documentBindings, "documentBindings").map( + (entry, i) => parseDocumentBinding(entry, i), + ); + return { + schemaVersion: SESSION_ARCHIVE_SCHEMA_VERSION, + exportedAt, + session, + engine, + documentBindings, + }; +} diff --git a/src/shared/session.ts b/src/shared/session.ts new file mode 100644 index 0000000..6cdd996 --- /dev/null +++ b/src/shared/session.ts @@ -0,0 +1,26 @@ +/** + * `Session` — a user-named workspace that owns one engine snapshot. + * + * Sessions partition the demo app: each one holds its own documents, + * annotations, evidence items, and links. Membership is implicit — a + * session "owns" whatever lives in its engine snapshot. The session + * record itself only carries the human metadata (name, timestamps) and + * the branded id used as a key in `localStorage` and the ZIP archive + * (see ADR-0008). + * + * The id is opaque (`sess_` per `ids.ts`). The name is the human + * label; uniqueness is enforced by the `SessionService` on create and + * rename. Names are *trimmed* before storage so a leading/trailing + * space does not let two sessions coexist with effectively the same + * label. + */ + +import type { SessionId } from "./ids"; + +export interface Session { + readonly id: SessionId; + readonly name: string; + readonly createdAt: string; + readonly updatedAt: string; + readonly lastOpenedAt?: string; +} diff --git a/src/source/index.ts b/src/source/index.ts index 0eea433..7b9e0e5 100644 --- a/src/source/index.ts +++ b/src/source/index.ts @@ -6,3 +6,13 @@ export { } from "./pdf/ingest"; export { extractPdf, type PdfExtractionResult } from "./pdf/extract"; export { fingerprintBytes } from "./pdf/fingerprint"; +export { + createPdfByteStore, + type CreatePdfByteStoreOptions, + type PdfByteRecord, + type PdfByteStore, +} from "./pdf/byte-store"; +export { + ingestPdfFromFile, + type IngestPdfFromFileOptions, +} from "./pdf/upload"; diff --git a/src/source/pdf/byte-store.test.ts b/src/source/pdf/byte-store.test.ts new file mode 100644 index 0000000..a797e51 --- /dev/null +++ b/src/source/pdf/byte-store.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { DocumentId } from "@shared/ids"; + +import { createPdfByteStore } from "./byte-store"; + +function stubUrlHelpers() { + let counter = 0; + const created: string[] = []; + const revoked: string[] = []; + const createObjectURL = vi.fn(() => { + const url = `blob:stub-${++counter}`; + created.push(url); + return url; + }); + const revokeObjectURL = vi.fn((url: string) => { + revoked.push(url); + }); + return { createObjectURL, revokeObjectURL, created, revoked }; +} + +describe("PdfByteStore", () => { + it("put / get round-trips bytes and exposes a blob URL", () => { + const helpers = stubUrlHelpers(); + const store = createPdfByteStore(helpers); + const bytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]); // %PDF + const record = store.put("doc_a" as DocumentId, bytes); + expect(record.blobUrl).toBe("blob:stub-1"); + expect(store.get("doc_a" as DocumentId)?.bytes).toBe(bytes); + expect(store.has("doc_a" as DocumentId)).toBe(true); + expect(store.list()).toEqual(["doc_a"]); + expect(store.size()).toBe(4); + }); + + it("put replaces an existing entry and revokes the old URL", () => { + const helpers = stubUrlHelpers(); + const store = createPdfByteStore(helpers); + const id = "doc_a" as DocumentId; + const first = store.put(id, new Uint8Array([1, 2])); + const second = store.put(id, new Uint8Array([3, 4, 5])); + expect(helpers.revoked).toEqual([first.blobUrl]); + expect(store.get(id)?.bytes).toHaveLength(3); + expect(second.blobUrl).not.toBe(first.blobUrl); + }); + + it("delete revokes the blob URL exactly once and is idempotent", () => { + const helpers = stubUrlHelpers(); + const store = createPdfByteStore(helpers); + const id = "doc_a" as DocumentId; + const record = store.put(id, new Uint8Array([1, 2, 3])); + expect(store.delete(id)).toBe(true); + expect(helpers.revoked).toEqual([record.blobUrl]); + expect(store.delete(id)).toBe(false); + // No additional revoke calls on the second delete. + expect(helpers.revoked).toHaveLength(1); + expect(store.get(id)).toBeNull(); + expect(store.has(id)).toBe(false); + }); + + it("clear revokes every URL and empties the store", () => { + const helpers = stubUrlHelpers(); + const store = createPdfByteStore(helpers); + const a = store.put("doc_a" as DocumentId, new Uint8Array([1])); + const b = store.put("doc_b" as DocumentId, new Uint8Array([2])); + store.clear(); + expect(helpers.revoked.sort()).toEqual([a.blobUrl, b.blobUrl].sort()); + expect(store.list()).toEqual([]); + expect(store.size()).toBe(0); + }); + + it("uses URL.createObjectURL by default when no override is supplied", () => { + const createObjectURL = vi.fn(() => "blob:built-in"); + const revokeObjectURL = vi.fn(); + const originalURL = globalThis.URL; + // happy-dom's URL has createObjectURL; node sometimes does not. Stub it. + Object.defineProperty(globalThis, "URL", { + configurable: true, + writable: true, + value: Object.assign(Object.create(originalURL.prototype as object), { + createObjectURL, + revokeObjectURL, + }), + }); + try { + const store = createPdfByteStore(); + const rec = store.put("doc_z" as DocumentId, new Uint8Array([9])); + expect(rec.blobUrl).toBe("blob:built-in"); + expect(createObjectURL).toHaveBeenCalledTimes(1); + store.delete("doc_z" as DocumentId); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:built-in"); + } finally { + Object.defineProperty(globalThis, "URL", { + configurable: true, + writable: true, + value: originalURL, + }); + } + }); +}); diff --git a/src/source/pdf/byte-store.ts b/src/source/pdf/byte-store.ts new file mode 100644 index 0000000..81bcbbc --- /dev/null +++ b/src/source/pdf/byte-store.ts @@ -0,0 +1,113 @@ +/** + * `PdfByteStore` — in-memory store for uploaded PDF bytes, keyed by + * `DocumentId`. + * + * CE-WP-0005 stores uploaded PDFs in memory only (per the workplan + * scoping decision). Bytes survive within a tab session; reloading the + * page loses them unless the user exported a ZIP. Re-importing the ZIP + * restores them. + * + * One store instance per active session. The session-management layer + * is responsible for swapping the active store when the user switches + * sessions. The store also owns a small registry of issued `blob:` + * URLs so it can revoke them on delete/clear — no cross-cutting + * cleanup at the app layer. + */ + +import type { DocumentId } from "@shared/ids"; + +export interface PdfByteRecord { + readonly bytes: Uint8Array; + /** A `blob:` URL the viewer can consume directly. */ + readonly blobUrl: string; +} + +export interface PdfByteStore { + put(documentId: DocumentId, bytes: Uint8Array): PdfByteRecord; + get(documentId: DocumentId): PdfByteRecord | null; + has(documentId: DocumentId): boolean; + delete(documentId: DocumentId): boolean; + list(): readonly DocumentId[]; + /** Revoke every blob URL and clear the store. */ + clear(): void; + /** Total bytes currently held — useful for UI dashboards. */ + size(): number; +} + +export interface CreatePdfByteStoreOptions { + /** + * Mint a URL for the given bytes. Defaults to `URL.createObjectURL` in + * environments that have it; tests can inject a deterministic stub. + */ + readonly createObjectURL?: (blob: Blob) => string; + /** Revoke a URL previously minted by `createObjectURL`. */ + readonly revokeObjectURL?: (url: string) => void; +} + +export function createPdfByteStore( + options: CreatePdfByteStoreOptions = {}, +): PdfByteStore { + const createUrl = + options.createObjectURL ?? + ((blob: Blob) => { + if (typeof URL === "undefined" || typeof URL.createObjectURL !== "function") { + throw new Error( + "createPdfByteStore: URL.createObjectURL not available — inject a stub via options", + ); + } + return URL.createObjectURL(blob); + }); + const revokeUrl = + options.revokeObjectURL ?? + ((url: string) => { + if (typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") { + URL.revokeObjectURL(url); + } + }); + + const records = new Map(); + + return { + put(documentId, bytes) { + // Replace previous record (revoking the prior URL) if any. + const prior = records.get(documentId); + if (prior) revokeUrl(prior.blobUrl); + // Cast: Blob() does accept Uint8Array at runtime, but TS narrows the + // buffer type to ArrayBufferLike (could be SharedArrayBuffer) and + // refuses without help. The bytes here always come from a fresh + // arrayBuffer() call, so a regular ArrayBuffer is guaranteed. + const blob = new Blob([bytes as unknown as ArrayBuffer], { + type: "application/pdf", + }); + const blobUrl = createUrl(blob); + const record: PdfByteRecord = { bytes, blobUrl }; + records.set(documentId, record); + return record; + }, + get(documentId) { + return records.get(documentId) ?? null; + }, + has(documentId) { + return records.has(documentId); + }, + delete(documentId) { + const record = records.get(documentId); + if (!record) return false; + revokeUrl(record.blobUrl); + records.delete(documentId); + return true; + }, + list() { + return [...records.keys()]; + }, + clear() { + for (const record of records.values()) revokeUrl(record.blobUrl); + records.clear(); + }, + size() { + let total = 0; + for (const r of records.values()) total += r.bytes.byteLength; + return total; + }, + }; +} diff --git a/src/source/pdf/upload.test.ts b/src/source/pdf/upload.test.ts new file mode 100644 index 0000000..f1b5165 --- /dev/null +++ b/src/source/pdf/upload.test.ts @@ -0,0 +1,91 @@ +/** + * `ingestPdfFromFile` end-to-end: pipes a fixture PDF through the + * upload path, asserts the byte store keeps the bytes and the document + * record carries the minted `blob:` URL. + */ + +import { readFile } from "node:fs/promises"; +import { describe, expect, it, vi } from "vitest"; + +import { createPdfByteStore } from "./byte-store"; +import { ingestPdfFromFile } from "./upload"; + +const FIXTURE_PATH = new URL( + "../../../fixtures/pdfs/Fristsetzung zur Bezifferung GÜ an Gegenseite 3 Wochen.pdf", + import.meta.url, +); + +async function fixtureBytes(): Promise { + return new Uint8Array(await readFile(FIXTURE_PATH)); +} + +class FakeFile { + readonly name: string; + private readonly bytes: Uint8Array; + constructor(bytes: Uint8Array, name: string) { + this.bytes = bytes; + this.name = name; + } + async arrayBuffer(): Promise { + const out = new ArrayBuffer(this.bytes.byteLength); + new Uint8Array(out).set(this.bytes); + return out; + } +} + +describe("ingestPdfFromFile", () => { + it("round-trips a fixture PDF through ingest + byte store + blob URL", async () => { + const bytes = await fixtureBytes(); + const file = new FakeFile(bytes, "demo.pdf") as unknown as File; + let counter = 0; + const store = createPdfByteStore({ + createObjectURL: () => `blob:upload-stub-${++counter}`, + revokeObjectURL: () => {}, + }); + + const { document, representation } = await ingestPdfFromFile(file, store); + + // Bytes are stored, retrievable by document id. + const stored = store.get(document.id); + expect(stored).not.toBeNull(); + expect(stored!.bytes.byteLength).toBe(bytes.byteLength); + + // Document carries the blob URL minted by the store. + expect(document.uri).toBe(`blob:upload-stub-${counter}`); + expect(document.title).toBe("demo.pdf"); + expect(document.fingerprint).toMatch(/^[0-9a-f]{64}$/); + + // Representation is the standard pdf-text one. + expect(representation.representationType).toBe("pdf-text"); + expect((representation.canonicalText ?? "").length).toBeGreaterThan(0); + }, 30_000); + + it("falls through to ingestPdf with no filename when given a plain Blob", async () => { + const bytes = await fixtureBytes(); + const blob = { + async arrayBuffer() { + const out = new ArrayBuffer(bytes.byteLength); + new Uint8Array(out).set(bytes); + return out; + }, + } as Blob; + const store = createPdfByteStore({ + createObjectURL: () => "blob:no-name", + revokeObjectURL: () => {}, + }); + const { document } = await ingestPdfFromFile(blob, store); + expect(document.title).toBeUndefined(); + expect(document.uri).toBe("blob:no-name"); + }, 30_000); + + it("explicit title option overrides the filename", async () => { + const bytes = await fixtureBytes(); + const file = new FakeFile(bytes, "anonymous-name.pdf") as unknown as File; + const store = createPdfByteStore({ + createObjectURL: vi.fn(() => "blob:override"), + revokeObjectURL: vi.fn(), + }); + const { document } = await ingestPdfFromFile(file, store, { title: "Custom" }); + expect(document.title).toBe("Custom"); + }, 30_000); +}); diff --git a/src/source/pdf/upload.ts b/src/source/pdf/upload.ts new file mode 100644 index 0000000..a72e44e --- /dev/null +++ b/src/source/pdf/upload.ts @@ -0,0 +1,45 @@ +/** + * Upload-side ingest path. + * + * The fixture-loading path in `App.tsx` fetches a known URL and calls + * `ingestPdf` directly; that path stays untouched for the optional + * "Sample sessions" quick-start. Uploaded files flow through here + * instead: + * + * 1. Read `file.arrayBuffer()` once into a `Uint8Array`. + * 2. Run the existing `ingestPdf(bytes, { filename })` pipeline to + * produce `{document, representation}`. + * 3. Push the bytes into the per-session `PdfByteStore`, which mints + * a `blob:` URL and stamps it onto `document.uri` so the viewer + * adapter can mount the PDF directly. + * 4. Hand the engine inputs back to the caller, which wires them via + * `engine.documents.register(...)`. + * + * Keeping URL-minting inside the byte store (rather than at the call + * site) means there is exactly one place that creates `blob:` URLs and + * exactly one place that revokes them. + */ + +import { ingestPdf, type IngestPdfResult } from "./ingest"; +import type { PdfByteStore } from "./byte-store"; + +export interface IngestPdfFromFileOptions { + /** Override the filename used as the document title. */ + readonly title?: string; +} + +export async function ingestPdfFromFile( + file: File | Blob, + store: PdfByteStore, + options: IngestPdfFromFileOptions = {}, +): Promise { + const bytes = new Uint8Array(await file.arrayBuffer()); + const filename = "name" in file && typeof file.name === "string" ? file.name : undefined; + const ingested = await ingestPdf(bytes, { + ...(filename !== undefined ? { filename } : {}), + ...(options.title !== undefined ? { title: options.title } : {}), + }); + const record = store.put(ingested.document.id, bytes); + const document = { ...ingested.document, uri: record.blobUrl }; + return { document, representation: ingested.representation }; +} diff --git a/src/work/CollectionList.dom.test.tsx b/src/work/CollectionList.dom.test.tsx new file mode 100644 index 0000000..d75655a --- /dev/null +++ b/src/work/CollectionList.dom.test.tsx @@ -0,0 +1,112 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { Document, DocumentRepresentation } from "@shared/document"; +import type { DocumentId, RepresentationId } from "@shared/ids"; + +import { CollectionList, EngineProvider, useEngine, usePdfByteStore } from "./index"; + +function makeDoc(suffix: string): { document: Document; representation: DocumentRepresentation } { + const id = `doc_${suffix}` as DocumentId; + const repId = `rep_${suffix}` as RepresentationId; + return { + document: { + id, + mediaType: "application/pdf", + title: `Doc ${suffix}`, + fingerprint: `hash-${suffix}`, + createdAt: "2026-05-25T00:00:00.000Z", + updatedAt: "2026-05-25T00:00:00.000Z", + }, + representation: { + id: repId, + documentId: id, + representationType: "pdf-text", + contentHash: `hash-${suffix}`, + canonicalText: `body ${suffix}`, + pageMap: [{ page: 1, width: 100, height: 100 }], + offsetMap: [{ page: 1, globalStart: 0, globalEnd: 6, pageLength: 6 }], + generatedAt: "2026-05-25T00:00:00.000Z", + }, + }; +} + +function Seed() { + const engine = useEngine(); + const store = usePdfByteStore(); + if (engine.documents.list().length === 0) { + const a = makeDoc("alpha"); + const b = makeDoc("beta"); + store.put(a.document.id, new Uint8Array([1, 2])); + store.put(b.document.id, new Uint8Array([3, 4])); + engine.documents.register(a); + engine.documents.register(b); + } + return null; +} + +beforeEach(() => { + globalThis.localStorage?.clear(); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe("CollectionList (session-scoped)", () => { + it("renders one row per registered document", async () => { + render( + + + + , + ); + await waitFor(() => { + expect(screen.getByText("Doc alpha")).toBeTruthy(); + expect(screen.getByText("Doc beta")).toBeTruthy(); + }); + expect(screen.getByText("Demo session")).toBeTruthy(); + }); + + it( + "per-row delete asks for confirmation, then removes the row and revokes the blob URL", + { timeout: 8000 }, + async () => { + let revokedUrl: string | null = null; + // Patch URL.revokeObjectURL so we can confirm the byte store fired it. + const original = URL.revokeObjectURL; + URL.revokeObjectURL = (url: string) => { + revokedUrl = url; + }; + + try { + render( + + + + , + ); + await screen.findByText("Doc alpha"); + + const user = userEvent.setup(); + const deleteBtn = await screen.findByTestId("collection-delete-doc_alpha"); + // First click → confirm prompt + await user.click(deleteBtn); + expect(deleteBtn.textContent).toContain("Confirm"); + // Second click → commit + await user.click(deleteBtn); + await waitFor(() => { + expect(screen.queryByText("Doc alpha")).toBeNull(); + }); + expect(revokedUrl).not.toBeNull(); + expect(revokedUrl!).toMatch(/^blob:/); + } finally { + URL.revokeObjectURL = original; + } + }, + ); +}); diff --git a/src/work/CollectionList.tsx b/src/work/CollectionList.tsx index c1a1f77..95b0f27 100644 --- a/src/work/CollectionList.tsx +++ b/src/work/CollectionList.tsx @@ -1,71 +1,76 @@ /** * CollectionList — the left pane. * - * Lists the fixture corpus (the MVP stand-in for a real document collection). - * Clicking a fixture fetches the bytes, runs `ingestPdf` (PDF.js extraction - * + fingerprint + canonical text), registers the result with the engine - * (emitting §4 events), and activates it as the current document. + * CE-WP-0005 turned this into a *session-scoped* list. It shows the + * documents currently registered with the active session's engine, + * with per-row Open + Delete actions and an inline upload affordance. * - * Per CE-WP-0002-T06, the loaded fixture set is hard-wired to - * `fixtures/pdfs/manifest.json`. Real collections arrive in a later - * workplan. + * Fixture-driven quick-start lives in + * `src/app/sessions/SampleSessions.tsx` and is no longer the default. */ -import { useCallback, useState } from "react"; -import { ingestPdf } from "@source/index"; -import { useEngine, useActiveDocumentId } from "./EngineContext"; -import type { DocumentId } from "@shared/ids"; -import manifest from "../../fixtures/pdfs/manifest.json"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { CSSProperties } from "react"; +import type { ReactNode } from "react"; -interface Fixture { - id: string; - filename: string; - description: string; - page_count: number; +import type { DocumentId } from "@shared/ids"; +import { + useActiveDocumentId, + useEngine, + useEngineEventTick, + useEngineRevision, + usePdfByteStore, +} from "./EngineContext"; + +export interface CollectionListProps { + /** + * Slot rendered above the list — typically the upload affordance. + * Kept as a slot so this component stays in `work/` (which cannot + * import `app/`). + */ + readonly upload?: ReactNode; + /** Optional session header text — typically the active session name. */ + readonly title?: string; } -const FIXTURES: readonly Fixture[] = (manifest as { fixtures: Fixture[] }).fixtures; - -export function CollectionList() { +export function CollectionList({ upload, title }: CollectionListProps) { const engine = useEngine(); + const byteStore = usePdfByteStore(); const { id: activeId, setId } = useActiveDocumentId(); - const [loadingFixtureId, setLoadingFixtureId] = useState(null); - const [error, setError] = useState(null); - // Remember which fixture-id maps to which loaded documentId so re-clicking - // a fixture activates the existing engine record rather than re-ingesting. - const [byFixture, setByFixture] = useState>({}); - const handleLoad = useCallback( - async (fixture: Fixture) => { - setError(null); + const importedTick = useEngineEventTick("DocumentImported"); + const removedTick = useEngineEventTick("DocumentRemoved"); + const revision = useEngineRevision(); - const existing = byFixture[fixture.id]; - if (existing) { - setId(existing); + const documents = useMemo( + () => engine.documents.list(), + [engine, importedTick, removedTick, revision], + ); + + // Confirm-on-delete UX without a modal: clicking Delete asks "Confirm?", + // a second click within ~3s commits. Esc clears the pending state. + const [pendingDeleteId, setPendingDeleteId] = useState(null); + + useEffect(() => { + if (!pendingDeleteId) return; + const t = setTimeout(() => setPendingDeleteId(null), 3000); + return () => clearTimeout(t); + }, [pendingDeleteId]); + + const handleDelete = useCallback( + (id: DocumentId) => { + if (pendingDeleteId !== id) { + setPendingDeleteId(id); return; } - - setLoadingFixtureId(fixture.id); - try { - const url = `/fixtures/pdfs/${encodeURIComponent(fixture.filename)}`; - const response = await fetch(url); - if (!response.ok) { - throw new Error(`fetch ${url} → ${response.status}`); - } - const buffer = await response.arrayBuffer(); - const { document, representation } = await ingestPdf(new Uint8Array(buffer), { - filename: fixture.filename, - }); - engine.documents.register({ document, representation }); - setByFixture((prev) => ({ ...prev, [fixture.id]: document.id })); - setId(document.id); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setLoadingFixtureId(null); - } + // Active doc was just deleted — clear the pointer so the viewer + // unmounts before the engine drops the record. + if (activeId === id) setId(null); + byteStore.delete(id); + engine.documents.remove(id); + setPendingDeleteId(null); }, - [byFixture, engine, setId], + [activeId, byteStore, engine, pendingDeleteId, setId], ); return ( @@ -78,44 +83,66 @@ export function CollectionList() { flex: "0 0 280px", }} > -

Collection

+

+ {title ?? "Collection"} +

- {FIXTURES.length} fixture PDF{FIXTURES.length === 1 ? "" : "s"} + {documents.length} document{documents.length === 1 ? "" : "s"}

- {error && ( -

- {error} + {upload &&

{upload}
} + {documents.length === 0 && !upload && ( +

+ No documents yet. Upload a PDF to get started.

)} -