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"
|
"typecheck": "tsc -b --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"pdfjs-dist": "^4.4.168",
|
"pdfjs-dist": "^4.4.168",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
|||||||
80
pnpm-lock.yaml
generated
80
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
jszip:
|
||||||
|
specifier: ^3.10.1
|
||||||
|
version: 3.10.1
|
||||||
pdfjs-dist:
|
pdfjs-dist:
|
||||||
specifier: ^4.4.168
|
specifier: ^4.4.168
|
||||||
version: 4.10.38
|
version: 4.10.38
|
||||||
@@ -1500,6 +1503,9 @@ packages:
|
|||||||
convert-source-map@2.0.0:
|
convert-source-map@2.0.0:
|
||||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||||
|
|
||||||
|
core-util-is@1.0.3:
|
||||||
|
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -1892,6 +1898,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
|
|
||||||
|
immediate@3.0.6:
|
||||||
|
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -1900,6 +1909,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
||||||
engines: {node: '>=0.8.19'}
|
engines: {node: '>=0.8.19'}
|
||||||
|
|
||||||
|
inherits@2.0.4:
|
||||||
|
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||||
|
|
||||||
internal-slot@1.1.0:
|
internal-slot@1.1.0:
|
||||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2007,6 +2019,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
|
resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
isarray@1.0.0:
|
||||||
|
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||||
|
|
||||||
isarray@2.0.5:
|
isarray@2.0.5:
|
||||||
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
|
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
|
||||||
|
|
||||||
@@ -2043,6 +2058,9 @@ packages:
|
|||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
jszip@3.10.1:
|
||||||
|
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||||
|
|
||||||
@@ -2050,6 +2068,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
|
lie@3.3.0:
|
||||||
|
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
|
||||||
|
|
||||||
locate-path@6.0.0:
|
locate-path@6.0.0:
|
||||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2231,6 +2252,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
||||||
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
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:
|
prop-types@15.8.1:
|
||||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||||
|
|
||||||
@@ -2312,6 +2336,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
readable-stream@2.3.8:
|
||||||
|
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||||
|
|
||||||
reflect.getprototypeof@1.0.10:
|
reflect.getprototypeof@1.0.10:
|
||||||
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2346,6 +2373,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==}
|
resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==}
|
||||||
engines: {node: '>=0.4'}
|
engines: {node: '>=0.4'}
|
||||||
|
|
||||||
|
safe-buffer@5.1.2:
|
||||||
|
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
|
||||||
|
|
||||||
safe-push-apply@1.0.0:
|
safe-push-apply@1.0.0:
|
||||||
resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
|
resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2378,6 +2408,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
|
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
setimmediate@1.0.5:
|
||||||
|
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
||||||
|
|
||||||
shebang-command@2.0.0:
|
shebang-command@2.0.0:
|
||||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -2434,6 +2467,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
|
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
string_decoder@1.1.1:
|
||||||
|
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
|
||||||
|
|
||||||
strip-bom@3.0.0:
|
strip-bom@3.0.0:
|
||||||
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
|
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -2568,6 +2604,9 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
util-deprecate@1.0.2:
|
||||||
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
vite-node@2.1.9:
|
vite-node@2.1.9:
|
||||||
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
|
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
|
||||||
engines: {node: ^18.0.0 || >=20.0.0}
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
@@ -4052,6 +4091,8 @@ snapshots:
|
|||||||
|
|
||||||
convert-source-map@2.0.0: {}
|
convert-source-map@2.0.0: {}
|
||||||
|
|
||||||
|
core-util-is@1.0.3: {}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
@@ -4542,6 +4583,8 @@ snapshots:
|
|||||||
|
|
||||||
ignore@7.0.5: {}
|
ignore@7.0.5: {}
|
||||||
|
|
||||||
|
immediate@3.0.6: {}
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
parent-module: 1.0.1
|
parent-module: 1.0.1
|
||||||
@@ -4549,6 +4592,8 @@ snapshots:
|
|||||||
|
|
||||||
imurmurhash@0.1.4: {}
|
imurmurhash@0.1.4: {}
|
||||||
|
|
||||||
|
inherits@2.0.4: {}
|
||||||
|
|
||||||
internal-slot@1.1.0:
|
internal-slot@1.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
@@ -4667,6 +4712,8 @@ snapshots:
|
|||||||
call-bound: 1.0.4
|
call-bound: 1.0.4
|
||||||
get-intrinsic: 1.3.0
|
get-intrinsic: 1.3.0
|
||||||
|
|
||||||
|
isarray@1.0.0: {}
|
||||||
|
|
||||||
isarray@2.0.5: {}
|
isarray@2.0.5: {}
|
||||||
|
|
||||||
isexe@2.0.0: {}
|
isexe@2.0.0: {}
|
||||||
@@ -4691,6 +4738,13 @@ snapshots:
|
|||||||
|
|
||||||
json5@2.2.3: {}
|
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:
|
keyv@4.5.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
json-buffer: 3.0.1
|
json-buffer: 3.0.1
|
||||||
@@ -4700,6 +4754,10 @@ snapshots:
|
|||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
type-check: 0.4.0
|
type-check: 0.4.0
|
||||||
|
|
||||||
|
lie@3.3.0:
|
||||||
|
dependencies:
|
||||||
|
immediate: 3.0.6
|
||||||
|
|
||||||
locate-path@6.0.0:
|
locate-path@6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
p-locate: 5.0.0
|
p-locate: 5.0.0
|
||||||
@@ -4876,6 +4934,8 @@ snapshots:
|
|||||||
ansi-styles: 5.2.0
|
ansi-styles: 5.2.0
|
||||||
react-is: 17.0.2
|
react-is: 17.0.2
|
||||||
|
|
||||||
|
process-nextick-args@2.0.1: {}
|
||||||
|
|
||||||
prop-types@15.8.1:
|
prop-types@15.8.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
@@ -4975,6 +5035,16 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
loose-envify: 1.4.0
|
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:
|
reflect.getprototypeof@1.0.10:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind: 1.0.9
|
call-bind: 1.0.9
|
||||||
@@ -5054,6 +5124,8 @@ snapshots:
|
|||||||
has-symbols: 1.1.0
|
has-symbols: 1.1.0
|
||||||
isarray: 2.0.5
|
isarray: 2.0.5
|
||||||
|
|
||||||
|
safe-buffer@5.1.2: {}
|
||||||
|
|
||||||
safe-push-apply@1.0.0:
|
safe-push-apply@1.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
@@ -5095,6 +5167,8 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
es-object-atoms: 1.1.2
|
es-object-atoms: 1.1.2
|
||||||
|
|
||||||
|
setimmediate@1.0.5: {}
|
||||||
|
|
||||||
shebang-command@2.0.0:
|
shebang-command@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
shebang-regex: 3.0.0
|
shebang-regex: 3.0.0
|
||||||
@@ -5167,6 +5241,10 @@ snapshots:
|
|||||||
define-properties: 1.2.1
|
define-properties: 1.2.1
|
||||||
es-object-atoms: 1.1.2
|
es-object-atoms: 1.1.2
|
||||||
|
|
||||||
|
string_decoder@1.1.1:
|
||||||
|
dependencies:
|
||||||
|
safe-buffer: 5.1.2
|
||||||
|
|
||||||
strip-bom@3.0.0: {}
|
strip-bom@3.0.0: {}
|
||||||
|
|
||||||
strip-json-comments@3.1.1: {}
|
strip-json-comments@3.1.1: {}
|
||||||
@@ -5326,6 +5404,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 18.3.29
|
'@types/react': 18.3.29
|
||||||
|
|
||||||
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
vite-node@2.1.9(@types/node@20.19.41):
|
vite-node@2.1.9(@types/node@20.19.41):
|
||||||
dependencies:
|
dependencies:
|
||||||
cac: 6.7.14
|
cac: 6.7.14
|
||||||
|
|||||||
360
src/app/App.tsx
360
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.
|
* SessionProvider (cross-session)
|
||||||
* - Forms mode (CE-WP-0003): form renderer / viewer / evidence strip,
|
* └─ AppShell — owns routing + the top bar
|
||||||
* with click-to-link interaction.
|
* ├─ 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
|
* The hash is the single source of truth for `{sessionId, mode}`. The
|
||||||
* Forms mode; anything else (including empty) lands in Review mode. The
|
* SessionService's active id is kept in sync with the hash via a
|
||||||
* top bar toggles between them. We keep the hash sync so reload + deep
|
* useEffect inside `AppShell`. Deep links to unknown sessions redirect
|
||||||
* links work; T08's E2E asserts the `/forms/demo` navigation path.
|
* to the empty state with a toast.
|
||||||
*
|
|
||||||
* Engine and binder providers are both mounted at the App root so
|
|
||||||
* evidence/annotations/links survive switching tabs.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { BinderProvider } from "@binder/index";
|
import { BinderProvider } from "@binder/index";
|
||||||
import {
|
import {
|
||||||
EngineProvider,
|
EngineProvider,
|
||||||
|
SessionProvider,
|
||||||
|
useActiveSession,
|
||||||
useEngine,
|
useEngine,
|
||||||
|
usePdfByteStore,
|
||||||
|
useSessionByteStoreRegistry,
|
||||||
|
useSessionService,
|
||||||
|
useSessionsHydrated,
|
||||||
|
useSessionVersion,
|
||||||
|
useSessionVersionBumper,
|
||||||
} from "@work/index";
|
} from "@work/index";
|
||||||
|
|
||||||
import { FormsApp } from "./forms/FormsApp";
|
import { FormsApp } from "./forms/FormsApp";
|
||||||
import { ReviewLayout } from "./ReviewLayout";
|
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 readRoute(): AppRoute {
|
||||||
|
if (typeof window === "undefined") return EMPTY_ROUTE;
|
||||||
function readModeFromHash(): Mode {
|
return parseRoute(window.location.hash);
|
||||||
if (typeof window === "undefined") return "review";
|
|
||||||
return window.location.hash === FORMS_HASH ? "forms" : "review";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeModeToHash(mode: Mode) {
|
function useHashRoute(): AppRoute {
|
||||||
if (typeof window === "undefined") return;
|
const [route, setRoute] = useState<AppRoute>(() => readRoute());
|
||||||
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());
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onHash() {
|
const handler = () => setRoute(readRoute());
|
||||||
setMode(readModeFromHash());
|
window.addEventListener("hashchange", handler);
|
||||||
}
|
return () => window.removeEventListener("hashchange", handler);
|
||||||
window.addEventListener("hashchange", onHash);
|
|
||||||
return () => window.removeEventListener("hashchange", onHash);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
return route;
|
||||||
|
}
|
||||||
|
|
||||||
const handleModeChange = (next: Mode) => {
|
function AppShell() {
|
||||||
writeModeToHash(next);
|
const route = useHashRoute();
|
||||||
setMode(next);
|
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",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
height: "100vh",
|
||||||
|
fontFamily: "system-ui, sans-serif",
|
||||||
|
color: "#888",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", height: "100vh", color: "#222" }}>
|
<div style={{ display: "flex", flexDirection: "column", height: "100vh", color: "#222" }}>
|
||||||
<TopBar mode={mode} onModeChange={handleModeChange} />
|
<EngineProvider key={`${sessionId}:${version}`} sessionId={sessionId}>
|
||||||
<div style={{ flex: 1, minHeight: 0 }}>
|
<ActiveTopBar route={route} showToast={toast.show} />
|
||||||
{mode === "review" ? <ReviewLayout /> : <FormsApp />}
|
<div style={{ flex: 1, minHeight: 0 }}>
|
||||||
</div>
|
<SessionScopedTree mode={route.mode} />
|
||||||
|
</div>
|
||||||
|
</EngineProvider>
|
||||||
|
<Toast toast={toast.toast} onDismiss={toast.dismiss} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TopBar({ mode, onModeChange }: { mode: Mode; onModeChange: (m: Mode) => void }) {
|
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 (
|
return (
|
||||||
<header
|
<header
|
||||||
style={{
|
style={{
|
||||||
@@ -89,20 +206,122 @@ function TopBar({ mode, onModeChange }: { mode: Mode; onModeChange: (m: Mode) =>
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<strong style={{ fontSize: 13, marginRight: 8 }}>citation-evidence</strong>
|
<strong style={{ fontSize: 13, marginRight: 8 }}>citation-evidence</strong>
|
||||||
<button
|
<SessionMenu onImportZip={() => pickAndImport(handleImport)} />
|
||||||
onClick={() => onModeChange("review")}
|
<Toast toast={toast.toast} onDismiss={toast.dismiss} />
|
||||||
aria-pressed={mode === "review"}
|
</header>
|
||||||
style={tabStyle(mode === "review")}
|
);
|
||||||
>
|
}
|
||||||
Review
|
|
||||||
</button>
|
function pickAndImport(onPicked: (file: File) => void): void {
|
||||||
<button
|
if (typeof document === "undefined") return;
|
||||||
onClick={() => onModeChange("forms")}
|
const input = document.createElement("input");
|
||||||
aria-pressed={mode === "forms"}
|
input.type = "file";
|
||||||
style={tabStyle(mode === "forms")}
|
input.accept = ".zip,application/zip";
|
||||||
>
|
input.onchange = () => {
|
||||||
Forms
|
const file = input.files?.[0];
|
||||||
</button>
|
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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => handleModeChange(t.id)}
|
||||||
|
aria-pressed={route.mode === t.id}
|
||||||
|
style={tabStyle(route.mode === t.id)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -118,19 +337,10 @@ function tabStyle(active: boolean) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppInner() {
|
|
||||||
const engine = useEngine();
|
|
||||||
return (
|
|
||||||
<BinderProvider bus={engine.bus}>
|
|
||||||
<ModeRouter />
|
|
||||||
</BinderProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<EngineProvider>
|
<SessionProvider>
|
||||||
<AppInner />
|
<AppShell />
|
||||||
</EngineProvider>
|
</SessionProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,27 @@
|
|||||||
* │ Collection │ Document Viewer │ Evidence │
|
* │ Collection │ Document Viewer │ Evidence │
|
||||||
* │ List │ │ Sidebar │
|
* │ 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 {
|
import {
|
||||||
CollectionList,
|
CollectionList,
|
||||||
EvidenceSidebar,
|
EvidenceSidebar,
|
||||||
ViewerShell,
|
ViewerShell,
|
||||||
|
useActiveSession,
|
||||||
} from "@work/index";
|
} from "@work/index";
|
||||||
|
|
||||||
export function ReviewLayout() {
|
export interface ReviewLayoutProps {
|
||||||
|
readonly upload?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReviewLayout({ upload }: ReviewLayoutProps) {
|
||||||
|
const session = useActiveSession();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -22,7 +34,7 @@ export function ReviewLayout() {
|
|||||||
fontFamily: "system-ui, sans-serif",
|
fontFamily: "system-ui, sans-serif",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CollectionList />
|
<CollectionList upload={upload} title={session?.name ?? "Collection"} />
|
||||||
<ViewerShell />
|
<ViewerShell />
|
||||||
<EvidenceSidebar />
|
<EvidenceSidebar />
|
||||||
</div>
|
</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,
|
EvidenceItemId,
|
||||||
EvidenceLinkId,
|
EvidenceLinkId,
|
||||||
RepresentationId,
|
RepresentationId,
|
||||||
|
SessionId,
|
||||||
} from "@shared/ids";
|
} from "@shared/ids";
|
||||||
|
import type { Session } from "@shared/session";
|
||||||
|
|
||||||
export interface DocumentImportedEvent {
|
export interface DocumentImportedEvent {
|
||||||
readonly type: "DocumentImported";
|
readonly type: "DocumentImported";
|
||||||
@@ -36,6 +38,11 @@ export interface DocumentRepresentationGeneratedEvent {
|
|||||||
readonly representation: DocumentRepresentation;
|
readonly representation: DocumentRepresentation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DocumentRemovedEvent {
|
||||||
|
readonly type: "DocumentRemoved";
|
||||||
|
readonly documentId: DocumentId;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AnnotationCreatedEvent {
|
export interface AnnotationCreatedEvent {
|
||||||
readonly type: "AnnotationCreated";
|
readonly type: "AnnotationCreated";
|
||||||
readonly annotationId: AnnotationId;
|
readonly annotationId: AnnotationId;
|
||||||
@@ -92,9 +99,34 @@ export interface FormFieldActivatedEvent {
|
|||||||
readonly previousTarget?: EvidenceTarget;
|
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 =
|
export type EngineEvent =
|
||||||
| DocumentImportedEvent
|
| DocumentImportedEvent
|
||||||
| DocumentRepresentationGeneratedEvent
|
| DocumentRepresentationGeneratedEvent
|
||||||
|
| DocumentRemovedEvent
|
||||||
| AnnotationCreatedEvent
|
| AnnotationCreatedEvent
|
||||||
| AnnotationResolvedEvent
|
| AnnotationResolvedEvent
|
||||||
| AnnotationResolutionFailedEvent
|
| AnnotationResolutionFailedEvent
|
||||||
@@ -103,7 +135,11 @@ export type EngineEvent =
|
|||||||
| EvidenceItemActivatedEvent
|
| EvidenceItemActivatedEvent
|
||||||
| EvidenceLinkCreatedEvent
|
| EvidenceLinkCreatedEvent
|
||||||
| EvidenceLinkUpdatedEvent
|
| EvidenceLinkUpdatedEvent
|
||||||
| FormFieldActivatedEvent;
|
| FormFieldActivatedEvent
|
||||||
|
| SessionCreatedEvent
|
||||||
|
| SessionRenamedEvent
|
||||||
|
| SessionDeletedEvent
|
||||||
|
| SessionActivatedEvent;
|
||||||
|
|
||||||
export type EngineEventType = EngineEvent["type"];
|
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;
|
get(id: DocumentId): Document | null;
|
||||||
list(): readonly Document[];
|
list(): readonly Document[];
|
||||||
update(document: Document): Document;
|
update(document: Document): Document;
|
||||||
|
delete(id: DocumentId): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RepresentationRepository {
|
export interface RepresentationRepository {
|
||||||
create(representation: DocumentRepresentation): DocumentRepresentation;
|
create(representation: DocumentRepresentation): DocumentRepresentation;
|
||||||
get(id: RepresentationId): DocumentRepresentation | null;
|
get(id: RepresentationId): DocumentRepresentation | null;
|
||||||
listByDocument(documentId: DocumentId): readonly DocumentRepresentation[];
|
listByDocument(documentId: DocumentId): readonly DocumentRepresentation[];
|
||||||
|
deleteByDocument(documentId: DocumentId): number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AnnotationRepository {
|
export interface AnnotationRepository {
|
||||||
@@ -82,6 +84,9 @@ export function createInMemoryRepos(): InMemoryRepos {
|
|||||||
documents.set(document.id, document);
|
documents.set(document.id, document);
|
||||||
return document;
|
return document;
|
||||||
},
|
},
|
||||||
|
delete(id) {
|
||||||
|
return documents.delete(id);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
representations: {
|
representations: {
|
||||||
create(representation) {
|
create(representation) {
|
||||||
@@ -98,6 +103,16 @@ export function createInMemoryRepos(): InMemoryRepos {
|
|||||||
}
|
}
|
||||||
return out;
|
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: {
|
annotations: {
|
||||||
create(annotation) {
|
create(annotation) {
|
||||||
|
|||||||
@@ -6,3 +6,7 @@ export {
|
|||||||
type AnnotationRepository,
|
type AnnotationRepository,
|
||||||
type EvidenceItemRepository,
|
type EvidenceItemRepository,
|
||||||
} from "./in-memory";
|
} from "./in-memory";
|
||||||
|
export {
|
||||||
|
createInMemorySessionRepository,
|
||||||
|
type SessionRepository,
|
||||||
|
} from "./in-memory-sessions";
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface DocumentService {
|
|||||||
list(): readonly Document[];
|
list(): readonly Document[];
|
||||||
getRepresentation(id: RepresentationId): DocumentRepresentation | null;
|
getRepresentation(id: RepresentationId): DocumentRepresentation | null;
|
||||||
listRepresentations(documentId: DocumentId): readonly DocumentRepresentation[];
|
listRepresentations(documentId: DocumentId): readonly DocumentRepresentation[];
|
||||||
|
remove(id: DocumentId): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDocumentService(
|
export function createDocumentService(
|
||||||
@@ -59,5 +60,15 @@ export function createDocumentService(
|
|||||||
listRepresentations(documentId) {
|
listRepresentations(documentId) {
|
||||||
return representations.listByDocument(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 EvidenceService,
|
||||||
type CreateEvidenceItemInput,
|
type CreateEvidenceItemInput,
|
||||||
} from "./evidence";
|
} 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 EvidenceLinkId = Brand<string, "EvidenceLinkId">;
|
||||||
export type CitationCardId = Brand<string, "CitationCardId">;
|
export type CitationCardId = Brand<string, "CitationCardId">;
|
||||||
export type CitationRecoveryAttemptId = Brand<string, "CitationRecoveryAttemptId">;
|
export type CitationRecoveryAttemptId = Brand<string, "CitationRecoveryAttemptId">;
|
||||||
|
export type SessionId = Brand<string, "SessionId">;
|
||||||
|
|
||||||
export type IdKindMap = {
|
export type IdKindMap = {
|
||||||
document: DocumentId;
|
document: DocumentId;
|
||||||
@@ -29,6 +30,7 @@ export type IdKindMap = {
|
|||||||
"evidence-link": EvidenceLinkId;
|
"evidence-link": EvidenceLinkId;
|
||||||
"citation-card": CitationCardId;
|
"citation-card": CitationCardId;
|
||||||
"citation-recovery": CitationRecoveryAttemptId;
|
"citation-recovery": CitationRecoveryAttemptId;
|
||||||
|
session: SessionId;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IdKind = keyof IdKindMap;
|
export type IdKind = keyof IdKindMap;
|
||||||
@@ -42,6 +44,7 @@ const PREFIXES: Record<IdKind, string> = {
|
|||||||
"evidence-link": "evlink",
|
"evidence-link": "evlink",
|
||||||
"citation-card": "card",
|
"citation-card": "card",
|
||||||
"citation-recovery": "crec",
|
"citation-recovery": "crec",
|
||||||
|
session: "sess",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,4 +8,6 @@ export * from "./evidence-set";
|
|||||||
export * from "./citation-card";
|
export * from "./citation-card";
|
||||||
export * from "./citation-card-source";
|
export * from "./citation-card-source";
|
||||||
export * from "./open-context-url";
|
export * from "./open-context-url";
|
||||||
|
export * from "./session";
|
||||||
|
export * from "./session-archive";
|
||||||
export { normalize, NORMALIZE_VERSION } from "./text/normalize";
|
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";
|
} from "./pdf/ingest";
|
||||||
export { extractPdf, type PdfExtractionResult } from "./pdf/extract";
|
export { extractPdf, type PdfExtractionResult } from "./pdf/extract";
|
||||||
export { fingerprintBytes } from "./pdf/fingerprint";
|
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.
|
* CollectionList — the left pane.
|
||||||
*
|
*
|
||||||
* Lists the fixture corpus (the MVP stand-in for a real document collection).
|
* CE-WP-0005 turned this into a *session-scoped* list. It shows the
|
||||||
* Clicking a fixture fetches the bytes, runs `ingestPdf` (PDF.js extraction
|
* documents currently registered with the active session's engine,
|
||||||
* + fingerprint + canonical text), registers the result with the engine
|
* with per-row Open + Delete actions and an inline upload affordance.
|
||||||
* (emitting §4 events), and activates it as the current document.
|
|
||||||
*
|
*
|
||||||
* Per CE-WP-0002-T06, the loaded fixture set is hard-wired to
|
* Fixture-driven quick-start lives in
|
||||||
* `fixtures/pdfs/manifest.json`. Real collections arrive in a later
|
* `src/app/sessions/SampleSessions.tsx` and is no longer the default.
|
||||||
* workplan.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { ingestPdf } from "@source/index";
|
import type { CSSProperties } from "react";
|
||||||
import { useEngine, useActiveDocumentId } from "./EngineContext";
|
import type { ReactNode } from "react";
|
||||||
import type { DocumentId } from "@shared/ids";
|
|
||||||
import manifest from "../../fixtures/pdfs/manifest.json";
|
|
||||||
|
|
||||||
interface Fixture {
|
import type { DocumentId } from "@shared/ids";
|
||||||
id: string;
|
import {
|
||||||
filename: string;
|
useActiveDocumentId,
|
||||||
description: string;
|
useEngine,
|
||||||
page_count: number;
|
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({ upload, title }: CollectionListProps) {
|
||||||
|
|
||||||
export function CollectionList() {
|
|
||||||
const engine = useEngine();
|
const engine = useEngine();
|
||||||
|
const byteStore = usePdfByteStore();
|
||||||
const { id: activeId, setId } = useActiveDocumentId();
|
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(
|
const importedTick = useEngineEventTick("DocumentImported");
|
||||||
async (fixture: Fixture) => {
|
const removedTick = useEngineEventTick("DocumentRemoved");
|
||||||
setError(null);
|
const revision = useEngineRevision();
|
||||||
|
|
||||||
const existing = byFixture[fixture.id];
|
const documents = useMemo(
|
||||||
if (existing) {
|
() => engine.documents.list(),
|
||||||
setId(existing);
|
[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;
|
return;
|
||||||
}
|
}
|
||||||
|
// Active doc was just deleted — clear the pointer so the viewer
|
||||||
setLoadingFixtureId(fixture.id);
|
// unmounts before the engine drops the record.
|
||||||
try {
|
if (activeId === id) setId(null);
|
||||||
const url = `/fixtures/pdfs/${encodeURIComponent(fixture.filename)}`;
|
byteStore.delete(id);
|
||||||
const response = await fetch(url);
|
engine.documents.remove(id);
|
||||||
if (!response.ok) {
|
setPendingDeleteId(null);
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[byFixture, engine, setId],
|
[activeId, byteStore, engine, pendingDeleteId, setId],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -78,44 +83,66 @@ export function CollectionList() {
|
|||||||
flex: "0 0 280px",
|
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 }}>
|
<p style={{ fontSize: 12, color: "#555", marginTop: 0 }}>
|
||||||
{FIXTURES.length} fixture PDF{FIXTURES.length === 1 ? "" : "s"}
|
{documents.length} document{documents.length === 1 ? "" : "s"}
|
||||||
</p>
|
</p>
|
||||||
{error && (
|
{upload && <div style={{ marginBottom: 8 }}>{upload}</div>}
|
||||||
<p style={{ fontSize: 12, color: "#b00020", background: "#fff4f4", padding: 6 }}>
|
{documents.length === 0 && !upload && (
|
||||||
{error}
|
<p style={{ fontSize: 12, color: "#888" }}>
|
||||||
|
No documents yet. Upload a PDF to get started.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
|
<ul
|
||||||
{FIXTURES.map((f) => {
|
data-testid="collection-list-items"
|
||||||
const isLoading = loadingFixtureId === f.id;
|
style={{ listStyle: "none", padding: 0, margin: 0 }}
|
||||||
const documentId = byFixture[f.id];
|
>
|
||||||
const isActive = documentId !== undefined && documentId === activeId;
|
{documents.map((doc) => {
|
||||||
|
const isActive = doc.id === activeId;
|
||||||
|
const isPending = pendingDeleteId === doc.id;
|
||||||
return (
|
return (
|
||||||
<li key={f.id} style={{ marginBottom: 6 }}>
|
<li key={doc.id} style={{ marginBottom: 6 }}>
|
||||||
<button
|
<div
|
||||||
onClick={() => {
|
|
||||||
void handleLoad(f);
|
|
||||||
}}
|
|
||||||
disabled={isLoading}
|
|
||||||
style={{
|
style={{
|
||||||
display: "block",
|
|
||||||
width: "100%",
|
|
||||||
textAlign: "left",
|
|
||||||
background: isActive ? "#e8f0ff" : "white",
|
|
||||||
border: "1px solid #ccc",
|
border: "1px solid #ccc",
|
||||||
padding: 6,
|
background: isActive ? "#e8f0ff" : "white",
|
||||||
cursor: isLoading ? "wait" : "pointer",
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
}}
|
}}
|
||||||
|
data-testid={`collection-item-${doc.id}`}
|
||||||
>
|
>
|
||||||
<div style={{ fontWeight: 600 }}>{f.id}</div>
|
<button
|
||||||
<div style={{ color: "#666", fontSize: 11 }}>
|
onClick={() => setId(doc.id)}
|
||||||
{f.page_count} page{f.page_count === 1 ? "" : "s"}
|
data-testid={`collection-open-${doc.id}`}
|
||||||
{isLoading ? " · loading…" : isActive ? " · open" : ""}
|
style={openButtonStyle}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600 }}>{doc.title ?? doc.id}</div>
|
||||||
|
<div style={{ color: "#666", fontSize: 11 }}>
|
||||||
|
{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>
|
||||||
</button>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -123,3 +150,14 @@ export function CollectionList() {
|
|||||||
</aside>
|
</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,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import type { Document, DocumentRepresentation } from "@shared/document";
|
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 type { Selector } from "@shared/selector";
|
||||||
import {
|
import {
|
||||||
attachPersister,
|
attachPersister,
|
||||||
createEngine,
|
createEngine,
|
||||||
|
engineSnapshotKey,
|
||||||
restoreFromStorage,
|
restoreFromStorage,
|
||||||
type Engine,
|
type Engine,
|
||||||
} from "@engine/index";
|
} from "@engine/index";
|
||||||
import type { PdfSelectionCapture } from "@anchor/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"
|
* Legacy single-bucket storage keys, kept for any user landing on a
|
||||||
* pointer. ADR-0005 frames both as deliberately temporary — real
|
* build without sessions. CE-WP-0005 switched persistence to per-session
|
||||||
* persistence later.
|
* 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";
|
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 pending selection lives in context (not local component state) because
|
||||||
* the toolbar that consumes it is rendered above the viewer, not inside it.
|
* the toolbar that consumes it is rendered above the viewer, not inside it.
|
||||||
@@ -51,6 +66,7 @@ export interface PendingSelection {
|
|||||||
|
|
||||||
interface EngineContextValue {
|
interface EngineContextValue {
|
||||||
readonly engine: Engine;
|
readonly engine: Engine;
|
||||||
|
readonly byteStore: PdfByteStore;
|
||||||
readonly activeDocumentId: DocumentId | null;
|
readonly activeDocumentId: DocumentId | null;
|
||||||
setActiveDocumentId(id: DocumentId | null): void;
|
setActiveDocumentId(id: DocumentId | null): void;
|
||||||
readonly pendingSelection: PendingSelection | null;
|
readonly pendingSelection: PendingSelection | null;
|
||||||
@@ -60,6 +76,13 @@ interface EngineContextValue {
|
|||||||
* so a second click on the same evidence item still triggers a scroll. */
|
* so a second click on the same evidence item still triggers a scroll. */
|
||||||
readonly scrollVersion: number;
|
readonly scrollVersion: number;
|
||||||
scrollToAnnotation(id: AnnotationId | null): void;
|
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);
|
const EngineContext = createContext<EngineContextValue | null>(null);
|
||||||
@@ -68,31 +91,67 @@ interface EngineProviderProps {
|
|||||||
readonly children: ReactNode;
|
readonly children: ReactNode;
|
||||||
/** Inject a pre-built engine for tests; production uses the default. */
|
/** Inject a pre-built engine for tests; production uses the default. */
|
||||||
readonly engine?: Engine;
|
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]);
|
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);
|
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 [pendingSelection, setPendingSelection] = useState<PendingSelection | null>(null);
|
||||||
const [scrollState, setScrollState] = useState<{ id: AnnotationId | null; version: number }>({
|
const [scrollState, setScrollState] = useState<{ id: AnnotationId | null; version: number }>({
|
||||||
id: null,
|
id: null,
|
||||||
version: 0,
|
version: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const snapshotKey = storageKeyFor(sessionId);
|
||||||
|
const activeDocKey = activeDocumentKeyFor(sessionId);
|
||||||
|
|
||||||
// Restore from localStorage on first mount, then attach the persister.
|
// Restore from localStorage on first mount, then attach the persister.
|
||||||
// The injected-engine path skips persistence (tests own their lifecycle).
|
// The injected-engine path skips persistence (tests own their lifecycle).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (injected) return;
|
if (injected) return;
|
||||||
if (typeof globalThis.localStorage === "undefined") return;
|
if (typeof globalThis.localStorage === "undefined") return;
|
||||||
const result = restoreFromStorage(engine, { key: STORAGE_KEY });
|
const result = restoreFromStorage(engine, { key: snapshotKey });
|
||||||
if (result.restored) {
|
if (result.restored) {
|
||||||
const saved = globalThis.localStorage.getItem(ACTIVE_KEY);
|
const saved = globalThis.localStorage.getItem(activeDocKey);
|
||||||
if (saved && engine.documents.get(saved as DocumentId)) {
|
if (saved && engine.documents.get(saved as DocumentId)) {
|
||||||
setActiveDocumentIdState(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 });
|
return attachPersister(engine, { key: snapshotKey });
|
||||||
}, [engine, injected]);
|
}, [engine, injected, snapshotKey, activeDocKey]);
|
||||||
|
|
||||||
// Persist the active-document pointer alongside the engine snapshot so a
|
// Persist the active-document pointer alongside the engine snapshot so a
|
||||||
// reload lands the user back where they were.
|
// reload lands the user back where they were.
|
||||||
@@ -100,11 +159,11 @@ export function EngineProvider({ children, engine: injected }: EngineProviderPro
|
|||||||
if (injected) return;
|
if (injected) return;
|
||||||
if (typeof globalThis.localStorage === "undefined") return;
|
if (typeof globalThis.localStorage === "undefined") return;
|
||||||
if (activeDocumentId) {
|
if (activeDocumentId) {
|
||||||
globalThis.localStorage.setItem(ACTIVE_KEY, activeDocumentId);
|
globalThis.localStorage.setItem(activeDocKey, activeDocumentId);
|
||||||
} else {
|
} else {
|
||||||
globalThis.localStorage.removeItem(ACTIVE_KEY);
|
globalThis.localStorage.removeItem(activeDocKey);
|
||||||
}
|
}
|
||||||
}, [activeDocumentId, injected]);
|
}, [activeDocumentId, injected, activeDocKey]);
|
||||||
|
|
||||||
// Switching the active document discards any pending selection — it
|
// Switching the active document discards any pending selection — it
|
||||||
// belongs to the previous document's viewer state.
|
// belongs to the previous document's viewer state.
|
||||||
@@ -121,6 +180,7 @@ export function EngineProvider({ children, engine: injected }: EngineProviderPro
|
|||||||
const value = useMemo<EngineContextValue>(
|
const value = useMemo<EngineContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
engine,
|
engine,
|
||||||
|
byteStore,
|
||||||
activeDocumentId,
|
activeDocumentId,
|
||||||
setActiveDocumentId,
|
setActiveDocumentId,
|
||||||
pendingSelection,
|
pendingSelection,
|
||||||
@@ -128,8 +188,18 @@ export function EngineProvider({ children, engine: injected }: EngineProviderPro
|
|||||||
scrollToAnnotationId: scrollState.id,
|
scrollToAnnotationId: scrollState.id,
|
||||||
scrollVersion: scrollState.version,
|
scrollVersion: scrollState.version,
|
||||||
scrollToAnnotation,
|
scrollToAnnotation,
|
||||||
|
engineRevision,
|
||||||
}),
|
}),
|
||||||
[engine, activeDocumentId, setActiveDocumentId, pendingSelection, scrollState, scrollToAnnotation],
|
[
|
||||||
|
engine,
|
||||||
|
byteStore,
|
||||||
|
activeDocumentId,
|
||||||
|
setActiveDocumentId,
|
||||||
|
pendingSelection,
|
||||||
|
scrollState,
|
||||||
|
scrollToAnnotation,
|
||||||
|
engineRevision,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <EngineContext.Provider value={value}>{children}</EngineContext.Provider>;
|
return <EngineContext.Provider value={value}>{children}</EngineContext.Provider>;
|
||||||
@@ -141,6 +211,18 @@ export function useEngine(): Engine {
|
|||||||
return ctx.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(): {
|
export function useActiveDocumentId(): {
|
||||||
readonly id: DocumentId | null;
|
readonly id: DocumentId | null;
|
||||||
setId(id: DocumentId | null): void;
|
setId(id: DocumentId | null): void;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
useActiveDocument,
|
useActiveDocument,
|
||||||
useEngine,
|
useEngine,
|
||||||
useEngineEventTick,
|
useEngineEventTick,
|
||||||
|
useEngineRevision,
|
||||||
useLastActivatedEvidence,
|
useLastActivatedEvidence,
|
||||||
useScrollToAnnotation,
|
useScrollToAnnotation,
|
||||||
} from "./EngineContext";
|
} from "./EngineContext";
|
||||||
@@ -75,11 +76,12 @@ export function EvidenceSidebar(props: EvidenceSidebarProps) {
|
|||||||
|
|
||||||
const createTick = useEngineEventTick("EvidenceItemCreated");
|
const createTick = useEngineEventTick("EvidenceItemCreated");
|
||||||
const updateTick = useEngineEventTick("EvidenceItemUpdated");
|
const updateTick = useEngineEventTick("EvidenceItemUpdated");
|
||||||
|
const revision = useEngineRevision();
|
||||||
|
|
||||||
const items = useMemo<readonly EvidenceItem[]>(() => {
|
const items = useMemo<readonly EvidenceItem[]>(() => {
|
||||||
if (!document) return [];
|
if (!document) return [];
|
||||||
return engine.evidence.listByDocument(document.id);
|
return engine.evidence.listByDocument(document.id);
|
||||||
}, [document, engine, createTick, updateTick]);
|
}, [document, engine, createTick, updateTick, revision]);
|
||||||
|
|
||||||
const [openExportFor, setOpenExportFor] = useState<EvidenceItemId | null>(null);
|
const [openExportFor, setOpenExportFor] = useState<EvidenceItemId | null>(null);
|
||||||
const [toast, setToast] = useState<ToastState | 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 { ViewerShell } from "./ViewerShell";
|
||||||
export { EvidenceSidebar, type EvidenceSidebarProps } from "./EvidenceSidebar";
|
export { EvidenceSidebar, type EvidenceSidebarProps } from "./EvidenceSidebar";
|
||||||
export {
|
export {
|
||||||
@@ -14,8 +14,23 @@ export {
|
|||||||
useActiveDocument,
|
useActiveDocument,
|
||||||
useActiveDocumentId,
|
useActiveDocumentId,
|
||||||
useEngineEventTick,
|
useEngineEventTick,
|
||||||
|
useEngineRevision,
|
||||||
useLastActivatedEvidence,
|
useLastActivatedEvidence,
|
||||||
|
usePdfByteStore,
|
||||||
usePendingSelection,
|
usePendingSelection,
|
||||||
useScrollToAnnotation,
|
useScrollToAnnotation,
|
||||||
type PendingSelection,
|
type PendingSelection,
|
||||||
} from "./EngineContext";
|
} 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { Selector } from "@shared/selector";
|
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 type { PdfSelectionCapture } from "@anchor/index";
|
||||||
import manifest from "../../fixtures/pdfs/manifest.json" with { type: "json" };
|
import manifest from "../../fixtures/pdfs/manifest.json" with { type: "json" };
|
||||||
|
|
||||||
@@ -90,42 +88,8 @@ const SYNTHETIC_CANONICAL = [
|
|||||||
"Trailing prose that comes after the quote.",
|
"Trailing prose that comes after the quote.",
|
||||||
].join(" ");
|
].join(" ");
|
||||||
|
|
||||||
vi.mock("@source/index", async (importOriginal) => {
|
// CE-WP-0005: pre-seed a session with the fixture doc; no ingestPdf mock needed.
|
||||||
const original = await importOriginal<typeof import("@source/index")>();
|
import { seedSessionWithDoc, type SeedResult } from "./helpers/seed-session";
|
||||||
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 };
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
@@ -160,40 +124,40 @@ async function loadApp() {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("App — PRD scenario steps 1-8 (CE-WP-0002-T09)", () => {
|
describe("App — PRD scenario steps 1-8 (CE-WP-0002-T09)", () => {
|
||||||
|
let seeded: SeedResult;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetViewerSnapshot();
|
resetViewerSnapshot();
|
||||||
// Each test starts with empty localStorage.
|
// Each test starts with empty localStorage.
|
||||||
globalThis.localStorage?.clear();
|
globalThis.localStorage?.clear();
|
||||||
// The fetch isn't reached (ingestPdf is mocked) — but stub it so that
|
if (typeof window !== "undefined") {
|
||||||
// any accidental call returns gracefully instead of TypeError.
|
history.replaceState(null, "", window.location.pathname);
|
||||||
globalThis.fetch = vi.fn(async () =>
|
}
|
||||||
new Response(new Uint8Array([0x25, 0x50, 0x44, 0x46]).buffer, {
|
seeded = seedSessionWithDoc({
|
||||||
status: 200,
|
sessionName: "Demo",
|
||||||
headers: { "Content-Type": "application/pdf" },
|
documentTitle: FIXTURE.filename,
|
||||||
}),
|
canonicalText: SYNTHETIC_CANONICAL,
|
||||||
);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
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();
|
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();
|
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.
|
// Step 2: the fixture doc is pre-seeded into the active session, so
|
||||||
const fixtureButton = screen.getByRole("button", { name: new RegExp(FIXTURE.id) });
|
// the viewer mounts automatically — no fixture-button click needed.
|
||||||
await user.click(fixtureButton);
|
|
||||||
|
|
||||||
// The mock viewer should have mounted with our test URL.
|
|
||||||
await waitFor(() => {
|
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.
|
// Step 3: programmatically inject a selection for the known-good quote.
|
||||||
expect(viewerSnapshot.onSelectionCaptured).not.toBeNull();
|
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]!;
|
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.
|
// 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();
|
expect(stored).toBeTruthy();
|
||||||
|
|
||||||
// Step 6: reload — unmount and remount the App. The same localStorage is
|
// 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
|
// The viewer should re-mount automatically because the active document
|
||||||
// was persisted.
|
// was persisted.
|
||||||
await waitFor(() => {
|
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.
|
// The sidebar should show the restored item.
|
||||||
const restoredItem = await screen.findByText(/Important deadline clause/);
|
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 userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
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 { Selector } from "@shared/selector";
|
||||||
|
|
||||||
import type { PdfSelectionCapture } from "@anchor/index";
|
import type { PdfSelectionCapture } from "@anchor/index";
|
||||||
@@ -73,42 +71,8 @@ const SYNTHETIC_CANONICAL = [
|
|||||||
"Post quote.",
|
"Post quote.",
|
||||||
].join(" ");
|
].join(" ");
|
||||||
|
|
||||||
vi.mock("@source/index", async (importOriginal) => {
|
// CE-WP-0005: pre-seed a session with the fixture doc; no ingestPdf mock needed.
|
||||||
const original = await importOriginal<typeof import("@source/index")>();
|
import { seedSessionWithDoc } from "./helpers/seed-session";
|
||||||
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 };
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
function syntheticCaptureFor(text: string, page: number): PdfSelectionCapture {
|
function syntheticCaptureFor(text: string, page: number): PdfSelectionCapture {
|
||||||
return {
|
return {
|
||||||
@@ -136,15 +100,14 @@ describe("FormsApp — active-evidence cycling (CE-WP-0003-T06)", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetSnapshot();
|
resetSnapshot();
|
||||||
globalThis.localStorage?.clear();
|
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") {
|
if (typeof window !== "undefined") {
|
||||||
history.replaceState(null, "", window.location.pathname);
|
history.replaceState(null, "", window.location.pathname);
|
||||||
}
|
}
|
||||||
|
seedSessionWithDoc({
|
||||||
|
sessionName: "T06-cycling",
|
||||||
|
documentTitle: FIXTURE.filename,
|
||||||
|
canonicalText: SYNTHETIC_CANONICAL,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -157,8 +120,7 @@ describe("FormsApp — active-evidence cycling (CE-WP-0003-T06)", () => {
|
|||||||
await loadApp();
|
await loadApp();
|
||||||
|
|
||||||
// --- Review mode: create an evidence item via the captured-selection flow.
|
// --- Review mode: create an evidence item via the captured-selection flow.
|
||||||
const fixtureBtn = screen.getByRole("button", { name: new RegExp(FIXTURE.id) });
|
// CE-WP-0005: doc pre-seeded — skip fixture click.
|
||||||
await user.click(fixtureBtn);
|
|
||||||
await waitFor(() => expect(viewerSnapshot.onSelectionCaptured).not.toBeNull());
|
await waitFor(() => expect(viewerSnapshot.onSelectionCaptured).not.toBeNull());
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
viewerSnapshot.onSelectionCaptured!(
|
viewerSnapshot.onSelectionCaptured!(
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
|||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { Document, DocumentRepresentation } from "@shared/document";
|
import type { AnnotationId } from "@shared/ids";
|
||||||
import type { AnnotationId, DocumentId, RepresentationId } from "@shared/ids";
|
|
||||||
import type { Selector } from "@shared/selector";
|
import type { Selector } from "@shared/selector";
|
||||||
|
|
||||||
import type { PdfSelectionCapture } from "@anchor/index";
|
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")!;
|
const FIXTURE = manifest.fixtures.find((f) => f.id === "fristsetzung-bezifferung")!;
|
||||||
|
|
||||||
vi.mock("@source/index", async (importOriginal) => {
|
// CE-WP-0005: pre-seed a session with the fixture doc; no ingestPdf mock needed.
|
||||||
const original = await importOriginal<typeof import("@source/index")>();
|
import { seedSessionWithDoc } from "./helpers/seed-session";
|
||||||
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 };
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadApp() {
|
async function loadApp() {
|
||||||
const { App } = await import("@app/App");
|
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)", () => {
|
describe("FormsApp — click-evidence-then-click-field linking (CE-WP-0003-T05)", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
globalThis.localStorage?.clear();
|
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.
|
// Forms mode is hash-driven; make sure we start clean.
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
history.replaceState(null, "", window.location.pathname);
|
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 () => {
|
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();
|
const user = userEvent.setup();
|
||||||
await loadApp();
|
await loadApp();
|
||||||
|
|
||||||
// Switch to Forms via the top-bar button.
|
// Switch to Forms via the top-bar button.
|
||||||
await user.click(screen.getByRole("button", { name: "Forms" }));
|
await user.click(screen.getByRole("button", { name: "Forms" }));
|
||||||
|
|
||||||
// The collection list is in the Forms layout too.
|
// CE-WP-0005: doc is pre-seeded into the active session.
|
||||||
const fixtureBtn = screen.getByRole("button", { name: new RegExp(FIXTURE.id) });
|
// Wait for the form to appear.
|
||||||
await user.click(fixtureBtn);
|
|
||||||
|
|
||||||
// Wait for the fixture to load and the form to appear.
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.queryByLabelText("Summary of the matter")).not.toBeNull();
|
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/);
|
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();
|
await loadApp();
|
||||||
expect(screen.getByText("Collection")).toBeTruthy();
|
// The empty-state landing is what users see now until they create
|
||||||
// Review pane's no-doc-open hint from EvidenceSidebar:
|
// a session.
|
||||||
expect(screen.queryByText(/No document open/)).not.toBeNull();
|
expect(screen.getByTestId("empty-state")).toBeTruthy();
|
||||||
// No demo form rendered yet
|
// No demo form rendered yet.
|
||||||
expect(screen.queryByText("Demo evidence-backed form")).toBeNull();
|
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 userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
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 { Selector } from "@shared/selector";
|
||||||
|
|
||||||
import type { PdfSelectionCapture } from "@anchor/index";
|
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 FIXTURE = manifest.fixtures.find((f) => f.id === "fristsetzung-bezifferung")!;
|
||||||
const SYNTHETIC_CANONICAL = ["Pre.", FIXTURE.known_good_quote, "Post."].join(" ");
|
const SYNTHETIC_CANONICAL = ["Pre.", FIXTURE.known_good_quote, "Post."].join(" ");
|
||||||
|
|
||||||
vi.mock("@source/index", async (importOriginal) => {
|
// CE-WP-0005: pre-seed a session with the fixture doc; no ingestPdf mock needed.
|
||||||
const original = await importOriginal<typeof import("@source/index")>();
|
import { seedSessionWithDoc } from "./helpers/seed-session";
|
||||||
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 };
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
function syntheticCaptureFor(text: string, page: number): PdfSelectionCapture {
|
function syntheticCaptureFor(text: string, page: number): PdfSelectionCapture {
|
||||||
return {
|
return {
|
||||||
@@ -135,15 +104,14 @@ describe("CE-WP-0003-T08 — PRD scenario steps 5-9 end-to-end", () => {
|
|||||||
viewerSnapshot.scrollToAnnotationId = null;
|
viewerSnapshot.scrollToAnnotationId = null;
|
||||||
viewerSnapshot.onSelectionCaptured = null;
|
viewerSnapshot.onSelectionCaptured = null;
|
||||||
globalThis.localStorage?.clear();
|
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") {
|
if (typeof window !== "undefined") {
|
||||||
history.replaceState(null, "", window.location.pathname);
|
history.replaceState(null, "", window.location.pathname);
|
||||||
}
|
}
|
||||||
|
seedSessionWithDoc({
|
||||||
|
sessionName: "T08-forms",
|
||||||
|
documentTitle: FIXTURE.filename,
|
||||||
|
canonicalText: SYNTHETIC_CANONICAL,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -159,8 +127,7 @@ describe("CE-WP-0003-T08 — PRD scenario steps 5-9 end-to-end", () => {
|
|||||||
await loadApp();
|
await loadApp();
|
||||||
|
|
||||||
// Steps 1-4 (CE-WP-0002 setup): create an evidence item in Review mode.
|
// Steps 1-4 (CE-WP-0002 setup): create an evidence item in Review mode.
|
||||||
const fixtureBtn = screen.getByRole("button", { name: new RegExp(FIXTURE.id) });
|
// CE-WP-0005: doc pre-seeded into the session — skip fixture click.
|
||||||
await user.click(fixtureBtn);
|
|
||||||
await waitFor(() => expect(viewerSnapshot.onSelectionCaptured).not.toBeNull());
|
await waitFor(() => expect(viewerSnapshot.onSelectionCaptured).not.toBeNull());
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
viewerSnapshot.onSelectionCaptured!(
|
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 user.click(screen.getByRole("button", { name: /Save evidence/ }));
|
||||||
await screen.findByText(/Overlay E2E 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" }));
|
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
|
// Step 6: stage the evidence in the strip, then click the summary
|
||||||
// field to create the link.
|
// 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
|
DocumentImported
|
||||||
DocumentRepresentationGenerated
|
DocumentRepresentationGenerated
|
||||||
|
DocumentRemoved
|
||||||
AnnotationCreated
|
AnnotationCreated
|
||||||
AnnotationResolved
|
AnnotationResolved
|
||||||
AnnotationResolutionFailed
|
AnnotationResolutionFailed
|
||||||
@@ -179,8 +180,17 @@ CitationCardRendered
|
|||||||
CitationRecoveryStarted
|
CitationRecoveryStarted
|
||||||
CitationRecoveryCandidateFound
|
CitationRecoveryCandidateFound
|
||||||
CitationRecoveryConfirmed
|
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
|
Subsystems must emit these events through a shared event bus owned by
|
||||||
`citation-engine`. Subsystems may listen to any event but must not invent
|
`citation-engine`. Subsystems may listen to any event but must not invent
|
||||||
event types without updating this document.
|
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