generated from coulomb/repo-seed
Implement CE-WP-0005 T01-T08: demo app — sessions, uploads, ZIP archive
Turn the MVP into a self-contained demo. Users now:
1. Land on an empty-state and create a named session.
2. Drag-drop or pick arbitrary PDFs into that session.
3. Annotate, build evidence, link to form fields — all session-scoped.
4. Export the whole session as a single .zip archive (manifest +
per-document PDFs).
5. Import a .zip back — into a new session, or merged into an
existing one (documents deduped by SHA-256 fingerprint;
annotations/evidence/links added additively).
Architecture:
- New shared types: SessionId, Session, SessionArchiveManifest +
parseSessionArchiveManifest with schema-version validation.
- SessionService (engine/services/sessions.ts) handles lifecycle
(create/rename/delete/setActive) + emits 4 new events through its
own bus; SharedContracts.md §4 lists the additions.
- SessionProvider (work/SessionContext.tsx) owns the cross-session
state: service, per-session PdfByteStore registry, per-session
version counter that drives EngineProvider remounts after imports.
- EngineProvider becomes session-aware (sessionId prop drives per-
session localStorage keys). Bumping engineRevision after
restoreFromStorage forces consumers to re-render so restored repos
show up immediately.
- PdfByteStore (source/pdf/byte-store.ts) holds Uint8Array bytes per
document and mints blob URLs; ingestPdfFromFile is the upload
entry-point that wraps the existing ingestPdf pipeline.
- ADR-0008 locks the ZIP layout (manifest.json + documents/<id>.pdf),
the manifest schema (schemaVersion 1), and the merge-on-collision
policy. JSZip is the only new dependency.
- App.tsx restructured: SessionProvider at the root, EngineProvider
keyed by ${sessionId}:${version}, hash routing #/s/<id>[/forms/demo],
SessionMenu top-bar, CreateFirstSession empty state.
- New DocumentRemoved event for per-document delete cleanup in
CollectionList; engine.documents.remove() is the new service method.
Tests:
- Unit: 16 SessionService lifecycle + persistence tests;
per-session snapshot round-trip; PdfByteStore + ingestPdfFromFile;
SessionArchive parser; exportSessionZip + importSessionZip with
create + merge + corrupt-archive paths.
- DOM: UploadDropzone, session-scoped CollectionList delete,
SessionMenu create/switch/rename, routing parser.
- E2E: tests/integration/session-export-reimport.dom.test.tsx walks
the full create → annotate → export → reimport flow and asserts
the additive merge (deduped doc + doubled evidence rows).
- Legacy E2Es updated to use a seed-session helper instead of the
removed fixture-button flow.
Known limitation (documented in ADR-0008): re-importing your own
freshly-exported ZIP creates duplicate annotations. Forward pointer
left for an importBundleId follow-up.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
134
docs/decisions/ADR-0008-session-archive-format.md
Normal file
134
docs/decisions/ADR-0008-session-archive-format.md
Normal file
@@ -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/
|
||||
<documentId>.pdf
|
||||
```
|
||||
|
||||
- `<documentId>` is the engine-minted branded id (`doc_<uuid>`). 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_<uuid>
|
||||
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.
|
||||
@@ -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",
|
||||
|
||||
80
pnpm-lock.yaml
generated
80
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
354
src/app/App.tsx
354
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<Mode>(() => readModeFromHash());
|
||||
|
||||
function useHashRoute(): AppRoute {
|
||||
const [route, setRoute] = useState<AppRoute>(() => 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<string | null>(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 (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100vh", color: "#222" }}>
|
||||
<TopBar mode={mode} onModeChange={handleModeChange} />
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
{mode === "review" ? <ReviewLayout /> : <FormsApp />}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100vh",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
color: "#888",
|
||||
}}
|
||||
>
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopBar({ mode, onModeChange }: { mode: Mode; onModeChange: (m: Mode) => void }) {
|
||||
if (route.sessionId === null) {
|
||||
return (
|
||||
<div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
|
||||
<EmptyTopBar />
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<CreateFirstSession />
|
||||
</div>
|
||||
<Toast toast={toast.toast} onDismiss={toast.dismiss} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <ActiveAppFrame route={route} toast={toast} />;
|
||||
}
|
||||
|
||||
function ActiveAppFrame({
|
||||
route,
|
||||
toast,
|
||||
}: {
|
||||
route: AppRoute;
|
||||
toast: ReturnType<typeof useToast>;
|
||||
}) {
|
||||
// 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 (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100vh", color: "#222" }}>
|
||||
<EngineProvider key={`${sessionId}:${version}`} sessionId={sessionId}>
|
||||
<ActiveTopBar route={route} showToast={toast.show} />
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<SessionScopedTree mode={route.mode} />
|
||||
</div>
|
||||
</EngineProvider>
|
||||
<Toast toast={toast.toast} onDismiss={toast.dismiss} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionScopedTree({ mode }: { mode: AppMode }) {
|
||||
const engine = useEngine();
|
||||
return (
|
||||
<BinderProvider bus={engine.bus}>
|
||||
{mode === "forms" ? <FormsApp /> : <ReviewLayout upload={<UploadDropzone />} />}
|
||||
</BinderProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<header
|
||||
style={{
|
||||
@@ -89,20 +206,122 @@ function TopBar({ mode, onModeChange }: { mode: Mode; onModeChange: (m: Mode) =>
|
||||
}}
|
||||
>
|
||||
<strong style={{ fontSize: 13, marginRight: 8 }}>citation-evidence</strong>
|
||||
<button
|
||||
onClick={() => onModeChange("review")}
|
||||
aria-pressed={mode === "review"}
|
||||
style={tabStyle(mode === "review")}
|
||||
<SessionMenu onImportZip={() => pickAndImport(handleImport)} />
|
||||
<Toast toast={toast.toast} onDismiss={toast.dismiss} />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<header
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
padding: "6px 12px",
|
||||
borderBottom: "1px solid #ddd",
|
||||
background: "#fafafa",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
Review
|
||||
</button>
|
||||
<strong style={{ fontSize: 13, marginRight: 8 }}>citation-evidence</strong>
|
||||
<SessionMenu
|
||||
onExportZip={() => void handleExport()}
|
||||
onImportZip={() => pickAndImport((file) => void handleImport(file))}
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 4, marginLeft: 12 }}>
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
onClick={() => onModeChange("forms")}
|
||||
aria-pressed={mode === "forms"}
|
||||
style={tabStyle(mode === "forms")}
|
||||
key={t.id}
|
||||
onClick={() => handleModeChange(t.id)}
|
||||
aria-pressed={route.mode === t.id}
|
||||
style={tabStyle(route.mode === t.id)}
|
||||
>
|
||||
Forms
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -118,19 +337,10 @@ function tabStyle(active: boolean) {
|
||||
};
|
||||
}
|
||||
|
||||
function AppInner() {
|
||||
const engine = useEngine();
|
||||
return (
|
||||
<BinderProvider bus={engine.bus}>
|
||||
<ModeRouter />
|
||||
</BinderProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<EngineProvider>
|
||||
<AppInner />
|
||||
</EngineProvider>
|
||||
<SessionProvider>
|
||||
<AppShell />
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
@@ -22,7 +34,7 @@ export function ReviewLayout() {
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
}}
|
||||
>
|
||||
<CollectionList />
|
||||
<CollectionList upload={upload} title={session?.name ?? "Collection"} />
|
||||
<ViewerShell />
|
||||
<EvidenceSidebar />
|
||||
</div>
|
||||
|
||||
104
src/app/sessions/CreateFirstSession.tsx
Normal file
104
src/app/sessions/CreateFirstSession.tsx
Normal file
@@ -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<string | null>(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 (
|
||||
<div
|
||||
data-testid="empty-state"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
background: "#fafafa",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: 22, margin: 0 }}>citation-evidence</h1>
|
||||
<p style={{ fontSize: 14, color: "#555", margin: 0 }}>
|
||||
{hasOthers
|
||||
? "Pick a session from the menu above, or create a new one."
|
||||
: "Create your first session to get started."}
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => 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();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
data-testid="empty-state-create"
|
||||
style={{
|
||||
fontSize: 14,
|
||||
padding: "6px 14px",
|
||||
border: "1px solid #0050b3",
|
||||
background: "#0050b3",
|
||||
color: "white",
|
||||
borderRadius: 3,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Create session
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<div
|
||||
data-testid="empty-state-error"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "#7a0000",
|
||||
background: "#fff4f4",
|
||||
padding: "4px 10px",
|
||||
border: "1px solid #f5cccc",
|
||||
borderRadius: 3,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
src/app/sessions/SampleSessions.tsx
Normal file
125
src/app/sessions/SampleSessions.tsx
Normal file
@@ -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<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [byFixture, setByFixture] = useState<Record<string, DocumentId>>({});
|
||||
|
||||
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 (
|
||||
<div data-testid="sample-sessions">
|
||||
<p style={{ fontSize: 12, color: "#555", margin: "0 0 6px" }}>
|
||||
Load a fixture PDF as a sample document for the active session.
|
||||
</p>
|
||||
{error && (
|
||||
<p style={{ fontSize: 12, color: "#b00020", background: "#fff4f4", padding: 6 }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
|
||||
{FIXTURES.map((f) => {
|
||||
const isLoading = loadingFixtureId === f.id;
|
||||
const documentId = byFixture[f.id];
|
||||
const isActive = documentId !== undefined && documentId === activeId;
|
||||
return (
|
||||
<li key={f.id} style={{ marginBottom: 6 }}>
|
||||
<button
|
||||
onClick={() => void handleLoad(f)}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
background: isActive ? "#e8f0ff" : "white",
|
||||
border: "1px solid #ccc",
|
||||
padding: 6,
|
||||
cursor: isLoading ? "wait" : "pointer",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>{f.id}</div>
|
||||
<div style={{ color: "#666", fontSize: 11 }}>
|
||||
{f.page_count} page{f.page_count === 1 ? "" : "s"}
|
||||
{isLoading ? " · loading…" : isActive ? " · open" : ""}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
src/app/sessions/SessionMenu.dom.test.tsx
Normal file
132
src/app/sessions/SessionMenu.dom.test.tsx
Normal file
@@ -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 (
|
||||
<SessionProvider>
|
||||
<HashSync />
|
||||
{children}
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function CurrentHash() {
|
||||
return <span data-testid="current-hash">{window.location.hash || "(empty)"}</span>;
|
||||
}
|
||||
|
||||
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/<id>", async () => {
|
||||
render(
|
||||
<Wrap>
|
||||
<CurrentHash />
|
||||
<SessionMenu />
|
||||
</Wrap>,
|
||||
);
|
||||
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(
|
||||
<Wrap>
|
||||
<SeedTwo />
|
||||
<CurrentHash />
|
||||
<SessionMenu />
|
||||
</Wrap>,
|
||||
);
|
||||
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(
|
||||
<Wrap>
|
||||
<SeedTwo />
|
||||
<SessionMenu />
|
||||
</Wrap>,
|
||||
);
|
||||
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/);
|
||||
});
|
||||
});
|
||||
362
src/app/sessions/SessionMenu.tsx
Normal file
362
src/app/sessions/SessionMenu.tsx
Normal file
@@ -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<string | null>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(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 (
|
||||
<div ref={wrapperRef} style={{ position: "relative" }} data-testid="session-menu">
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
data-testid="session-menu-toggle"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
padding: "4px 10px",
|
||||
border: "1px solid #888",
|
||||
background: "white",
|
||||
cursor: "pointer",
|
||||
minWidth: 160,
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
{active ? active.name : "No session"}
|
||||
<span style={{ float: "right", color: "#888" }}>▾</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
data-testid="session-menu-panel"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 28,
|
||||
left: 0,
|
||||
zIndex: 30,
|
||||
background: "white",
|
||||
border: "1px solid #888",
|
||||
borderRadius: 3,
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
||||
padding: 4,
|
||||
minWidth: 240,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{sessions.length > 0 && (
|
||||
<>
|
||||
<div style={{ padding: "4px 8px", color: "#666", fontSize: 11 }}>
|
||||
Switch to…
|
||||
</div>
|
||||
{sessions.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid={`session-switch-${s.id}`}
|
||||
onClick={() => switchTo(s.id)}
|
||||
style={{
|
||||
...menuItemStyle,
|
||||
background: active?.id === s.id ? "#e8f0ff" : "transparent",
|
||||
}}
|
||||
>
|
||||
{s.name}
|
||||
{active?.id === s.id ? " · open" : ""}
|
||||
</button>
|
||||
))}
|
||||
<hr style={dividerStyle} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{!creating && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="session-menu-new"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setCreating(true);
|
||||
setNewName("");
|
||||
}}
|
||||
style={menuItemStyle}
|
||||
>
|
||||
New session…
|
||||
</button>
|
||||
)}
|
||||
{creating && (
|
||||
<div style={{ padding: 4, display: "flex", gap: 4 }}>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => 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);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
data-testid="session-new-confirm"
|
||||
style={smallButtonStyle}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{active && (
|
||||
<>
|
||||
<hr style={dividerStyle} />
|
||||
{!renaming && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="session-menu-rename"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setRenaming(true);
|
||||
setRenameValue(active.name);
|
||||
}}
|
||||
style={menuItemStyle}
|
||||
>
|
||||
Rename…
|
||||
</button>
|
||||
)}
|
||||
{renaming && (
|
||||
<div style={{ padding: 4, display: "flex", gap: 4 }}>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={renameValue}
|
||||
onChange={(e) => 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);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRename}
|
||||
data-testid="session-rename-confirm"
|
||||
style={smallButtonStyle}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="session-menu-delete"
|
||||
onClick={handleDelete}
|
||||
style={{ ...menuItemStyle, color: "#7a0000" }}
|
||||
>
|
||||
{pendingDelete ? "Confirm delete?" : "Delete…"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(onExportZip || onImportZip || onOpenSamples) && (
|
||||
<hr style={dividerStyle} />
|
||||
)}
|
||||
{onExportZip && active && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="session-menu-export"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onExportZip();
|
||||
}}
|
||||
style={menuItemStyle}
|
||||
>
|
||||
Export ZIP
|
||||
</button>
|
||||
)}
|
||||
{onImportZip && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="session-menu-import"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onImportZip();
|
||||
}}
|
||||
style={menuItemStyle}
|
||||
>
|
||||
Import ZIP
|
||||
</button>
|
||||
)}
|
||||
{onOpenSamples && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="session-menu-samples"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onOpenSamples();
|
||||
}}
|
||||
style={menuItemStyle}
|
||||
>
|
||||
Sample sessions ▸
|
||||
</button>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
data-testid="session-menu-error"
|
||||
style={{
|
||||
padding: 6,
|
||||
background: "#fff4f4",
|
||||
color: "#7a0000",
|
||||
fontSize: 11,
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
};
|
||||
94
src/app/sessions/Toast.tsx
Normal file
94
src/app/sessions/Toast.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="session-toast"
|
||||
data-tone={toast.tone}
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
zIndex: 50,
|
||||
padding: "8px 12px",
|
||||
fontSize: 12,
|
||||
background:
|
||||
toast.tone === "success"
|
||||
? "#d6f0d6"
|
||||
: toast.tone === "error"
|
||||
? "#f9d6d6"
|
||||
: "#e0e8f5",
|
||||
color:
|
||||
toast.tone === "success"
|
||||
? "#0a5a0a"
|
||||
: toast.tone === "error"
|
||||
? "#7a0000"
|
||||
: "#003a7a",
|
||||
border: `1px solid ${
|
||||
toast.tone === "success"
|
||||
? "#0a5a0a"
|
||||
: toast.tone === "error"
|
||||
? "#7a0000"
|
||||
: "#003a7a"
|
||||
}`,
|
||||
borderRadius: 3,
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
}}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
93
src/app/sessions/UploadDropzone.dom.test.tsx
Normal file
93
src/app/sessions/UploadDropzone.dom.test.tsx
Normal file
@@ -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<typeof import("@source/index")>();
|
||||
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(
|
||||
<EngineProvider>
|
||||
<UploadDropzone />
|
||||
</EngineProvider>,
|
||||
);
|
||||
|
||||
// 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");
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
});
|
||||
189
src/app/sessions/UploadDropzone.tsx
Normal file
189
src/app/sessions/UploadDropzone.tsx
Normal file
@@ -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<readonly UploadEntry[]>([]);
|
||||
const [isOver, setIsOver] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(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<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsOver(false);
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
void processFiles(files);
|
||||
},
|
||||
[processFiles],
|
||||
);
|
||||
|
||||
const onDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsOver(true);
|
||||
}, []);
|
||||
|
||||
const onDragLeave = useCallback(() => {
|
||||
setIsOver(false);
|
||||
}, []);
|
||||
|
||||
const openPicker = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const onPicked = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div data-testid="upload-dropzone">
|
||||
<div
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
role="region"
|
||||
aria-label="PDF upload"
|
||||
style={{
|
||||
border: `2px dashed ${isOver ? "#0050b3" : "#bbb"}`,
|
||||
background: isOver ? "#e8f0ff" : "#fafafa",
|
||||
padding: 16,
|
||||
textAlign: "center",
|
||||
fontSize: 12,
|
||||
color: "#555",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<div>Drop PDF files here</div>
|
||||
<div style={{ margin: "6px 0", color: "#888" }}>or</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openPicker}
|
||||
data-testid="upload-pick-button"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
padding: "4px 10px",
|
||||
border: "1px solid #888",
|
||||
background: "white",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Choose PDF…
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/pdf,.pdf"
|
||||
multiple
|
||||
onChange={onPicked}
|
||||
style={{ display: "none" }}
|
||||
data-testid="upload-file-input"
|
||||
/>
|
||||
</div>
|
||||
{entries.length > 0 && (
|
||||
<ul
|
||||
data-testid="upload-progress"
|
||||
style={{ listStyle: "none", padding: 0, margin: "8px 0 0", fontSize: 11 }}
|
||||
>
|
||||
{entries.map((entry, i) => (
|
||||
<li
|
||||
key={`${entry.file.name}-${i}`}
|
||||
data-status={entry.status}
|
||||
style={{
|
||||
padding: "2px 4px",
|
||||
color:
|
||||
entry.status === "error"
|
||||
? "#7a0000"
|
||||
: entry.status === "done"
|
||||
? "#0a5a0a"
|
||||
: "#333",
|
||||
}}
|
||||
>
|
||||
{entry.file.name} — {entry.status}
|
||||
{entry.error ? `: ${entry.error}` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
154
src/app/sessions/exportSessionZip.test.ts
Normal file
154
src/app/sessions/exportSessionZip.test.ts
Normal file
@@ -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/<id>.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");
|
||||
});
|
||||
});
|
||||
|
||||
148
src/app/sessions/exportSessionZip.ts
Normal file
148
src/app/sessions/exportSessionZip.ts
Normal file
@@ -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/<documentId>.pdf`.
|
||||
* 3. Push `manifest.json` (pretty-printed JSON).
|
||||
* 4. `zip.generateAsync({ type: "blob" })`.
|
||||
*
|
||||
* `triggerSessionDownload` creates an `<a download>` link and clicks
|
||||
* it. The filename is `<slug>-<isoDate>.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<Blob> {
|
||||
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 `<a>` 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 };
|
||||
276
src/app/sessions/importSessionZip.test.ts
Normal file
276
src/app/sessions/importSessionZip.test.ts
Normal file
@@ -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<Storage, "getItem" | "setItem" | "removeItem"> {
|
||||
const map = new Map<string, string>();
|
||||
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<SessionId, PdfByteStore>();
|
||||
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<typeof freshStores>["stores"];
|
||||
byteStoreFor(sessionId: SessionId): PdfByteStore;
|
||||
bumps: SessionId[];
|
||||
storage: ReturnType<typeof memoryStorage>;
|
||||
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<Storage, "getItem" | "setItem" | "removeItem">;
|
||||
}): 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/);
|
||||
});
|
||||
});
|
||||
314
src/app/sessions/importSessionZip.ts
Normal file
314
src/app/sessions/importSessionZip.ts
Normal file
@@ -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<Storage, "getItem" | "setItem" | "removeItem">;
|
||||
}
|
||||
|
||||
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<ImportSessionResult> {
|
||||
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<DocumentId, Uint8Array>();
|
||||
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<DocumentId, DocumentId>();
|
||||
const existingByFingerprint = new Map<string, DocumentId>();
|
||||
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<AnnotationId, AnnotationId>();
|
||||
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<JSZip> {
|
||||
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 };
|
||||
28
src/app/sessions/index.ts
Normal file
28
src/app/sessions/index.ts
Normal file
@@ -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";
|
||||
51
src/app/sessions/routing.test.ts
Normal file
51
src/app/sessions/routing.test.ts
Normal file
@@ -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/<id> 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/<id>/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);
|
||||
});
|
||||
});
|
||||
61
src/app/sessions/routing.ts
Normal file
61
src/app/sessions/routing.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Hash routing for the demo app.
|
||||
*
|
||||
* #/ → empty state ("create your first session")
|
||||
* #/s/<sessionId> → review mode, scoped to <sessionId>
|
||||
* #/s/<sessionId>/forms/demo → forms mode, scoped to <sessionId>
|
||||
*
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -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"];
|
||||
|
||||
|
||||
47
src/engine/repos/in-memory-sessions.ts
Normal file
47
src/engine/repos/in-memory-sessions.ts
Normal file
@@ -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<SessionId, Session>();
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -6,3 +6,7 @@ export {
|
||||
type AnnotationRepository,
|
||||
type EvidenceItemRepository,
|
||||
} from "./in-memory";
|
||||
export {
|
||||
createInMemorySessionRepository,
|
||||
type SessionRepository,
|
||||
} from "./in-memory-sessions";
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
204
src/engine/services/sessions.test.ts
Normal file
204
src/engine/services/sessions.test.ts
Normal file
@@ -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<Storage, "getItem" | "setItem" | "removeItem"> {
|
||||
const map = new Map<string, string>();
|
||||
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:<id>:engine-snapshot:v1", () => {
|
||||
expect(engineSnapshotKey("sess_abc" as SessionId)).toBe(
|
||||
"citation-evidence:session:sess_abc:engine-snapshot:v1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionService — lifecycle", () => {
|
||||
let s: ReturnType<typeof freshService>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
302
src/engine/services/sessions.ts
Normal file
302
src/engine/services/sessions.ts
Normal file
@@ -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:<sessionId>: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<Storage, "getItem" | "setItem" | "removeItem">;
|
||||
}
|
||||
|
||||
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<SessionsFile>;
|
||||
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 };
|
||||
}
|
||||
}
|
||||
98
src/engine/session-snapshot.test.ts
Normal file
98
src/engine/session-snapshot.test.ts
Normal file
@@ -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<Storage, "getItem" | "setItem" | "removeItem"> {
|
||||
const map = new Map<string, string>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ export type EvidenceSetId = Brand<string, "EvidenceSetId">;
|
||||
export type EvidenceLinkId = Brand<string, "EvidenceLinkId">;
|
||||
export type CitationCardId = Brand<string, "CitationCardId">;
|
||||
export type CitationRecoveryAttemptId = Brand<string, "CitationRecoveryAttemptId">;
|
||||
export type SessionId = Brand<string, "SessionId">;
|
||||
|
||||
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<IdKind, string> = {
|
||||
"evidence-link": "evlink",
|
||||
"citation-card": "card",
|
||||
"citation-recovery": "crec",
|
||||
session: "sess",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
|
||||
88
src/shared/session-archive.test.ts
Normal file
88
src/shared/session-archive.test.ts
Normal file
@@ -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/);
|
||||
});
|
||||
});
|
||||
150
src/shared/session-archive.ts
Normal file
150
src/shared/session-archive.ts
Normal file
@@ -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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
26
src/shared/session.ts
Normal file
26
src/shared/session.ts
Normal file
@@ -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_<uuid>` 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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
99
src/source/pdf/byte-store.test.ts
Normal file
99
src/source/pdf/byte-store.test.ts
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
113
src/source/pdf/byte-store.ts
Normal file
113
src/source/pdf/byte-store.ts
Normal file
@@ -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<DocumentId, PdfByteRecord>();
|
||||
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
91
src/source/pdf/upload.test.ts
Normal file
91
src/source/pdf/upload.test.ts
Normal file
@@ -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<Uint8Array> {
|
||||
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<ArrayBuffer> {
|
||||
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);
|
||||
});
|
||||
45
src/source/pdf/upload.ts
Normal file
45
src/source/pdf/upload.ts
Normal file
@@ -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<IngestPdfResult> {
|
||||
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 };
|
||||
}
|
||||
112
src/work/CollectionList.dom.test.tsx
Normal file
112
src/work/CollectionList.dom.test.tsx
Normal file
@@ -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(
|
||||
<EngineProvider>
|
||||
<Seed />
|
||||
<CollectionList title="Demo session" />
|
||||
</EngineProvider>,
|
||||
);
|
||||
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(
|
||||
<EngineProvider>
|
||||
<Seed />
|
||||
<CollectionList />
|
||||
</EngineProvider>,
|
||||
);
|
||||
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;
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(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<Record<string, DocumentId>>({});
|
||||
|
||||
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<DocumentId | null>(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",
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginTop: 0, fontSize: 16 }}>Collection</h2>
|
||||
<h2 style={{ marginTop: 0, fontSize: 16 }}>
|
||||
{title ?? "Collection"}
|
||||
</h2>
|
||||
<p style={{ fontSize: 12, color: "#555", marginTop: 0 }}>
|
||||
{FIXTURES.length} fixture PDF{FIXTURES.length === 1 ? "" : "s"}
|
||||
{documents.length} document{documents.length === 1 ? "" : "s"}
|
||||
</p>
|
||||
{error && (
|
||||
<p style={{ fontSize: 12, color: "#b00020", background: "#fff4f4", padding: 6 }}>
|
||||
{error}
|
||||
{upload && <div style={{ marginBottom: 8 }}>{upload}</div>}
|
||||
{documents.length === 0 && !upload && (
|
||||
<p style={{ fontSize: 12, color: "#888" }}>
|
||||
No documents yet. Upload a PDF to get started.
|
||||
</p>
|
||||
)}
|
||||
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
|
||||
{FIXTURES.map((f) => {
|
||||
const isLoading = loadingFixtureId === f.id;
|
||||
const documentId = byFixture[f.id];
|
||||
const isActive = documentId !== undefined && documentId === activeId;
|
||||
<ul
|
||||
data-testid="collection-list-items"
|
||||
style={{ listStyle: "none", padding: 0, margin: 0 }}
|
||||
>
|
||||
{documents.map((doc) => {
|
||||
const isActive = doc.id === activeId;
|
||||
const isPending = pendingDeleteId === doc.id;
|
||||
return (
|
||||
<li key={f.id} style={{ marginBottom: 6 }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
void handleLoad(f);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
<li key={doc.id} style={{ marginBottom: 6 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
background: isActive ? "#e8f0ff" : "white",
|
||||
border: "1px solid #ccc",
|
||||
padding: 6,
|
||||
cursor: isLoading ? "wait" : "pointer",
|
||||
background: isActive ? "#e8f0ff" : "white",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
fontSize: 12,
|
||||
}}
|
||||
data-testid={`collection-item-${doc.id}`}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>{f.id}</div>
|
||||
<button
|
||||
onClick={() => setId(doc.id)}
|
||||
data-testid={`collection-open-${doc.id}`}
|
||||
style={openButtonStyle}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>{doc.title ?? doc.id}</div>
|
||||
<div style={{ color: "#666", fontSize: 11 }}>
|
||||
{f.page_count} page{f.page_count === 1 ? "" : "s"}
|
||||
{isLoading ? " · loading…" : isActive ? " · open" : ""}
|
||||
{doc.id}
|
||||
{isActive ? " · open" : ""}
|
||||
</div>
|
||||
</button>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", padding: 4, gap: 4 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(doc.id)}
|
||||
data-testid={`collection-delete-${doc.id}`}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: "2px 8px",
|
||||
border: "1px solid #b00",
|
||||
background: isPending ? "#ffe5e5" : "white",
|
||||
color: "#7a0000",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{isPending ? "Confirm delete?" : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@@ -123,3 +150,14 @@ export function CollectionList() {
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
const openButtonStyle: CSSProperties = {
|
||||
display: "block",
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
padding: 8,
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
@@ -21,24 +21,39 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { Document, DocumentRepresentation } from "@shared/document";
|
||||
import type { AnnotationId, DocumentId } from "@shared/ids";
|
||||
import type { AnnotationId, DocumentId, SessionId } from "@shared/ids";
|
||||
import type { Selector } from "@shared/selector";
|
||||
import {
|
||||
attachPersister,
|
||||
createEngine,
|
||||
engineSnapshotKey,
|
||||
restoreFromStorage,
|
||||
type Engine,
|
||||
} from "@engine/index";
|
||||
import type { PdfSelectionCapture } from "@anchor/index";
|
||||
import { createPdfByteStore, type PdfByteStore } from "@source/index";
|
||||
import { useContext as useReactContext } from "react";
|
||||
import { SessionInternalContext } from "./SessionContextInternal";
|
||||
|
||||
/**
|
||||
* localStorage keys for the engine snapshot and the UI's "what was open"
|
||||
* pointer. ADR-0005 frames both as deliberately temporary — real
|
||||
* persistence later.
|
||||
* Legacy single-bucket storage keys, kept for any user landing on a
|
||||
* build without sessions. CE-WP-0005 switched persistence to per-session
|
||||
* keys (`engineSnapshotKey(sessionId)`); the unscoped keys below are
|
||||
* only consulted when no `sessionId` is provided to the provider.
|
||||
*/
|
||||
const STORAGE_KEY = "citation-evidence:engine-snapshot:v1";
|
||||
const LEGACY_STORAGE_KEY = "citation-evidence:engine-snapshot:v1";
|
||||
const ACTIVE_KEY = "citation-evidence:active-document-id:v1";
|
||||
|
||||
function storageKeyFor(sessionId: SessionId | null): string {
|
||||
return sessionId ? engineSnapshotKey(sessionId) : LEGACY_STORAGE_KEY;
|
||||
}
|
||||
|
||||
function activeDocumentKeyFor(sessionId: SessionId | null): string {
|
||||
return sessionId
|
||||
? `citation-evidence:session:${sessionId}:active-document-id:v1`
|
||||
: ACTIVE_KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
* The pending selection lives in context (not local component state) because
|
||||
* the toolbar that consumes it is rendered above the viewer, not inside it.
|
||||
@@ -51,6 +66,7 @@ export interface PendingSelection {
|
||||
|
||||
interface EngineContextValue {
|
||||
readonly engine: Engine;
|
||||
readonly byteStore: PdfByteStore;
|
||||
readonly activeDocumentId: DocumentId | null;
|
||||
setActiveDocumentId(id: DocumentId | null): void;
|
||||
readonly pendingSelection: PendingSelection | null;
|
||||
@@ -60,6 +76,13 @@ interface EngineContextValue {
|
||||
* so a second click on the same evidence item still triggers a scroll. */
|
||||
readonly scrollVersion: number;
|
||||
scrollToAnnotation(id: AnnotationId | null): void;
|
||||
/**
|
||||
* Bumps each time the engine's repos are mutated outside the normal
|
||||
* event-emitting service path — currently only on `restoreFromStorage`.
|
||||
* Consumers that cache `engine.documents.list()` via `useMemo` add this
|
||||
* to their deps so the restored state is reflected on remount.
|
||||
*/
|
||||
readonly engineRevision: number;
|
||||
}
|
||||
|
||||
const EngineContext = createContext<EngineContextValue | null>(null);
|
||||
@@ -68,31 +91,67 @@ interface EngineProviderProps {
|
||||
readonly children: ReactNode;
|
||||
/** Inject a pre-built engine for tests; production uses the default. */
|
||||
readonly engine?: Engine;
|
||||
/**
|
||||
* Active session id. Drives the per-session storage key for the engine
|
||||
* snapshot and the active-document pointer. `null`/omitted falls back
|
||||
* to the legacy unscoped keys for back-compat with pre-CE-WP-0005
|
||||
* builds.
|
||||
*
|
||||
* To switch sessions, parents should *re-key* this provider
|
||||
* (`<EngineProvider key={sessionId} sessionId={sessionId}>`) so React
|
||||
* unmounts the subtree and a fresh engine is created.
|
||||
*/
|
||||
readonly sessionId?: SessionId | null;
|
||||
}
|
||||
|
||||
export function EngineProvider({ children, engine: injected }: EngineProviderProps) {
|
||||
export function EngineProvider({
|
||||
children,
|
||||
engine: injected,
|
||||
sessionId = null,
|
||||
}: EngineProviderProps) {
|
||||
const engine = useMemo(() => injected ?? createEngine(), [injected]);
|
||||
// Prefer the SessionProvider's per-session byte store registry when
|
||||
// available; fall back to a provider-local store for tests that mount
|
||||
// EngineProvider on its own.
|
||||
const sessionCtx = useReactContext(SessionInternalContext);
|
||||
const [fallbackByteStore] = useState<PdfByteStore>(() => createPdfByteStore());
|
||||
const byteStore =
|
||||
sessionId && sessionCtx
|
||||
? sessionCtx.getOrCreateByteStore(sessionId)
|
||||
: fallbackByteStore;
|
||||
const [activeDocumentId, setActiveDocumentIdState] = useState<DocumentId | null>(null);
|
||||
// `restoreFromStorage` writes directly to the engine's repos without
|
||||
// firing engine events (by design — see persistence.ts). That means
|
||||
// consuming components (CollectionList etc.) wouldn't normally
|
||||
// re-render to reflect the restored state. Bumping `engineRevision`
|
||||
// after restore is what consumers add to their `useMemo` deps so
|
||||
// the restored state shows up on (re-)mount.
|
||||
const [engineRevision, setEngineRevision] = useState(0);
|
||||
const [pendingSelection, setPendingSelection] = useState<PendingSelection | null>(null);
|
||||
const [scrollState, setScrollState] = useState<{ id: AnnotationId | null; version: number }>({
|
||||
id: null,
|
||||
version: 0,
|
||||
});
|
||||
|
||||
const snapshotKey = storageKeyFor(sessionId);
|
||||
const activeDocKey = activeDocumentKeyFor(sessionId);
|
||||
|
||||
// Restore from localStorage on first mount, then attach the persister.
|
||||
// The injected-engine path skips persistence (tests own their lifecycle).
|
||||
useEffect(() => {
|
||||
if (injected) return;
|
||||
if (typeof globalThis.localStorage === "undefined") return;
|
||||
const result = restoreFromStorage(engine, { key: STORAGE_KEY });
|
||||
const result = restoreFromStorage(engine, { key: snapshotKey });
|
||||
if (result.restored) {
|
||||
const saved = globalThis.localStorage.getItem(ACTIVE_KEY);
|
||||
const saved = globalThis.localStorage.getItem(activeDocKey);
|
||||
if (saved && engine.documents.get(saved as DocumentId)) {
|
||||
setActiveDocumentIdState(saved as DocumentId);
|
||||
}
|
||||
// Force a re-render so consumers see the restored repos.
|
||||
setEngineRevision((n) => n + 1);
|
||||
}
|
||||
return attachPersister(engine, { key: STORAGE_KEY });
|
||||
}, [engine, injected]);
|
||||
return attachPersister(engine, { key: snapshotKey });
|
||||
}, [engine, injected, snapshotKey, activeDocKey]);
|
||||
|
||||
// Persist the active-document pointer alongside the engine snapshot so a
|
||||
// reload lands the user back where they were.
|
||||
@@ -100,11 +159,11 @@ export function EngineProvider({ children, engine: injected }: EngineProviderPro
|
||||
if (injected) return;
|
||||
if (typeof globalThis.localStorage === "undefined") return;
|
||||
if (activeDocumentId) {
|
||||
globalThis.localStorage.setItem(ACTIVE_KEY, activeDocumentId);
|
||||
globalThis.localStorage.setItem(activeDocKey, activeDocumentId);
|
||||
} else {
|
||||
globalThis.localStorage.removeItem(ACTIVE_KEY);
|
||||
globalThis.localStorage.removeItem(activeDocKey);
|
||||
}
|
||||
}, [activeDocumentId, injected]);
|
||||
}, [activeDocumentId, injected, activeDocKey]);
|
||||
|
||||
// Switching the active document discards any pending selection — it
|
||||
// belongs to the previous document's viewer state.
|
||||
@@ -121,6 +180,7 @@ export function EngineProvider({ children, engine: injected }: EngineProviderPro
|
||||
const value = useMemo<EngineContextValue>(
|
||||
() => ({
|
||||
engine,
|
||||
byteStore,
|
||||
activeDocumentId,
|
||||
setActiveDocumentId,
|
||||
pendingSelection,
|
||||
@@ -128,8 +188,18 @@ export function EngineProvider({ children, engine: injected }: EngineProviderPro
|
||||
scrollToAnnotationId: scrollState.id,
|
||||
scrollVersion: scrollState.version,
|
||||
scrollToAnnotation,
|
||||
engineRevision,
|
||||
}),
|
||||
[engine, activeDocumentId, setActiveDocumentId, pendingSelection, scrollState, scrollToAnnotation],
|
||||
[
|
||||
engine,
|
||||
byteStore,
|
||||
activeDocumentId,
|
||||
setActiveDocumentId,
|
||||
pendingSelection,
|
||||
scrollState,
|
||||
scrollToAnnotation,
|
||||
engineRevision,
|
||||
],
|
||||
);
|
||||
|
||||
return <EngineContext.Provider value={value}>{children}</EngineContext.Provider>;
|
||||
@@ -141,6 +211,18 @@ export function useEngine(): Engine {
|
||||
return ctx.engine;
|
||||
}
|
||||
|
||||
export function usePdfByteStore(): PdfByteStore {
|
||||
const ctx = useContext(EngineContext);
|
||||
if (!ctx) throw new Error("usePdfByteStore: missing EngineProvider");
|
||||
return ctx.byteStore;
|
||||
}
|
||||
|
||||
export function useEngineRevision(): number {
|
||||
const ctx = useContext(EngineContext);
|
||||
if (!ctx) throw new Error("useEngineRevision: missing EngineProvider");
|
||||
return ctx.engineRevision;
|
||||
}
|
||||
|
||||
export function useActiveDocumentId(): {
|
||||
readonly id: DocumentId | null;
|
||||
setId(id: DocumentId | null): void;
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
useActiveDocument,
|
||||
useEngine,
|
||||
useEngineEventTick,
|
||||
useEngineRevision,
|
||||
useLastActivatedEvidence,
|
||||
useScrollToAnnotation,
|
||||
} from "./EngineContext";
|
||||
@@ -75,11 +76,12 @@ export function EvidenceSidebar(props: EvidenceSidebarProps) {
|
||||
|
||||
const createTick = useEngineEventTick("EvidenceItemCreated");
|
||||
const updateTick = useEngineEventTick("EvidenceItemUpdated");
|
||||
const revision = useEngineRevision();
|
||||
|
||||
const items = useMemo<readonly EvidenceItem[]>(() => {
|
||||
if (!document) return [];
|
||||
return engine.evidence.listByDocument(document.id);
|
||||
}, [document, engine, createTick, updateTick]);
|
||||
}, [document, engine, createTick, updateTick, revision]);
|
||||
|
||||
const [openExportFor, setOpenExportFor] = useState<EvidenceItemId | null>(null);
|
||||
const [toast, setToast] = useState<ToastState | null>(null);
|
||||
|
||||
241
src/work/SessionContext.tsx
Normal file
241
src/work/SessionContext.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* SessionProvider — owns the cross-session services.
|
||||
*
|
||||
* Layers above the per-session `EngineProvider`. Responsibilities:
|
||||
*
|
||||
* - hold the `SessionService` + its own bus instance
|
||||
* - hydrate sessions from `localStorage` on first mount
|
||||
* - expose `useSessionService()`, `useActiveSession()`, hooks to
|
||||
* subscribe to session bus events
|
||||
*
|
||||
* Switching sessions is a side effect of calling
|
||||
* `useSessionService().setActive(...)`. The hook tracks the active id
|
||||
* via the bus's `SessionActivated` event so the value stays a single
|
||||
* source of truth.
|
||||
*
|
||||
* NB: this module does *not* mount the `EngineProvider`. T04 wires the
|
||||
* top-level App so the EngineProvider is keyed by the active session id
|
||||
* (`<EngineProvider key={activeId} sessionId={activeId} />`). Keeping
|
||||
* the two providers separate lets tests target one without the other.
|
||||
*/
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import type { SessionId } from "@shared/ids";
|
||||
import type { Session } from "@shared/session";
|
||||
|
||||
import {
|
||||
attachSessionPersister,
|
||||
createEventBus,
|
||||
createInMemorySessionRepository,
|
||||
createSessionService,
|
||||
restoreSessionsFromStorage,
|
||||
type EventBus,
|
||||
type SessionService,
|
||||
} from "@engine/index";
|
||||
import { createPdfByteStore, type PdfByteStore } from "@source/index";
|
||||
|
||||
import {
|
||||
SessionInternalContext,
|
||||
type SessionInternalContextValue,
|
||||
} from "./SessionContextInternal";
|
||||
|
||||
const SessionContext = SessionInternalContext;
|
||||
type SessionContextValue = SessionInternalContextValue;
|
||||
|
||||
interface SessionProviderProps {
|
||||
readonly children: ReactNode;
|
||||
/** Inject a pre-built service for tests; production uses the default. */
|
||||
readonly service?: SessionService;
|
||||
readonly bus?: EventBus;
|
||||
}
|
||||
|
||||
export function SessionProvider({
|
||||
children,
|
||||
service: injectedService,
|
||||
bus: injectedBus,
|
||||
}: SessionProviderProps) {
|
||||
const bus = useMemo(() => injectedBus ?? createEventBus(), [injectedBus]);
|
||||
const [repo] = useState(() => createInMemorySessionRepository());
|
||||
const service = useMemo(
|
||||
() => injectedService ?? createSessionService(repo, bus),
|
||||
[injectedService, repo, bus],
|
||||
);
|
||||
|
||||
const [activeId, setActiveId] = useState<SessionId | null>(null);
|
||||
const [hydrated, setHydrated] = useState<boolean>(false);
|
||||
const [byteStores] = useState<Map<SessionId, PdfByteStore>>(() => new Map());
|
||||
const [sessionVersions, setSessionVersions] = useState<ReadonlyMap<SessionId, number>>(
|
||||
() => new Map(),
|
||||
);
|
||||
|
||||
const getOrCreateByteStore = useCallback(
|
||||
(sessionId: SessionId) => {
|
||||
let store = byteStores.get(sessionId);
|
||||
if (!store) {
|
||||
store = createPdfByteStore();
|
||||
byteStores.set(sessionId, store);
|
||||
}
|
||||
return store;
|
||||
},
|
||||
[byteStores],
|
||||
);
|
||||
|
||||
const getSessionVersion = useCallback(
|
||||
(sessionId: SessionId) => sessionVersions.get(sessionId) ?? 0,
|
||||
[sessionVersions],
|
||||
);
|
||||
|
||||
const bumpSessionVersion = useCallback((sessionId: SessionId) => {
|
||||
setSessionVersions((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(sessionId, (prev.get(sessionId) ?? 0) + 1);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Hydrate from storage, then attach the persister.
|
||||
useEffect(() => {
|
||||
if (injectedService) {
|
||||
setHydrated(true);
|
||||
return;
|
||||
}
|
||||
if (typeof globalThis.localStorage === "undefined") {
|
||||
setHydrated(true);
|
||||
return;
|
||||
}
|
||||
const result = restoreSessionsFromStorage(repo, service);
|
||||
if (result.restored && result.activeSessionId) {
|
||||
setActiveId(result.activeSessionId);
|
||||
}
|
||||
setHydrated(true);
|
||||
return attachSessionPersister(service, bus);
|
||||
}, [bus, injectedService, repo, service]);
|
||||
|
||||
// Keep the active-id mirror in sync with the bus.
|
||||
useEffect(() => {
|
||||
return bus.on("SessionActivated", (e) => {
|
||||
setActiveId(e.sessionId);
|
||||
});
|
||||
}, [bus]);
|
||||
|
||||
// Drop byte stores for sessions that get deleted (revoking blob URLs).
|
||||
useEffect(() => {
|
||||
return bus.on("SessionDeleted", (e) => {
|
||||
const store = byteStores.get(e.sessionId);
|
||||
if (store) {
|
||||
store.clear();
|
||||
byteStores.delete(e.sessionId);
|
||||
}
|
||||
});
|
||||
}, [bus, byteStores]);
|
||||
|
||||
const value = useMemo<SessionContextValue>(
|
||||
() => ({
|
||||
service,
|
||||
bus,
|
||||
activeId,
|
||||
hydrated,
|
||||
getOrCreateByteStore,
|
||||
getSessionVersion,
|
||||
bumpSessionVersion,
|
||||
}),
|
||||
[
|
||||
service,
|
||||
bus,
|
||||
activeId,
|
||||
hydrated,
|
||||
getOrCreateByteStore,
|
||||
getSessionVersion,
|
||||
bumpSessionVersion,
|
||||
],
|
||||
);
|
||||
|
||||
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
||||
}
|
||||
|
||||
export function useSessionService(): SessionService {
|
||||
const ctx = useContext(SessionContext);
|
||||
if (!ctx) throw new Error("useSessionService: missing SessionProvider");
|
||||
return ctx.service;
|
||||
}
|
||||
|
||||
export function useSessionBus(): EventBus {
|
||||
const ctx = useContext(SessionContext);
|
||||
if (!ctx) throw new Error("useSessionBus: missing SessionProvider");
|
||||
return ctx.bus;
|
||||
}
|
||||
|
||||
export function useActiveSessionId(): SessionId | null {
|
||||
const ctx = useContext(SessionContext);
|
||||
if (!ctx) throw new Error("useActiveSessionId: missing SessionProvider");
|
||||
return ctx.activeId;
|
||||
}
|
||||
|
||||
export function useActiveSession(): Session | null {
|
||||
const ctx = useContext(SessionContext);
|
||||
if (!ctx) throw new Error("useActiveSession: missing SessionProvider");
|
||||
return ctx.activeId ? ctx.service.get(ctx.activeId) : null;
|
||||
}
|
||||
|
||||
export function useSessionsHydrated(): boolean {
|
||||
const ctx = useContext(SessionContext);
|
||||
if (!ctx) throw new Error("useSessionsHydrated: missing SessionProvider");
|
||||
return ctx.hydrated;
|
||||
}
|
||||
|
||||
export function useSessionByteStore(sessionId: SessionId): PdfByteStore {
|
||||
const ctx = useContext(SessionContext);
|
||||
if (!ctx) throw new Error("useSessionByteStore: missing SessionProvider");
|
||||
return ctx.getOrCreateByteStore(sessionId);
|
||||
}
|
||||
|
||||
export function useSessionVersionBumper(): (sessionId: SessionId) => void {
|
||||
const ctx = useContext(SessionContext);
|
||||
if (!ctx) throw new Error("useSessionVersionBumper: missing SessionProvider");
|
||||
return ctx.bumpSessionVersion;
|
||||
}
|
||||
|
||||
export function useSessionVersion(sessionId: SessionId): number {
|
||||
const ctx = useContext(SessionContext);
|
||||
if (!ctx) throw new Error("useSessionVersion: missing SessionProvider");
|
||||
return ctx.getSessionVersion(sessionId);
|
||||
}
|
||||
|
||||
export function useSessionByteStoreRegistry(): {
|
||||
getOrCreateByteStore(sessionId: SessionId): PdfByteStore;
|
||||
} {
|
||||
const ctx = useContext(SessionContext);
|
||||
if (!ctx) throw new Error("useSessionByteStoreRegistry: missing SessionProvider");
|
||||
return { getOrCreateByteStore: ctx.getOrCreateByteStore };
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-render whenever the session list mutates. Returns a tick counter
|
||||
* that callers can use as a `useMemo`/`useEffect` dependency to read
|
||||
* `service.list()` lazily.
|
||||
*/
|
||||
export function useSessionListTick(): number {
|
||||
const ctx = useContext(SessionContext);
|
||||
if (!ctx) throw new Error("useSessionListTick: missing SessionProvider");
|
||||
const [tick, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const bump = () => setTick((t) => t + 1);
|
||||
const offs = [
|
||||
ctx.bus.on("SessionCreated", bump),
|
||||
ctx.bus.on("SessionRenamed", bump),
|
||||
ctx.bus.on("SessionDeleted", bump),
|
||||
];
|
||||
return () => {
|
||||
for (const off of offs) off();
|
||||
};
|
||||
}, [ctx.bus]);
|
||||
return tick;
|
||||
}
|
||||
27
src/work/SessionContextInternal.ts
Normal file
27
src/work/SessionContextInternal.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Internal: the React Context object backing `SessionProvider`.
|
||||
*
|
||||
* Lives in its own module so `EngineContext.tsx` can subscribe without
|
||||
* importing the full `SessionContext.tsx` (which would re-export the
|
||||
* EngineProvider via the same `@work` barrel and create a circular
|
||||
* dependency at module-init time).
|
||||
*/
|
||||
|
||||
import { createContext } from "react";
|
||||
|
||||
import type { SessionId } from "@shared/ids";
|
||||
|
||||
import type { EventBus, SessionService } from "@engine/index";
|
||||
import type { PdfByteStore } from "@source/index";
|
||||
|
||||
export interface SessionInternalContextValue {
|
||||
readonly service: SessionService;
|
||||
readonly bus: EventBus;
|
||||
readonly activeId: SessionId | null;
|
||||
readonly hydrated: boolean;
|
||||
getOrCreateByteStore(sessionId: SessionId): PdfByteStore;
|
||||
getSessionVersion(sessionId: SessionId): number;
|
||||
bumpSessionVersion(sessionId: SessionId): void;
|
||||
}
|
||||
|
||||
export const SessionInternalContext = createContext<SessionInternalContextValue | null>(null);
|
||||
@@ -1,4 +1,4 @@
|
||||
export { CollectionList } from "./CollectionList";
|
||||
export { CollectionList, type CollectionListProps } from "./CollectionList";
|
||||
export { ViewerShell } from "./ViewerShell";
|
||||
export { EvidenceSidebar, type EvidenceSidebarProps } from "./EvidenceSidebar";
|
||||
export {
|
||||
@@ -14,8 +14,23 @@ export {
|
||||
useActiveDocument,
|
||||
useActiveDocumentId,
|
||||
useEngineEventTick,
|
||||
useEngineRevision,
|
||||
useLastActivatedEvidence,
|
||||
usePdfByteStore,
|
||||
usePendingSelection,
|
||||
useScrollToAnnotation,
|
||||
type PendingSelection,
|
||||
} from "./EngineContext";
|
||||
export {
|
||||
SessionProvider,
|
||||
useActiveSession,
|
||||
useActiveSessionId,
|
||||
useSessionBus,
|
||||
useSessionByteStore,
|
||||
useSessionByteStoreRegistry,
|
||||
useSessionListTick,
|
||||
useSessionService,
|
||||
useSessionsHydrated,
|
||||
useSessionVersion,
|
||||
useSessionVersionBumper,
|
||||
} from "./SessionContext";
|
||||
|
||||
@@ -28,8 +28,6 @@ import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Selector } from "@shared/selector";
|
||||
import type { DocumentId, RepresentationId } from "@shared/ids";
|
||||
import type { Document, DocumentRepresentation } from "@shared/document";
|
||||
import type { PdfSelectionCapture } from "@anchor/index";
|
||||
import manifest from "../../fixtures/pdfs/manifest.json" with { type: "json" };
|
||||
|
||||
@@ -90,42 +88,8 @@ const SYNTHETIC_CANONICAL = [
|
||||
"Trailing prose that comes after the quote.",
|
||||
].join(" ");
|
||||
|
||||
vi.mock("@source/index", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("@source/index")>();
|
||||
return {
|
||||
...original,
|
||||
ingestPdf: vi.fn(async (_input: unknown, options?: { filename?: string }) => {
|
||||
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 document: Document = {
|
||||
id: documentId,
|
||||
mediaType: "application/pdf",
|
||||
...(options?.filename ? { title: options.filename } : {}),
|
||||
fingerprint: "synthetic-fingerprint-for-test",
|
||||
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-fingerprint-for-test",
|
||||
canonicalText: SYNTHETIC_CANONICAL,
|
||||
pageMap: [{ page: 1, width: 595, height: 842 }],
|
||||
offsetMap: [
|
||||
{
|
||||
page: 1,
|
||||
globalStart: 0,
|
||||
globalEnd: SYNTHETIC_CANONICAL.length,
|
||||
pageLength: SYNTHETIC_CANONICAL.length,
|
||||
},
|
||||
],
|
||||
generatedAt: "2026-05-25T00:00:00.000Z",
|
||||
};
|
||||
return { document, representation };
|
||||
}),
|
||||
};
|
||||
});
|
||||
// CE-WP-0005: pre-seed a session with the fixture doc; no ingestPdf mock needed.
|
||||
import { seedSessionWithDoc, type SeedResult } from "./helpers/seed-session";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -160,40 +124,40 @@ async function loadApp() {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("App — PRD scenario steps 1-8 (CE-WP-0002-T09)", () => {
|
||||
let seeded: SeedResult;
|
||||
|
||||
beforeEach(() => {
|
||||
resetViewerSnapshot();
|
||||
// Each test starts with empty localStorage.
|
||||
globalThis.localStorage?.clear();
|
||||
// The fetch isn't reached (ingestPdf is mocked) — but stub it so that
|
||||
// any accidental call returns gracefully instead of TypeError.
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
new Response(new Uint8Array([0x25, 0x50, 0x44, 0x46]).buffer, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/pdf" },
|
||||
}),
|
||||
);
|
||||
if (typeof window !== "undefined") {
|
||||
history.replaceState(null, "", window.location.pathname);
|
||||
}
|
||||
seeded = seedSessionWithDoc({
|
||||
sessionName: "Demo",
|
||||
documentTitle: FIXTURE.filename,
|
||||
canonicalText: SYNTHETIC_CANONICAL,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("walks the full slice-1 scenario: load → select → save → reload → click → scroll", async () => {
|
||||
it("walks the full slice-1 scenario: load → select → save → reload → click → scroll", { timeout: 15000 }, async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Step 1: open the app.
|
||||
// Step 1: open the app. CE-WP-0005: a pre-seeded session boots
|
||||
// straight into the active layout (no empty state).
|
||||
const { unmount } = await loadApp();
|
||||
expect(screen.getByText("Collection")).toBeTruthy();
|
||||
expect(screen.queryByTestId("empty-state")).toBeNull();
|
||||
expect(screen.getByTestId("session-menu-toggle").textContent).toContain("Demo");
|
||||
|
||||
// Step 2: pick a fixture.
|
||||
const fixtureButton = screen.getByRole("button", { name: new RegExp(FIXTURE.id) });
|
||||
await user.click(fixtureButton);
|
||||
|
||||
// The mock viewer should have mounted with our test URL.
|
||||
// Step 2: the fixture doc is pre-seeded into the active session, so
|
||||
// the viewer mounts automatically — no fixture-button click needed.
|
||||
await waitFor(() => {
|
||||
expect(viewerSnapshot.pdfUrl).toBeTruthy();
|
||||
expect(viewerSnapshot.onSelectionCaptured).not.toBeNull();
|
||||
});
|
||||
expect(decodeURIComponent(viewerSnapshot.pdfUrl!)).toContain(FIXTURE.filename);
|
||||
|
||||
// Step 3: programmatically inject a selection for the known-good quote.
|
||||
expect(viewerSnapshot.onSelectionCaptured).not.toBeNull();
|
||||
@@ -223,9 +187,11 @@ describe("App — PRD scenario steps 1-8 (CE-WP-0002-T09)", () => {
|
||||
});
|
||||
const savedAnnotationId = viewerSnapshot.storedAnnotationIds[0]!;
|
||||
|
||||
// Snapshot key from EngineContext.STORAGE_KEY — implementation detail
|
||||
// Per-session snapshot key from CE-WP-0005 — implementation detail
|
||||
// but worth asserting once at the integration layer.
|
||||
const stored = globalThis.localStorage?.getItem("citation-evidence:engine-snapshot:v1");
|
||||
const stored = globalThis.localStorage?.getItem(
|
||||
`citation-evidence:session:${seeded.sessionId}:engine-snapshot:v1`,
|
||||
);
|
||||
expect(stored).toBeTruthy();
|
||||
|
||||
// Step 6: reload — unmount and remount the App. The same localStorage is
|
||||
@@ -237,9 +203,8 @@ describe("App — PRD scenario steps 1-8 (CE-WP-0002-T09)", () => {
|
||||
// The viewer should re-mount automatically because the active document
|
||||
// was persisted.
|
||||
await waitFor(() => {
|
||||
expect(viewerSnapshot.pdfUrl).toBeTruthy();
|
||||
expect(viewerSnapshot.onSelectionCaptured).not.toBeNull();
|
||||
});
|
||||
expect(decodeURIComponent(viewerSnapshot.pdfUrl!)).toContain(FIXTURE.filename);
|
||||
|
||||
// The sidebar should show the restored item.
|
||||
const restoredItem = await screen.findByText(/Important deadline clause/);
|
||||
|
||||
@@ -20,8 +20,6 @@ import { act, 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 type { Selector } from "@shared/selector";
|
||||
|
||||
import type { PdfSelectionCapture } from "@anchor/index";
|
||||
@@ -73,42 +71,8 @@ const SYNTHETIC_CANONICAL = [
|
||||
"Post quote.",
|
||||
].join(" ");
|
||||
|
||||
vi.mock("@source/index", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("@source/index")>();
|
||||
return {
|
||||
...original,
|
||||
ingestPdf: vi.fn(async (_input: unknown, options?: { filename?: string }) => {
|
||||
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 document: Document = {
|
||||
id: documentId,
|
||||
mediaType: "application/pdf",
|
||||
...(options?.filename ? { title: options.filename } : {}),
|
||||
fingerprint: "synthetic-fingerprint-for-test",
|
||||
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-fingerprint-for-test",
|
||||
canonicalText: SYNTHETIC_CANONICAL,
|
||||
pageMap: [{ page: 1, width: 595, height: 842 }],
|
||||
offsetMap: [
|
||||
{
|
||||
page: 1,
|
||||
globalStart: 0,
|
||||
globalEnd: SYNTHETIC_CANONICAL.length,
|
||||
pageLength: SYNTHETIC_CANONICAL.length,
|
||||
},
|
||||
],
|
||||
generatedAt: "2026-05-25T00:00:00.000Z",
|
||||
};
|
||||
return { document, representation };
|
||||
}),
|
||||
};
|
||||
});
|
||||
// CE-WP-0005: pre-seed a session with the fixture doc; no ingestPdf mock needed.
|
||||
import { seedSessionWithDoc } from "./helpers/seed-session";
|
||||
|
||||
function syntheticCaptureFor(text: string, page: number): PdfSelectionCapture {
|
||||
return {
|
||||
@@ -136,15 +100,14 @@ describe("FormsApp — active-evidence cycling (CE-WP-0003-T06)", () => {
|
||||
beforeEach(() => {
|
||||
resetSnapshot();
|
||||
globalThis.localStorage?.clear();
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
new Response(new Uint8Array([0x25, 0x50, 0x44, 0x46]).buffer, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/pdf" },
|
||||
}),
|
||||
);
|
||||
if (typeof window !== "undefined") {
|
||||
history.replaceState(null, "", window.location.pathname);
|
||||
}
|
||||
seedSessionWithDoc({
|
||||
sessionName: "T06-cycling",
|
||||
documentTitle: FIXTURE.filename,
|
||||
canonicalText: SYNTHETIC_CANONICAL,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -157,8 +120,7 @@ describe("FormsApp — active-evidence cycling (CE-WP-0003-T06)", () => {
|
||||
await loadApp();
|
||||
|
||||
// --- Review mode: create an evidence item via the captured-selection flow.
|
||||
const fixtureBtn = screen.getByRole("button", { name: new RegExp(FIXTURE.id) });
|
||||
await user.click(fixtureBtn);
|
||||
// CE-WP-0005: doc pre-seeded — skip fixture click.
|
||||
await waitFor(() => expect(viewerSnapshot.onSelectionCaptured).not.toBeNull());
|
||||
await act(async () => {
|
||||
viewerSnapshot.onSelectionCaptured!(
|
||||
|
||||
@@ -22,8 +22,7 @@ import { act, 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 { AnnotationId, DocumentId, RepresentationId } from "@shared/ids";
|
||||
import type { AnnotationId } from "@shared/ids";
|
||||
import type { Selector } from "@shared/selector";
|
||||
|
||||
import type { PdfSelectionCapture } from "@anchor/index";
|
||||
@@ -55,38 +54,8 @@ vi.mock("@anchor/index", async (importOriginal) => {
|
||||
|
||||
const FIXTURE = manifest.fixtures.find((f) => f.id === "fristsetzung-bezifferung")!;
|
||||
|
||||
vi.mock("@source/index", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("@source/index")>();
|
||||
return {
|
||||
...original,
|
||||
ingestPdf: vi.fn(async (_input: unknown, options?: { filename?: string }) => {
|
||||
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 synthetic = "Synthetic canonical text for the form-link test.";
|
||||
const document: Document = {
|
||||
id: documentId,
|
||||
mediaType: "application/pdf",
|
||||
...(options?.filename ? { title: options.filename } : {}),
|
||||
fingerprint: "synthetic-fingerprint-for-test",
|
||||
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-fingerprint-for-test",
|
||||
canonicalText: synthetic,
|
||||
pageMap: [{ page: 1, width: 595, height: 842 }],
|
||||
offsetMap: [
|
||||
{ page: 1, globalStart: 0, globalEnd: synthetic.length, pageLength: synthetic.length },
|
||||
],
|
||||
generatedAt: "2026-05-25T00:00:00.000Z",
|
||||
};
|
||||
return { document, representation };
|
||||
}),
|
||||
};
|
||||
});
|
||||
// CE-WP-0005: pre-seed a session with the fixture doc; no ingestPdf mock needed.
|
||||
import { seedSessionWithDoc } from "./helpers/seed-session";
|
||||
|
||||
async function loadApp() {
|
||||
const { App } = await import("@app/App");
|
||||
@@ -96,12 +65,6 @@ async function loadApp() {
|
||||
describe("FormsApp — click-evidence-then-click-field linking (CE-WP-0003-T05)", () => {
|
||||
beforeEach(() => {
|
||||
globalThis.localStorage?.clear();
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
new Response(new Uint8Array([0x25, 0x50, 0x44, 0x46]).buffer, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/pdf" },
|
||||
}),
|
||||
);
|
||||
// Forms mode is hash-driven; make sure we start clean.
|
||||
if (typeof window !== "undefined") {
|
||||
history.replaceState(null, "", window.location.pathname);
|
||||
@@ -114,17 +77,19 @@ describe("FormsApp — click-evidence-then-click-field linking (CE-WP-0003-T05)"
|
||||
});
|
||||
|
||||
it("stages an evidence item then links it to the clicked field", async () => {
|
||||
seedSessionWithDoc({
|
||||
sessionName: "T05-link",
|
||||
documentTitle: FIXTURE.filename,
|
||||
canonicalText: "Synthetic canonical text for the form-link test.",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
await loadApp();
|
||||
|
||||
// Switch to Forms via the top-bar button.
|
||||
await user.click(screen.getByRole("button", { name: "Forms" }));
|
||||
|
||||
// The collection list is in the Forms layout too.
|
||||
const fixtureBtn = screen.getByRole("button", { name: new RegExp(FIXTURE.id) });
|
||||
await user.click(fixtureBtn);
|
||||
|
||||
// Wait for the fixture to load and the form to appear.
|
||||
// CE-WP-0005: doc is pre-seeded into the active session.
|
||||
// Wait for the form to appear.
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText("Summary of the matter")).not.toBeNull();
|
||||
});
|
||||
@@ -170,12 +135,12 @@ describe("FormsApp — click-evidence-then-click-field linking (CE-WP-0003-T05)"
|
||||
expect(screen.getByTestId("field-summary-chip").textContent).toMatch(/1 evidence/);
|
||||
});
|
||||
|
||||
it("starts in Review mode by default and switches to Forms via hash", async () => {
|
||||
it("starts in the empty state when no session is active (CE-WP-0005 default)", async () => {
|
||||
await loadApp();
|
||||
expect(screen.getByText("Collection")).toBeTruthy();
|
||||
// Review pane's no-doc-open hint from EvidenceSidebar:
|
||||
expect(screen.queryByText(/No document open/)).not.toBeNull();
|
||||
// No demo form rendered yet
|
||||
// The empty-state landing is what users see now until they create
|
||||
// a session.
|
||||
expect(screen.getByTestId("empty-state")).toBeTruthy();
|
||||
// No demo form rendered yet.
|
||||
expect(screen.queryByText("Demo evidence-backed form")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,8 +27,6 @@ import { act, 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 type { Selector } from "@shared/selector";
|
||||
|
||||
import type { PdfSelectionCapture } from "@anchor/index";
|
||||
@@ -82,37 +80,8 @@ vi.mock("@anchor/index", async (importOriginal) => {
|
||||
const FIXTURE = manifest.fixtures.find((f) => f.id === "fristsetzung-bezifferung")!;
|
||||
const SYNTHETIC_CANONICAL = ["Pre.", FIXTURE.known_good_quote, "Post."].join(" ");
|
||||
|
||||
vi.mock("@source/index", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("@source/index")>();
|
||||
return {
|
||||
...original,
|
||||
ingestPdf: vi.fn(async (_input: unknown, options?: { filename?: string }) => {
|
||||
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 document: Document = {
|
||||
id: documentId,
|
||||
mediaType: "application/pdf",
|
||||
...(options?.filename ? { title: options.filename } : {}),
|
||||
fingerprint: "synthetic-fingerprint-for-test",
|
||||
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-fingerprint-for-test",
|
||||
canonicalText: SYNTHETIC_CANONICAL,
|
||||
pageMap: [{ page: 1, width: 595, height: 842 }],
|
||||
offsetMap: [
|
||||
{ page: 1, globalStart: 0, globalEnd: SYNTHETIC_CANONICAL.length, pageLength: SYNTHETIC_CANONICAL.length },
|
||||
],
|
||||
generatedAt: "2026-05-25T00:00:00.000Z",
|
||||
};
|
||||
return { document, representation };
|
||||
}),
|
||||
};
|
||||
});
|
||||
// CE-WP-0005: pre-seed a session with the fixture doc; no ingestPdf mock needed.
|
||||
import { seedSessionWithDoc } from "./helpers/seed-session";
|
||||
|
||||
function syntheticCaptureFor(text: string, page: number): PdfSelectionCapture {
|
||||
return {
|
||||
@@ -135,15 +104,14 @@ describe("CE-WP-0003-T08 — PRD scenario steps 5-9 end-to-end", () => {
|
||||
viewerSnapshot.scrollToAnnotationId = null;
|
||||
viewerSnapshot.onSelectionCaptured = null;
|
||||
globalThis.localStorage?.clear();
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
new Response(new Uint8Array([0x25, 0x50, 0x44, 0x46]).buffer, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/pdf" },
|
||||
}),
|
||||
);
|
||||
if (typeof window !== "undefined") {
|
||||
history.replaceState(null, "", window.location.pathname);
|
||||
}
|
||||
seedSessionWithDoc({
|
||||
sessionName: "T08-forms",
|
||||
documentTitle: FIXTURE.filename,
|
||||
canonicalText: SYNTHETIC_CANONICAL,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -159,8 +127,7 @@ describe("CE-WP-0003-T08 — PRD scenario steps 5-9 end-to-end", () => {
|
||||
await loadApp();
|
||||
|
||||
// Steps 1-4 (CE-WP-0002 setup): create an evidence item in Review mode.
|
||||
const fixtureBtn = screen.getByRole("button", { name: new RegExp(FIXTURE.id) });
|
||||
await user.click(fixtureBtn);
|
||||
// CE-WP-0005: doc pre-seeded into the session — skip fixture click.
|
||||
await waitFor(() => expect(viewerSnapshot.onSelectionCaptured).not.toBeNull());
|
||||
await act(async () => {
|
||||
viewerSnapshot.onSelectionCaptured!(
|
||||
@@ -175,9 +142,10 @@ describe("CE-WP-0003-T08 — PRD scenario steps 5-9 end-to-end", () => {
|
||||
await user.click(screen.getByRole("button", { name: /Save evidence/ }));
|
||||
await screen.findByText(/Overlay E2E evidence/);
|
||||
|
||||
// Step 5: navigate to /forms/demo via the top-bar.
|
||||
// Step 5: navigate to forms via the top-bar.
|
||||
await user.click(screen.getByRole("button", { name: "Forms" }));
|
||||
expect(window.location.hash).toBe("#/forms/demo");
|
||||
// CE-WP-0005: route is now session-scoped.
|
||||
expect(window.location.hash).toMatch(/^#\/s\/sess_[^/]+\/forms\/demo$/);
|
||||
|
||||
// Step 6: stage the evidence in the strip, then click the summary
|
||||
// field to create the link.
|
||||
|
||||
116
tests/integration/helpers/seed-session.ts
Normal file
116
tests/integration/helpers/seed-session.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Test helper: pre-seed `localStorage` with an active session that holds
|
||||
* one document + one representation, so an integration test can skip
|
||||
* the empty-state + upload flow and mount straight into a populated
|
||||
* Review/Forms layout.
|
||||
*
|
||||
* Pre-CE-WP-0005 integration tests clicked a "fixture button" in the
|
||||
* `CollectionList` to load a sample PDF; after the session refactor
|
||||
* there is no fixture list directly in the active-session UI. These
|
||||
* legacy tests now call `seedSessionWithDoc` in `beforeEach` instead.
|
||||
*
|
||||
* The seed bypasses `SessionService.create()` (which mints a random
|
||||
* id) so tests can reference the resulting `documentId` /
|
||||
* `representationId` synchronously, before the app boots.
|
||||
*/
|
||||
|
||||
import type { Document, DocumentRepresentation } from "@shared/document";
|
||||
import type { DocumentId, RepresentationId, SessionId } from "@shared/ids";
|
||||
import type { Session } from "@shared/session";
|
||||
|
||||
export interface SeedOptions {
|
||||
readonly sessionName?: string;
|
||||
readonly documentTitle?: string;
|
||||
readonly canonicalText: string;
|
||||
readonly mode?: "review" | "forms";
|
||||
}
|
||||
|
||||
export interface SeedResult {
|
||||
readonly sessionId: SessionId;
|
||||
readonly documentId: DocumentId;
|
||||
readonly representationId: RepresentationId;
|
||||
}
|
||||
|
||||
function rid(prefix: string): string {
|
||||
return `${prefix}_seed_${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export function seedSessionWithDoc(opts: SeedOptions): SeedResult {
|
||||
const sessionId = rid("sess") as SessionId;
|
||||
const documentId = rid("doc") as DocumentId;
|
||||
const representationId = rid("rep") as RepresentationId;
|
||||
const now = "2026-05-25T00:00:00.000Z";
|
||||
|
||||
const session: Session = {
|
||||
id: sessionId,
|
||||
name: opts.sessionName ?? "Test Session",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastOpenedAt: now,
|
||||
};
|
||||
|
||||
const document: Document = {
|
||||
id: documentId,
|
||||
mediaType: "application/pdf",
|
||||
...(opts.documentTitle ? { title: opts.documentTitle } : {}),
|
||||
fingerprint: "seed-fingerprint",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const representation: DocumentRepresentation = {
|
||||
id: representationId,
|
||||
documentId,
|
||||
representationType: "pdf-text",
|
||||
contentHash: "seed-fingerprint",
|
||||
canonicalText: opts.canonicalText,
|
||||
pageMap: [{ page: 1, width: 595, height: 842 }],
|
||||
offsetMap: [
|
||||
{
|
||||
page: 1,
|
||||
globalStart: 0,
|
||||
globalEnd: opts.canonicalText.length,
|
||||
pageLength: opts.canonicalText.length,
|
||||
},
|
||||
],
|
||||
generatedAt: now,
|
||||
};
|
||||
|
||||
const snapshot = {
|
||||
version: 1,
|
||||
documents: [document],
|
||||
representations: [representation],
|
||||
annotations: [],
|
||||
evidenceItems: [],
|
||||
};
|
||||
|
||||
const sessionsFile = {
|
||||
version: 1,
|
||||
sessions: [session],
|
||||
activeSessionId: sessionId,
|
||||
};
|
||||
|
||||
localStorage.setItem("citation-evidence:sessions:v1", JSON.stringify(sessionsFile));
|
||||
localStorage.setItem("citation-evidence:active-session-id:v1", sessionId);
|
||||
localStorage.setItem(
|
||||
`citation-evidence:session:${sessionId}:engine-snapshot:v1`,
|
||||
JSON.stringify(snapshot),
|
||||
);
|
||||
localStorage.setItem(
|
||||
`citation-evidence:session:${sessionId}:active-document-id:v1`,
|
||||
documentId,
|
||||
);
|
||||
|
||||
// Set the hash so the App routes straight into the session.
|
||||
const hash =
|
||||
opts.mode === "forms"
|
||||
? `#/s/${sessionId}/forms/demo`
|
||||
: `#/s/${sessionId}`;
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
window.location.pathname + window.location.search + hash,
|
||||
);
|
||||
|
||||
return { sessionId, documentId, representationId };
|
||||
}
|
||||
350
tests/integration/session-export-reimport.dom.test.tsx
Normal file
350
tests/integration/session-export-reimport.dom.test.tsx
Normal file
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* CE-WP-0005-T08 — full create → annotate → export → reimport E2E.
|
||||
*
|
||||
* Walks the demo loop end-to-end:
|
||||
*
|
||||
* 1. Load app → empty state.
|
||||
* 2. Create session "Demo" via the inline name input.
|
||||
* 3. Upload a synthetic PDF.
|
||||
* 4. Inject a selection for the manifest's known-good quote → save
|
||||
* evidence with a commentary.
|
||||
* 5. (Sanity) Click Export → Copy as Markdown; assert the clipboard
|
||||
* payload contains the quote + commentary + openContextUrl.
|
||||
* 6. Click Export ZIP; capture the Blob via a URL.createObjectURL
|
||||
* spy.
|
||||
* 7. Click Import ZIP with the captured Blob; assert merge:
|
||||
* - one document (deduped by fingerprint)
|
||||
* - two evidence rows (additive merge)
|
||||
* 8. Click Export → Copy as Markdown on the merged evidence; assert
|
||||
* the citation card text matches the original.
|
||||
*/
|
||||
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, 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 type { Selector } from "@shared/selector";
|
||||
import type { PdfSelectionCapture } from "@anchor/index";
|
||||
import manifest from "../../fixtures/pdfs/manifest.json" with { type: "json" };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ViewerProps {
|
||||
pdfUrl: string;
|
||||
storedAnnotations: readonly { id: string; text: string; selectors: readonly Selector[] }[];
|
||||
scrollToAnnotationId?: string;
|
||||
onSelectionCaptured(capture: PdfSelectionCapture, selectors: Selector[]): void;
|
||||
}
|
||||
|
||||
interface ViewerSnapshot {
|
||||
pdfUrl: string | null;
|
||||
onSelectionCaptured: ViewerProps["onSelectionCaptured"] | null;
|
||||
}
|
||||
|
||||
const viewerSnapshot: ViewerSnapshot = {
|
||||
pdfUrl: null,
|
||||
onSelectionCaptured: null,
|
||||
};
|
||||
|
||||
vi.mock("@anchor/index", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("@anchor/index")>();
|
||||
const MockPdfSpikeViewer = (props: ViewerProps) => {
|
||||
viewerSnapshot.pdfUrl = props.pdfUrl;
|
||||
viewerSnapshot.onSelectionCaptured = props.onSelectionCaptured;
|
||||
return <div data-testid="mock-pdf-viewer" />;
|
||||
};
|
||||
return { ...original, PdfSpikeViewer: MockPdfSpikeViewer };
|
||||
});
|
||||
|
||||
const FIXTURE = manifest.fixtures.find((f) => f.id === "fristsetzung-bezifferung")!;
|
||||
const SYNTHETIC_CANONICAL = ["Pre.", FIXTURE.known_good_quote, "Post."].join(" ");
|
||||
|
||||
// Bypass real PDF.js extraction. The mock returns a stable fingerprint
|
||||
// so the merge-on-fingerprint dedup logic actually fires on re-import.
|
||||
vi.mock("@source/index", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("@source/index")>();
|
||||
let counter = 0;
|
||||
return {
|
||||
...original,
|
||||
ingestPdfFromFile: vi.fn(
|
||||
async (file: File | Blob, store: import("@source/index").PdfByteStore) => {
|
||||
counter += 1;
|
||||
const filename =
|
||||
"name" in file && typeof file.name === "string" ? file.name : "uploaded.pdf";
|
||||
const documentId = ("doc_e2e_" + counter) as DocumentId;
|
||||
const representationId = ("rep_e2e_" + counter) 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,
|
||||
// Stable fingerprint — the importer dedupes by fingerprint, so
|
||||
// re-importing the export merges into the same doc.
|
||||
fingerprint: "fp-stable-e2e",
|
||||
createdAt: "2026-05-25T00:00:00.000Z",
|
||||
updatedAt: "2026-05-25T00:00:00.000Z",
|
||||
};
|
||||
const representation: DocumentRepresentation = {
|
||||
id: representationId,
|
||||
documentId,
|
||||
representationType: "pdf-text",
|
||||
contentHash: "fp-stable-e2e",
|
||||
canonicalText: SYNTHETIC_CANONICAL,
|
||||
pageMap: [{ page: 1, width: 595, height: 842 }],
|
||||
offsetMap: [
|
||||
{
|
||||
page: 1,
|
||||
globalStart: 0,
|
||||
globalEnd: SYNTHETIC_CANONICAL.length,
|
||||
pageLength: SYNTHETIC_CANONICAL.length,
|
||||
},
|
||||
],
|
||||
generatedAt: "2026-05-25T00:00:00.000Z",
|
||||
};
|
||||
return { document, representation };
|
||||
},
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test-time intercepts: clipboard, URL.createObjectURL, file-picker input.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function syntheticCaptureFor(text: string, page: number): PdfSelectionCapture {
|
||||
return {
|
||||
kind: "pdf",
|
||||
text,
|
||||
page,
|
||||
rects: [{ x: 0.1, y: 0.2, width: 0.4, height: 0.04 }],
|
||||
boundingRect: { x: 0.1, y: 0.2, width: 0.4, height: 0.04 },
|
||||
};
|
||||
}
|
||||
|
||||
let writeText: ReturnType<typeof vi.fn>;
|
||||
function installClipboardSpy() {
|
||||
writeText = vi.fn(async () => undefined);
|
||||
const proto = Object.getPrototypeOf(navigator.clipboard);
|
||||
Object.defineProperty(proto, "writeText", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: writeText,
|
||||
});
|
||||
}
|
||||
|
||||
interface UrlInterceptor {
|
||||
blobs: Blob[];
|
||||
restore(): void;
|
||||
}
|
||||
function installUrlInterceptor(): UrlInterceptor {
|
||||
const blobs: Blob[] = [];
|
||||
const origCreate = URL.createObjectURL;
|
||||
const origRevoke = URL.revokeObjectURL;
|
||||
let counter = 0;
|
||||
URL.createObjectURL = ((b: Blob) => {
|
||||
blobs.push(b);
|
||||
counter += 1;
|
||||
return `blob:test-${counter}`;
|
||||
}) as typeof URL.createObjectURL;
|
||||
URL.revokeObjectURL = (() => {}) as typeof URL.revokeObjectURL;
|
||||
return {
|
||||
blobs,
|
||||
restore() {
|
||||
URL.createObjectURL = origCreate;
|
||||
URL.revokeObjectURL = origRevoke;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface FilePickerInterceptor {
|
||||
inputs: HTMLInputElement[];
|
||||
restore(): void;
|
||||
}
|
||||
function installFilePickerInterceptor(): FilePickerInterceptor {
|
||||
const inputs: HTMLInputElement[] = [];
|
||||
const origCreate = document.createElement.bind(document);
|
||||
(document.createElement as unknown) = (tag: string) => {
|
||||
const el = origCreate(tag);
|
||||
if (tag === "input") inputs.push(el as HTMLInputElement);
|
||||
return el;
|
||||
};
|
||||
return {
|
||||
inputs,
|
||||
restore() {
|
||||
document.createElement = origCreate;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function loadApp() {
|
||||
const { App } = await import("@app/App");
|
||||
return render(<App />);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("CE-WP-0005-T08 — full create → annotate → export → reimport E2E", () => {
|
||||
let url: UrlInterceptor;
|
||||
let picker: FilePickerInterceptor;
|
||||
|
||||
beforeEach(() => {
|
||||
viewerSnapshot.pdfUrl = null;
|
||||
viewerSnapshot.onSelectionCaptured = null;
|
||||
globalThis.localStorage?.clear();
|
||||
if (typeof window !== "undefined") {
|
||||
history.replaceState(null, "", window.location.pathname);
|
||||
}
|
||||
url = installUrlInterceptor();
|
||||
picker = installFilePickerInterceptor();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
url.restore();
|
||||
picker.restore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it(
|
||||
"creates session, annotates, exports ZIP, re-imports ZIP, and round-trips the citation card",
|
||||
{ timeout: 30000 },
|
||||
async () => {
|
||||
const user = userEvent.setup();
|
||||
await loadApp();
|
||||
|
||||
// Step 1: empty state.
|
||||
await screen.findByTestId("empty-state");
|
||||
|
||||
// Step 2: create session "Demo".
|
||||
const nameInput = screen.getByTestId("empty-state-input");
|
||||
await user.type(nameInput, "Demo");
|
||||
await user.click(screen.getByTestId("empty-state-create"));
|
||||
|
||||
// Wait for the active session UI (collection list + upload dropzone).
|
||||
await screen.findByTestId("upload-dropzone");
|
||||
|
||||
// Step 3: upload a synthetic PDF.
|
||||
installClipboardSpy();
|
||||
const fileBytes = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d]); // %PDF-
|
||||
const pdfFile = new File([fileBytes], "e2e.pdf", { type: "application/pdf" });
|
||||
const uploadInput = screen.getByTestId("upload-file-input") as HTMLInputElement;
|
||||
await user.upload(uploadInput, pdfFile);
|
||||
|
||||
// Wait for the viewer to mount with the uploaded doc.
|
||||
await waitFor(() => expect(viewerSnapshot.onSelectionCaptured).not.toBeNull());
|
||||
|
||||
// Step 4: inject a selection + save evidence with commentary.
|
||||
await act(async () => {
|
||||
viewerSnapshot.onSelectionCaptured!(
|
||||
syntheticCaptureFor(FIXTURE.known_good_quote, FIXTURE.known_good_quote_page),
|
||||
[{ type: "TextQuoteSelector", exact: FIXTURE.known_good_quote }],
|
||||
);
|
||||
});
|
||||
await user.type(
|
||||
screen.getByPlaceholderText(/Add a one-line comment/),
|
||||
"E2E session commentary",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /Save evidence/ }));
|
||||
await screen.findByText(/E2E session commentary/);
|
||||
|
||||
// Step 5 (sanity): export the evidence as Markdown via the sidebar.
|
||||
installClipboardSpy();
|
||||
await user.click(await screen.findByLabelText("Export evidence item"));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Copy as Markdown" }));
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalled());
|
||||
const firstCard = writeText.mock.calls[0]![0] as string;
|
||||
expect(firstCard).toContain(FIXTURE.known_good_quote);
|
||||
expect(firstCard).toContain("E2E session commentary");
|
||||
expect(firstCard).toMatch(/\/viewer\?document=doc_[^&]+&annotation=ann_[^)]+/);
|
||||
|
||||
// Step 6: click Export ZIP from the session menu and capture the
|
||||
// generated Blob via the URL.createObjectURL spy.
|
||||
const urlCountBeforeExport = url.blobs.length;
|
||||
await user.click(screen.getByTestId("session-menu-toggle"));
|
||||
await user.click(await screen.findByTestId("session-menu-export"));
|
||||
await waitFor(() => expect(url.blobs.length).toBeGreaterThan(urlCountBeforeExport));
|
||||
// The exported ZIP is the most-recently-minted blob URL'd to <a download>.
|
||||
const zipBlob = url.blobs[url.blobs.length - 1]!;
|
||||
expect(zipBlob.size).toBeGreaterThan(0);
|
||||
|
||||
// Step 7: import the ZIP back via the menu. Intercept the file
|
||||
// input the App opens, set our captured blob as its file, fire
|
||||
// change.
|
||||
const inputsBefore = picker.inputs.length;
|
||||
await user.click(screen.getByTestId("session-menu-toggle"));
|
||||
await user.click(await screen.findByTestId("session-menu-import"));
|
||||
// The handler created a new file input.
|
||||
await waitFor(() => expect(picker.inputs.length).toBeGreaterThan(inputsBefore));
|
||||
const importInput = picker.inputs[picker.inputs.length - 1]!;
|
||||
const zipFile = new File([await zipBlob.arrayBuffer()], "demo.zip", {
|
||||
type: "application/zip",
|
||||
});
|
||||
// happy-dom's `files` is a getter on HTMLInputElement.prototype;
|
||||
// a value-override on the instance silently doesn't take effect.
|
||||
// Replace with a property getter that returns our fake FileList.
|
||||
const fakeFileList = {
|
||||
0: zipFile,
|
||||
length: 1,
|
||||
item: (i: number) => (i === 0 ? zipFile : null),
|
||||
[Symbol.iterator]: function* () {
|
||||
yield zipFile;
|
||||
},
|
||||
};
|
||||
Object.defineProperty(importInput, "files", {
|
||||
configurable: true,
|
||||
get: () => fakeFileList,
|
||||
});
|
||||
await act(async () => {
|
||||
if (typeof importInput.onchange === "function") {
|
||||
importInput.onchange(new Event("change"));
|
||||
}
|
||||
// Yield so the import promise gets a chance to start.
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
|
||||
// Wait for the merge to manifest in the DOM. ADR-0008's additive
|
||||
// policy means we should now see TWO evidence rows for the same
|
||||
// commentary (the original + the imported copy), and the
|
||||
// collection should still show ONE document (deduped by
|
||||
// fingerprint).
|
||||
await waitFor(
|
||||
() => {
|
||||
const matches = screen.queryAllByText(/E2E session commentary/);
|
||||
expect(matches.length).toBeGreaterThanOrEqual(2);
|
||||
},
|
||||
{ timeout: 12000 },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const items = screen
|
||||
.getByTestId("collection-list-items")
|
||||
.querySelectorAll("li");
|
||||
expect(items.length).toBe(1);
|
||||
});
|
||||
|
||||
// Step 8: export the *merged* evidence as Markdown and assert the
|
||||
// round-trip preserves quote + commentary + URL shape.
|
||||
installClipboardSpy();
|
||||
const toggles = screen.getAllByLabelText("Export evidence item");
|
||||
// Use the last evidence row (the one from the import) just to
|
||||
// distinguish from the original.
|
||||
await user.click(toggles[toggles.length - 1]!);
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Copy as Markdown" }));
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalled());
|
||||
const mergedCard = writeText.mock.calls[0]![0] as string;
|
||||
expect(mergedCard).toContain(FIXTURE.known_good_quote);
|
||||
expect(mergedCard).toContain("E2E session commentary");
|
||||
expect(mergedCard).toMatch(/\/viewer\?document=doc_[^&]+&annotation=ann_[^)]+/);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -166,6 +166,7 @@ event vocabulary for the MVP is:
|
||||
```
|
||||
DocumentImported
|
||||
DocumentRepresentationGenerated
|
||||
DocumentRemoved
|
||||
AnnotationCreated
|
||||
AnnotationResolved
|
||||
AnnotationResolutionFailed
|
||||
@@ -179,8 +180,17 @@ CitationCardRendered
|
||||
CitationRecoveryStarted
|
||||
CitationRecoveryCandidateFound
|
||||
CitationRecoveryConfirmed
|
||||
SessionCreated
|
||||
SessionRenamed
|
||||
SessionDeleted
|
||||
SessionActivated
|
||||
```
|
||||
|
||||
The `Session*` events live on the cross-session session bus (the
|
||||
SessionService's own EventBus instance — see CE-WP-0005). The remaining
|
||||
events live on the per-session engine bus and are scoped to whatever
|
||||
session is currently active.
|
||||
|
||||
Subsystems must emit these events through a shared event bus owned by
|
||||
`citation-engine`. Subsystems may listen to any event but must not invent
|
||||
event types without updating this document.
|
||||
|
||||
507
workplans/CE-WP-0005-demo-sessions-zip-archive.md
Normal file
507
workplans/CE-WP-0005-demo-sessions-zip-archive.md
Normal file
@@ -0,0 +1,507 @@
|
||||
---
|
||||
id: CE-WP-0005
|
||||
type: workplan
|
||||
title: "Demo app — Named sessions, document uploads, ZIP export/import"
|
||||
domain: citation_evidence
|
||||
repo: citation-evidence
|
||||
repo_id: a677c189-b4e2-4f2a-9e48-faa482c277e6
|
||||
topic_slug: citation_evidence_mvp
|
||||
topic_id: 96fa8e80-9f74-40f2-84cd-644e9747b9ec
|
||||
state_hub_workstream_id: ec88caf3-85ad-413c-8ddd-ef7278f6ce57
|
||||
status: done
|
||||
owner: Bernd
|
||||
created: 2026-05-25
|
||||
updated: 2026-05-26
|
||||
depends_on_workplan: CE-WP-0004
|
||||
spec_refs:
|
||||
- wiki/ProductRequirementsDocument.md
|
||||
- wiki/ArchitectureOverview.md
|
||||
- wiki/SharedContracts.md
|
||||
---
|
||||
|
||||
# CE-WP-0005 — Demo App: Sessions + Uploads + ZIP Archive
|
||||
|
||||
Turn the MVP into a self-contained demo that a stranger can pick up and use.
|
||||
After this workplan, a user can:
|
||||
|
||||
1. Land on the app and create a **named session** ("Lease 2024", "Klage
|
||||
Müller", …).
|
||||
2. Drag-drop or pick **arbitrary PDFs** into that session (no fixtures
|
||||
required).
|
||||
3. Annotate, build evidence, link to form fields, and export citation
|
||||
cards — same flows as CE-WP-0002..0004, now scoped to the active
|
||||
session.
|
||||
4. **Export the whole session** as a single `.zip` archive: every PDF
|
||||
plus a manifest with the engine snapshot.
|
||||
5. **Import a `.zip` back** — into a new session, or merged into an
|
||||
existing one (documents deduped by fingerprint; annotations and
|
||||
evidence added additively).
|
||||
|
||||
The demo replaces the current single-bucket app: Review and Forms modes
|
||||
both become **session-scoped**. The previous fixture-driven workflow
|
||||
survives as an optional "Sample sessions" quick-start.
|
||||
|
||||
## Scoping decisions (locked before drafting)
|
||||
|
||||
- **Demo placement:** the demo *replaces* the main app. The MVP Review
|
||||
and Forms layouts continue to work, but now under a session.
|
||||
- **PDF byte storage:** in-memory only. PDFs survive within a tab
|
||||
session; reloading the page loses uploaded bytes unless the ZIP was
|
||||
exported. Re-importing the ZIP restores them. No IndexedDB tier in
|
||||
this workplan.
|
||||
- **Import conflict policy:** if a ZIP carries a session name that
|
||||
already exists, **merge**. Documents are deduped by SHA-256
|
||||
fingerprint (incoming references rebound to the existing
|
||||
`documentId`). Annotations, evidence, and links are added as fresh
|
||||
ids — additive, never overwriting. Locked in
|
||||
[`ADR-0008`](../docs/decisions/ADR-0008-session-archive-format.md)
|
||||
(created in T05).
|
||||
|
||||
## Dependency Order
|
||||
|
||||
```
|
||||
T01 (Session model + service + per-session snapshots)
|
||||
├─ T02 (PdfByteStore + uploaded-document ingest path)
|
||||
│ └─ T03 (Upload UI + session-scoped CollectionList)
|
||||
└─ T04 (Session management UI — top-bar menu + hash routing)
|
||||
↓
|
||||
T05 (ADR-0008 + SessionArchive schema)
|
||||
├─ T06 (Export session as ZIP) ───┐
|
||||
└─ T07 (Import ZIP with merge) │
|
||||
↓
|
||||
T08 (E2E test of full flow)
|
||||
```
|
||||
|
||||
T03 and T04 can land in either order once T01+T02 are done. T06 and T07
|
||||
can be parallelised within T05.
|
||||
|
||||
---
|
||||
|
||||
## T01 — Session model + service + per-session engine snapshots
|
||||
|
||||
```task
|
||||
id: CE-WP-0005-T01
|
||||
state_hub_task_id: 5b479bf5-b54a-4fc8-b500-ec49f5d68f6a
|
||||
priority: high
|
||||
status: done
|
||||
```
|
||||
|
||||
Under `src/shared/`:
|
||||
|
||||
- `src/shared/session.ts` — `SessionId` branded type added to
|
||||
`ids.ts`; `Session` interface with `{ id, name, createdAt,
|
||||
updatedAt, lastOpenedAt? }`. No `documentIds` field — membership is
|
||||
implicit (a session "owns" the documents in its engine snapshot).
|
||||
|
||||
Under `src/engine/`:
|
||||
|
||||
- Extend `events/types.ts` with the four new events:
|
||||
`SessionCreated`, `SessionRenamed`, `SessionDeleted`,
|
||||
`SessionActivated`. Add to the `EngineEvent` union.
|
||||
- `src/engine/services/sessions.ts` — `SessionService` with
|
||||
`create(name)`, `rename(id, name)`, `delete(id)`, `list()`,
|
||||
`get(id)`, `setActive(id | null)`, `getActive()`. Backed by a
|
||||
repo + the event bus.
|
||||
- `src/engine/repos/in-memory-sessions.ts` — Map-backed
|
||||
`SessionRepository`.
|
||||
|
||||
Per-session **engine snapshot persistence**:
|
||||
|
||||
- `STORAGE_KEY` becomes a function of `sessionId`:
|
||||
`citation-evidence:session:<sessionId>:engine-snapshot:v1`.
|
||||
- A separate index key
|
||||
`citation-evidence:sessions:v1` stores the list of all known
|
||||
sessions.
|
||||
- The active session id is held in
|
||||
`citation-evidence:active-session-id:v1`.
|
||||
- When the active session changes, the old engine snapshot's
|
||||
persister stops; a new persister is attached against the new
|
||||
session's key. The engine itself is **recreated** (the cleanest
|
||||
way to reset every in-memory repo); `EngineProvider` is keyed by
|
||||
`sessionId` so React unmounts/remounts on switch.
|
||||
|
||||
Tests:
|
||||
|
||||
- Unit: `SessionService` lifecycle (create/rename/delete/setActive),
|
||||
event emission, conflict on rename to a duplicate name.
|
||||
- `restoreFromStorage` round-trip with the new per-session key
|
||||
scheme — drop in a fixture set of two sessions, restore each,
|
||||
assert engines hold the right documents.
|
||||
|
||||
---
|
||||
|
||||
## T02 — PdfByteStore + uploaded-document ingest path
|
||||
|
||||
```task
|
||||
id: CE-WP-0005-T02
|
||||
state_hub_task_id: 25626309-4cad-44b5-ac44-7e0dc7ea48fa
|
||||
priority: high
|
||||
status: done
|
||||
depends_on: [T01]
|
||||
```
|
||||
|
||||
Under `src/source/pdf/`:
|
||||
|
||||
- `byte-store.ts` — `createPdfByteStore()` returns a
|
||||
`Map<DocumentId, Uint8Array>` wrapper with `put`, `get`, `delete`,
|
||||
`list`, `clear`. Scoped per session (one instance per active
|
||||
session; replaced on switch).
|
||||
- `upload.ts` — `ingestPdfFromFile(file: File | Blob, store):
|
||||
Promise<{ document, representation }>`:
|
||||
1. Read bytes via `file.arrayBuffer()`.
|
||||
2. Call existing `ingestPdf(bytes, { filename: file.name })`.
|
||||
3. `store.put(document.id, bytes)`.
|
||||
4. Mint a `blob:` URL from a fresh `Blob([bytes], { type: "application/pdf" })`
|
||||
and stash it on `document.uri` so the viewer adapter can mount it.
|
||||
5. Return the engine inputs ready for
|
||||
`engine.documents.register(...)`.
|
||||
- Blob URL **revocation**: when a document is deleted from a session,
|
||||
`URL.revokeObjectURL(document.uri)` runs before the engine drops the
|
||||
record. A small helper inside the byte store handles this so the
|
||||
app layer doesn't have to remember.
|
||||
|
||||
The fixture-loading path (current `App.tsx` fetch + `ingestPdf`)
|
||||
remains as-is for the optional "Sample sessions" quick-start; the
|
||||
upload path is a parallel branch that ends at the same engine call.
|
||||
|
||||
Tests:
|
||||
|
||||
- Unit: round-trip a known-bytes PDF through `ingestPdfFromFile`,
|
||||
assert `store.get(documentId)` returns the same bytes.
|
||||
- Unit: delete revokes the blob URL exactly once even if called
|
||||
twice.
|
||||
|
||||
---
|
||||
|
||||
## T03 — Upload UI + session-scoped Collection list
|
||||
|
||||
```task
|
||||
id: CE-WP-0005-T03
|
||||
state_hub_task_id: 55275918-e610-4513-ba2d-c05018ecd42d
|
||||
priority: high
|
||||
status: done
|
||||
depends_on: [T02]
|
||||
```
|
||||
|
||||
Under `src/app/sessions/`:
|
||||
|
||||
- `UploadDropzone.tsx` — drag-drop region and a file picker that
|
||||
accepts `application/pdf` (multi-select). On drop:
|
||||
1. For each `File`: call `ingestPdfFromFile(file, byteStore)` then
|
||||
`engine.documents.register(...)`. Show a per-file progress chip;
|
||||
surface a toast on failure.
|
||||
2. Make the most-recently-uploaded document the active document.
|
||||
|
||||
Under `src/work/CollectionList.tsx`:
|
||||
|
||||
- Rework to list the **active session's** documents (read from
|
||||
`engine.documents.list()`), not the fixture manifest.
|
||||
- Header bar with the session name + an inline "Upload PDF" button
|
||||
that opens the dropzone.
|
||||
- Per-item: title (filename), document id, "Open" + "Delete" actions.
|
||||
Delete confirms via a small inline state, then calls into the byte
|
||||
store + engine repo.
|
||||
|
||||
Fixtures become an optional **Sample sessions** entry inside the
|
||||
session menu (T04). The current `CollectionList`'s manifest-driven
|
||||
fixture loader moves into
|
||||
`src/app/sessions/SampleSessions.tsx`, kept for tests and
|
||||
demonstration.
|
||||
|
||||
Tests:
|
||||
|
||||
- DOM: dropping a synthetic File triggers ingest and the new document
|
||||
appears in the list.
|
||||
- DOM: per-item delete removes the row and revokes the blob URL.
|
||||
|
||||
---
|
||||
|
||||
## T04 — Session management UI (top-bar menu, hash routing)
|
||||
|
||||
```task
|
||||
id: CE-WP-0005-T04
|
||||
state_hub_task_id: e008524c-9cef-448f-b95b-fa524c725bc3
|
||||
priority: medium
|
||||
status: done
|
||||
depends_on: [T01]
|
||||
```
|
||||
|
||||
Under `src/app/sessions/`:
|
||||
|
||||
- `SessionMenu.tsx` — top-bar dropdown showing the active session
|
||||
name. Menu items:
|
||||
- **Switch to…** (list of all sessions sorted by `lastOpenedAt`)
|
||||
- **New session…** (opens an inline name-input modal)
|
||||
- **Rename…**, **Delete…** (with confirmation) for the active
|
||||
session
|
||||
- **Export ZIP** (T06)
|
||||
- **Import ZIP** (T07)
|
||||
- **Sample sessions ▸** (T03, optional submenu)
|
||||
- Empty state: if no sessions exist, the app body is replaced by a
|
||||
centred "Create your first session" call-to-action with an inline
|
||||
name input.
|
||||
|
||||
Hash routing:
|
||||
|
||||
- Current routes (`#/forms/demo`, default Review) become
|
||||
session-scoped: `#/s/<sessionId>`,
|
||||
`#/s/<sessionId>/forms/demo`. The active-session pointer is the
|
||||
router's responsibility (single source of truth = the hash); the
|
||||
`SessionService.setActive(...)` call is a side effect of hash
|
||||
change.
|
||||
- A bare `#/` (no session) renders the empty state.
|
||||
- Deep links into a deleted/unknown session redirect to the empty
|
||||
state with a toast.
|
||||
|
||||
Tests:
|
||||
|
||||
- DOM: switching sessions in the menu updates the hash and unmounts +
|
||||
remounts the engine (verified by checking that the previous
|
||||
session's documents disappear from the CollectionList).
|
||||
- DOM: deep-link to a known session loads that session's documents.
|
||||
|
||||
---
|
||||
|
||||
## T05 — ADR-0008 + SessionArchive manifest schema
|
||||
|
||||
```task
|
||||
id: CE-WP-0005-T05
|
||||
state_hub_task_id: 50d525b1-ba7d-454e-91b4-34d96bc5ab7b
|
||||
priority: high
|
||||
status: done
|
||||
```
|
||||
|
||||
Add `docs/decisions/ADR-0008-session-archive-format.md`. Locks:
|
||||
|
||||
- **ZIP layout**:
|
||||
```
|
||||
manifest.json
|
||||
documents/
|
||||
<documentId>.pdf
|
||||
```
|
||||
`<documentId>` is the engine's branded id (`doc_…`), used as the
|
||||
filename. Future variants (per-representation files,
|
||||
per-attachment) are intentionally deferred.
|
||||
- **manifest.json shape** (top-level fields):
|
||||
- `schemaVersion: 1`
|
||||
- `exportedAt: string` (ISO-8601)
|
||||
- `session: { id, name, createdAt, updatedAt }`
|
||||
- `engine: EngineSnapshot` (the same shape produced by
|
||||
`captureSnapshot()` — re-used verbatim so the round-trip stays
|
||||
one-way).
|
||||
- `documentBindings: Array<{ documentId, filename, fingerprint }>` —
|
||||
pairs every engine document with its file inside `documents/`.
|
||||
- **Merge-on-name-collision policy** (T07 spec): documents are
|
||||
deduped by fingerprint; annotations/evidence/links are imported
|
||||
with fresh ids and rebound to the deduped `documentId`. Re-importing
|
||||
*your own* freshly-exported ZIP into the same session therefore
|
||||
duplicates annotations — documented as a known limitation. A
|
||||
later workplan can add idempotent imports via an `importBundleId`
|
||||
field.
|
||||
|
||||
Under `src/shared/`:
|
||||
|
||||
- `session-archive.ts` — TypeScript interfaces for
|
||||
`SessionArchiveManifest` matching the ADR. Pure types + a
|
||||
`parseSessionArchiveManifest(json: unknown):
|
||||
SessionArchiveManifest` that throws on schema mismatch (used by
|
||||
the importer in T07).
|
||||
|
||||
Tests:
|
||||
|
||||
- Unit conformance: round-trip a synthetic manifest object →
|
||||
`JSON.stringify` → parse → deep-equal.
|
||||
- Unit failure: a manifest missing required fields, or with the
|
||||
wrong `schemaVersion`, throws with a useful message.
|
||||
|
||||
---
|
||||
|
||||
## T06 — Export session as ZIP archive
|
||||
|
||||
```task
|
||||
id: CE-WP-0005-T06
|
||||
state_hub_task_id: 07546a24-90d8-4b5d-9833-2648d2936ea2
|
||||
priority: high
|
||||
status: done
|
||||
depends_on: [T05]
|
||||
```
|
||||
|
||||
Dependency add: `jszip` (small, MIT, battle-tested). Use the ESM
|
||||
build to keep the bundle clean.
|
||||
|
||||
Under `src/app/sessions/`:
|
||||
|
||||
- `exportSessionZip.ts`:
|
||||
```ts
|
||||
export async function exportSessionZip(
|
||||
sessionId: SessionId,
|
||||
engine: Engine,
|
||||
byteStore: PdfByteStore,
|
||||
session: Session,
|
||||
): Promise<Blob>
|
||||
```
|
||||
Steps:
|
||||
1. Build the manifest from `captureSnapshot(engine)` + session
|
||||
metadata + per-document `{ filename, fingerprint }` derived
|
||||
from `engine.documents`.
|
||||
2. For each `documentBindings[i]`, push `bytes` into
|
||||
`documents/<documentId>.pdf`.
|
||||
3. Push `manifest.json` (stringified, pretty-printed).
|
||||
4. `zip.generateAsync({ type: "blob" })`.
|
||||
- `triggerSessionDownload(blob, filename)` — creates an `<a download>`
|
||||
element, clicks it, revokes the URL. Filename:
|
||||
`<slugified session name>-<ISO date>.zip`.
|
||||
|
||||
UI:
|
||||
|
||||
- **Export ZIP** menu item in `SessionMenu` calls the above. Show a
|
||||
brief spinner state on the menu item while the zip generates.
|
||||
- Surface a success/error toast (re-use the toast pattern from
|
||||
CE-WP-0004 sidebar, lifted into `src/app/sessions/Toast.tsx`).
|
||||
|
||||
Tests:
|
||||
|
||||
- DOM: synthesise a one-document session, click Export, capture the
|
||||
generated Blob, unzip in the test (via JSZip), assert the manifest
|
||||
matches the engine snapshot and `documents/<id>.pdf` contains the
|
||||
original bytes.
|
||||
|
||||
---
|
||||
|
||||
## T07 — Import ZIP with merge-on-name-collision + fingerprint dedup
|
||||
|
||||
```task
|
||||
id: CE-WP-0005-T07
|
||||
state_hub_task_id: 2fedab8d-6af7-458a-90d3-383241978f4e
|
||||
priority: high
|
||||
status: done
|
||||
depends_on: [T05]
|
||||
```
|
||||
|
||||
Under `src/app/sessions/`:
|
||||
|
||||
- `importSessionZip.ts`:
|
||||
```ts
|
||||
export interface ImportSessionResult {
|
||||
readonly sessionId: SessionId;
|
||||
readonly outcome: "created" | "merged-into";
|
||||
readonly stats: {
|
||||
readonly documentsAdded: number;
|
||||
readonly documentsDeduped: number;
|
||||
readonly annotationsAdded: number;
|
||||
readonly evidenceAdded: number;
|
||||
readonly linksAdded: number;
|
||||
};
|
||||
}
|
||||
export async function importSessionZip(
|
||||
file: File | Blob,
|
||||
services: SessionImportServices,
|
||||
): Promise<ImportSessionResult>;
|
||||
```
|
||||
Steps:
|
||||
1. Read with JSZip; parse `manifest.json` via
|
||||
`parseSessionArchiveManifest`. Reject on schema mismatch.
|
||||
2. Find target session: if a session with the manifest's
|
||||
`session.name` exists → that one (`merged-into`). Else: create a
|
||||
fresh session (`created`) preserving the imported name.
|
||||
3. For each `documentBindings[i]`:
|
||||
- If a document with the same `fingerprint` already lives in the
|
||||
target session's engine: reuse its `documentId`; record a
|
||||
remap `incoming.documentId → existing.documentId`. Skip the
|
||||
bytes (we already have them).
|
||||
- Else: register a new engine document with a freshly minted
|
||||
`documentId` (the manifest's id is not preserved — it could
|
||||
collide with future imports). Push bytes into the byte store.
|
||||
Remap `incoming.documentId → new.documentId`.
|
||||
4. Apply remaps:
|
||||
- Annotations: mint new `annotationId`, rebind `documentId` to
|
||||
the remapped value, call `engine.annotations.create(...)`.
|
||||
- Evidence: mint new `evidenceItemId`, remap
|
||||
`annotationIds`, call `engine.evidence.create(...)`.
|
||||
- EvidenceLinks: mint new `evidenceLinkId`, remap
|
||||
`evidenceItemId`, call `bindings.linkEvidenceToTarget(...)`.
|
||||
5. Switch the active session to the target.
|
||||
- Errors surface as toasts (corrupt zip, version mismatch, missing
|
||||
binary file referenced by the manifest, generic IO).
|
||||
|
||||
UI:
|
||||
|
||||
- **Import ZIP** menu item in `SessionMenu` opens a file picker
|
||||
(`accept=".zip,application/zip"`).
|
||||
- After success: show a toast like *"Imported 'Demo' — 1 new
|
||||
document, 2 annotations, 1 evidence item"*.
|
||||
|
||||
Tests:
|
||||
|
||||
- Unit: in-process round-trip — export a synthetic session, import
|
||||
into an empty engine, assert outcome=`created`, document/anno/ev
|
||||
counts match.
|
||||
- Unit: same export, but import into a session of the same name that
|
||||
already holds the document → assert `outcome="merged-into"`,
|
||||
`documentsDeduped=1`, annotation/evidence counts double (additive
|
||||
behaviour, per ADR-0008).
|
||||
- Unit: corrupt manifest (drop a required field) → import rejects
|
||||
with the parser error.
|
||||
|
||||
---
|
||||
|
||||
## T08 — E2E test of full create → annotate → export → reimport flow
|
||||
|
||||
```task
|
||||
id: CE-WP-0005-T08
|
||||
state_hub_task_id: 72d92828-8814-4034-96cc-7da5b6a5e281
|
||||
priority: high
|
||||
status: done
|
||||
depends_on: [T03, T04, T06, T07]
|
||||
```
|
||||
|
||||
`tests/integration/session-export-reimport.dom.test.tsx`. Mocks: the
|
||||
PDF viewer (same pattern as
|
||||
`tests/integration/citation-card-export-e2e.dom.test.tsx`); the
|
||||
ingest path can use the real `ingestPdf` because we hand it real
|
||||
fixture bytes.
|
||||
|
||||
Walk:
|
||||
|
||||
1. Load the app — empty state appears.
|
||||
2. Create session "Demo" via the inline name input.
|
||||
3. Upload a fixture PDF (read fixture bytes via
|
||||
`node:fs` and wrap in a `File`).
|
||||
4. Inject a selection for the manifest's known-good quote → save
|
||||
evidence with a commentary.
|
||||
5. (Sanity) Click Export → Copy as Markdown; assert the clipboard
|
||||
payload contains the quote + commentary + openContextUrl.
|
||||
6. Click **Export ZIP** in the SessionMenu. Intercept the
|
||||
`<a download>` invocation; capture the Blob.
|
||||
7. Click **Import ZIP** with the captured Blob. Assert merge:
|
||||
- `outcome="merged-into"`, `documentsDeduped=1`,
|
||||
`annotationsAdded=1`, `evidenceAdded=1`.
|
||||
- The CollectionList still shows one document (deduped).
|
||||
- The EvidenceSidebar now shows **two** evidence rows for the
|
||||
same passage (one original + one from the merge — the known
|
||||
additive behaviour per ADR-0008).
|
||||
8. Click Export → Copy as Markdown on the *merged* evidence item;
|
||||
assert the citation card output matches the original (proves the
|
||||
round-trip preserves quote + commentary + URL shape).
|
||||
|
||||
If T08 passes, the MVP demo loop is complete and the project can ship
|
||||
as a usable single-page demo.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope (deferred to later workplans)
|
||||
|
||||
- **IndexedDB persistence** of PDF bytes between page reloads.
|
||||
Currently only the ZIP path persists binaries.
|
||||
- **Idempotent re-imports** (avoiding annotation duplication when
|
||||
re-importing your own export). Requires an `importBundleId` field
|
||||
in the manifest and a dedupe pass during T07. Track as a future
|
||||
improvement; ADR-0008 already calls it out.
|
||||
- **Session sharing via URL** (one-click "open this session in a
|
||||
read-only viewer"). Adjacent to the deep-link URL scheme from
|
||||
`wiki/ArchitectureOverview.md` §14.3 but not in scope here.
|
||||
- **Per-document download** (export a single PDF + its annotations
|
||||
as a `.zip`). The session-level export covers the demo loop; a
|
||||
per-document variant is a small follow-up if asked for.
|
||||
- **Polish**: branding, theme, first-run tutorial. Once the loop
|
||||
works end-to-end, a separate workplan can tackle the look-and-feel.
|
||||
Reference in New Issue
Block a user