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:
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.
|
||||
* - Forms mode (CE-WP-0003): form renderer / viewer / evidence strip,
|
||||
* with click-to-link interaction.
|
||||
* SessionProvider (cross-session)
|
||||
* └─ AppShell — owns routing + the top bar
|
||||
* ├─ if no active session → CreateFirstSession (empty state)
|
||||
* └─ else
|
||||
* EngineProvider key={sessionId} sessionId={sessionId}
|
||||
* └─ BinderProvider bus={engine.bus}
|
||||
* └─ ReviewLayout | FormsApp (per `mode`)
|
||||
*
|
||||
* Mode selection is driven by `location.hash`: `#/forms/demo` lands in
|
||||
* Forms mode; anything else (including empty) lands in Review mode. The
|
||||
* top bar toggles between them. We keep the hash sync so reload + deep
|
||||
* links work; T08's E2E asserts the `/forms/demo` navigation path.
|
||||
*
|
||||
* Engine and binder providers are both mounted at the App root so
|
||||
* evidence/annotations/links survive switching tabs.
|
||||
* The hash is the single source of truth for `{sessionId, mode}`. The
|
||||
* SessionService's active id is kept in sync with the hash via a
|
||||
* useEffect inside `AppShell`. Deep links to unknown sessions redirect
|
||||
* to the empty state with a toast.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { BinderProvider } from "@binder/index";
|
||||
import {
|
||||
EngineProvider,
|
||||
SessionProvider,
|
||||
useActiveSession,
|
||||
useEngine,
|
||||
usePdfByteStore,
|
||||
useSessionByteStoreRegistry,
|
||||
useSessionService,
|
||||
useSessionsHydrated,
|
||||
useSessionVersion,
|
||||
useSessionVersionBumper,
|
||||
} from "@work/index";
|
||||
|
||||
import { FormsApp } from "./forms/FormsApp";
|
||||
import { ReviewLayout } from "./ReviewLayout";
|
||||
|
||||
type Mode = "review" | "forms";
|
||||
import {
|
||||
CreateFirstSession,
|
||||
EMPTY_ROUTE,
|
||||
exportSessionZip,
|
||||
importSessionZip,
|
||||
parseRoute,
|
||||
navigateTo,
|
||||
SessionMenu,
|
||||
sessionZipFilename,
|
||||
Toast,
|
||||
triggerSessionDownload,
|
||||
UploadDropzone,
|
||||
useToast,
|
||||
type AppMode,
|
||||
type AppRoute,
|
||||
} from "./sessions";
|
||||
|
||||
const FORMS_HASH = "#/forms/demo";
|
||||
|
||||
function readModeFromHash(): Mode {
|
||||
if (typeof window === "undefined") return "review";
|
||||
return window.location.hash === FORMS_HASH ? "forms" : "review";
|
||||
function readRoute(): AppRoute {
|
||||
if (typeof window === "undefined") return EMPTY_ROUTE;
|
||||
return parseRoute(window.location.hash);
|
||||
}
|
||||
|
||||
function writeModeToHash(mode: Mode) {
|
||||
if (typeof window === "undefined") return;
|
||||
const target = mode === "forms" ? FORMS_HASH : "";
|
||||
if (window.location.hash !== target) {
|
||||
if (target) {
|
||||
window.location.hash = target;
|
||||
} else {
|
||||
// Clear hash without leaving "#" trailing in the URL bar.
|
||||
history.replaceState(null, "", window.location.pathname + window.location.search);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ModeRouter() {
|
||||
const [mode, setMode] = useState<Mode>(() => readModeFromHash());
|
||||
|
||||
function useHashRoute(): AppRoute {
|
||||
const [route, setRoute] = useState<AppRoute>(() => readRoute());
|
||||
useEffect(() => {
|
||||
function onHash() {
|
||||
setMode(readModeFromHash());
|
||||
}
|
||||
window.addEventListener("hashchange", onHash);
|
||||
return () => window.removeEventListener("hashchange", onHash);
|
||||
const handler = () => setRoute(readRoute());
|
||||
window.addEventListener("hashchange", handler);
|
||||
return () => window.removeEventListener("hashchange", handler);
|
||||
}, []);
|
||||
return route;
|
||||
}
|
||||
|
||||
const handleModeChange = (next: Mode) => {
|
||||
writeModeToHash(next);
|
||||
setMode(next);
|
||||
};
|
||||
function AppShell() {
|
||||
const route = useHashRoute();
|
||||
const service = useSessionService();
|
||||
const hydrated = useSessionsHydrated();
|
||||
const toast = useToast();
|
||||
// Guards the "unknown session id → toast + redirect" path against an
|
||||
// infinite loop: `useToast.show` creates a fresh `toast` object every
|
||||
// render, which would otherwise re-fire the effect.
|
||||
const lastHandledSessionIdRef = useRef<string | null>(null);
|
||||
|
||||
// Sync hash → SessionService.setActive. Unknown session ids fall back
|
||||
// to the empty state with a toast.
|
||||
useEffect(() => {
|
||||
if (!hydrated) return;
|
||||
const key = route.sessionId ?? "";
|
||||
if (lastHandledSessionIdRef.current === key) return;
|
||||
lastHandledSessionIdRef.current = key;
|
||||
|
||||
if (route.sessionId === null) {
|
||||
service.setActive(null);
|
||||
return;
|
||||
}
|
||||
const exists = service.get(route.sessionId);
|
||||
if (exists) {
|
||||
service.setActive(route.sessionId);
|
||||
} else {
|
||||
toast.show("Session not found — opened the empty state instead", "error");
|
||||
navigateTo(EMPTY_ROUTE);
|
||||
}
|
||||
}, [route.sessionId, service, hydrated, toast]);
|
||||
|
||||
if (!hydrated) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
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 (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100vh", color: "#222" }}>
|
||||
<TopBar mode={mode} onModeChange={handleModeChange} />
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
{mode === "review" ? <ReviewLayout /> : <FormsApp />}
|
||||
</div>
|
||||
<EngineProvider key={`${sessionId}:${version}`} sessionId={sessionId}>
|
||||
<ActiveTopBar route={route} showToast={toast.show} />
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<SessionScopedTree mode={route.mode} />
|
||||
</div>
|
||||
</EngineProvider>
|
||||
<Toast toast={toast.toast} onDismiss={toast.dismiss} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function 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 (
|
||||
<header
|
||||
style={{
|
||||
@@ -89,20 +206,122 @@ function TopBar({ mode, onModeChange }: { mode: Mode; onModeChange: (m: Mode) =>
|
||||
}}
|
||||
>
|
||||
<strong style={{ fontSize: 13, marginRight: 8 }}>citation-evidence</strong>
|
||||
<button
|
||||
onClick={() => onModeChange("review")}
|
||||
aria-pressed={mode === "review"}
|
||||
style={tabStyle(mode === "review")}
|
||||
>
|
||||
Review
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onModeChange("forms")}
|
||||
aria-pressed={mode === "forms"}
|
||||
style={tabStyle(mode === "forms")}
|
||||
>
|
||||
Forms
|
||||
</button>
|
||||
<SessionMenu onImportZip={() => pickAndImport(handleImport)} />
|
||||
<Toast toast={toast.toast} onDismiss={toast.dismiss} />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function pickAndImport(onPicked: (file: File) => void): void {
|
||||
if (typeof document === "undefined") return;
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ".zip,application/zip";
|
||||
input.onchange = () => {
|
||||
const file = input.files?.[0];
|
||||
if (file) onPicked(file);
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
|
||||
function ActiveTopBar({
|
||||
route,
|
||||
showToast,
|
||||
}: {
|
||||
route: AppRoute;
|
||||
showToast: (msg: string, tone?: "success" | "error" | "info") => void;
|
||||
}) {
|
||||
const engine = useEngine();
|
||||
const byteStore = usePdfByteStore();
|
||||
const session = useActiveSession();
|
||||
const sessionService = useSessionService();
|
||||
const registry = useSessionByteStoreRegistry();
|
||||
const bumpVersion = useSessionVersionBumper();
|
||||
|
||||
const handleModeChange = useCallback(
|
||||
(next: AppMode) => {
|
||||
if (!route.sessionId) return;
|
||||
navigateTo({ sessionId: route.sessionId, mode: next });
|
||||
},
|
||||
[route.sessionId],
|
||||
);
|
||||
|
||||
const handleExport = useCallback(async () => {
|
||||
if (!session) return;
|
||||
try {
|
||||
const blob = await exportSessionZip(engine, byteStore, session);
|
||||
triggerSessionDownload(blob, sessionZipFilename(session));
|
||||
showToast("Session exported", "success");
|
||||
} catch (err) {
|
||||
showToast(
|
||||
err instanceof Error ? `Export failed: ${err.message}` : "Export failed",
|
||||
"error",
|
||||
);
|
||||
}
|
||||
}, [engine, byteStore, session, showToast]);
|
||||
|
||||
const handleImport = useCallback(
|
||||
async (file: File) => {
|
||||
try {
|
||||
const result = await importSessionZip(file, {
|
||||
sessionService,
|
||||
getOrCreateByteStore: registry.getOrCreateByteStore,
|
||||
bumpSessionVersion: bumpVersion,
|
||||
});
|
||||
navigateTo({ sessionId: result.sessionId, mode: "review" });
|
||||
const totals = result.stats;
|
||||
const summary =
|
||||
result.outcome === "created"
|
||||
? `Imported new session — ${totals.documentsAdded} document${totals.documentsAdded === 1 ? "" : "s"}, ${totals.annotationsAdded} annotation${totals.annotationsAdded === 1 ? "" : "s"}`
|
||||
: `Merged into existing — ${totals.documentsAdded} new doc${totals.documentsAdded === 1 ? "" : "s"}, ${totals.documentsDeduped} deduped`;
|
||||
showToast(summary, "success");
|
||||
} catch (err) {
|
||||
showToast(
|
||||
err instanceof Error ? `Import failed: ${err.message}` : "Import failed",
|
||||
"error",
|
||||
);
|
||||
}
|
||||
},
|
||||
[sessionService, registry, bumpVersion, showToast],
|
||||
);
|
||||
|
||||
const tabs = useMemo(
|
||||
() => [
|
||||
{ id: "review" as const, label: "Review" },
|
||||
{ id: "forms" as const, label: "Forms" },
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<header
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
padding: "6px 12px",
|
||||
borderBottom: "1px solid #ddd",
|
||||
background: "#fafafa",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -118,19 +337,10 @@ function tabStyle(active: boolean) {
|
||||
};
|
||||
}
|
||||
|
||||
function AppInner() {
|
||||
const engine = useEngine();
|
||||
return (
|
||||
<BinderProvider bus={engine.bus}>
|
||||
<ModeRouter />
|
||||
</BinderProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<EngineProvider>
|
||||
<AppInner />
|
||||
</EngineProvider>
|
||||
<SessionProvider>
|
||||
<AppShell />
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,15 +5,27 @@
|
||||
* │ Collection │ Document Viewer │ Evidence │
|
||||
* │ List │ │ Sidebar │
|
||||
* └────────────┴──────────────────┴────────────┘
|
||||
*
|
||||
* CE-WP-0005 added an `upload` slot for the active session's upload
|
||||
* dropzone, threaded in by the app composition root so this component
|
||||
* stays inside the `work` boundary (which cannot import `app`).
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import {
|
||||
CollectionList,
|
||||
EvidenceSidebar,
|
||||
ViewerShell,
|
||||
useActiveSession,
|
||||
} from "@work/index";
|
||||
|
||||
export function ReviewLayout() {
|
||||
export interface ReviewLayoutProps {
|
||||
readonly upload?: ReactNode;
|
||||
}
|
||||
|
||||
export function ReviewLayout({ upload }: ReviewLayoutProps) {
|
||||
const session = useActiveSession();
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -22,7 +34,7 @@ export function ReviewLayout() {
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
}}
|
||||
>
|
||||
<CollectionList />
|
||||
<CollectionList upload={upload} title={session?.name ?? "Collection"} />
|
||||
<ViewerShell />
|
||||
<EvidenceSidebar />
|
||||
</div>
|
||||
|
||||
104
src/app/sessions/CreateFirstSession.tsx
Normal file
104
src/app/sessions/CreateFirstSession.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Empty-state landing — shown when no session is active.
|
||||
*
|
||||
* Inline name input + Create button. On success, navigates the hash to
|
||||
* the new session so the rest of the app mounts. Used both on first
|
||||
* launch (no sessions yet) and after the last session was deleted.
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { useSessionService } from "@work/index";
|
||||
|
||||
import { navigateTo } from "./routing";
|
||||
|
||||
export function CreateFirstSession() {
|
||||
const service = useSessionService();
|
||||
const [name, setName] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const hasOthers = service.list().length > 0;
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
setError(null);
|
||||
try {
|
||||
const created = service.create(name);
|
||||
navigateTo({ sessionId: created.id, mode: "review" });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [name, service]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="empty-state"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
background: "#fafafa",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: 22, margin: 0 }}>citation-evidence</h1>
|
||||
<p style={{ fontSize: 14, color: "#555", margin: 0 }}>
|
||||
{hasOthers
|
||||
? "Pick a session from the menu above, or create a new one."
|
||||
: "Create your first session to get started."}
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Session name (e.g. Lease 2024)"
|
||||
data-testid="empty-state-input"
|
||||
style={{
|
||||
fontSize: 14,
|
||||
padding: "6px 10px",
|
||||
border: "1px solid #888",
|
||||
borderRadius: 3,
|
||||
minWidth: 260,
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleCreate();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
data-testid="empty-state-create"
|
||||
style={{
|
||||
fontSize: 14,
|
||||
padding: "6px 14px",
|
||||
border: "1px solid #0050b3",
|
||||
background: "#0050b3",
|
||||
color: "white",
|
||||
borderRadius: 3,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Create session
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<div
|
||||
data-testid="empty-state-error"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "#7a0000",
|
||||
background: "#fff4f4",
|
||||
padding: "4px 10px",
|
||||
border: "1px solid #f5cccc",
|
||||
borderRadius: 3,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
src/app/sessions/SampleSessions.tsx
Normal file
125
src/app/sessions/SampleSessions.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* SampleSessions — optional fixture-driven quick-start.
|
||||
*
|
||||
* The MVP collection list (pre-CE-WP-0005) ingested fixture PDFs over
|
||||
* `fetch`. After the session refactor that workflow is no longer the
|
||||
* default; it survives here as an optional way to seed the active
|
||||
* session with a sample document for demo and testing.
|
||||
*
|
||||
* Mounted by `SessionMenu` (T04) under a "Sample sessions ▸" entry
|
||||
* and by the integration tests under CE-WP-0002-T09 / -T05 that need
|
||||
* a known-good document.
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { ingestPdf } from "@source/index";
|
||||
import type { DocumentId } from "@shared/ids";
|
||||
import {
|
||||
useActiveDocumentId,
|
||||
useEngine,
|
||||
usePdfByteStore,
|
||||
} from "@work/index";
|
||||
|
||||
import manifest from "../../../fixtures/pdfs/manifest.json";
|
||||
|
||||
interface Fixture {
|
||||
id: string;
|
||||
filename: string;
|
||||
description: string;
|
||||
page_count: number;
|
||||
}
|
||||
|
||||
const FIXTURES: readonly Fixture[] = (manifest as { fixtures: Fixture[] }).fixtures;
|
||||
|
||||
export function SampleSessions() {
|
||||
const engine = useEngine();
|
||||
const byteStore = usePdfByteStore();
|
||||
const { id: activeId, setId } = useActiveDocumentId();
|
||||
const [loadingFixtureId, setLoadingFixtureId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [byFixture, setByFixture] = useState<Record<string, DocumentId>>({});
|
||||
|
||||
const handleLoad = useCallback(
|
||||
async (fixture: Fixture) => {
|
||||
setError(null);
|
||||
const existing = byFixture[fixture.id];
|
||||
if (existing) {
|
||||
setId(existing);
|
||||
return;
|
||||
}
|
||||
setLoadingFixtureId(fixture.id);
|
||||
try {
|
||||
const url = `/fixtures/pdfs/${encodeURIComponent(fixture.filename)}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`fetch ${url} → ${response.status}`);
|
||||
}
|
||||
const buffer = await response.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const { document, representation } = await ingestPdf(bytes, {
|
||||
filename: fixture.filename,
|
||||
});
|
||||
// Push the bytes into the byte store so the viewer can mount them via
|
||||
// the same blob URL machinery used by the upload path. The document
|
||||
// record carries the blob URL on `uri` for the viewer adapter.
|
||||
const record = byteStore.put(document.id, bytes);
|
||||
engine.documents.register({
|
||||
document: { ...document, uri: record.blobUrl },
|
||||
representation,
|
||||
});
|
||||
setByFixture((prev) => ({ ...prev, [fixture.id]: document.id }));
|
||||
setId(document.id);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoadingFixtureId(null);
|
||||
}
|
||||
},
|
||||
[byFixture, byteStore, engine, setId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid="sample-sessions">
|
||||
<p style={{ fontSize: 12, color: "#555", margin: "0 0 6px" }}>
|
||||
Load a fixture PDF as a sample document for the active session.
|
||||
</p>
|
||||
{error && (
|
||||
<p style={{ fontSize: 12, color: "#b00020", background: "#fff4f4", padding: 6 }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
|
||||
{FIXTURES.map((f) => {
|
||||
const isLoading = loadingFixtureId === f.id;
|
||||
const documentId = byFixture[f.id];
|
||||
const isActive = documentId !== undefined && documentId === activeId;
|
||||
return (
|
||||
<li key={f.id} style={{ marginBottom: 6 }}>
|
||||
<button
|
||||
onClick={() => void handleLoad(f)}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
background: isActive ? "#e8f0ff" : "white",
|
||||
border: "1px solid #ccc",
|
||||
padding: 6,
|
||||
cursor: isLoading ? "wait" : "pointer",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>{f.id}</div>
|
||||
<div style={{ color: "#666", fontSize: 11 }}>
|
||||
{f.page_count} page{f.page_count === 1 ? "" : "s"}
|
||||
{isLoading ? " · loading…" : isActive ? " · open" : ""}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
src/app/sessions/SessionMenu.dom.test.tsx
Normal file
132
src/app/sessions/SessionMenu.dom.test.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { SessionId } from "@shared/ids";
|
||||
import { SessionProvider, useSessionService } from "@work/index";
|
||||
|
||||
import { SessionMenu } from "./SessionMenu";
|
||||
import { parseRoute } from "./routing";
|
||||
|
||||
function HashSync() {
|
||||
// Mirrors the production AppShell effect: hash changes drive
|
||||
// SessionService.setActive so useActiveSession() resolves correctly.
|
||||
const service = useSessionService();
|
||||
const [tick, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const onHash = () => setTick((t) => t + 1);
|
||||
window.addEventListener("hashchange", onHash);
|
||||
return () => window.removeEventListener("hashchange", onHash);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const route = parseRoute(window.location.hash);
|
||||
if (route.sessionId && service.get(route.sessionId as SessionId)) {
|
||||
service.setActive(route.sessionId as SessionId);
|
||||
} else {
|
||||
service.setActive(null);
|
||||
}
|
||||
}, [tick, service]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function Wrap({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SessionProvider>
|
||||
<HashSync />
|
||||
{children}
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function CurrentHash() {
|
||||
return <span data-testid="current-hash">{window.location.hash || "(empty)"}</span>;
|
||||
}
|
||||
|
||||
function SeedTwo() {
|
||||
const service = useSessionService();
|
||||
if (service.list().length === 0) {
|
||||
service.create({ name: "Alpha" });
|
||||
service.create({ name: "Beta" });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.localStorage?.clear();
|
||||
history.replaceState(null, "", window.location.pathname);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("SessionMenu", () => {
|
||||
it("creating a new session navigates the hash to /s/<id>", async () => {
|
||||
render(
|
||||
<Wrap>
|
||||
<CurrentHash />
|
||||
<SessionMenu />
|
||||
</Wrap>,
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId("session-menu-toggle"));
|
||||
await user.click(screen.getByTestId("session-menu-new"));
|
||||
await user.type(screen.getByTestId("session-new-input"), "Demo");
|
||||
await user.click(screen.getByTestId("session-new-confirm"));
|
||||
await waitFor(() => {
|
||||
const route = parseRoute(window.location.hash);
|
||||
expect(route.sessionId).toMatch(/^sess_/);
|
||||
expect(route.mode).toBe("review");
|
||||
});
|
||||
});
|
||||
|
||||
it("switching sessions writes the chosen id into the hash", async () => {
|
||||
render(
|
||||
<Wrap>
|
||||
<SeedTwo />
|
||||
<CurrentHash />
|
||||
<SessionMenu />
|
||||
</Wrap>,
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId("session-menu-toggle"));
|
||||
|
||||
const alphaBtn = await screen.findByText(/Alpha/);
|
||||
await user.click(alphaBtn);
|
||||
await waitFor(() => {
|
||||
const route = parseRoute(window.location.hash);
|
||||
expect(route.sessionId).not.toBeNull();
|
||||
expect(route.mode).toBe("review");
|
||||
});
|
||||
});
|
||||
|
||||
it("rename rejects a duplicate name with an inline error", async () => {
|
||||
render(
|
||||
<Wrap>
|
||||
<SeedTwo />
|
||||
<SessionMenu />
|
||||
</Wrap>,
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
// Switch to Alpha first so it becomes active and rename becomes available.
|
||||
await user.click(screen.getByTestId("session-menu-toggle"));
|
||||
const alphaBtn = await screen.findByText(/Alpha/);
|
||||
await user.click(alphaBtn);
|
||||
|
||||
// Re-open menu (it closed after switch) and try rename → Beta (taken).
|
||||
await user.click(screen.getByTestId("session-menu-toggle"));
|
||||
await user.click(screen.getByTestId("session-menu-rename"));
|
||||
const input = screen.getByTestId("session-rename-input") as HTMLInputElement;
|
||||
// Clear existing value and type new
|
||||
await user.clear(input);
|
||||
await user.type(input, "Beta");
|
||||
await user.click(screen.getByTestId("session-rename-confirm"));
|
||||
const error = await screen.findByTestId("session-menu-error");
|
||||
expect(error.textContent).toMatch(/already exists/);
|
||||
});
|
||||
});
|
||||
362
src/app/sessions/SessionMenu.tsx
Normal file
362
src/app/sessions/SessionMenu.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* SessionMenu — top-bar dropdown that drives the SessionService.
|
||||
*
|
||||
* Holds the only place in the UI where sessions get created, renamed,
|
||||
* deleted, and switched. Export/Import ZIP menu items are slots —
|
||||
* T06/T07 wire them.
|
||||
*
|
||||
* Switching sessions writes the new id into the URL hash; the routing
|
||||
* layer is the source of truth (see `routing.ts`). That keeps deep
|
||||
* links + browser back/forward behaving naturally.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import type { Session } from "@shared/session";
|
||||
import { useActiveSession, useSessionListTick, useSessionService } from "@work/index";
|
||||
|
||||
import { navigateTo } from "./routing";
|
||||
|
||||
interface SessionMenuProps {
|
||||
readonly onExportZip?: () => void;
|
||||
readonly onImportZip?: () => void;
|
||||
readonly onOpenSamples?: () => void;
|
||||
}
|
||||
|
||||
export function SessionMenu({ onExportZip, onImportZip, onOpenSamples }: SessionMenuProps) {
|
||||
const service = useSessionService();
|
||||
const tick = useSessionListTick();
|
||||
const active = useActiveSession();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [pendingDelete, setPendingDelete] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const sessions = useMemo(() => {
|
||||
// sorted by lastOpenedAt desc, then by createdAt desc
|
||||
void tick;
|
||||
const list = [...service.list()];
|
||||
list.sort((a: Session, b: Session) => {
|
||||
const aKey = a.lastOpenedAt ?? a.createdAt;
|
||||
const bKey = b.lastOpenedAt ?? b.createdAt;
|
||||
return bKey.localeCompare(aKey);
|
||||
});
|
||||
return list;
|
||||
}, [service, tick]);
|
||||
|
||||
// Click outside closes the menu.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (!wrapperRef.current) return;
|
||||
if (!wrapperRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
setCreating(false);
|
||||
setRenaming(false);
|
||||
setPendingDelete(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("mousedown", handler);
|
||||
return () => window.removeEventListener("mousedown", handler);
|
||||
}, [open]);
|
||||
|
||||
const switchTo = useCallback(
|
||||
(sessionId: import("@shared/ids").SessionId) => {
|
||||
navigateTo({ sessionId, mode: "review" });
|
||||
setOpen(false);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
setError(null);
|
||||
try {
|
||||
const created = service.create(newName);
|
||||
setNewName("");
|
||||
setCreating(false);
|
||||
setOpen(false);
|
||||
navigateTo({ sessionId: created.id, mode: "review" });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [newName, service]);
|
||||
|
||||
const handleRename = useCallback(() => {
|
||||
if (!active) return;
|
||||
setError(null);
|
||||
try {
|
||||
service.rename(active.id, renameValue);
|
||||
setRenaming(false);
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [active, renameValue, service]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!active) return;
|
||||
if (!pendingDelete) {
|
||||
setPendingDelete(true);
|
||||
return;
|
||||
}
|
||||
service.delete(active.id);
|
||||
setPendingDelete(false);
|
||||
setOpen(false);
|
||||
navigateTo({ sessionId: null, mode: "review" });
|
||||
}, [active, pendingDelete, service]);
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} style={{ position: "relative" }} data-testid="session-menu">
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
data-testid="session-menu-toggle"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
padding: "4px 10px",
|
||||
border: "1px solid #888",
|
||||
background: "white",
|
||||
cursor: "pointer",
|
||||
minWidth: 160,
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
{active ? active.name : "No session"}
|
||||
<span style={{ float: "right", color: "#888" }}>▾</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
data-testid="session-menu-panel"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 28,
|
||||
left: 0,
|
||||
zIndex: 30,
|
||||
background: "white",
|
||||
border: "1px solid #888",
|
||||
borderRadius: 3,
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
||||
padding: 4,
|
||||
minWidth: 240,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{sessions.length > 0 && (
|
||||
<>
|
||||
<div style={{ padding: "4px 8px", color: "#666", fontSize: 11 }}>
|
||||
Switch to…
|
||||
</div>
|
||||
{sessions.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid={`session-switch-${s.id}`}
|
||||
onClick={() => switchTo(s.id)}
|
||||
style={{
|
||||
...menuItemStyle,
|
||||
background: active?.id === s.id ? "#e8f0ff" : "transparent",
|
||||
}}
|
||||
>
|
||||
{s.name}
|
||||
{active?.id === s.id ? " · open" : ""}
|
||||
</button>
|
||||
))}
|
||||
<hr style={dividerStyle} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{!creating && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="session-menu-new"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setCreating(true);
|
||||
setNewName("");
|
||||
}}
|
||||
style={menuItemStyle}
|
||||
>
|
||||
New session…
|
||||
</button>
|
||||
)}
|
||||
{creating && (
|
||||
<div style={{ padding: 4, display: "flex", gap: 4 }}>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="Session name"
|
||||
data-testid="session-new-input"
|
||||
style={{ flex: 1, fontSize: 12, padding: 4 }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleCreate();
|
||||
if (e.key === "Escape") setCreating(false);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
data-testid="session-new-confirm"
|
||||
style={smallButtonStyle}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{active && (
|
||||
<>
|
||||
<hr style={dividerStyle} />
|
||||
{!renaming && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="session-menu-rename"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setRenaming(true);
|
||||
setRenameValue(active.name);
|
||||
}}
|
||||
style={menuItemStyle}
|
||||
>
|
||||
Rename…
|
||||
</button>
|
||||
)}
|
||||
{renaming && (
|
||||
<div style={{ padding: 4, display: "flex", gap: 4 }}>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
data-testid="session-rename-input"
|
||||
style={{ flex: 1, fontSize: 12, padding: 4 }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleRename();
|
||||
if (e.key === "Escape") setRenaming(false);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRename}
|
||||
data-testid="session-rename-confirm"
|
||||
style={smallButtonStyle}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="session-menu-delete"
|
||||
onClick={handleDelete}
|
||||
style={{ ...menuItemStyle, color: "#7a0000" }}
|
||||
>
|
||||
{pendingDelete ? "Confirm delete?" : "Delete…"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(onExportZip || onImportZip || onOpenSamples) && (
|
||||
<hr style={dividerStyle} />
|
||||
)}
|
||||
{onExportZip && active && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="session-menu-export"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onExportZip();
|
||||
}}
|
||||
style={menuItemStyle}
|
||||
>
|
||||
Export ZIP
|
||||
</button>
|
||||
)}
|
||||
{onImportZip && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="session-menu-import"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onImportZip();
|
||||
}}
|
||||
style={menuItemStyle}
|
||||
>
|
||||
Import ZIP
|
||||
</button>
|
||||
)}
|
||||
{onOpenSamples && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="session-menu-samples"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onOpenSamples();
|
||||
}}
|
||||
style={menuItemStyle}
|
||||
>
|
||||
Sample sessions ▸
|
||||
</button>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
data-testid="session-menu-error"
|
||||
style={{
|
||||
padding: 6,
|
||||
background: "#fff4f4",
|
||||
color: "#7a0000",
|
||||
fontSize: 11,
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const menuItemStyle: CSSProperties = {
|
||||
display: "block",
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
padding: "4px 8px",
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
const smallButtonStyle: CSSProperties = {
|
||||
fontSize: 12,
|
||||
padding: "2px 8px",
|
||||
border: "1px solid #888",
|
||||
background: "white",
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const dividerStyle: CSSProperties = {
|
||||
border: "none",
|
||||
borderTop: "1px solid #eee",
|
||||
margin: "4px 0",
|
||||
};
|
||||
94
src/app/sessions/Toast.tsx
Normal file
94
src/app/sessions/Toast.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Small reusable toast for session-scoped messages.
|
||||
*
|
||||
* Mirrors the CE-WP-0004 EvidenceSidebar pattern. Used by SessionMenu
|
||||
* for "no such session" redirects, by T06 for export success/error,
|
||||
* and by T07 for import results.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export type ToastTone = "success" | "error" | "info";
|
||||
|
||||
export interface ToastApi {
|
||||
show(message: string, tone?: ToastTone): void;
|
||||
dismiss(): void;
|
||||
}
|
||||
|
||||
export interface ToastProps {
|
||||
readonly toast: { readonly message: string; readonly tone: ToastTone; readonly key: number } | null;
|
||||
readonly onDismiss: () => void;
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
|
||||
export function Toast({ toast, onDismiss, timeoutMs = 3500 }: ToastProps) {
|
||||
useEffect(() => {
|
||||
if (!toast) return;
|
||||
const t = setTimeout(onDismiss, timeoutMs);
|
||||
return () => clearTimeout(t);
|
||||
}, [toast, onDismiss, timeoutMs]);
|
||||
if (!toast) return null;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="session-toast"
|
||||
data-tone={toast.tone}
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
zIndex: 50,
|
||||
padding: "8px 12px",
|
||||
fontSize: 12,
|
||||
background:
|
||||
toast.tone === "success"
|
||||
? "#d6f0d6"
|
||||
: toast.tone === "error"
|
||||
? "#f9d6d6"
|
||||
: "#e0e8f5",
|
||||
color:
|
||||
toast.tone === "success"
|
||||
? "#0a5a0a"
|
||||
: toast.tone === "error"
|
||||
? "#7a0000"
|
||||
: "#003a7a",
|
||||
border: `1px solid ${
|
||||
toast.tone === "success"
|
||||
? "#0a5a0a"
|
||||
: toast.tone === "error"
|
||||
? "#7a0000"
|
||||
: "#003a7a"
|
||||
}`,
|
||||
borderRadius: 3,
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
}}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast(): {
|
||||
toast: { message: string; tone: ToastTone; key: number } | null;
|
||||
show(message: string, tone?: ToastTone): void;
|
||||
dismiss(): void;
|
||||
} {
|
||||
const [toast, setToast] = useState<{ message: string; tone: ToastTone; key: number } | null>(
|
||||
null,
|
||||
);
|
||||
const [, setCounter] = useState(0);
|
||||
return {
|
||||
toast,
|
||||
show(message, tone = "info") {
|
||||
setCounter((c) => {
|
||||
const next = c + 1;
|
||||
setToast({ message, tone, key: next });
|
||||
return next;
|
||||
});
|
||||
},
|
||||
dismiss() {
|
||||
setToast(null);
|
||||
},
|
||||
};
|
||||
}
|
||||
93
src/app/sessions/UploadDropzone.dom.test.tsx
Normal file
93
src/app/sessions/UploadDropzone.dom.test.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Document, DocumentRepresentation } from "@shared/document";
|
||||
import type { DocumentId, RepresentationId } from "@shared/ids";
|
||||
|
||||
import { EngineProvider } from "@work/index";
|
||||
|
||||
import { UploadDropzone } from "./UploadDropzone";
|
||||
|
||||
// Bypass PDF.js extraction in this DOM test. Mock `ingestPdfFromFile`
|
||||
// (the entry point the dropzone calls) so it stamps a synthetic
|
||||
// document onto the byte store without ever opening pdfjs.
|
||||
vi.mock("@source/index", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("@source/index")>();
|
||||
return {
|
||||
...original,
|
||||
ingestPdfFromFile: vi.fn(
|
||||
async (file: File | Blob, store: import("@source/index").PdfByteStore) => {
|
||||
const filename =
|
||||
"name" in file && typeof file.name === "string" ? file.name : "uploaded.pdf";
|
||||
const documentId = ("doc_test_" + Math.random().toString(36).slice(2, 10)) as DocumentId;
|
||||
const representationId = ("rep_test_" +
|
||||
Math.random().toString(36).slice(2, 10)) as RepresentationId;
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
const record = store.put(documentId, bytes);
|
||||
const document: Document = {
|
||||
id: documentId,
|
||||
mediaType: "application/pdf",
|
||||
title: filename,
|
||||
uri: record.blobUrl,
|
||||
fingerprint: `synthetic-${documentId}`,
|
||||
createdAt: "2026-05-25T00:00:00.000Z",
|
||||
updatedAt: "2026-05-25T00:00:00.000Z",
|
||||
};
|
||||
const representation: DocumentRepresentation = {
|
||||
id: representationId,
|
||||
documentId,
|
||||
representationType: "pdf-text",
|
||||
contentHash: `synthetic-${documentId}`,
|
||||
canonicalText: "synthetic body",
|
||||
pageMap: [{ page: 1, width: 595, height: 842 }],
|
||||
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 14, pageLength: 14 }],
|
||||
generatedAt: "2026-05-25T00:00:00.000Z",
|
||||
};
|
||||
return { document, representation };
|
||||
},
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.localStorage?.clear();
|
||||
// happy-dom's URL.createObjectURL returns blob:null/...; that's fine for tests.
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("UploadDropzone", () => {
|
||||
it(
|
||||
"ingests a dropped PDF and reports a 'done' progress entry",
|
||||
{ timeout: 10000 },
|
||||
async () => {
|
||||
render(
|
||||
<EngineProvider>
|
||||
<UploadDropzone />
|
||||
</EngineProvider>,
|
||||
);
|
||||
|
||||
// happy-dom doesn't synthesise drag events well, so go through the
|
||||
// file input — same processFiles path either way.
|
||||
const input = screen.getByTestId("upload-file-input") as HTMLInputElement;
|
||||
const bytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
|
||||
const file = new File([bytes], "demo.pdf", { type: "application/pdf" });
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.upload(input, file);
|
||||
|
||||
await waitFor(() => {
|
||||
const items = screen.getByTestId("upload-progress").querySelectorAll("li");
|
||||
expect(items.length).toBe(1);
|
||||
expect(items[0]?.getAttribute("data-status")).toBe("done");
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
});
|
||||
189
src/app/sessions/UploadDropzone.tsx
Normal file
189
src/app/sessions/UploadDropzone.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* UploadDropzone — drag-drop + file-picker for uploading PDFs into the
|
||||
* active session.
|
||||
*
|
||||
* On every successful drop:
|
||||
* 1. read each File as bytes,
|
||||
* 2. run the source-layer `ingestPdfFromFile` (mints the blob URL
|
||||
* via the session's `PdfByteStore`),
|
||||
* 3. register the resulting `{document, representation}` with the
|
||||
* engine,
|
||||
* 4. activate the most-recently-uploaded document.
|
||||
*
|
||||
* Failures (non-PDFs, ingest errors) are surfaced inline above the
|
||||
* dropzone; the caller doesn't need a separate toast for them.
|
||||
*/
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
import { ingestPdfFromFile } from "@source/index";
|
||||
import {
|
||||
useActiveDocumentId,
|
||||
useEngine,
|
||||
usePdfByteStore,
|
||||
} from "@work/index";
|
||||
|
||||
interface UploadEntry {
|
||||
readonly file: File;
|
||||
status: "queued" | "uploading" | "done" | "error";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface UploadDropzoneProps {
|
||||
/** Optional callback fired after each successful upload. */
|
||||
readonly onUploaded?: (documentId: import("@shared/ids").DocumentId) => void;
|
||||
}
|
||||
|
||||
export function UploadDropzone({ onUploaded }: UploadDropzoneProps) {
|
||||
const engine = useEngine();
|
||||
const byteStore = usePdfByteStore();
|
||||
const { setId } = useActiveDocumentId();
|
||||
const [entries, setEntries] = useState<readonly UploadEntry[]>([]);
|
||||
const [isOver, setIsOver] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const processFiles = useCallback(
|
||||
async (files: readonly File[]) => {
|
||||
if (files.length === 0) return;
|
||||
const initial: UploadEntry[] = files.map((file) => {
|
||||
const isPdf =
|
||||
file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf");
|
||||
if (isPdf) return { file, status: "queued" };
|
||||
return {
|
||||
file,
|
||||
status: "error",
|
||||
error: "Not a PDF (only application/pdf accepted)",
|
||||
};
|
||||
});
|
||||
setEntries((prev) => [...prev, ...initial]);
|
||||
|
||||
let lastDocumentId: import("@shared/ids").DocumentId | null = null;
|
||||
for (const entry of initial) {
|
||||
if (entry.status === "error") continue;
|
||||
entry.status = "uploading";
|
||||
setEntries((prev) => [...prev]);
|
||||
try {
|
||||
const { document, representation } = await ingestPdfFromFile(
|
||||
entry.file,
|
||||
byteStore,
|
||||
);
|
||||
engine.documents.register({ document, representation });
|
||||
entry.status = "done";
|
||||
lastDocumentId = document.id;
|
||||
onUploaded?.(document.id);
|
||||
} catch (err) {
|
||||
entry.status = "error";
|
||||
entry.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
setEntries((prev) => [...prev]);
|
||||
}
|
||||
if (lastDocumentId) setId(lastDocumentId);
|
||||
},
|
||||
[byteStore, engine, onUploaded, setId],
|
||||
);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsOver(false);
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
void processFiles(files);
|
||||
},
|
||||
[processFiles],
|
||||
);
|
||||
|
||||
const onDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsOver(true);
|
||||
}, []);
|
||||
|
||||
const onDragLeave = useCallback(() => {
|
||||
setIsOver(false);
|
||||
}, []);
|
||||
|
||||
const openPicker = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const onPicked = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files ? Array.from(e.target.files) : [];
|
||||
void processFiles(files);
|
||||
// Reset so the same filename can be picked again.
|
||||
e.target.value = "";
|
||||
},
|
||||
[processFiles],
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid="upload-dropzone">
|
||||
<div
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
role="region"
|
||||
aria-label="PDF upload"
|
||||
style={{
|
||||
border: `2px dashed ${isOver ? "#0050b3" : "#bbb"}`,
|
||||
background: isOver ? "#e8f0ff" : "#fafafa",
|
||||
padding: 16,
|
||||
textAlign: "center",
|
||||
fontSize: 12,
|
||||
color: "#555",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<div>Drop PDF files here</div>
|
||||
<div style={{ margin: "6px 0", color: "#888" }}>or</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openPicker}
|
||||
data-testid="upload-pick-button"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
padding: "4px 10px",
|
||||
border: "1px solid #888",
|
||||
background: "white",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Choose PDF…
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/pdf,.pdf"
|
||||
multiple
|
||||
onChange={onPicked}
|
||||
style={{ display: "none" }}
|
||||
data-testid="upload-file-input"
|
||||
/>
|
||||
</div>
|
||||
{entries.length > 0 && (
|
||||
<ul
|
||||
data-testid="upload-progress"
|
||||
style={{ listStyle: "none", padding: 0, margin: "8px 0 0", fontSize: 11 }}
|
||||
>
|
||||
{entries.map((entry, i) => (
|
||||
<li
|
||||
key={`${entry.file.name}-${i}`}
|
||||
data-status={entry.status}
|
||||
style={{
|
||||
padding: "2px 4px",
|
||||
color:
|
||||
entry.status === "error"
|
||||
? "#7a0000"
|
||||
: entry.status === "done"
|
||||
? "#0a5a0a"
|
||||
: "#333",
|
||||
}}
|
||||
>
|
||||
{entry.file.name} — {entry.status}
|
||||
{entry.error ? `: ${entry.error}` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
154
src/app/sessions/exportSessionZip.test.ts
Normal file
154
src/app/sessions/exportSessionZip.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Round-trip an exported session through JSZip and assert the
|
||||
* archive matches ADR-0008 (manifest + per-document PDF bytes).
|
||||
*/
|
||||
|
||||
import JSZip from "jszip";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createEngine } from "@engine/index";
|
||||
import type { DocumentId, RepresentationId, SessionId } from "@shared/ids";
|
||||
import type { Session } from "@shared/session";
|
||||
import { parseSessionArchiveManifest } from "@shared/session-archive";
|
||||
import { createPdfByteStore } from "@source/index";
|
||||
|
||||
import { exportSessionZip, sessionZipFilename } from "./exportSessionZip";
|
||||
|
||||
function makeSession(id: string, name: string): Session {
|
||||
return {
|
||||
id: id as SessionId,
|
||||
name,
|
||||
createdAt: "2026-05-25T00:00:00.000Z",
|
||||
updatedAt: "2026-05-25T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("exportSessionZip", () => {
|
||||
it("produces a ZIP with manifest.json + documents/<id>.pdf for each binding", async () => {
|
||||
const engine = createEngine();
|
||||
const byteStore = createPdfByteStore({
|
||||
createObjectURL: () => "blob:test-1",
|
||||
revokeObjectURL: () => {},
|
||||
});
|
||||
|
||||
const docId = "doc_test" as DocumentId;
|
||||
const repId = "rep_test" as RepresentationId;
|
||||
const bytes = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d]); // %PDF-
|
||||
byteStore.put(docId, bytes);
|
||||
engine.documents.register({
|
||||
document: {
|
||||
id: docId,
|
||||
mediaType: "application/pdf",
|
||||
title: "demo.pdf",
|
||||
fingerprint: "fingerprint-abc",
|
||||
createdAt: "2026-05-25T00:00:00.000Z",
|
||||
updatedAt: "2026-05-25T00:00:00.000Z",
|
||||
},
|
||||
representation: {
|
||||
id: repId,
|
||||
documentId: docId,
|
||||
representationType: "pdf-text",
|
||||
contentHash: "fingerprint-abc",
|
||||
canonicalText: "Quoted passage.",
|
||||
pageMap: [{ page: 1, width: 595, height: 842 }],
|
||||
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 15, pageLength: 15 }],
|
||||
generatedAt: "2026-05-25T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
// Add an annotation + evidence item so the snapshot exercises that path.
|
||||
const ann = engine.annotations.create({
|
||||
documentId: docId,
|
||||
representationId: repId,
|
||||
quote: "Quoted",
|
||||
selectors: [{ type: "TextQuoteSelector", exact: "Quoted" }],
|
||||
});
|
||||
engine.evidence.create({ annotationIds: [ann.id], commentary: "hi" });
|
||||
|
||||
const session = makeSession("sess_x", "Demo session");
|
||||
const blob = await exportSessionZip(engine, byteStore, session, {
|
||||
exportedAt: "2026-05-25T12:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(blob.size).toBeGreaterThan(0);
|
||||
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||
expect(zip.file("manifest.json")).not.toBeNull();
|
||||
expect(zip.file(`documents/${docId}.pdf`)).not.toBeNull();
|
||||
|
||||
const manifestText = await zip.file("manifest.json")!.async("string");
|
||||
const manifest = parseSessionArchiveManifest(JSON.parse(manifestText));
|
||||
expect(manifest.schemaVersion).toBe(1);
|
||||
expect(manifest.session.id).toBe("sess_x");
|
||||
expect(manifest.session.name).toBe("Demo session");
|
||||
expect(manifest.documentBindings).toHaveLength(1);
|
||||
expect(manifest.documentBindings[0]).toMatchObject({
|
||||
documentId: docId,
|
||||
filename: "demo.pdf",
|
||||
fingerprint: "fingerprint-abc",
|
||||
});
|
||||
expect(manifest.engine.documents).toHaveLength(1);
|
||||
expect(manifest.engine.representations).toHaveLength(1);
|
||||
expect(manifest.engine.annotations).toHaveLength(1);
|
||||
expect(manifest.engine.evidenceItems).toHaveLength(1);
|
||||
|
||||
const storedBytes = await zip.file(`documents/${docId}.pdf`)!.async("uint8array");
|
||||
expect(Array.from(storedBytes)).toEqual(Array.from(bytes));
|
||||
});
|
||||
|
||||
it("skips the binary file when the byte store has no bytes for a document", async () => {
|
||||
const engine = createEngine();
|
||||
const byteStore = createPdfByteStore({
|
||||
createObjectURL: () => "blob:test-noop",
|
||||
revokeObjectURL: () => {},
|
||||
});
|
||||
const docId = "doc_no_bytes" as DocumentId;
|
||||
engine.documents.register({
|
||||
document: {
|
||||
id: docId,
|
||||
mediaType: "application/pdf",
|
||||
title: "ghost.pdf",
|
||||
fingerprint: "ghost-fp",
|
||||
createdAt: "2026-05-25T00:00:00.000Z",
|
||||
updatedAt: "2026-05-25T00:00:00.000Z",
|
||||
},
|
||||
representation: {
|
||||
id: "rep_no_bytes" as RepresentationId,
|
||||
documentId: docId,
|
||||
representationType: "pdf-text",
|
||||
contentHash: "ghost-fp",
|
||||
canonicalText: "",
|
||||
pageMap: [],
|
||||
offsetMap: [],
|
||||
generatedAt: "2026-05-25T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
const blob = await exportSessionZip(
|
||||
engine,
|
||||
byteStore,
|
||||
makeSession("sess_nb", "No Bytes"),
|
||||
);
|
||||
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
||||
expect(zip.file(`documents/${docId}.pdf`)).toBeNull();
|
||||
const manifestText = await zip.file("manifest.json")!.async("string");
|
||||
const manifest = parseSessionArchiveManifest(JSON.parse(manifestText));
|
||||
expect(manifest.documentBindings).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionZipFilename", () => {
|
||||
it("slugifies the session name and stamps the date in UTC", () => {
|
||||
const session = makeSession("sess_a", "Lease — 2024 / München!");
|
||||
const fixed = new Date(Date.UTC(2026, 4, 25, 14, 7));
|
||||
expect(sessionZipFilename(session, fixed)).toBe("lease-2024-m-nchen-20260525-1407.zip");
|
||||
});
|
||||
|
||||
it("falls back to 'session' when slugification produces empty string", () => {
|
||||
const session = makeSession("sess_x", "!!!");
|
||||
const fixed = new Date(Date.UTC(2026, 0, 1));
|
||||
expect(sessionZipFilename(session, fixed)).toBe("session-20260101-0000.zip");
|
||||
});
|
||||
});
|
||||
|
||||
148
src/app/sessions/exportSessionZip.ts
Normal file
148
src/app/sessions/exportSessionZip.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* `exportSessionZip` — pack a session's engine snapshot + uploaded PDF
|
||||
* bytes into a single `.zip` archive (ADR-0008 layout).
|
||||
*
|
||||
* Steps:
|
||||
* 1. Build the manifest from `captureSnapshot(engine)` + session
|
||||
* metadata + per-document `{filename, fingerprint}` derived from
|
||||
* `engine.documents`.
|
||||
* 2. For each binding, push `bytes` into `documents/<documentId>.pdf`.
|
||||
* 3. Push `manifest.json` (pretty-printed JSON).
|
||||
* 4. `zip.generateAsync({ type: "blob" })`.
|
||||
*
|
||||
* `triggerSessionDownload` creates an `<a download>` link and clicks
|
||||
* it. The filename is `<slug>-<isoDate>.zip` so two exports of the
|
||||
* same session don't collide on disk.
|
||||
*/
|
||||
|
||||
import JSZip from "jszip";
|
||||
|
||||
import { captureSnapshot, type Engine } from "@engine/index";
|
||||
import type { DocumentId } from "@shared/ids";
|
||||
import type { Session } from "@shared/session";
|
||||
import {
|
||||
SESSION_ARCHIVE_SCHEMA_VERSION,
|
||||
type SessionArchiveDocumentBinding,
|
||||
type SessionArchiveManifest,
|
||||
} from "@shared/session-archive";
|
||||
|
||||
import type { PdfByteStore } from "@source/index";
|
||||
|
||||
export interface ExportSessionZipOptions {
|
||||
/** Override the timestamp embedded in the manifest. */
|
||||
readonly exportedAt?: string;
|
||||
}
|
||||
|
||||
export async function exportSessionZip(
|
||||
engine: Engine,
|
||||
byteStore: PdfByteStore,
|
||||
session: Session,
|
||||
options: ExportSessionZipOptions = {},
|
||||
): Promise<Blob> {
|
||||
const snapshot = captureSnapshot(engine);
|
||||
const documents = engine.documents.list();
|
||||
|
||||
const bindings: SessionArchiveDocumentBinding[] = [];
|
||||
const zip = new JSZip();
|
||||
const documentsFolder = zip.folder("documents");
|
||||
if (!documentsFolder) {
|
||||
throw new Error("exportSessionZip: JSZip refused to create 'documents/' folder");
|
||||
}
|
||||
|
||||
for (const doc of documents) {
|
||||
const filename =
|
||||
doc.title ??
|
||||
(typeof doc.metadata?.["filename"] === "string"
|
||||
? (doc.metadata["filename"] as string)
|
||||
: `${doc.id}.pdf`);
|
||||
const fingerprint = doc.fingerprint ?? "";
|
||||
bindings.push({ documentId: doc.id, filename, fingerprint });
|
||||
const record = byteStore.get(doc.id);
|
||||
if (record) {
|
||||
documentsFolder.file(`${doc.id}.pdf`, record.bytes);
|
||||
}
|
||||
// If bytes are missing (e.g. fixture-loaded doc whose bytes weren't
|
||||
// pushed into the store), the manifest still lists the binding but
|
||||
// the binary is absent — the importer surfaces this as a warning
|
||||
// in T07.
|
||||
}
|
||||
|
||||
const manifest: SessionArchiveManifest = {
|
||||
schemaVersion: SESSION_ARCHIVE_SCHEMA_VERSION,
|
||||
exportedAt: options.exportedAt ?? new Date().toISOString(),
|
||||
session: {
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: session.updatedAt,
|
||||
},
|
||||
engine: snapshot,
|
||||
documentBindings: bindings,
|
||||
};
|
||||
|
||||
zip.file("manifest.json", JSON.stringify(manifest, null, 2));
|
||||
|
||||
return zip.generateAsync({ type: "blob" });
|
||||
}
|
||||
|
||||
export function sessionZipFilename(session: Session, now: Date = new Date()): string {
|
||||
const slug =
|
||||
session.name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "session";
|
||||
// YYYYMMDD-HHMM
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const stamp = `${now.getUTCFullYear()}${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())}-${pad(now.getUTCHours())}${pad(now.getUTCMinutes())}`;
|
||||
return `${slug}-${stamp}.zip`;
|
||||
}
|
||||
|
||||
export interface TriggerDownloadHooks {
|
||||
/** Override the `<a>` creation — used by tests to intercept the click. */
|
||||
readonly createAnchor?: () => HTMLAnchorElement;
|
||||
readonly createObjectURL?: (blob: Blob) => string;
|
||||
readonly revokeObjectURL?: (url: string) => void;
|
||||
}
|
||||
|
||||
export function triggerSessionDownload(
|
||||
blob: Blob,
|
||||
filename: string,
|
||||
hooks: TriggerDownloadHooks = {},
|
||||
): void {
|
||||
const createObjectURL =
|
||||
hooks.createObjectURL ??
|
||||
((b: Blob) => {
|
||||
if (typeof URL === "undefined" || typeof URL.createObjectURL !== "function") {
|
||||
throw new Error("triggerSessionDownload: URL.createObjectURL unavailable");
|
||||
}
|
||||
return URL.createObjectURL(b);
|
||||
});
|
||||
const revokeObjectURL =
|
||||
hooks.revokeObjectURL ??
|
||||
((url: string) => {
|
||||
if (typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
});
|
||||
const createAnchor =
|
||||
hooks.createAnchor ??
|
||||
(() => {
|
||||
if (typeof document === "undefined") {
|
||||
throw new Error("triggerSessionDownload: document is not available");
|
||||
}
|
||||
return document.createElement("a");
|
||||
});
|
||||
|
||||
const url = createObjectURL(blob);
|
||||
const a = createAnchor();
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
// Revoke after the click so the browser has a chance to start the download.
|
||||
setTimeout(() => revokeObjectURL(url), 1_000);
|
||||
}
|
||||
|
||||
// Re-export the DocumentId type so consumers can write
|
||||
// `exportSessionZip(...)` without an extra import. Tree-shakeable.
|
||||
export type { DocumentId };
|
||||
276
src/app/sessions/importSessionZip.test.ts
Normal file
276
src/app/sessions/importSessionZip.test.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createEngine,
|
||||
createEventBus,
|
||||
createInMemorySessionRepository,
|
||||
createSessionService,
|
||||
engineSnapshotKey,
|
||||
restoreFromStorage,
|
||||
type SessionService,
|
||||
} from "@engine/index";
|
||||
import type {
|
||||
DocumentId,
|
||||
RepresentationId,
|
||||
SessionId,
|
||||
} from "@shared/ids";
|
||||
import type { Session } from "@shared/session";
|
||||
import { createPdfByteStore, type PdfByteStore } from "@source/index";
|
||||
|
||||
import { exportSessionZip } from "./exportSessionZip";
|
||||
import {
|
||||
importSessionZip,
|
||||
SessionImportError,
|
||||
type ImportSessionServices,
|
||||
} from "./importSessionZip";
|
||||
|
||||
function memoryStorage(): Pick<Storage, "getItem" | "setItem" | "removeItem"> {
|
||||
const map = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k) => map.get(k) ?? null,
|
||||
setItem: (k, v) => void map.set(k, v),
|
||||
removeItem: (k) => void map.delete(k),
|
||||
};
|
||||
}
|
||||
|
||||
function makeService(): SessionService {
|
||||
const repo = createInMemorySessionRepository();
|
||||
const bus = createEventBus();
|
||||
return createSessionService(repo, bus);
|
||||
}
|
||||
|
||||
function freshStores() {
|
||||
const stores = new Map<SessionId, PdfByteStore>();
|
||||
return {
|
||||
stores,
|
||||
get(sessionId: SessionId): PdfByteStore {
|
||||
let s = stores.get(sessionId);
|
||||
if (!s) {
|
||||
s = createPdfByteStore({
|
||||
createObjectURL: () => `blob:t-${sessionId}-${Math.random()}`,
|
||||
revokeObjectURL: () => {},
|
||||
});
|
||||
stores.set(sessionId, s);
|
||||
}
|
||||
return s;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
service: SessionService;
|
||||
stores: ReturnType<typeof freshStores>["stores"];
|
||||
byteStoreFor(sessionId: SessionId): PdfByteStore;
|
||||
bumps: SessionId[];
|
||||
storage: ReturnType<typeof memoryStorage>;
|
||||
services: ImportSessionServices;
|
||||
}
|
||||
|
||||
function harness(): Harness {
|
||||
const service = makeService();
|
||||
const stores = freshStores();
|
||||
const bumps: SessionId[] = [];
|
||||
const storage = memoryStorage();
|
||||
return {
|
||||
service,
|
||||
stores: stores.stores,
|
||||
byteStoreFor: stores.get,
|
||||
bumps,
|
||||
storage,
|
||||
services: {
|
||||
sessionService: service,
|
||||
getOrCreateByteStore: stores.get,
|
||||
bumpSessionVersion: (id) => bumps.push(id),
|
||||
storage,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function seedAndExport(opts: {
|
||||
sessionName: string;
|
||||
storage: Pick<Storage, "getItem" | "setItem" | "removeItem">;
|
||||
}): Promise<{ blob: Blob; session: Session; docId: DocumentId }> {
|
||||
const engine = createEngine();
|
||||
const byteStore = createPdfByteStore({
|
||||
createObjectURL: () => "blob:src",
|
||||
revokeObjectURL: () => {},
|
||||
});
|
||||
const session: Session = {
|
||||
id: "sess_src" as SessionId,
|
||||
name: opts.sessionName,
|
||||
createdAt: "2026-05-25T00:00:00.000Z",
|
||||
updatedAt: "2026-05-25T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const docId = "doc_src" as DocumentId;
|
||||
const repId = "rep_src" as RepresentationId;
|
||||
const bytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
|
||||
byteStore.put(docId, bytes);
|
||||
engine.documents.register({
|
||||
document: {
|
||||
id: docId,
|
||||
mediaType: "application/pdf",
|
||||
title: "src.pdf",
|
||||
fingerprint: "fp-shared",
|
||||
createdAt: "2026-05-25T00:00:00.000Z",
|
||||
updatedAt: "2026-05-25T00:00:00.000Z",
|
||||
},
|
||||
representation: {
|
||||
id: repId,
|
||||
documentId: docId,
|
||||
representationType: "pdf-text",
|
||||
contentHash: "fp-shared",
|
||||
canonicalText: "The quote.",
|
||||
pageMap: [{ page: 1, width: 595, height: 842 }],
|
||||
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 10, pageLength: 10 }],
|
||||
generatedAt: "2026-05-25T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
const ann = engine.annotations.create({
|
||||
documentId: docId,
|
||||
representationId: repId,
|
||||
quote: "The quote.",
|
||||
selectors: [{ type: "TextQuoteSelector", exact: "The quote." }],
|
||||
});
|
||||
engine.evidence.create({ annotationIds: [ann.id], commentary: "important" });
|
||||
|
||||
const blob = await exportSessionZip(engine, byteStore, session);
|
||||
// The "blob" JSZip produces inside a node test isn't a real Blob —
|
||||
// re-pack as a fresh Blob over an ArrayBuffer so JSZip.loadAsync (in
|
||||
// the importer) can consume it.
|
||||
const buf = await blob.arrayBuffer();
|
||||
const portableBlob = new Blob([buf], { type: "application/zip" });
|
||||
// Silence unused-storage lint
|
||||
void opts.storage;
|
||||
return { blob: portableBlob, session, docId };
|
||||
}
|
||||
|
||||
describe("importSessionZip — create path", () => {
|
||||
it("imports a fresh session and stamps a new engine snapshot in storage", async () => {
|
||||
const h = harness();
|
||||
const { blob } = await seedAndExport({
|
||||
sessionName: "From Export",
|
||||
storage: h.storage,
|
||||
});
|
||||
|
||||
const result = await importSessionZip(blob, h.services);
|
||||
|
||||
expect(result.outcome).toBe("created");
|
||||
expect(result.sessionId).toMatch(/^sess_/);
|
||||
expect(result.stats.documentsAdded).toBe(1);
|
||||
expect(result.stats.documentsDeduped).toBe(0);
|
||||
expect(result.stats.annotationsAdded).toBe(1);
|
||||
expect(result.stats.evidenceAdded).toBe(1);
|
||||
|
||||
// The session record exists in the service.
|
||||
const created = h.service.get(result.sessionId);
|
||||
expect(created?.name).toBe("From Export");
|
||||
|
||||
// The engine snapshot was persisted to localStorage at the per-
|
||||
// session key.
|
||||
const raw = h.storage.getItem(engineSnapshotKey(result.sessionId));
|
||||
expect(raw).not.toBeNull();
|
||||
const restored = createEngine();
|
||||
restoreFromStorage(restored, {
|
||||
key: engineSnapshotKey(result.sessionId),
|
||||
storage: h.storage,
|
||||
});
|
||||
expect(restored.documents.list()).toHaveLength(1);
|
||||
expect(restored.annotations.listByDocument(restored.documents.list()[0]!.id)).toHaveLength(1);
|
||||
|
||||
// The byte store registry got the bytes.
|
||||
const bytesStore = h.byteStoreFor(result.sessionId);
|
||||
expect(bytesStore.list()).toHaveLength(1);
|
||||
|
||||
// setActive was called + version bumped.
|
||||
expect(h.service.getActive()).toBe(result.sessionId);
|
||||
expect(h.bumps).toContain(result.sessionId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("importSessionZip — merge path", () => {
|
||||
it("dedupes documents by fingerprint and adds annotations additively", async () => {
|
||||
const h = harness();
|
||||
// Pre-create a session with the same name + same fingerprint
|
||||
// document so the merge has something to dedupe against.
|
||||
const targetSession = h.service.create({ name: "Demo" });
|
||||
{
|
||||
const seedEngine = createEngine();
|
||||
const seedStore = h.byteStoreFor(targetSession.id);
|
||||
seedStore.put("doc_pre" as DocumentId, new Uint8Array([1]));
|
||||
seedEngine.documents.register({
|
||||
document: {
|
||||
id: "doc_pre" as DocumentId,
|
||||
mediaType: "application/pdf",
|
||||
title: "pre.pdf",
|
||||
fingerprint: "fp-shared",
|
||||
createdAt: "2026-05-25T00:00:00.000Z",
|
||||
updatedAt: "2026-05-25T00:00:00.000Z",
|
||||
},
|
||||
representation: {
|
||||
id: "rep_pre" as RepresentationId,
|
||||
documentId: "doc_pre" as DocumentId,
|
||||
representationType: "pdf-text",
|
||||
contentHash: "fp-shared",
|
||||
canonicalText: "x",
|
||||
pageMap: [],
|
||||
offsetMap: [],
|
||||
generatedAt: "2026-05-25T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
const seedSnap = await import("@engine/index").then((m) => m.captureSnapshot(seedEngine));
|
||||
h.storage.setItem(engineSnapshotKey(targetSession.id), JSON.stringify(seedSnap));
|
||||
}
|
||||
|
||||
const { blob } = await seedAndExport({
|
||||
sessionName: "Demo",
|
||||
storage: h.storage,
|
||||
});
|
||||
|
||||
const result = await importSessionZip(blob, h.services);
|
||||
|
||||
expect(result.outcome).toBe("merged-into");
|
||||
expect(result.sessionId).toBe(targetSession.id);
|
||||
expect(result.stats.documentsAdded).toBe(0);
|
||||
expect(result.stats.documentsDeduped).toBe(1);
|
||||
expect(result.stats.annotationsAdded).toBe(1);
|
||||
expect(result.stats.evidenceAdded).toBe(1);
|
||||
|
||||
// Re-load the snapshot — there should still be ONE document
|
||||
// (deduped), and the annotation/evidence we added are now visible
|
||||
// on that existing document.
|
||||
const restored = createEngine();
|
||||
restoreFromStorage(restored, {
|
||||
key: engineSnapshotKey(targetSession.id),
|
||||
storage: h.storage,
|
||||
});
|
||||
expect(restored.documents.list()).toHaveLength(1);
|
||||
expect(restored.documents.list()[0]!.id).toBe("doc_pre" as DocumentId);
|
||||
const annsOnDoc = restored.annotations.listByDocument("doc_pre" as DocumentId);
|
||||
expect(annsOnDoc).toHaveLength(1);
|
||||
expect(annsOnDoc[0]!.quote).toBe("The quote.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("importSessionZip — error path", () => {
|
||||
it("rejects an archive with a malformed manifest", async () => {
|
||||
const h = harness();
|
||||
// Build a minimal zip with a malformed manifest.
|
||||
const { default: JSZip } = await import("jszip");
|
||||
const zip = new JSZip();
|
||||
zip.file("manifest.json", JSON.stringify({ schemaVersion: 999, exportedAt: "x" }));
|
||||
const buf = await zip.generateAsync({ type: "arraybuffer" });
|
||||
const blob = new Blob([buf], { type: "application/zip" });
|
||||
await expect(importSessionZip(blob, h.services)).rejects.toThrow(SessionImportError);
|
||||
});
|
||||
|
||||
it("rejects an archive without a manifest", async () => {
|
||||
const h = harness();
|
||||
const { default: JSZip } = await import("jszip");
|
||||
const zip = new JSZip();
|
||||
zip.file("something-else.txt", "hello");
|
||||
const buf = await zip.generateAsync({ type: "arraybuffer" });
|
||||
const blob = new Blob([buf], { type: "application/zip" });
|
||||
await expect(importSessionZip(blob, h.services)).rejects.toThrow(/manifest\.json missing/);
|
||||
});
|
||||
});
|
||||
314
src/app/sessions/importSessionZip.ts
Normal file
314
src/app/sessions/importSessionZip.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* `importSessionZip` — read a session ZIP archive, dedupe documents by
|
||||
* fingerprint, additively merge annotations/evidence/links into the
|
||||
* target session. ADR-0008 is the authoritative spec.
|
||||
*
|
||||
* Target session resolution:
|
||||
* - If a session with the manifest's `session.name` exists (case
|
||||
* insensitive, matching SessionService rules), that's the target
|
||||
* and `outcome` is `"merged-into"`.
|
||||
* - Otherwise a fresh session is created with the imported name and
|
||||
* `outcome` is `"created"`.
|
||||
*
|
||||
* Per-archive document handling:
|
||||
* - SHA-256 fingerprint match against the target session's existing
|
||||
* documents → reuse the existing `documentId`, skip the binary,
|
||||
* record a remap.
|
||||
* - No match → mint a new branded `documentId`, push the bytes into
|
||||
* the target's byte store, register with the target's engine,
|
||||
* record the remap.
|
||||
*
|
||||
* Per-archive annotation/evidence/link handling:
|
||||
* - Always mint fresh ids; rewrite any `documentId` / `annotationId`
|
||||
* / `evidenceItemId` references via the remap.
|
||||
*
|
||||
* Known limitation: re-importing your own export creates duplicate
|
||||
* annotations (no idempotency). See ADR-0008 §"Known limitation" for
|
||||
* the planned `importBundleId` follow-up.
|
||||
*
|
||||
* The importer works against a *fresh* off-React `Engine` for the
|
||||
* target session and writes the resulting snapshot directly to
|
||||
* `localStorage` at `engineSnapshotKey(targetSession.id)`. Callers
|
||||
* then invoke `bumpSessionVersion(target.id)` to force the React
|
||||
* EngineProvider to remount + restore the new snapshot.
|
||||
*/
|
||||
|
||||
import JSZip from "jszip";
|
||||
|
||||
import type { Annotation } from "@shared/annotation";
|
||||
import type { Document, DocumentRepresentation } from "@shared/document";
|
||||
import type { EvidenceItem } from "@shared/evidence";
|
||||
import {
|
||||
newId,
|
||||
type AnnotationId,
|
||||
type DocumentId,
|
||||
type RepresentationId,
|
||||
type SessionId,
|
||||
} from "@shared/ids";
|
||||
import {
|
||||
parseSessionArchiveManifest,
|
||||
type SessionArchiveDocumentBinding,
|
||||
type SessionArchiveManifest,
|
||||
} from "@shared/session-archive";
|
||||
|
||||
import {
|
||||
captureSnapshot,
|
||||
createEngine,
|
||||
engineSnapshotKey,
|
||||
restoreFromStorage,
|
||||
type SessionService,
|
||||
} from "@engine/index";
|
||||
import type { PdfByteStore } from "@source/index";
|
||||
|
||||
export interface ImportSessionServices {
|
||||
readonly sessionService: SessionService;
|
||||
getOrCreateByteStore(sessionId: SessionId): PdfByteStore;
|
||||
bumpSessionVersion(sessionId: SessionId): void;
|
||||
/** Storage shim — defaults to globalThis.localStorage. */
|
||||
readonly storage?: Pick<Storage, "getItem" | "setItem" | "removeItem">;
|
||||
}
|
||||
|
||||
export type ImportOutcome = "created" | "merged-into";
|
||||
|
||||
export interface ImportSessionStats {
|
||||
readonly documentsAdded: number;
|
||||
readonly documentsDeduped: number;
|
||||
readonly annotationsAdded: number;
|
||||
readonly evidenceAdded: number;
|
||||
readonly linksAdded: number;
|
||||
}
|
||||
|
||||
export interface ImportSessionResult {
|
||||
readonly sessionId: SessionId;
|
||||
readonly outcome: ImportOutcome;
|
||||
readonly stats: ImportSessionStats;
|
||||
}
|
||||
|
||||
export class SessionImportError extends Error {
|
||||
constructor(message: string) {
|
||||
super(`Session import failed: ${message}`);
|
||||
this.name = "SessionImportError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function importSessionZip(
|
||||
file: File | Blob,
|
||||
services: ImportSessionServices,
|
||||
): Promise<ImportSessionResult> {
|
||||
const storage = services.storage ?? globalThis.localStorage;
|
||||
if (!storage) {
|
||||
throw new SessionImportError("no storage available");
|
||||
}
|
||||
|
||||
// 1. Open the zip + parse the manifest.
|
||||
const zip = await loadZip(file);
|
||||
const manifestEntry = zip.file("manifest.json");
|
||||
if (!manifestEntry) {
|
||||
throw new SessionImportError("manifest.json missing from archive");
|
||||
}
|
||||
let manifest: SessionArchiveManifest;
|
||||
try {
|
||||
const text = await manifestEntry.async("string");
|
||||
manifest = parseSessionArchiveManifest(JSON.parse(text));
|
||||
} catch (err) {
|
||||
throw new SessionImportError(
|
||||
err instanceof Error ? err.message : `manifest parse failed: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Read all binary files referenced by the manifest. We tolerate
|
||||
// missing files — they appear as 0 documents added for that binding.
|
||||
const incomingBytes = new Map<DocumentId, Uint8Array>();
|
||||
for (const binding of manifest.documentBindings) {
|
||||
const entry = zip.file(`documents/${binding.documentId}.pdf`);
|
||||
if (entry) {
|
||||
incomingBytes.set(binding.documentId, await entry.async("uint8array"));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Resolve target session.
|
||||
const matchingExisting = services.sessionService
|
||||
.list()
|
||||
.find((s) => s.name.trim().toLocaleLowerCase() === manifest.session.name.trim().toLocaleLowerCase());
|
||||
|
||||
let targetSessionId: SessionId;
|
||||
let outcome: ImportOutcome;
|
||||
if (matchingExisting) {
|
||||
targetSessionId = matchingExisting.id;
|
||||
outcome = "merged-into";
|
||||
} else {
|
||||
const created = services.sessionService.create({ name: manifest.session.name });
|
||||
targetSessionId = created.id;
|
||||
outcome = "created";
|
||||
}
|
||||
|
||||
// 4. Build an off-React engine for the target — populated either from
|
||||
// the target's existing snapshot (merge path) or empty (create path).
|
||||
const targetEngine = createEngine();
|
||||
if (outcome === "merged-into") {
|
||||
restoreFromStorage(targetEngine, {
|
||||
key: engineSnapshotKey(targetSessionId),
|
||||
storage,
|
||||
});
|
||||
}
|
||||
const targetByteStore = services.getOrCreateByteStore(targetSessionId);
|
||||
|
||||
// 5. Build the document remap.
|
||||
const docRemap = new Map<DocumentId, DocumentId>();
|
||||
const existingByFingerprint = new Map<string, DocumentId>();
|
||||
for (const doc of targetEngine.documents.list()) {
|
||||
if (doc.fingerprint) existingByFingerprint.set(doc.fingerprint, doc.id);
|
||||
}
|
||||
|
||||
let documentsAdded = 0;
|
||||
let documentsDeduped = 0;
|
||||
|
||||
const incomingDocs = manifest.engine.documents as readonly Document[];
|
||||
const incomingReps = manifest.engine.representations as readonly DocumentRepresentation[];
|
||||
|
||||
for (const binding of manifest.documentBindings) {
|
||||
const remappedExisting = existingByFingerprint.get(binding.fingerprint);
|
||||
if (remappedExisting) {
|
||||
docRemap.set(binding.documentId, remappedExisting);
|
||||
documentsDeduped += 1;
|
||||
continue;
|
||||
}
|
||||
const incomingDoc = incomingDocs.find((d) => d.id === binding.documentId);
|
||||
if (!incomingDoc) {
|
||||
// Manifest pointed to a binding without an engine record for it —
|
||||
// skip silently, matches the "tolerate missing files" rule.
|
||||
continue;
|
||||
}
|
||||
const newDocId = newId("document");
|
||||
const incomingDocReps = incomingReps.filter((r) => r.documentId === binding.documentId);
|
||||
// Push bytes into the byte store; mint a fresh blob URL on the way.
|
||||
const bytes = incomingBytes.get(binding.documentId);
|
||||
const blobUrl = bytes ? targetByteStore.put(newDocId, bytes).blobUrl : undefined;
|
||||
const newDoc: Document = {
|
||||
...incomingDoc,
|
||||
id: newDocId,
|
||||
...(blobUrl !== undefined ? { uri: blobUrl } : {}),
|
||||
};
|
||||
const newReps: DocumentRepresentation[] = incomingDocReps.map((rep) => ({
|
||||
...rep,
|
||||
id: newId("representation") as RepresentationId,
|
||||
documentId: newDocId,
|
||||
}));
|
||||
const firstRep = newReps[0];
|
||||
if (firstRep) {
|
||||
// Use the service for the first rep so events fire + dedup logic
|
||||
// in the repos runs. Extra reps go in via the repo directly.
|
||||
targetEngine.documents.register({ document: newDoc, representation: firstRep });
|
||||
for (let i = 1; i < newReps.length; i++) {
|
||||
targetEngine.repos.representations.create(newReps[i]!);
|
||||
}
|
||||
} else {
|
||||
// Engine snapshot somehow lacks a representation — push the doc
|
||||
// directly so the snapshot stays self-consistent.
|
||||
targetEngine.repos.documents.create(newDoc);
|
||||
}
|
||||
docRemap.set(binding.documentId, newDocId);
|
||||
documentsAdded += 1;
|
||||
}
|
||||
|
||||
// 6. Remap annotations.
|
||||
const annRemap = new Map<AnnotationId, AnnotationId>();
|
||||
let annotationsAdded = 0;
|
||||
const incomingAnns = manifest.engine.annotations as readonly Annotation[];
|
||||
for (const ann of incomingAnns) {
|
||||
const newDocId = docRemap.get(ann.documentId);
|
||||
if (!newDocId) continue; // orphan — no doc imported
|
||||
const newAnnId = newId("annotation");
|
||||
const newAnn: Annotation = {
|
||||
...ann,
|
||||
id: newAnnId,
|
||||
documentId: newDocId,
|
||||
};
|
||||
// Write through the repo + emit AnnotationCreated so any future
|
||||
// listeners (none in T07 itself) get the event. Mirrors the
|
||||
// snapshot-restore pattern.
|
||||
targetEngine.repos.annotations.create(newAnn);
|
||||
targetEngine.bus.emit({
|
||||
type: "AnnotationCreated",
|
||||
annotationId: newAnnId,
|
||||
annotation: newAnn,
|
||||
});
|
||||
annRemap.set(ann.id, newAnnId);
|
||||
annotationsAdded += 1;
|
||||
}
|
||||
|
||||
// 7. Remap evidence items.
|
||||
let evidenceAdded = 0;
|
||||
const incomingEvidence = manifest.engine.evidenceItems as readonly EvidenceItem[];
|
||||
for (const item of incomingEvidence) {
|
||||
const newAnnIds: AnnotationId[] = [];
|
||||
for (const aid of item.annotationIds) {
|
||||
const remapped = annRemap.get(aid);
|
||||
if (remapped) newAnnIds.push(remapped);
|
||||
}
|
||||
if (newAnnIds.length === 0) continue;
|
||||
const newEvId = newId("evidence");
|
||||
const newItem: EvidenceItem = {
|
||||
...item,
|
||||
id: newEvId,
|
||||
annotationIds: newAnnIds,
|
||||
};
|
||||
targetEngine.repos.evidenceItems.create(newItem);
|
||||
targetEngine.bus.emit({
|
||||
type: "EvidenceItemCreated",
|
||||
evidenceItemId: newEvId,
|
||||
evidenceItem: newItem,
|
||||
});
|
||||
evidenceAdded += 1;
|
||||
}
|
||||
|
||||
// 8. EvidenceLinks live on the binder, not the engine snapshot. The
|
||||
// schema-version-1 manifest does not carry them yet — `linksAdded`
|
||||
// stays 0 until a future ADR extends the snapshot.
|
||||
const linksAdded = 0;
|
||||
|
||||
// 9. Persist the merged snapshot directly to the per-session storage
|
||||
// key. The version bump (below) forces the EngineProvider to remount
|
||||
// and restore from there.
|
||||
const snapshot = captureSnapshot(targetEngine);
|
||||
try {
|
||||
storage.setItem(engineSnapshotKey(targetSessionId), JSON.stringify(snapshot));
|
||||
} catch (err) {
|
||||
throw new SessionImportError(
|
||||
`failed to persist target snapshot: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 10. Make the target active + bump its version so React picks up the
|
||||
// new state.
|
||||
services.sessionService.setActive(targetSessionId);
|
||||
services.bumpSessionVersion(targetSessionId);
|
||||
|
||||
return {
|
||||
sessionId: targetSessionId,
|
||||
outcome,
|
||||
stats: {
|
||||
documentsAdded,
|
||||
documentsDeduped,
|
||||
annotationsAdded,
|
||||
evidenceAdded,
|
||||
linksAdded,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function loadZip(file: File | Blob): Promise<JSZip> {
|
||||
try {
|
||||
// Convert to ArrayBuffer first — JSZip can't always consume a Blob
|
||||
// in Node (which the test runner uses), but ArrayBuffer is portable.
|
||||
const buf = await file.arrayBuffer();
|
||||
return await JSZip.loadAsync(buf);
|
||||
} catch (err) {
|
||||
throw new SessionImportError(
|
||||
`corrupt ZIP: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export the binding type for callers that want to inspect manifests.
|
||||
export type { SessionArchiveDocumentBinding };
|
||||
28
src/app/sessions/index.ts
Normal file
28
src/app/sessions/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export { UploadDropzone, type UploadDropzoneProps } from "./UploadDropzone";
|
||||
export { SampleSessions } from "./SampleSessions";
|
||||
export { SessionMenu } from "./SessionMenu";
|
||||
export { CreateFirstSession } from "./CreateFirstSession";
|
||||
export { Toast, useToast, type ToastTone } from "./Toast";
|
||||
export {
|
||||
EMPTY_ROUTE,
|
||||
navigateTo,
|
||||
parseRoute,
|
||||
serializeRoute,
|
||||
type AppMode,
|
||||
type AppRoute,
|
||||
} from "./routing";
|
||||
export {
|
||||
exportSessionZip,
|
||||
sessionZipFilename,
|
||||
triggerSessionDownload,
|
||||
type ExportSessionZipOptions,
|
||||
type TriggerDownloadHooks,
|
||||
} from "./exportSessionZip";
|
||||
export {
|
||||
importSessionZip,
|
||||
SessionImportError,
|
||||
type ImportOutcome,
|
||||
type ImportSessionResult,
|
||||
type ImportSessionServices,
|
||||
type ImportSessionStats,
|
||||
} from "./importSessionZip";
|
||||
51
src/app/sessions/routing.test.ts
Normal file
51
src/app/sessions/routing.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { SessionId } from "@shared/ids";
|
||||
|
||||
import { EMPTY_ROUTE, parseRoute, serializeRoute } from "./routing";
|
||||
|
||||
describe("routing.parseRoute", () => {
|
||||
it("returns the empty route for an empty hash", () => {
|
||||
expect(parseRoute("")).toEqual(EMPTY_ROUTE);
|
||||
expect(parseRoute("#")).toEqual(EMPTY_ROUTE);
|
||||
expect(parseRoute("#/")).toEqual(EMPTY_ROUTE);
|
||||
});
|
||||
|
||||
it("parses #/s/<id> as review mode for that session", () => {
|
||||
const route = parseRoute("#/s/sess_abc");
|
||||
expect(route.sessionId).toBe("sess_abc");
|
||||
expect(route.mode).toBe("review");
|
||||
});
|
||||
|
||||
it("parses #/s/<id>/forms/demo as forms mode", () => {
|
||||
const route = parseRoute("#/s/sess_xyz/forms/demo");
|
||||
expect(route.sessionId).toBe("sess_xyz");
|
||||
expect(route.mode).toBe("forms");
|
||||
});
|
||||
|
||||
it("treats legacy #/forms/demo as the empty route (session must be chosen first)", () => {
|
||||
expect(parseRoute("#/forms/demo")).toEqual(EMPTY_ROUTE);
|
||||
});
|
||||
|
||||
it("trims trailing slashes", () => {
|
||||
expect(parseRoute("#/s/sess_abc/")).toMatchObject({ sessionId: "sess_abc" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("routing.serializeRoute", () => {
|
||||
it("returns empty string for the empty route", () => {
|
||||
expect(serializeRoute(EMPTY_ROUTE)).toBe("");
|
||||
});
|
||||
|
||||
it("round-trips review mode", () => {
|
||||
const route = { sessionId: "sess_abc" as SessionId, mode: "review" as const };
|
||||
expect(serializeRoute(route)).toBe("#/s/sess_abc");
|
||||
expect(parseRoute(serializeRoute(route))).toEqual(route);
|
||||
});
|
||||
|
||||
it("round-trips forms mode", () => {
|
||||
const route = { sessionId: "sess_xyz" as SessionId, mode: "forms" as const };
|
||||
expect(serializeRoute(route)).toBe("#/s/sess_xyz/forms/demo");
|
||||
expect(parseRoute(serializeRoute(route))).toEqual(route);
|
||||
});
|
||||
});
|
||||
61
src/app/sessions/routing.ts
Normal file
61
src/app/sessions/routing.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Hash routing for the demo app.
|
||||
*
|
||||
* #/ → empty state ("create your first session")
|
||||
* #/s/<sessionId> → review mode, scoped to <sessionId>
|
||||
* #/s/<sessionId>/forms/demo → forms mode, scoped to <sessionId>
|
||||
*
|
||||
* The hash is the single source of truth for the active session and the
|
||||
* active mode. `SessionProvider.setActive(...)` is wired as a side
|
||||
* effect of hash changes so back/forward and deep links behave
|
||||
* naturally.
|
||||
*/
|
||||
|
||||
import type { SessionId } from "@shared/ids";
|
||||
|
||||
export type AppMode = "review" | "forms";
|
||||
|
||||
export interface AppRoute {
|
||||
readonly sessionId: SessionId | null;
|
||||
readonly mode: AppMode;
|
||||
}
|
||||
|
||||
export const EMPTY_ROUTE: AppRoute = { sessionId: null, mode: "review" };
|
||||
|
||||
export function parseRoute(hash: string): AppRoute {
|
||||
// Normalise: drop leading "#", trim any trailing slashes.
|
||||
const cleaned = hash.replace(/^#/, "").replace(/^\/+|\/+$/g, "");
|
||||
if (cleaned === "") return EMPTY_ROUTE;
|
||||
const parts = cleaned.split("/");
|
||||
if (parts.length >= 2 && parts[0] === "s") {
|
||||
const sessionId = parts[1]! as SessionId;
|
||||
const mode: AppMode =
|
||||
parts[2] === "forms" && parts[3] === "demo" ? "forms" : "review";
|
||||
return { sessionId, mode };
|
||||
}
|
||||
// Legacy `#/forms/demo` (pre-CE-WP-0005) maps to the empty state — the
|
||||
// user has to pick a session first.
|
||||
return EMPTY_ROUTE;
|
||||
}
|
||||
|
||||
export function serializeRoute(route: AppRoute): string {
|
||||
if (!route.sessionId) return "";
|
||||
const base = `#/s/${route.sessionId}`;
|
||||
return route.mode === "forms" ? `${base}/forms/demo` : base;
|
||||
}
|
||||
|
||||
export function navigateTo(route: AppRoute): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const target = serializeRoute(route);
|
||||
if (target === "") {
|
||||
// Clear the hash entirely so the URL stays clean.
|
||||
history.replaceState(null, "", window.location.pathname + window.location.search);
|
||||
// history.replaceState doesn't fire hashchange — dispatch one so
|
||||
// subscribers re-read.
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
return;
|
||||
}
|
||||
if (window.location.hash !== target) {
|
||||
window.location.hash = target;
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,9 @@ import type {
|
||||
EvidenceItemId,
|
||||
EvidenceLinkId,
|
||||
RepresentationId,
|
||||
SessionId,
|
||||
} from "@shared/ids";
|
||||
import type { Session } from "@shared/session";
|
||||
|
||||
export interface DocumentImportedEvent {
|
||||
readonly type: "DocumentImported";
|
||||
@@ -36,6 +38,11 @@ export interface DocumentRepresentationGeneratedEvent {
|
||||
readonly representation: DocumentRepresentation;
|
||||
}
|
||||
|
||||
export interface DocumentRemovedEvent {
|
||||
readonly type: "DocumentRemoved";
|
||||
readonly documentId: DocumentId;
|
||||
}
|
||||
|
||||
export interface AnnotationCreatedEvent {
|
||||
readonly type: "AnnotationCreated";
|
||||
readonly annotationId: AnnotationId;
|
||||
@@ -92,9 +99,34 @@ export interface FormFieldActivatedEvent {
|
||||
readonly previousTarget?: EvidenceTarget;
|
||||
}
|
||||
|
||||
export interface SessionCreatedEvent {
|
||||
readonly type: "SessionCreated";
|
||||
readonly sessionId: SessionId;
|
||||
readonly session: Session;
|
||||
}
|
||||
|
||||
export interface SessionRenamedEvent {
|
||||
readonly type: "SessionRenamed";
|
||||
readonly sessionId: SessionId;
|
||||
readonly session: Session;
|
||||
readonly previousName: string;
|
||||
}
|
||||
|
||||
export interface SessionDeletedEvent {
|
||||
readonly type: "SessionDeleted";
|
||||
readonly sessionId: SessionId;
|
||||
}
|
||||
|
||||
export interface SessionActivatedEvent {
|
||||
readonly type: "SessionActivated";
|
||||
readonly sessionId: SessionId | null;
|
||||
readonly previousSessionId: SessionId | null;
|
||||
}
|
||||
|
||||
export type EngineEvent =
|
||||
| DocumentImportedEvent
|
||||
| DocumentRepresentationGeneratedEvent
|
||||
| DocumentRemovedEvent
|
||||
| AnnotationCreatedEvent
|
||||
| AnnotationResolvedEvent
|
||||
| AnnotationResolutionFailedEvent
|
||||
@@ -103,7 +135,11 @@ export type EngineEvent =
|
||||
| EvidenceItemActivatedEvent
|
||||
| EvidenceLinkCreatedEvent
|
||||
| EvidenceLinkUpdatedEvent
|
||||
| FormFieldActivatedEvent;
|
||||
| FormFieldActivatedEvent
|
||||
| SessionCreatedEvent
|
||||
| SessionRenamedEvent
|
||||
| SessionDeletedEvent
|
||||
| SessionActivatedEvent;
|
||||
|
||||
export type EngineEventType = EngineEvent["type"];
|
||||
|
||||
|
||||
47
src/engine/repos/in-memory-sessions.ts
Normal file
47
src/engine/repos/in-memory-sessions.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* In-memory `Map`-backed `SessionRepository`.
|
||||
*
|
||||
* Sister to `in-memory.ts` but for `Session` objects. The session repo
|
||||
* lives *outside* the per-session `Engine` (one repo for the whole app;
|
||||
* each session's engine snapshot is keyed by `session.id`). Keeping it
|
||||
* in the same `engine/repos` directory keeps the storage layer
|
||||
* conventions together so the eventual ADR-0005 swap touches one
|
||||
* neighbourhood.
|
||||
*/
|
||||
|
||||
import type { SessionId } from "@shared/ids";
|
||||
import type { Session } from "@shared/session";
|
||||
|
||||
export interface SessionRepository {
|
||||
create(session: Session): Session;
|
||||
get(id: SessionId): Session | null;
|
||||
list(): readonly Session[];
|
||||
update(session: Session): Session;
|
||||
delete(id: SessionId): boolean;
|
||||
}
|
||||
|
||||
export function createInMemorySessionRepository(): SessionRepository {
|
||||
const sessions = new Map<SessionId, Session>();
|
||||
return {
|
||||
create(session) {
|
||||
sessions.set(session.id, session);
|
||||
return session;
|
||||
},
|
||||
get(id) {
|
||||
return sessions.get(id) ?? null;
|
||||
},
|
||||
list() {
|
||||
return [...sessions.values()];
|
||||
},
|
||||
update(session) {
|
||||
if (!sessions.has(session.id)) {
|
||||
throw new Error(`SessionRepository.update: unknown id ${session.id}`);
|
||||
}
|
||||
sessions.set(session.id, session);
|
||||
return session;
|
||||
},
|
||||
delete(id) {
|
||||
return sessions.delete(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -25,12 +25,14 @@ export interface DocumentRepository {
|
||||
get(id: DocumentId): Document | null;
|
||||
list(): readonly Document[];
|
||||
update(document: Document): Document;
|
||||
delete(id: DocumentId): boolean;
|
||||
}
|
||||
|
||||
export interface RepresentationRepository {
|
||||
create(representation: DocumentRepresentation): DocumentRepresentation;
|
||||
get(id: RepresentationId): DocumentRepresentation | null;
|
||||
listByDocument(documentId: DocumentId): readonly DocumentRepresentation[];
|
||||
deleteByDocument(documentId: DocumentId): number;
|
||||
}
|
||||
|
||||
export interface AnnotationRepository {
|
||||
@@ -82,6 +84,9 @@ export function createInMemoryRepos(): InMemoryRepos {
|
||||
documents.set(document.id, document);
|
||||
return document;
|
||||
},
|
||||
delete(id) {
|
||||
return documents.delete(id);
|
||||
},
|
||||
},
|
||||
representations: {
|
||||
create(representation) {
|
||||
@@ -98,6 +103,16 @@ export function createInMemoryRepos(): InMemoryRepos {
|
||||
}
|
||||
return out;
|
||||
},
|
||||
deleteByDocument(documentId) {
|
||||
let removed = 0;
|
||||
for (const [id, rep] of representations) {
|
||||
if (rep.documentId === documentId) {
|
||||
representations.delete(id);
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
},
|
||||
},
|
||||
annotations: {
|
||||
create(annotation) {
|
||||
|
||||
@@ -6,3 +6,7 @@ export {
|
||||
type AnnotationRepository,
|
||||
type EvidenceItemRepository,
|
||||
} from "./in-memory";
|
||||
export {
|
||||
createInMemorySessionRepository,
|
||||
type SessionRepository,
|
||||
} from "./in-memory-sessions";
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface DocumentService {
|
||||
list(): readonly Document[];
|
||||
getRepresentation(id: RepresentationId): DocumentRepresentation | null;
|
||||
listRepresentations(documentId: DocumentId): readonly DocumentRepresentation[];
|
||||
remove(id: DocumentId): boolean;
|
||||
}
|
||||
|
||||
export function createDocumentService(
|
||||
@@ -59,5 +60,15 @@ export function createDocumentService(
|
||||
listRepresentations(documentId) {
|
||||
return representations.listByDocument(documentId);
|
||||
},
|
||||
remove(id) {
|
||||
const existing = documents.get(id);
|
||||
if (!existing) return false;
|
||||
representations.deleteByDocument(id);
|
||||
const removed = documents.delete(id);
|
||||
if (removed) {
|
||||
bus.emit({ type: "DocumentRemoved", documentId: id });
|
||||
}
|
||||
return removed;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,3 +12,16 @@ export {
|
||||
type EvidenceService,
|
||||
type CreateEvidenceItemInput,
|
||||
} from "./evidence";
|
||||
export {
|
||||
ACTIVE_SESSION_KEY,
|
||||
attachSessionPersister,
|
||||
createSessionService,
|
||||
DuplicateSessionNameError,
|
||||
engineSnapshotKey,
|
||||
restoreSessionsFromStorage,
|
||||
SESSIONS_INDEX_KEY,
|
||||
type CreateSessionInput,
|
||||
type RestoreSessionsResult,
|
||||
type SessionPersisterOptions,
|
||||
type SessionService,
|
||||
} from "./sessions";
|
||||
|
||||
204
src/engine/services/sessions.test.ts
Normal file
204
src/engine/services/sessions.test.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { SessionId } from "@shared/ids";
|
||||
import type { Session } from "@shared/session";
|
||||
|
||||
import { createEventBus, type EventBus, type EngineEvent } from "../events";
|
||||
import {
|
||||
createInMemorySessionRepository,
|
||||
type SessionRepository,
|
||||
} from "../repos";
|
||||
import {
|
||||
ACTIVE_SESSION_KEY,
|
||||
attachSessionPersister,
|
||||
createSessionService,
|
||||
DuplicateSessionNameError,
|
||||
engineSnapshotKey,
|
||||
restoreSessionsFromStorage,
|
||||
SESSIONS_INDEX_KEY,
|
||||
type SessionService,
|
||||
} from "./sessions";
|
||||
|
||||
function memoryStorage(): Pick<Storage, "getItem" | "setItem" | "removeItem"> {
|
||||
const map = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k) => map.get(k) ?? null,
|
||||
setItem: (k, v) => void map.set(k, v),
|
||||
removeItem: (k) => void map.delete(k),
|
||||
};
|
||||
}
|
||||
|
||||
function freshService(): {
|
||||
service: SessionService;
|
||||
repo: SessionRepository;
|
||||
bus: EventBus;
|
||||
events: EngineEvent[];
|
||||
} {
|
||||
const repo = createInMemorySessionRepository();
|
||||
const bus = createEventBus();
|
||||
const events: EngineEvent[] = [];
|
||||
bus.onAny((e) => events.push(e));
|
||||
const service = createSessionService(repo, bus);
|
||||
return { service, repo, bus, events };
|
||||
}
|
||||
|
||||
describe("engineSnapshotKey", () => {
|
||||
it("formats as citation-evidence:session:<id>:engine-snapshot:v1", () => {
|
||||
expect(engineSnapshotKey("sess_abc" as SessionId)).toBe(
|
||||
"citation-evidence:session:sess_abc:engine-snapshot:v1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionService — lifecycle", () => {
|
||||
let s: ReturnType<typeof freshService>;
|
||||
beforeEach(() => {
|
||||
s = freshService();
|
||||
});
|
||||
|
||||
it("creates a session and emits SessionCreated", () => {
|
||||
const created = s.service.create("Lease 2024");
|
||||
expect(created.name).toBe("Lease 2024");
|
||||
expect(created.id).toMatch(/^sess_/);
|
||||
expect(s.events).toHaveLength(1);
|
||||
expect(s.events[0]!.type).toBe("SessionCreated");
|
||||
});
|
||||
|
||||
it("trims whitespace in names", () => {
|
||||
const created = s.service.create(" Trimmed ");
|
||||
expect(created.name).toBe("Trimmed");
|
||||
});
|
||||
|
||||
it("rejects empty names", () => {
|
||||
expect(() => s.service.create(" ")).toThrow(/must not be empty/);
|
||||
});
|
||||
|
||||
it("rejects case-insensitive duplicates", () => {
|
||||
s.service.create("Demo");
|
||||
expect(() => s.service.create("demo")).toThrow(DuplicateSessionNameError);
|
||||
expect(() => s.service.create(" Demo ")).toThrow(DuplicateSessionNameError);
|
||||
});
|
||||
|
||||
it("rename emits SessionRenamed with previousName", () => {
|
||||
const created = s.service.create("Old");
|
||||
s.events.length = 0;
|
||||
const renamed = s.service.rename(created.id, "New");
|
||||
expect(renamed.name).toBe("New");
|
||||
expect(s.events).toHaveLength(1);
|
||||
const evt = s.events[0]!;
|
||||
expect(evt.type).toBe("SessionRenamed");
|
||||
if (evt.type === "SessionRenamed") {
|
||||
expect(evt.previousName).toBe("Old");
|
||||
}
|
||||
});
|
||||
|
||||
it("rename rejects a duplicate (but allows renaming to your own current name)", () => {
|
||||
const a = s.service.create("Alpha");
|
||||
s.service.create("Beta");
|
||||
expect(() => s.service.rename(a.id, "Beta")).toThrow(DuplicateSessionNameError);
|
||||
// self-rename is fine
|
||||
const same = s.service.rename(a.id, "Alpha");
|
||||
expect(same.name).toBe("Alpha");
|
||||
});
|
||||
|
||||
it("delete emits SessionDeleted and clears active if it was the active one", () => {
|
||||
const created = s.service.create("To Delete");
|
||||
s.service.setActive(created.id);
|
||||
s.events.length = 0;
|
||||
const ok = s.service.delete(created.id);
|
||||
expect(ok).toBe(true);
|
||||
const types = s.events.map((e) => e.type);
|
||||
expect(types).toContain("SessionActivated"); // active cleared first
|
||||
expect(types).toContain("SessionDeleted");
|
||||
expect(s.service.getActive()).toBeNull();
|
||||
});
|
||||
|
||||
it("delete on an unknown id is a no-op (returns false, no events)", () => {
|
||||
const ok = s.service.delete("sess_missing" as SessionId);
|
||||
expect(ok).toBe(false);
|
||||
expect(s.events).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("setActive on an unknown id throws", () => {
|
||||
expect(() => s.service.setActive("sess_nope" as SessionId)).toThrow(/unknown session/);
|
||||
});
|
||||
|
||||
it("setActive emits SessionActivated with previousSessionId", () => {
|
||||
const a = s.service.create("A");
|
||||
const b = s.service.create("B");
|
||||
s.events.length = 0;
|
||||
s.service.setActive(a.id);
|
||||
s.service.setActive(b.id);
|
||||
const activated = s.events.filter((e) => e.type === "SessionActivated");
|
||||
expect(activated).toHaveLength(2);
|
||||
if (activated[1]!.type === "SessionActivated") {
|
||||
expect(activated[1]!.previousSessionId).toBe(a.id);
|
||||
expect(activated[1]!.sessionId).toBe(b.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("setActive to the same id is a no-op (no event)", () => {
|
||||
const a = s.service.create("A");
|
||||
s.service.setActive(a.id);
|
||||
s.events.length = 0;
|
||||
s.service.setActive(a.id);
|
||||
expect(s.events).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("setActive stamps lastOpenedAt", () => {
|
||||
const a = s.service.create("A");
|
||||
expect(a.lastOpenedAt).toBeUndefined();
|
||||
s.service.setActive(a.id);
|
||||
const reread = s.service.get(a.id);
|
||||
expect(reread?.lastOpenedAt).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("attachSessionPersister + restoreSessionsFromStorage", () => {
|
||||
it("round-trips a session index through storage", () => {
|
||||
const storage = memoryStorage();
|
||||
const src = freshService();
|
||||
attachSessionPersister(src.service, src.bus, { storage });
|
||||
|
||||
const a = src.service.create("Alpha");
|
||||
const b = src.service.create("Beta");
|
||||
src.service.setActive(b.id);
|
||||
|
||||
// Read-back into a fresh service.
|
||||
const dst = freshService();
|
||||
const result = restoreSessionsFromStorage(dst.repo, dst.service, { storage });
|
||||
expect(result.restored).toBe(true);
|
||||
expect(result.sessions.map((s: Session) => s.name).sort()).toEqual(["Alpha", "Beta"]);
|
||||
expect(result.activeSessionId).toBe(b.id);
|
||||
expect(dst.service.getActive()).toBe(b.id);
|
||||
// a is still in the repo too
|
||||
expect(dst.service.get(a.id)?.name).toBe("Alpha");
|
||||
});
|
||||
|
||||
it("returns {restored:false} when storage is empty", () => {
|
||||
const storage = memoryStorage();
|
||||
const dst = freshService();
|
||||
const result = restoreSessionsFromStorage(dst.repo, dst.service, { storage });
|
||||
expect(result.restored).toBe(false);
|
||||
expect(result.sessions).toHaveLength(0);
|
||||
expect(result.activeSessionId).toBeNull();
|
||||
});
|
||||
|
||||
it("delete clears both the index entry and the per-session snapshot key", () => {
|
||||
const storage = memoryStorage();
|
||||
const src = freshService();
|
||||
attachSessionPersister(src.service, src.bus, { storage });
|
||||
const created = src.service.create("Doomed");
|
||||
// Pretend an engine snapshot was written by the per-session persister.
|
||||
storage.setItem(engineSnapshotKey(created.id), "{}");
|
||||
|
||||
src.service.delete(created.id);
|
||||
|
||||
expect(storage.getItem(engineSnapshotKey(created.id))).toBeNull();
|
||||
const raw = storage.getItem(SESSIONS_INDEX_KEY);
|
||||
expect(raw).not.toBeNull();
|
||||
const parsed = JSON.parse(raw!);
|
||||
expect(parsed.sessions).toHaveLength(0);
|
||||
expect(storage.getItem(ACTIVE_SESSION_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
302
src/engine/services/sessions.ts
Normal file
302
src/engine/services/sessions.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* SessionService — lifecycle for `Session` records.
|
||||
*
|
||||
* Lives *above* the per-session `Engine` (the engine itself is recreated
|
||||
* each time the active session changes). The service owns its own
|
||||
* `EventBus` instance — independent of any engine bus — but uses the
|
||||
* same `EngineEvent` shape so consumers can subscribe with the standard
|
||||
* `bus.on("SessionCreated", …)` pattern.
|
||||
*
|
||||
* Per-session engine snapshot persistence is handled by attaching the
|
||||
* existing `attachPersister(engine, { key: engineSnapshotKey(sessionId) })`
|
||||
* inside the app's `EngineProvider`. The helpers in this file own the
|
||||
* *cross-session* storage: the session index + the active-session
|
||||
* pointer.
|
||||
*
|
||||
* Naming rules:
|
||||
* - Names are trimmed on input.
|
||||
* - Case-insensitive uniqueness — two sessions cannot coexist with
|
||||
* names that differ only in case ("Demo" vs "demo"). This avoids
|
||||
* surprising the ZIP-merge path in T07, which uses `session.name`
|
||||
* to find an existing target.
|
||||
*/
|
||||
|
||||
import { newId } from "@shared/ids";
|
||||
import type { SessionId } from "@shared/ids";
|
||||
import type { Session } from "@shared/session";
|
||||
|
||||
import type { EventBus } from "../events";
|
||||
import type { SessionRepository } from "../repos";
|
||||
|
||||
const SESSIONS_INDEX_KEY = "citation-evidence:sessions:v1";
|
||||
const ACTIVE_SESSION_KEY = "citation-evidence:active-session-id:v1";
|
||||
|
||||
export { SESSIONS_INDEX_KEY, ACTIVE_SESSION_KEY };
|
||||
|
||||
/**
|
||||
* Build the engine-snapshot storage key for a given session.
|
||||
*
|
||||
* Format: `citation-evidence:session:<sessionId>:engine-snapshot:v1`.
|
||||
* The `v1` tail leaves room for a future migration that changes the
|
||||
* snapshot shape without sweeping the legacy keys.
|
||||
*/
|
||||
export function engineSnapshotKey(sessionId: SessionId): string {
|
||||
return `citation-evidence:session:${sessionId}:engine-snapshot:v1`;
|
||||
}
|
||||
|
||||
export class DuplicateSessionNameError extends Error {
|
||||
constructor(name: string) {
|
||||
super(`Session with name "${name}" already exists`);
|
||||
this.name = "DuplicateSessionNameError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface CreateSessionInput {
|
||||
readonly name: string;
|
||||
/** Override the generated id — primarily for tests and importers. */
|
||||
readonly id?: SessionId;
|
||||
readonly now?: string;
|
||||
}
|
||||
|
||||
export interface SessionService {
|
||||
create(input: string | CreateSessionInput): Session;
|
||||
rename(id: SessionId, name: string): Session;
|
||||
delete(id: SessionId): boolean;
|
||||
list(): readonly Session[];
|
||||
get(id: SessionId): Session | null;
|
||||
setActive(id: SessionId | null): void;
|
||||
getActive(): SessionId | null;
|
||||
/** Record an "I just opened this" timestamp on the session. */
|
||||
touch(id: SessionId): Session | null;
|
||||
}
|
||||
|
||||
function nowIso(now?: string): string {
|
||||
return now ?? new Date().toISOString();
|
||||
}
|
||||
|
||||
function normalisedName(name: string): { display: string; key: string } {
|
||||
const display = name.trim();
|
||||
return { display, key: display.toLocaleLowerCase() };
|
||||
}
|
||||
|
||||
export function createSessionService(
|
||||
repo: SessionRepository,
|
||||
bus: EventBus,
|
||||
): SessionService {
|
||||
let activeId: SessionId | null = null;
|
||||
|
||||
function assertUniqueName(name: string, exceptId?: SessionId) {
|
||||
const { key } = normalisedName(name);
|
||||
for (const existing of repo.list()) {
|
||||
if (exceptId && existing.id === exceptId) continue;
|
||||
if (existing.name.trim().toLocaleLowerCase() === key) {
|
||||
throw new DuplicateSessionNameError(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
create(input) {
|
||||
const { name, id, now } =
|
||||
typeof input === "string" ? { name: input, id: undefined, now: undefined } : input;
|
||||
const { display } = normalisedName(name);
|
||||
if (display.length === 0) {
|
||||
throw new Error("SessionService.create: name must not be empty");
|
||||
}
|
||||
assertUniqueName(display);
|
||||
const ts = nowIso(now);
|
||||
const session: Session = {
|
||||
id: id ?? newId("session"),
|
||||
name: display,
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
};
|
||||
const stored = repo.create(session);
|
||||
bus.emit({ type: "SessionCreated", sessionId: stored.id, session: stored });
|
||||
return stored;
|
||||
},
|
||||
rename(id, name) {
|
||||
const existing = repo.get(id);
|
||||
if (!existing) {
|
||||
throw new Error(`SessionService.rename: unknown session ${id}`);
|
||||
}
|
||||
const { display } = normalisedName(name);
|
||||
if (display.length === 0) {
|
||||
throw new Error("SessionService.rename: name must not be empty");
|
||||
}
|
||||
assertUniqueName(display, id);
|
||||
const previousName = existing.name;
|
||||
const next: Session = {
|
||||
...existing,
|
||||
name: display,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
const stored = repo.update(next);
|
||||
bus.emit({
|
||||
type: "SessionRenamed",
|
||||
sessionId: stored.id,
|
||||
session: stored,
|
||||
previousName,
|
||||
});
|
||||
return stored;
|
||||
},
|
||||
delete(id) {
|
||||
const removed = repo.delete(id);
|
||||
if (removed) {
|
||||
if (activeId === id) {
|
||||
const previousSessionId = activeId;
|
||||
activeId = null;
|
||||
bus.emit({
|
||||
type: "SessionActivated",
|
||||
sessionId: null,
|
||||
previousSessionId,
|
||||
});
|
||||
}
|
||||
bus.emit({ type: "SessionDeleted", sessionId: id });
|
||||
}
|
||||
return removed;
|
||||
},
|
||||
list() {
|
||||
return repo.list();
|
||||
},
|
||||
get(id) {
|
||||
return repo.get(id);
|
||||
},
|
||||
setActive(id) {
|
||||
if (id !== null && !repo.get(id)) {
|
||||
throw new Error(`SessionService.setActive: unknown session ${id}`);
|
||||
}
|
||||
if (id === activeId) return;
|
||||
const previousSessionId = activeId;
|
||||
activeId = id;
|
||||
if (id) {
|
||||
const existing = repo.get(id);
|
||||
if (existing) {
|
||||
repo.update({ ...existing, lastOpenedAt: nowIso() });
|
||||
}
|
||||
}
|
||||
bus.emit({
|
||||
type: "SessionActivated",
|
||||
sessionId: id,
|
||||
previousSessionId,
|
||||
});
|
||||
},
|
||||
getActive() {
|
||||
return activeId;
|
||||
},
|
||||
touch(id) {
|
||||
const existing = repo.get(id);
|
||||
if (!existing) return null;
|
||||
return repo.update({ ...existing, lastOpenedAt: nowIso() });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-session persistence (the session index + active-session pointer).
|
||||
// Per-session engine snapshots are handled by `attachPersister` against
|
||||
// `engineSnapshotKey(sessionId)`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SessionPersisterOptions {
|
||||
readonly storage?: Pick<Storage, "getItem" | "setItem" | "removeItem">;
|
||||
}
|
||||
|
||||
interface SessionsFile {
|
||||
readonly version: 1;
|
||||
readonly sessions: readonly Session[];
|
||||
readonly activeSessionId: SessionId | null;
|
||||
}
|
||||
|
||||
export function attachSessionPersister(
|
||||
service: SessionService,
|
||||
bus: EventBus,
|
||||
options: SessionPersisterOptions = {},
|
||||
): () => void {
|
||||
const storage = options.storage ?? globalThis.localStorage;
|
||||
const writeIndex = () => {
|
||||
const file: SessionsFile = {
|
||||
version: 1,
|
||||
sessions: service.list(),
|
||||
activeSessionId: service.getActive(),
|
||||
};
|
||||
try {
|
||||
storage.setItem(SESSIONS_INDEX_KEY, JSON.stringify(file));
|
||||
if (file.activeSessionId) {
|
||||
storage.setItem(ACTIVE_SESSION_KEY, file.activeSessionId);
|
||||
} else {
|
||||
storage.removeItem(ACTIVE_SESSION_KEY);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("attachSessionPersister: write failed", err);
|
||||
}
|
||||
};
|
||||
const writeIndexAndCleanup = () => {
|
||||
writeIndex();
|
||||
};
|
||||
const writeOnDelete = (sessionId: SessionId) => {
|
||||
writeIndex();
|
||||
try {
|
||||
storage.removeItem(engineSnapshotKey(sessionId));
|
||||
} catch (err) {
|
||||
console.warn("attachSessionPersister: snapshot cleanup failed", err);
|
||||
}
|
||||
};
|
||||
const offs = [
|
||||
bus.on("SessionCreated", writeIndexAndCleanup),
|
||||
bus.on("SessionRenamed", writeIndexAndCleanup),
|
||||
bus.on("SessionActivated", writeIndexAndCleanup),
|
||||
bus.on("SessionDeleted", (e) => writeOnDelete(e.sessionId)),
|
||||
];
|
||||
return () => {
|
||||
for (const off of offs) off();
|
||||
};
|
||||
}
|
||||
|
||||
export interface RestoreSessionsResult {
|
||||
readonly restored: boolean;
|
||||
readonly sessions: readonly Session[];
|
||||
readonly activeSessionId: SessionId | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate the session repo from storage *without* firing events. Mirrors
|
||||
* `restoreSnapshot`'s "direct repo write" pattern so the persister
|
||||
* (which is attached after restore) doesn't immediately re-write what
|
||||
* we just read.
|
||||
*/
|
||||
export function restoreSessionsFromStorage(
|
||||
repo: SessionRepository,
|
||||
service: SessionService,
|
||||
options: SessionPersisterOptions = {},
|
||||
): RestoreSessionsResult {
|
||||
const storage = options.storage ?? globalThis.localStorage;
|
||||
const raw = storage.getItem(SESSIONS_INDEX_KEY);
|
||||
if (!raw) return { restored: false, sessions: [], activeSessionId: null };
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<SessionsFile>;
|
||||
if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.sessions)) {
|
||||
return { restored: false, sessions: [], activeSessionId: null };
|
||||
}
|
||||
for (const s of parsed.sessions) repo.create(s);
|
||||
const activeRaw =
|
||||
typeof parsed.activeSessionId === "string" ? parsed.activeSessionId : null;
|
||||
const fallbackActiveRaw = storage.getItem(ACTIVE_SESSION_KEY);
|
||||
const candidate = (activeRaw ?? fallbackActiveRaw) as SessionId | null;
|
||||
let activeSessionId: SessionId | null = null;
|
||||
if (candidate && repo.get(candidate)) {
|
||||
activeSessionId = candidate;
|
||||
// Use service.setActive to keep the in-memory activeId aligned —
|
||||
// suppress the resulting event so we don't bounce the persister.
|
||||
// The bus listener attached *after* restore is what does the
|
||||
// persistence, so emitting here is harmless either way; but
|
||||
// skipping it keeps restore strictly side-effect-free from the
|
||||
// listener's point of view.
|
||||
service.setActive(activeSessionId);
|
||||
}
|
||||
return { restored: true, sessions: repo.list(), activeSessionId };
|
||||
} catch (err) {
|
||||
console.warn("restoreSessionsFromStorage: parse failed", err);
|
||||
return { restored: false, sessions: [], activeSessionId: null };
|
||||
}
|
||||
}
|
||||
98
src/engine/session-snapshot.test.ts
Normal file
98
src/engine/session-snapshot.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Per-session engine snapshot round-trip.
|
||||
*
|
||||
* The workplan (CE-WP-0005-T01) requires that two sessions persisted
|
||||
* under the per-session key scheme can each be restored independently
|
||||
* — proving the storage layout actually partitions data by session.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Document, DocumentRepresentation } from "@shared/document";
|
||||
import type { DocumentId, RepresentationId, SessionId } from "@shared/ids";
|
||||
|
||||
import {
|
||||
attachPersister,
|
||||
createEngine,
|
||||
engineSnapshotKey,
|
||||
restoreFromStorage,
|
||||
type Engine,
|
||||
type EngineSnapshot,
|
||||
} from "./index";
|
||||
|
||||
function memoryStorage(): Pick<Storage, "getItem" | "setItem" | "removeItem"> {
|
||||
const map = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k) => map.get(k) ?? null,
|
||||
setItem: (k, v) => void map.set(k, v),
|
||||
removeItem: (k) => void map.delete(k),
|
||||
};
|
||||
}
|
||||
|
||||
function seedDoc(engine: Engine, label: string): { id: DocumentId } {
|
||||
const id = `doc_${label}` as DocumentId;
|
||||
const repId = `rep_${label}` as RepresentationId;
|
||||
const document: Document = {
|
||||
id,
|
||||
mediaType: "application/pdf",
|
||||
title: `Doc ${label}`,
|
||||
createdAt: "2026-05-25T00:00:00.000Z",
|
||||
updatedAt: "2026-05-25T00:00:00.000Z",
|
||||
};
|
||||
const representation: DocumentRepresentation = {
|
||||
id: repId,
|
||||
documentId: id,
|
||||
representationType: "pdf-text",
|
||||
contentHash: `hash-${label}`,
|
||||
canonicalText: `text for ${label}`,
|
||||
pageMap: [{ page: 1, width: 100, height: 100 }],
|
||||
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 12, pageLength: 12 }],
|
||||
generatedAt: "2026-05-25T00:00:00.000Z",
|
||||
};
|
||||
engine.documents.register({ document, representation });
|
||||
return { id };
|
||||
}
|
||||
|
||||
describe("per-session engine snapshot round-trip", () => {
|
||||
it("keeps two sessions' snapshots isolated under per-session storage keys", () => {
|
||||
const storage = memoryStorage();
|
||||
const sessA = "sess_aaa" as SessionId;
|
||||
const sessB = "sess_bbb" as SessionId;
|
||||
|
||||
// Author session A.
|
||||
const engineA = createEngine();
|
||||
const offA = attachPersister(engineA, { key: engineSnapshotKey(sessA), storage });
|
||||
const a1 = seedDoc(engineA, "a1");
|
||||
const a2 = seedDoc(engineA, "a2");
|
||||
offA();
|
||||
|
||||
// Author session B with completely different documents.
|
||||
const engineB = createEngine();
|
||||
const offB = attachPersister(engineB, { key: engineSnapshotKey(sessB), storage });
|
||||
const b1 = seedDoc(engineB, "b1");
|
||||
offB();
|
||||
|
||||
// Restore each into its own fresh engine; assert isolation.
|
||||
const restoredA = createEngine();
|
||||
const resA = restoreFromStorage(restoredA, { key: engineSnapshotKey(sessA), storage });
|
||||
expect(resA.restored).toBe(true);
|
||||
const aIds = restoredA.documents.list().map((d) => d.id).sort();
|
||||
expect(aIds).toEqual([a1.id, a2.id].sort());
|
||||
|
||||
const restoredB = createEngine();
|
||||
const resB = restoreFromStorage(restoredB, { key: engineSnapshotKey(sessB), storage });
|
||||
expect(resB.restored).toBe(true);
|
||||
const bIds = restoredB.documents.list().map((d) => d.id);
|
||||
expect(bIds).toEqual([b1.id]);
|
||||
|
||||
// Sanity: each snapshot key really does hold a distinct snapshot.
|
||||
const rawA = storage.getItem(engineSnapshotKey(sessA));
|
||||
const rawB = storage.getItem(engineSnapshotKey(sessB));
|
||||
expect(rawA).not.toBeNull();
|
||||
expect(rawB).not.toBeNull();
|
||||
const snapA = JSON.parse(rawA!) as EngineSnapshot;
|
||||
const snapB = JSON.parse(rawB!) as EngineSnapshot;
|
||||
expect(snapA.documents).toHaveLength(2);
|
||||
expect(snapB.documents).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ export type EvidenceSetId = Brand<string, "EvidenceSetId">;
|
||||
export type EvidenceLinkId = Brand<string, "EvidenceLinkId">;
|
||||
export type CitationCardId = Brand<string, "CitationCardId">;
|
||||
export type CitationRecoveryAttemptId = Brand<string, "CitationRecoveryAttemptId">;
|
||||
export type SessionId = Brand<string, "SessionId">;
|
||||
|
||||
export type IdKindMap = {
|
||||
document: DocumentId;
|
||||
@@ -29,6 +30,7 @@ export type IdKindMap = {
|
||||
"evidence-link": EvidenceLinkId;
|
||||
"citation-card": CitationCardId;
|
||||
"citation-recovery": CitationRecoveryAttemptId;
|
||||
session: SessionId;
|
||||
};
|
||||
|
||||
export type IdKind = keyof IdKindMap;
|
||||
@@ -42,6 +44,7 @@ const PREFIXES: Record<IdKind, string> = {
|
||||
"evidence-link": "evlink",
|
||||
"citation-card": "card",
|
||||
"citation-recovery": "crec",
|
||||
session: "sess",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,4 +8,6 @@ export * from "./evidence-set";
|
||||
export * from "./citation-card";
|
||||
export * from "./citation-card-source";
|
||||
export * from "./open-context-url";
|
||||
export * from "./session";
|
||||
export * from "./session-archive";
|
||||
export { normalize, NORMALIZE_VERSION } from "./text/normalize";
|
||||
|
||||
88
src/shared/session-archive.test.ts
Normal file
88
src/shared/session-archive.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { DocumentId, SessionId } from "./ids";
|
||||
import {
|
||||
parseSessionArchiveManifest,
|
||||
SESSION_ARCHIVE_SCHEMA_VERSION,
|
||||
SessionArchiveParseError,
|
||||
type SessionArchiveManifest,
|
||||
} from "./session-archive";
|
||||
|
||||
function validManifest(): SessionArchiveManifest {
|
||||
return {
|
||||
schemaVersion: SESSION_ARCHIVE_SCHEMA_VERSION,
|
||||
exportedAt: "2026-05-25T00:00:00.000Z",
|
||||
session: {
|
||||
id: "sess_abc" as SessionId,
|
||||
name: "Demo",
|
||||
createdAt: "2026-05-25T00:00:00.000Z",
|
||||
updatedAt: "2026-05-25T00:00:00.000Z",
|
||||
},
|
||||
engine: {
|
||||
version: 1,
|
||||
documents: [],
|
||||
representations: [],
|
||||
annotations: [],
|
||||
evidenceItems: [],
|
||||
},
|
||||
documentBindings: [
|
||||
{
|
||||
documentId: "doc_abc" as DocumentId,
|
||||
filename: "demo.pdf",
|
||||
fingerprint: "abc123",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("parseSessionArchiveManifest", () => {
|
||||
it("round-trips a valid manifest through JSON.stringify + parse", () => {
|
||||
const m = validManifest();
|
||||
const round = parseSessionArchiveManifest(JSON.parse(JSON.stringify(m)));
|
||||
expect(round).toEqual(m);
|
||||
});
|
||||
|
||||
it("rejects an unsupported schemaVersion", () => {
|
||||
const m = { ...validManifest(), schemaVersion: 999 as unknown };
|
||||
expect(() => parseSessionArchiveManifest(m)).toThrow(SessionArchiveParseError);
|
||||
expect(() => parseSessionArchiveManifest(m)).toThrow(/unsupported schemaVersion/);
|
||||
});
|
||||
|
||||
it("rejects a missing required top-level field", () => {
|
||||
const m = validManifest();
|
||||
const broken = { ...m, exportedAt: undefined as unknown };
|
||||
expect(() => parseSessionArchiveManifest(broken)).toThrow(/exportedAt/);
|
||||
});
|
||||
|
||||
it("rejects a malformed session sub-object", () => {
|
||||
const m = validManifest();
|
||||
const broken = { ...m, session: { ...m.session, id: 12345 as unknown } };
|
||||
expect(() => parseSessionArchiveManifest(broken)).toThrow(/session\.id/);
|
||||
});
|
||||
|
||||
it("rejects a malformed engine snapshot", () => {
|
||||
const m = validManifest();
|
||||
const broken = { ...m, engine: { ...m.engine, version: "1" as unknown } };
|
||||
expect(() => parseSessionArchiveManifest(broken)).toThrow(/engine\.version/);
|
||||
});
|
||||
|
||||
it("rejects a non-array documentBindings", () => {
|
||||
const m = validManifest();
|
||||
const broken = { ...m, documentBindings: "nope" as unknown };
|
||||
expect(() => parseSessionArchiveManifest(broken)).toThrow(/documentBindings/);
|
||||
});
|
||||
|
||||
it("rejects a malformed documentBindings entry", () => {
|
||||
const m = validManifest();
|
||||
const broken = {
|
||||
...m,
|
||||
documentBindings: [{ documentId: "doc_x", fingerprint: "abc" }] as unknown[],
|
||||
};
|
||||
expect(() => parseSessionArchiveManifest(broken)).toThrow(/documentBindings\[0\]\.filename/);
|
||||
});
|
||||
|
||||
it("rejects a non-object root", () => {
|
||||
expect(() => parseSessionArchiveManifest("oops")).toThrow(/manifest/);
|
||||
expect(() => parseSessionArchiveManifest(null)).toThrow(/manifest/);
|
||||
});
|
||||
});
|
||||
150
src/shared/session-archive.ts
Normal file
150
src/shared/session-archive.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* SessionArchiveManifest — JSON contract for `manifest.json` inside a
|
||||
* session ZIP archive.
|
||||
*
|
||||
* Schema version 1; see `docs/decisions/ADR-0008-session-archive-format.md`
|
||||
* for the authoritative spec. This module is the executable contract:
|
||||
* `parseSessionArchiveManifest` validates an `unknown` JSON value and
|
||||
* narrows it to `SessionArchiveManifest` or throws with a useful
|
||||
* message.
|
||||
*
|
||||
* The `engine` field re-uses the in-memory `EngineSnapshot` shape so
|
||||
* the in-memory ↔ archive round-trip stays a one-way conversion.
|
||||
* Only minimal structural validation runs here; the engine helpers
|
||||
* (`restoreSnapshot`) handle deeper validation when actually
|
||||
* hydrating an engine.
|
||||
*/
|
||||
|
||||
import type { DocumentId, SessionId } from "./ids";
|
||||
|
||||
export const SESSION_ARCHIVE_SCHEMA_VERSION = 1 as const;
|
||||
|
||||
export interface SessionArchiveSessionRecord {
|
||||
readonly id: SessionId;
|
||||
readonly name: string;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SessionArchiveDocumentBinding {
|
||||
readonly documentId: DocumentId;
|
||||
readonly filename: string;
|
||||
readonly fingerprint: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror of `EngineSnapshot` — kept loose here (record of unknown) to
|
||||
* avoid the cross-package dependency back into `@engine`. The engine
|
||||
* persistence layer owns the authoritative shape.
|
||||
*/
|
||||
export interface SessionArchiveEngineSnapshot {
|
||||
readonly version: number;
|
||||
readonly documents: readonly unknown[];
|
||||
readonly representations: readonly unknown[];
|
||||
readonly annotations: readonly unknown[];
|
||||
readonly evidenceItems: readonly unknown[];
|
||||
}
|
||||
|
||||
export interface SessionArchiveManifest {
|
||||
readonly schemaVersion: typeof SESSION_ARCHIVE_SCHEMA_VERSION;
|
||||
readonly exportedAt: string;
|
||||
readonly session: SessionArchiveSessionRecord;
|
||||
readonly engine: SessionArchiveEngineSnapshot;
|
||||
readonly documentBindings: readonly SessionArchiveDocumentBinding[];
|
||||
}
|
||||
|
||||
export class SessionArchiveParseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(`SessionArchiveManifest parse failed: ${message}`);
|
||||
this.name = "SessionArchiveParseError";
|
||||
}
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function assertString(value: unknown, field: string): string {
|
||||
if (typeof value !== "string") {
|
||||
throw new SessionArchiveParseError(`field "${field}" must be a string`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertObject(value: unknown, field: string): Record<string, unknown> {
|
||||
if (!isObject(value)) {
|
||||
throw new SessionArchiveParseError(`field "${field}" must be an object`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertArray(value: unknown, field: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new SessionArchiveParseError(`field "${field}" must be an array`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseSessionRecord(raw: unknown): SessionArchiveSessionRecord {
|
||||
const obj = assertObject(raw, "session");
|
||||
return {
|
||||
id: assertString(obj.id, "session.id") as SessionId,
|
||||
name: assertString(obj.name, "session.name"),
|
||||
createdAt: assertString(obj.createdAt, "session.createdAt"),
|
||||
updatedAt: assertString(obj.updatedAt, "session.updatedAt"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseDocumentBinding(
|
||||
raw: unknown,
|
||||
index: number,
|
||||
): SessionArchiveDocumentBinding {
|
||||
const obj = assertObject(raw, `documentBindings[${index}]`);
|
||||
return {
|
||||
documentId: assertString(obj.documentId, `documentBindings[${index}].documentId`) as DocumentId,
|
||||
filename: assertString(obj.filename, `documentBindings[${index}].filename`),
|
||||
fingerprint: assertString(obj.fingerprint, `documentBindings[${index}].fingerprint`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseEngineSnapshot(raw: unknown): SessionArchiveEngineSnapshot {
|
||||
const obj = assertObject(raw, "engine");
|
||||
const version = obj.version;
|
||||
if (typeof version !== "number") {
|
||||
throw new SessionArchiveParseError(`field "engine.version" must be a number`);
|
||||
}
|
||||
const documents = assertArray(obj.documents, "engine.documents");
|
||||
const representations = assertArray(obj.representations, "engine.representations");
|
||||
const annotations = assertArray(obj.annotations, "engine.annotations");
|
||||
const evidenceItems = assertArray(obj.evidenceItems, "engine.evidenceItems");
|
||||
return {
|
||||
version,
|
||||
documents,
|
||||
representations,
|
||||
annotations,
|
||||
evidenceItems,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSessionArchiveManifest(raw: unknown): SessionArchiveManifest {
|
||||
const obj = assertObject(raw, "manifest");
|
||||
const schemaVersion = obj.schemaVersion;
|
||||
if (schemaVersion !== SESSION_ARCHIVE_SCHEMA_VERSION) {
|
||||
throw new SessionArchiveParseError(
|
||||
`unsupported schemaVersion ${String(schemaVersion)} — expected ${SESSION_ARCHIVE_SCHEMA_VERSION}`,
|
||||
);
|
||||
}
|
||||
const exportedAt = assertString(obj.exportedAt, "exportedAt");
|
||||
const session = parseSessionRecord(obj.session);
|
||||
const engine = parseEngineSnapshot(obj.engine);
|
||||
const documentBindings = assertArray(obj.documentBindings, "documentBindings").map(
|
||||
(entry, i) => parseDocumentBinding(entry, i),
|
||||
);
|
||||
return {
|
||||
schemaVersion: SESSION_ARCHIVE_SCHEMA_VERSION,
|
||||
exportedAt,
|
||||
session,
|
||||
engine,
|
||||
documentBindings,
|
||||
};
|
||||
}
|
||||
26
src/shared/session.ts
Normal file
26
src/shared/session.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* `Session` — a user-named workspace that owns one engine snapshot.
|
||||
*
|
||||
* Sessions partition the demo app: each one holds its own documents,
|
||||
* annotations, evidence items, and links. Membership is implicit — a
|
||||
* session "owns" whatever lives in its engine snapshot. The session
|
||||
* record itself only carries the human metadata (name, timestamps) and
|
||||
* the branded id used as a key in `localStorage` and the ZIP archive
|
||||
* (see ADR-0008).
|
||||
*
|
||||
* The id is opaque (`sess_<uuid>` per `ids.ts`). The name is the human
|
||||
* label; uniqueness is enforced by the `SessionService` on create and
|
||||
* rename. Names are *trimmed* before storage so a leading/trailing
|
||||
* space does not let two sessions coexist with effectively the same
|
||||
* label.
|
||||
*/
|
||||
|
||||
import type { SessionId } from "./ids";
|
||||
|
||||
export interface Session {
|
||||
readonly id: SessionId;
|
||||
readonly name: string;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
readonly lastOpenedAt?: string;
|
||||
}
|
||||
@@ -6,3 +6,13 @@ export {
|
||||
} from "./pdf/ingest";
|
||||
export { extractPdf, type PdfExtractionResult } from "./pdf/extract";
|
||||
export { fingerprintBytes } from "./pdf/fingerprint";
|
||||
export {
|
||||
createPdfByteStore,
|
||||
type CreatePdfByteStoreOptions,
|
||||
type PdfByteRecord,
|
||||
type PdfByteStore,
|
||||
} from "./pdf/byte-store";
|
||||
export {
|
||||
ingestPdfFromFile,
|
||||
type IngestPdfFromFileOptions,
|
||||
} from "./pdf/upload";
|
||||
|
||||
99
src/source/pdf/byte-store.test.ts
Normal file
99
src/source/pdf/byte-store.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { DocumentId } from "@shared/ids";
|
||||
|
||||
import { createPdfByteStore } from "./byte-store";
|
||||
|
||||
function stubUrlHelpers() {
|
||||
let counter = 0;
|
||||
const created: string[] = [];
|
||||
const revoked: string[] = [];
|
||||
const createObjectURL = vi.fn(() => {
|
||||
const url = `blob:stub-${++counter}`;
|
||||
created.push(url);
|
||||
return url;
|
||||
});
|
||||
const revokeObjectURL = vi.fn((url: string) => {
|
||||
revoked.push(url);
|
||||
});
|
||||
return { createObjectURL, revokeObjectURL, created, revoked };
|
||||
}
|
||||
|
||||
describe("PdfByteStore", () => {
|
||||
it("put / get round-trips bytes and exposes a blob URL", () => {
|
||||
const helpers = stubUrlHelpers();
|
||||
const store = createPdfByteStore(helpers);
|
||||
const bytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]); // %PDF
|
||||
const record = store.put("doc_a" as DocumentId, bytes);
|
||||
expect(record.blobUrl).toBe("blob:stub-1");
|
||||
expect(store.get("doc_a" as DocumentId)?.bytes).toBe(bytes);
|
||||
expect(store.has("doc_a" as DocumentId)).toBe(true);
|
||||
expect(store.list()).toEqual(["doc_a"]);
|
||||
expect(store.size()).toBe(4);
|
||||
});
|
||||
|
||||
it("put replaces an existing entry and revokes the old URL", () => {
|
||||
const helpers = stubUrlHelpers();
|
||||
const store = createPdfByteStore(helpers);
|
||||
const id = "doc_a" as DocumentId;
|
||||
const first = store.put(id, new Uint8Array([1, 2]));
|
||||
const second = store.put(id, new Uint8Array([3, 4, 5]));
|
||||
expect(helpers.revoked).toEqual([first.blobUrl]);
|
||||
expect(store.get(id)?.bytes).toHaveLength(3);
|
||||
expect(second.blobUrl).not.toBe(first.blobUrl);
|
||||
});
|
||||
|
||||
it("delete revokes the blob URL exactly once and is idempotent", () => {
|
||||
const helpers = stubUrlHelpers();
|
||||
const store = createPdfByteStore(helpers);
|
||||
const id = "doc_a" as DocumentId;
|
||||
const record = store.put(id, new Uint8Array([1, 2, 3]));
|
||||
expect(store.delete(id)).toBe(true);
|
||||
expect(helpers.revoked).toEqual([record.blobUrl]);
|
||||
expect(store.delete(id)).toBe(false);
|
||||
// No additional revoke calls on the second delete.
|
||||
expect(helpers.revoked).toHaveLength(1);
|
||||
expect(store.get(id)).toBeNull();
|
||||
expect(store.has(id)).toBe(false);
|
||||
});
|
||||
|
||||
it("clear revokes every URL and empties the store", () => {
|
||||
const helpers = stubUrlHelpers();
|
||||
const store = createPdfByteStore(helpers);
|
||||
const a = store.put("doc_a" as DocumentId, new Uint8Array([1]));
|
||||
const b = store.put("doc_b" as DocumentId, new Uint8Array([2]));
|
||||
store.clear();
|
||||
expect(helpers.revoked.sort()).toEqual([a.blobUrl, b.blobUrl].sort());
|
||||
expect(store.list()).toEqual([]);
|
||||
expect(store.size()).toBe(0);
|
||||
});
|
||||
|
||||
it("uses URL.createObjectURL by default when no override is supplied", () => {
|
||||
const createObjectURL = vi.fn(() => "blob:built-in");
|
||||
const revokeObjectURL = vi.fn();
|
||||
const originalURL = globalThis.URL;
|
||||
// happy-dom's URL has createObjectURL; node sometimes does not. Stub it.
|
||||
Object.defineProperty(globalThis, "URL", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: Object.assign(Object.create(originalURL.prototype as object), {
|
||||
createObjectURL,
|
||||
revokeObjectURL,
|
||||
}),
|
||||
});
|
||||
try {
|
||||
const store = createPdfByteStore();
|
||||
const rec = store.put("doc_z" as DocumentId, new Uint8Array([9]));
|
||||
expect(rec.blobUrl).toBe("blob:built-in");
|
||||
expect(createObjectURL).toHaveBeenCalledTimes(1);
|
||||
store.delete("doc_z" as DocumentId);
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith("blob:built-in");
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "URL", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalURL,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
113
src/source/pdf/byte-store.ts
Normal file
113
src/source/pdf/byte-store.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* `PdfByteStore` — in-memory store for uploaded PDF bytes, keyed by
|
||||
* `DocumentId`.
|
||||
*
|
||||
* CE-WP-0005 stores uploaded PDFs in memory only (per the workplan
|
||||
* scoping decision). Bytes survive within a tab session; reloading the
|
||||
* page loses them unless the user exported a ZIP. Re-importing the ZIP
|
||||
* restores them.
|
||||
*
|
||||
* One store instance per active session. The session-management layer
|
||||
* is responsible for swapping the active store when the user switches
|
||||
* sessions. The store also owns a small registry of issued `blob:`
|
||||
* URLs so it can revoke them on delete/clear — no cross-cutting
|
||||
* cleanup at the app layer.
|
||||
*/
|
||||
|
||||
import type { DocumentId } from "@shared/ids";
|
||||
|
||||
export interface PdfByteRecord {
|
||||
readonly bytes: Uint8Array;
|
||||
/** A `blob:` URL the viewer can consume directly. */
|
||||
readonly blobUrl: string;
|
||||
}
|
||||
|
||||
export interface PdfByteStore {
|
||||
put(documentId: DocumentId, bytes: Uint8Array): PdfByteRecord;
|
||||
get(documentId: DocumentId): PdfByteRecord | null;
|
||||
has(documentId: DocumentId): boolean;
|
||||
delete(documentId: DocumentId): boolean;
|
||||
list(): readonly DocumentId[];
|
||||
/** Revoke every blob URL and clear the store. */
|
||||
clear(): void;
|
||||
/** Total bytes currently held — useful for UI dashboards. */
|
||||
size(): number;
|
||||
}
|
||||
|
||||
export interface CreatePdfByteStoreOptions {
|
||||
/**
|
||||
* Mint a URL for the given bytes. Defaults to `URL.createObjectURL` in
|
||||
* environments that have it; tests can inject a deterministic stub.
|
||||
*/
|
||||
readonly createObjectURL?: (blob: Blob) => string;
|
||||
/** Revoke a URL previously minted by `createObjectURL`. */
|
||||
readonly revokeObjectURL?: (url: string) => void;
|
||||
}
|
||||
|
||||
export function createPdfByteStore(
|
||||
options: CreatePdfByteStoreOptions = {},
|
||||
): PdfByteStore {
|
||||
const createUrl =
|
||||
options.createObjectURL ??
|
||||
((blob: Blob) => {
|
||||
if (typeof URL === "undefined" || typeof URL.createObjectURL !== "function") {
|
||||
throw new Error(
|
||||
"createPdfByteStore: URL.createObjectURL not available — inject a stub via options",
|
||||
);
|
||||
}
|
||||
return URL.createObjectURL(blob);
|
||||
});
|
||||
const revokeUrl =
|
||||
options.revokeObjectURL ??
|
||||
((url: string) => {
|
||||
if (typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
});
|
||||
|
||||
const records = new Map<DocumentId, PdfByteRecord>();
|
||||
|
||||
return {
|
||||
put(documentId, bytes) {
|
||||
// Replace previous record (revoking the prior URL) if any.
|
||||
const prior = records.get(documentId);
|
||||
if (prior) revokeUrl(prior.blobUrl);
|
||||
// Cast: Blob() does accept Uint8Array at runtime, but TS narrows the
|
||||
// buffer type to ArrayBufferLike (could be SharedArrayBuffer) and
|
||||
// refuses without help. The bytes here always come from a fresh
|
||||
// arrayBuffer() call, so a regular ArrayBuffer is guaranteed.
|
||||
const blob = new Blob([bytes as unknown as ArrayBuffer], {
|
||||
type: "application/pdf",
|
||||
});
|
||||
const blobUrl = createUrl(blob);
|
||||
const record: PdfByteRecord = { bytes, blobUrl };
|
||||
records.set(documentId, record);
|
||||
return record;
|
||||
},
|
||||
get(documentId) {
|
||||
return records.get(documentId) ?? null;
|
||||
},
|
||||
has(documentId) {
|
||||
return records.has(documentId);
|
||||
},
|
||||
delete(documentId) {
|
||||
const record = records.get(documentId);
|
||||
if (!record) return false;
|
||||
revokeUrl(record.blobUrl);
|
||||
records.delete(documentId);
|
||||
return true;
|
||||
},
|
||||
list() {
|
||||
return [...records.keys()];
|
||||
},
|
||||
clear() {
|
||||
for (const record of records.values()) revokeUrl(record.blobUrl);
|
||||
records.clear();
|
||||
},
|
||||
size() {
|
||||
let total = 0;
|
||||
for (const r of records.values()) total += r.bytes.byteLength;
|
||||
return total;
|
||||
},
|
||||
};
|
||||
}
|
||||
91
src/source/pdf/upload.test.ts
Normal file
91
src/source/pdf/upload.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* `ingestPdfFromFile` end-to-end: pipes a fixture PDF through the
|
||||
* upload path, asserts the byte store keeps the bytes and the document
|
||||
* record carries the minted `blob:` URL.
|
||||
*/
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createPdfByteStore } from "./byte-store";
|
||||
import { ingestPdfFromFile } from "./upload";
|
||||
|
||||
const FIXTURE_PATH = new URL(
|
||||
"../../../fixtures/pdfs/Fristsetzung zur Bezifferung GÜ an Gegenseite 3 Wochen.pdf",
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
async function fixtureBytes(): Promise<Uint8Array> {
|
||||
return new Uint8Array(await readFile(FIXTURE_PATH));
|
||||
}
|
||||
|
||||
class FakeFile {
|
||||
readonly name: string;
|
||||
private readonly bytes: Uint8Array;
|
||||
constructor(bytes: Uint8Array, name: string) {
|
||||
this.bytes = bytes;
|
||||
this.name = name;
|
||||
}
|
||||
async arrayBuffer(): Promise<ArrayBuffer> {
|
||||
const out = new ArrayBuffer(this.bytes.byteLength);
|
||||
new Uint8Array(out).set(this.bytes);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
describe("ingestPdfFromFile", () => {
|
||||
it("round-trips a fixture PDF through ingest + byte store + blob URL", async () => {
|
||||
const bytes = await fixtureBytes();
|
||||
const file = new FakeFile(bytes, "demo.pdf") as unknown as File;
|
||||
let counter = 0;
|
||||
const store = createPdfByteStore({
|
||||
createObjectURL: () => `blob:upload-stub-${++counter}`,
|
||||
revokeObjectURL: () => {},
|
||||
});
|
||||
|
||||
const { document, representation } = await ingestPdfFromFile(file, store);
|
||||
|
||||
// Bytes are stored, retrievable by document id.
|
||||
const stored = store.get(document.id);
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored!.bytes.byteLength).toBe(bytes.byteLength);
|
||||
|
||||
// Document carries the blob URL minted by the store.
|
||||
expect(document.uri).toBe(`blob:upload-stub-${counter}`);
|
||||
expect(document.title).toBe("demo.pdf");
|
||||
expect(document.fingerprint).toMatch(/^[0-9a-f]{64}$/);
|
||||
|
||||
// Representation is the standard pdf-text one.
|
||||
expect(representation.representationType).toBe("pdf-text");
|
||||
expect((representation.canonicalText ?? "").length).toBeGreaterThan(0);
|
||||
}, 30_000);
|
||||
|
||||
it("falls through to ingestPdf with no filename when given a plain Blob", async () => {
|
||||
const bytes = await fixtureBytes();
|
||||
const blob = {
|
||||
async arrayBuffer() {
|
||||
const out = new ArrayBuffer(bytes.byteLength);
|
||||
new Uint8Array(out).set(bytes);
|
||||
return out;
|
||||
},
|
||||
} as Blob;
|
||||
const store = createPdfByteStore({
|
||||
createObjectURL: () => "blob:no-name",
|
||||
revokeObjectURL: () => {},
|
||||
});
|
||||
const { document } = await ingestPdfFromFile(blob, store);
|
||||
expect(document.title).toBeUndefined();
|
||||
expect(document.uri).toBe("blob:no-name");
|
||||
}, 30_000);
|
||||
|
||||
it("explicit title option overrides the filename", async () => {
|
||||
const bytes = await fixtureBytes();
|
||||
const file = new FakeFile(bytes, "anonymous-name.pdf") as unknown as File;
|
||||
const store = createPdfByteStore({
|
||||
createObjectURL: vi.fn(() => "blob:override"),
|
||||
revokeObjectURL: vi.fn(),
|
||||
});
|
||||
const { document } = await ingestPdfFromFile(file, store, { title: "Custom" });
|
||||
expect(document.title).toBe("Custom");
|
||||
}, 30_000);
|
||||
});
|
||||
45
src/source/pdf/upload.ts
Normal file
45
src/source/pdf/upload.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Upload-side ingest path.
|
||||
*
|
||||
* The fixture-loading path in `App.tsx` fetches a known URL and calls
|
||||
* `ingestPdf` directly; that path stays untouched for the optional
|
||||
* "Sample sessions" quick-start. Uploaded files flow through here
|
||||
* instead:
|
||||
*
|
||||
* 1. Read `file.arrayBuffer()` once into a `Uint8Array`.
|
||||
* 2. Run the existing `ingestPdf(bytes, { filename })` pipeline to
|
||||
* produce `{document, representation}`.
|
||||
* 3. Push the bytes into the per-session `PdfByteStore`, which mints
|
||||
* a `blob:` URL and stamps it onto `document.uri` so the viewer
|
||||
* adapter can mount the PDF directly.
|
||||
* 4. Hand the engine inputs back to the caller, which wires them via
|
||||
* `engine.documents.register(...)`.
|
||||
*
|
||||
* Keeping URL-minting inside the byte store (rather than at the call
|
||||
* site) means there is exactly one place that creates `blob:` URLs and
|
||||
* exactly one place that revokes them.
|
||||
*/
|
||||
|
||||
import { ingestPdf, type IngestPdfResult } from "./ingest";
|
||||
import type { PdfByteStore } from "./byte-store";
|
||||
|
||||
export interface IngestPdfFromFileOptions {
|
||||
/** Override the filename used as the document title. */
|
||||
readonly title?: string;
|
||||
}
|
||||
|
||||
export async function ingestPdfFromFile(
|
||||
file: File | Blob,
|
||||
store: PdfByteStore,
|
||||
options: IngestPdfFromFileOptions = {},
|
||||
): Promise<IngestPdfResult> {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
const filename = "name" in file && typeof file.name === "string" ? file.name : undefined;
|
||||
const ingested = await ingestPdf(bytes, {
|
||||
...(filename !== undefined ? { filename } : {}),
|
||||
...(options.title !== undefined ? { title: options.title } : {}),
|
||||
});
|
||||
const record = store.put(ingested.document.id, bytes);
|
||||
const document = { ...ingested.document, uri: record.blobUrl };
|
||||
return { document, representation: ingested.representation };
|
||||
}
|
||||
112
src/work/CollectionList.dom.test.tsx
Normal file
112
src/work/CollectionList.dom.test.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Document, DocumentRepresentation } from "@shared/document";
|
||||
import type { DocumentId, RepresentationId } from "@shared/ids";
|
||||
|
||||
import { CollectionList, EngineProvider, useEngine, usePdfByteStore } from "./index";
|
||||
|
||||
function makeDoc(suffix: string): { document: Document; representation: DocumentRepresentation } {
|
||||
const id = `doc_${suffix}` as DocumentId;
|
||||
const repId = `rep_${suffix}` as RepresentationId;
|
||||
return {
|
||||
document: {
|
||||
id,
|
||||
mediaType: "application/pdf",
|
||||
title: `Doc ${suffix}`,
|
||||
fingerprint: `hash-${suffix}`,
|
||||
createdAt: "2026-05-25T00:00:00.000Z",
|
||||
updatedAt: "2026-05-25T00:00:00.000Z",
|
||||
},
|
||||
representation: {
|
||||
id: repId,
|
||||
documentId: id,
|
||||
representationType: "pdf-text",
|
||||
contentHash: `hash-${suffix}`,
|
||||
canonicalText: `body ${suffix}`,
|
||||
pageMap: [{ page: 1, width: 100, height: 100 }],
|
||||
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 6, pageLength: 6 }],
|
||||
generatedAt: "2026-05-25T00:00:00.000Z",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function Seed() {
|
||||
const engine = useEngine();
|
||||
const store = usePdfByteStore();
|
||||
if (engine.documents.list().length === 0) {
|
||||
const a = makeDoc("alpha");
|
||||
const b = makeDoc("beta");
|
||||
store.put(a.document.id, new Uint8Array([1, 2]));
|
||||
store.put(b.document.id, new Uint8Array([3, 4]));
|
||||
engine.documents.register(a);
|
||||
engine.documents.register(b);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.localStorage?.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("CollectionList (session-scoped)", () => {
|
||||
it("renders one row per registered document", async () => {
|
||||
render(
|
||||
<EngineProvider>
|
||||
<Seed />
|
||||
<CollectionList title="Demo session" />
|
||||
</EngineProvider>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Doc alpha")).toBeTruthy();
|
||||
expect(screen.getByText("Doc beta")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByText("Demo session")).toBeTruthy();
|
||||
});
|
||||
|
||||
it(
|
||||
"per-row delete asks for confirmation, then removes the row and revokes the blob URL",
|
||||
{ timeout: 8000 },
|
||||
async () => {
|
||||
let revokedUrl: string | null = null;
|
||||
// Patch URL.revokeObjectURL so we can confirm the byte store fired it.
|
||||
const original = URL.revokeObjectURL;
|
||||
URL.revokeObjectURL = (url: string) => {
|
||||
revokedUrl = url;
|
||||
};
|
||||
|
||||
try {
|
||||
render(
|
||||
<EngineProvider>
|
||||
<Seed />
|
||||
<CollectionList />
|
||||
</EngineProvider>,
|
||||
);
|
||||
await screen.findByText("Doc alpha");
|
||||
|
||||
const user = userEvent.setup();
|
||||
const deleteBtn = await screen.findByTestId("collection-delete-doc_alpha");
|
||||
// First click → confirm prompt
|
||||
await user.click(deleteBtn);
|
||||
expect(deleteBtn.textContent).toContain("Confirm");
|
||||
// Second click → commit
|
||||
await user.click(deleteBtn);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Doc alpha")).toBeNull();
|
||||
});
|
||||
expect(revokedUrl).not.toBeNull();
|
||||
expect(revokedUrl!).toMatch(/^blob:/);
|
||||
} finally {
|
||||
URL.revokeObjectURL = original;
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,71 +1,76 @@
|
||||
/**
|
||||
* CollectionList — the left pane.
|
||||
*
|
||||
* Lists the fixture corpus (the MVP stand-in for a real document collection).
|
||||
* Clicking a fixture fetches the bytes, runs `ingestPdf` (PDF.js extraction
|
||||
* + fingerprint + canonical text), registers the result with the engine
|
||||
* (emitting §4 events), and activates it as the current document.
|
||||
* CE-WP-0005 turned this into a *session-scoped* list. It shows the
|
||||
* documents currently registered with the active session's engine,
|
||||
* with per-row Open + Delete actions and an inline upload affordance.
|
||||
*
|
||||
* Per CE-WP-0002-T06, the loaded fixture set is hard-wired to
|
||||
* `fixtures/pdfs/manifest.json`. Real collections arrive in a later
|
||||
* workplan.
|
||||
* Fixture-driven quick-start lives in
|
||||
* `src/app/sessions/SampleSessions.tsx` and is no longer the default.
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { ingestPdf } from "@source/index";
|
||||
import { useEngine, useActiveDocumentId } from "./EngineContext";
|
||||
import type { DocumentId } from "@shared/ids";
|
||||
import manifest from "../../fixtures/pdfs/manifest.json";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface Fixture {
|
||||
id: string;
|
||||
filename: string;
|
||||
description: string;
|
||||
page_count: number;
|
||||
import type { DocumentId } from "@shared/ids";
|
||||
import {
|
||||
useActiveDocumentId,
|
||||
useEngine,
|
||||
useEngineEventTick,
|
||||
useEngineRevision,
|
||||
usePdfByteStore,
|
||||
} from "./EngineContext";
|
||||
|
||||
export interface CollectionListProps {
|
||||
/**
|
||||
* Slot rendered above the list — typically the upload affordance.
|
||||
* Kept as a slot so this component stays in `work/` (which cannot
|
||||
* import `app/`).
|
||||
*/
|
||||
readonly upload?: ReactNode;
|
||||
/** Optional session header text — typically the active session name. */
|
||||
readonly title?: string;
|
||||
}
|
||||
|
||||
const FIXTURES: readonly Fixture[] = (manifest as { fixtures: Fixture[] }).fixtures;
|
||||
|
||||
export function CollectionList() {
|
||||
export function CollectionList({ upload, title }: CollectionListProps) {
|
||||
const engine = useEngine();
|
||||
const byteStore = usePdfByteStore();
|
||||
const { id: activeId, setId } = useActiveDocumentId();
|
||||
const [loadingFixtureId, setLoadingFixtureId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Remember which fixture-id maps to which loaded documentId so re-clicking
|
||||
// a fixture activates the existing engine record rather than re-ingesting.
|
||||
const [byFixture, setByFixture] = useState<Record<string, DocumentId>>({});
|
||||
|
||||
const handleLoad = useCallback(
|
||||
async (fixture: Fixture) => {
|
||||
setError(null);
|
||||
const importedTick = useEngineEventTick("DocumentImported");
|
||||
const removedTick = useEngineEventTick("DocumentRemoved");
|
||||
const revision = useEngineRevision();
|
||||
|
||||
const existing = byFixture[fixture.id];
|
||||
if (existing) {
|
||||
setId(existing);
|
||||
const documents = useMemo(
|
||||
() => engine.documents.list(),
|
||||
[engine, importedTick, removedTick, revision],
|
||||
);
|
||||
|
||||
// Confirm-on-delete UX without a modal: clicking Delete asks "Confirm?",
|
||||
// a second click within ~3s commits. Esc clears the pending state.
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<DocumentId | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingDeleteId) return;
|
||||
const t = setTimeout(() => setPendingDeleteId(null), 3000);
|
||||
return () => clearTimeout(t);
|
||||
}, [pendingDeleteId]);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(id: DocumentId) => {
|
||||
if (pendingDeleteId !== id) {
|
||||
setPendingDeleteId(id);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingFixtureId(fixture.id);
|
||||
try {
|
||||
const url = `/fixtures/pdfs/${encodeURIComponent(fixture.filename)}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`fetch ${url} → ${response.status}`);
|
||||
}
|
||||
const buffer = await response.arrayBuffer();
|
||||
const { document, representation } = await ingestPdf(new Uint8Array(buffer), {
|
||||
filename: fixture.filename,
|
||||
});
|
||||
engine.documents.register({ document, representation });
|
||||
setByFixture((prev) => ({ ...prev, [fixture.id]: document.id }));
|
||||
setId(document.id);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoadingFixtureId(null);
|
||||
}
|
||||
// Active doc was just deleted — clear the pointer so the viewer
|
||||
// unmounts before the engine drops the record.
|
||||
if (activeId === id) setId(null);
|
||||
byteStore.delete(id);
|
||||
engine.documents.remove(id);
|
||||
setPendingDeleteId(null);
|
||||
},
|
||||
[byFixture, engine, setId],
|
||||
[activeId, byteStore, engine, pendingDeleteId, setId],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -78,44 +83,66 @@ export function CollectionList() {
|
||||
flex: "0 0 280px",
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginTop: 0, fontSize: 16 }}>Collection</h2>
|
||||
<h2 style={{ marginTop: 0, fontSize: 16 }}>
|
||||
{title ?? "Collection"}
|
||||
</h2>
|
||||
<p style={{ fontSize: 12, color: "#555", marginTop: 0 }}>
|
||||
{FIXTURES.length} fixture PDF{FIXTURES.length === 1 ? "" : "s"}
|
||||
{documents.length} document{documents.length === 1 ? "" : "s"}
|
||||
</p>
|
||||
{error && (
|
||||
<p style={{ fontSize: 12, color: "#b00020", background: "#fff4f4", padding: 6 }}>
|
||||
{error}
|
||||
{upload && <div style={{ marginBottom: 8 }}>{upload}</div>}
|
||||
{documents.length === 0 && !upload && (
|
||||
<p style={{ fontSize: 12, color: "#888" }}>
|
||||
No documents yet. Upload a PDF to get started.
|
||||
</p>
|
||||
)}
|
||||
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
|
||||
{FIXTURES.map((f) => {
|
||||
const isLoading = loadingFixtureId === f.id;
|
||||
const documentId = byFixture[f.id];
|
||||
const isActive = documentId !== undefined && documentId === activeId;
|
||||
<ul
|
||||
data-testid="collection-list-items"
|
||||
style={{ listStyle: "none", padding: 0, margin: 0 }}
|
||||
>
|
||||
{documents.map((doc) => {
|
||||
const isActive = doc.id === activeId;
|
||||
const isPending = pendingDeleteId === doc.id;
|
||||
return (
|
||||
<li key={f.id} style={{ marginBottom: 6 }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
void handleLoad(f);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
<li key={doc.id} style={{ marginBottom: 6 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
background: isActive ? "#e8f0ff" : "white",
|
||||
border: "1px solid #ccc",
|
||||
padding: 6,
|
||||
cursor: isLoading ? "wait" : "pointer",
|
||||
background: isActive ? "#e8f0ff" : "white",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
fontSize: 12,
|
||||
}}
|
||||
data-testid={`collection-item-${doc.id}`}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>{f.id}</div>
|
||||
<div style={{ color: "#666", fontSize: 11 }}>
|
||||
{f.page_count} page{f.page_count === 1 ? "" : "s"}
|
||||
{isLoading ? " · loading…" : isActive ? " · open" : ""}
|
||||
<button
|
||||
onClick={() => setId(doc.id)}
|
||||
data-testid={`collection-open-${doc.id}`}
|
||||
style={openButtonStyle}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>{doc.title ?? doc.id}</div>
|
||||
<div style={{ color: "#666", fontSize: 11 }}>
|
||||
{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>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@@ -123,3 +150,14 @@ export function CollectionList() {
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
const openButtonStyle: CSSProperties = {
|
||||
display: "block",
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
padding: 8,
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
@@ -21,24 +21,39 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { Document, DocumentRepresentation } from "@shared/document";
|
||||
import type { AnnotationId, DocumentId } from "@shared/ids";
|
||||
import type { AnnotationId, DocumentId, SessionId } from "@shared/ids";
|
||||
import type { Selector } from "@shared/selector";
|
||||
import {
|
||||
attachPersister,
|
||||
createEngine,
|
||||
engineSnapshotKey,
|
||||
restoreFromStorage,
|
||||
type Engine,
|
||||
} from "@engine/index";
|
||||
import type { PdfSelectionCapture } from "@anchor/index";
|
||||
import { createPdfByteStore, type PdfByteStore } from "@source/index";
|
||||
import { useContext as useReactContext } from "react";
|
||||
import { SessionInternalContext } from "./SessionContextInternal";
|
||||
|
||||
/**
|
||||
* localStorage keys for the engine snapshot and the UI's "what was open"
|
||||
* pointer. ADR-0005 frames both as deliberately temporary — real
|
||||
* persistence later.
|
||||
* Legacy single-bucket storage keys, kept for any user landing on a
|
||||
* build without sessions. CE-WP-0005 switched persistence to per-session
|
||||
* keys (`engineSnapshotKey(sessionId)`); the unscoped keys below are
|
||||
* only consulted when no `sessionId` is provided to the provider.
|
||||
*/
|
||||
const STORAGE_KEY = "citation-evidence:engine-snapshot:v1";
|
||||
const LEGACY_STORAGE_KEY = "citation-evidence:engine-snapshot:v1";
|
||||
const ACTIVE_KEY = "citation-evidence:active-document-id:v1";
|
||||
|
||||
function storageKeyFor(sessionId: SessionId | null): string {
|
||||
return sessionId ? engineSnapshotKey(sessionId) : LEGACY_STORAGE_KEY;
|
||||
}
|
||||
|
||||
function activeDocumentKeyFor(sessionId: SessionId | null): string {
|
||||
return sessionId
|
||||
? `citation-evidence:session:${sessionId}:active-document-id:v1`
|
||||
: ACTIVE_KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
* The pending selection lives in context (not local component state) because
|
||||
* the toolbar that consumes it is rendered above the viewer, not inside it.
|
||||
@@ -51,6 +66,7 @@ export interface PendingSelection {
|
||||
|
||||
interface EngineContextValue {
|
||||
readonly engine: Engine;
|
||||
readonly byteStore: PdfByteStore;
|
||||
readonly activeDocumentId: DocumentId | null;
|
||||
setActiveDocumentId(id: DocumentId | null): void;
|
||||
readonly pendingSelection: PendingSelection | null;
|
||||
@@ -60,6 +76,13 @@ interface EngineContextValue {
|
||||
* so a second click on the same evidence item still triggers a scroll. */
|
||||
readonly scrollVersion: number;
|
||||
scrollToAnnotation(id: AnnotationId | null): void;
|
||||
/**
|
||||
* Bumps each time the engine's repos are mutated outside the normal
|
||||
* event-emitting service path — currently only on `restoreFromStorage`.
|
||||
* Consumers that cache `engine.documents.list()` via `useMemo` add this
|
||||
* to their deps so the restored state is reflected on remount.
|
||||
*/
|
||||
readonly engineRevision: number;
|
||||
}
|
||||
|
||||
const EngineContext = createContext<EngineContextValue | null>(null);
|
||||
@@ -68,31 +91,67 @@ interface EngineProviderProps {
|
||||
readonly children: ReactNode;
|
||||
/** Inject a pre-built engine for tests; production uses the default. */
|
||||
readonly engine?: Engine;
|
||||
/**
|
||||
* Active session id. Drives the per-session storage key for the engine
|
||||
* snapshot and the active-document pointer. `null`/omitted falls back
|
||||
* to the legacy unscoped keys for back-compat with pre-CE-WP-0005
|
||||
* builds.
|
||||
*
|
||||
* To switch sessions, parents should *re-key* this provider
|
||||
* (`<EngineProvider key={sessionId} sessionId={sessionId}>`) so React
|
||||
* unmounts the subtree and a fresh engine is created.
|
||||
*/
|
||||
readonly sessionId?: SessionId | null;
|
||||
}
|
||||
|
||||
export function EngineProvider({ children, engine: injected }: EngineProviderProps) {
|
||||
export function EngineProvider({
|
||||
children,
|
||||
engine: injected,
|
||||
sessionId = null,
|
||||
}: EngineProviderProps) {
|
||||
const engine = useMemo(() => injected ?? createEngine(), [injected]);
|
||||
// Prefer the SessionProvider's per-session byte store registry when
|
||||
// available; fall back to a provider-local store for tests that mount
|
||||
// EngineProvider on its own.
|
||||
const sessionCtx = useReactContext(SessionInternalContext);
|
||||
const [fallbackByteStore] = useState<PdfByteStore>(() => createPdfByteStore());
|
||||
const byteStore =
|
||||
sessionId && sessionCtx
|
||||
? sessionCtx.getOrCreateByteStore(sessionId)
|
||||
: fallbackByteStore;
|
||||
const [activeDocumentId, setActiveDocumentIdState] = useState<DocumentId | null>(null);
|
||||
// `restoreFromStorage` writes directly to the engine's repos without
|
||||
// firing engine events (by design — see persistence.ts). That means
|
||||
// consuming components (CollectionList etc.) wouldn't normally
|
||||
// re-render to reflect the restored state. Bumping `engineRevision`
|
||||
// after restore is what consumers add to their `useMemo` deps so
|
||||
// the restored state shows up on (re-)mount.
|
||||
const [engineRevision, setEngineRevision] = useState(0);
|
||||
const [pendingSelection, setPendingSelection] = useState<PendingSelection | null>(null);
|
||||
const [scrollState, setScrollState] = useState<{ id: AnnotationId | null; version: number }>({
|
||||
id: null,
|
||||
version: 0,
|
||||
});
|
||||
|
||||
const snapshotKey = storageKeyFor(sessionId);
|
||||
const activeDocKey = activeDocumentKeyFor(sessionId);
|
||||
|
||||
// Restore from localStorage on first mount, then attach the persister.
|
||||
// The injected-engine path skips persistence (tests own their lifecycle).
|
||||
useEffect(() => {
|
||||
if (injected) return;
|
||||
if (typeof globalThis.localStorage === "undefined") return;
|
||||
const result = restoreFromStorage(engine, { key: STORAGE_KEY });
|
||||
const result = restoreFromStorage(engine, { key: snapshotKey });
|
||||
if (result.restored) {
|
||||
const saved = globalThis.localStorage.getItem(ACTIVE_KEY);
|
||||
const saved = globalThis.localStorage.getItem(activeDocKey);
|
||||
if (saved && engine.documents.get(saved as DocumentId)) {
|
||||
setActiveDocumentIdState(saved as DocumentId);
|
||||
}
|
||||
// Force a re-render so consumers see the restored repos.
|
||||
setEngineRevision((n) => n + 1);
|
||||
}
|
||||
return attachPersister(engine, { key: STORAGE_KEY });
|
||||
}, [engine, injected]);
|
||||
return attachPersister(engine, { key: snapshotKey });
|
||||
}, [engine, injected, snapshotKey, activeDocKey]);
|
||||
|
||||
// Persist the active-document pointer alongside the engine snapshot so a
|
||||
// reload lands the user back where they were.
|
||||
@@ -100,11 +159,11 @@ export function EngineProvider({ children, engine: injected }: EngineProviderPro
|
||||
if (injected) return;
|
||||
if (typeof globalThis.localStorage === "undefined") return;
|
||||
if (activeDocumentId) {
|
||||
globalThis.localStorage.setItem(ACTIVE_KEY, activeDocumentId);
|
||||
globalThis.localStorage.setItem(activeDocKey, activeDocumentId);
|
||||
} else {
|
||||
globalThis.localStorage.removeItem(ACTIVE_KEY);
|
||||
globalThis.localStorage.removeItem(activeDocKey);
|
||||
}
|
||||
}, [activeDocumentId, injected]);
|
||||
}, [activeDocumentId, injected, activeDocKey]);
|
||||
|
||||
// Switching the active document discards any pending selection — it
|
||||
// belongs to the previous document's viewer state.
|
||||
@@ -121,6 +180,7 @@ export function EngineProvider({ children, engine: injected }: EngineProviderPro
|
||||
const value = useMemo<EngineContextValue>(
|
||||
() => ({
|
||||
engine,
|
||||
byteStore,
|
||||
activeDocumentId,
|
||||
setActiveDocumentId,
|
||||
pendingSelection,
|
||||
@@ -128,8 +188,18 @@ export function EngineProvider({ children, engine: injected }: EngineProviderPro
|
||||
scrollToAnnotationId: scrollState.id,
|
||||
scrollVersion: scrollState.version,
|
||||
scrollToAnnotation,
|
||||
engineRevision,
|
||||
}),
|
||||
[engine, activeDocumentId, setActiveDocumentId, pendingSelection, scrollState, scrollToAnnotation],
|
||||
[
|
||||
engine,
|
||||
byteStore,
|
||||
activeDocumentId,
|
||||
setActiveDocumentId,
|
||||
pendingSelection,
|
||||
scrollState,
|
||||
scrollToAnnotation,
|
||||
engineRevision,
|
||||
],
|
||||
);
|
||||
|
||||
return <EngineContext.Provider value={value}>{children}</EngineContext.Provider>;
|
||||
@@ -141,6 +211,18 @@ export function useEngine(): Engine {
|
||||
return ctx.engine;
|
||||
}
|
||||
|
||||
export function usePdfByteStore(): PdfByteStore {
|
||||
const ctx = useContext(EngineContext);
|
||||
if (!ctx) throw new Error("usePdfByteStore: missing EngineProvider");
|
||||
return ctx.byteStore;
|
||||
}
|
||||
|
||||
export function useEngineRevision(): number {
|
||||
const ctx = useContext(EngineContext);
|
||||
if (!ctx) throw new Error("useEngineRevision: missing EngineProvider");
|
||||
return ctx.engineRevision;
|
||||
}
|
||||
|
||||
export function useActiveDocumentId(): {
|
||||
readonly id: DocumentId | null;
|
||||
setId(id: DocumentId | null): void;
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
useActiveDocument,
|
||||
useEngine,
|
||||
useEngineEventTick,
|
||||
useEngineRevision,
|
||||
useLastActivatedEvidence,
|
||||
useScrollToAnnotation,
|
||||
} from "./EngineContext";
|
||||
@@ -75,11 +76,12 @@ export function EvidenceSidebar(props: EvidenceSidebarProps) {
|
||||
|
||||
const createTick = useEngineEventTick("EvidenceItemCreated");
|
||||
const updateTick = useEngineEventTick("EvidenceItemUpdated");
|
||||
const revision = useEngineRevision();
|
||||
|
||||
const items = useMemo<readonly EvidenceItem[]>(() => {
|
||||
if (!document) return [];
|
||||
return engine.evidence.listByDocument(document.id);
|
||||
}, [document, engine, createTick, updateTick]);
|
||||
}, [document, engine, createTick, updateTick, revision]);
|
||||
|
||||
const [openExportFor, setOpenExportFor] = useState<EvidenceItemId | null>(null);
|
||||
const [toast, setToast] = useState<ToastState | null>(null);
|
||||
|
||||
241
src/work/SessionContext.tsx
Normal file
241
src/work/SessionContext.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* SessionProvider — owns the cross-session services.
|
||||
*
|
||||
* Layers above the per-session `EngineProvider`. Responsibilities:
|
||||
*
|
||||
* - hold the `SessionService` + its own bus instance
|
||||
* - hydrate sessions from `localStorage` on first mount
|
||||
* - expose `useSessionService()`, `useActiveSession()`, hooks to
|
||||
* subscribe to session bus events
|
||||
*
|
||||
* Switching sessions is a side effect of calling
|
||||
* `useSessionService().setActive(...)`. The hook tracks the active id
|
||||
* via the bus's `SessionActivated` event so the value stays a single
|
||||
* source of truth.
|
||||
*
|
||||
* NB: this module does *not* mount the `EngineProvider`. T04 wires the
|
||||
* top-level App so the EngineProvider is keyed by the active session id
|
||||
* (`<EngineProvider key={activeId} sessionId={activeId} />`). Keeping
|
||||
* the two providers separate lets tests target one without the other.
|
||||
*/
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import type { SessionId } from "@shared/ids";
|
||||
import type { Session } from "@shared/session";
|
||||
|
||||
import {
|
||||
attachSessionPersister,
|
||||
createEventBus,
|
||||
createInMemorySessionRepository,
|
||||
createSessionService,
|
||||
restoreSessionsFromStorage,
|
||||
type EventBus,
|
||||
type SessionService,
|
||||
} from "@engine/index";
|
||||
import { createPdfByteStore, type PdfByteStore } from "@source/index";
|
||||
|
||||
import {
|
||||
SessionInternalContext,
|
||||
type SessionInternalContextValue,
|
||||
} from "./SessionContextInternal";
|
||||
|
||||
const SessionContext = SessionInternalContext;
|
||||
type SessionContextValue = SessionInternalContextValue;
|
||||
|
||||
interface SessionProviderProps {
|
||||
readonly children: ReactNode;
|
||||
/** Inject a pre-built service for tests; production uses the default. */
|
||||
readonly service?: SessionService;
|
||||
readonly bus?: EventBus;
|
||||
}
|
||||
|
||||
export function SessionProvider({
|
||||
children,
|
||||
service: injectedService,
|
||||
bus: injectedBus,
|
||||
}: SessionProviderProps) {
|
||||
const bus = useMemo(() => injectedBus ?? createEventBus(), [injectedBus]);
|
||||
const [repo] = useState(() => createInMemorySessionRepository());
|
||||
const service = useMemo(
|
||||
() => injectedService ?? createSessionService(repo, bus),
|
||||
[injectedService, repo, bus],
|
||||
);
|
||||
|
||||
const [activeId, setActiveId] = useState<SessionId | null>(null);
|
||||
const [hydrated, setHydrated] = useState<boolean>(false);
|
||||
const [byteStores] = useState<Map<SessionId, PdfByteStore>>(() => new Map());
|
||||
const [sessionVersions, setSessionVersions] = useState<ReadonlyMap<SessionId, number>>(
|
||||
() => new Map(),
|
||||
);
|
||||
|
||||
const getOrCreateByteStore = useCallback(
|
||||
(sessionId: SessionId) => {
|
||||
let store = byteStores.get(sessionId);
|
||||
if (!store) {
|
||||
store = createPdfByteStore();
|
||||
byteStores.set(sessionId, store);
|
||||
}
|
||||
return store;
|
||||
},
|
||||
[byteStores],
|
||||
);
|
||||
|
||||
const getSessionVersion = useCallback(
|
||||
(sessionId: SessionId) => sessionVersions.get(sessionId) ?? 0,
|
||||
[sessionVersions],
|
||||
);
|
||||
|
||||
const bumpSessionVersion = useCallback((sessionId: SessionId) => {
|
||||
setSessionVersions((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(sessionId, (prev.get(sessionId) ?? 0) + 1);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Hydrate from storage, then attach the persister.
|
||||
useEffect(() => {
|
||||
if (injectedService) {
|
||||
setHydrated(true);
|
||||
return;
|
||||
}
|
||||
if (typeof globalThis.localStorage === "undefined") {
|
||||
setHydrated(true);
|
||||
return;
|
||||
}
|
||||
const result = restoreSessionsFromStorage(repo, service);
|
||||
if (result.restored && result.activeSessionId) {
|
||||
setActiveId(result.activeSessionId);
|
||||
}
|
||||
setHydrated(true);
|
||||
return attachSessionPersister(service, bus);
|
||||
}, [bus, injectedService, repo, service]);
|
||||
|
||||
// Keep the active-id mirror in sync with the bus.
|
||||
useEffect(() => {
|
||||
return bus.on("SessionActivated", (e) => {
|
||||
setActiveId(e.sessionId);
|
||||
});
|
||||
}, [bus]);
|
||||
|
||||
// Drop byte stores for sessions that get deleted (revoking blob URLs).
|
||||
useEffect(() => {
|
||||
return bus.on("SessionDeleted", (e) => {
|
||||
const store = byteStores.get(e.sessionId);
|
||||
if (store) {
|
||||
store.clear();
|
||||
byteStores.delete(e.sessionId);
|
||||
}
|
||||
});
|
||||
}, [bus, byteStores]);
|
||||
|
||||
const value = useMemo<SessionContextValue>(
|
||||
() => ({
|
||||
service,
|
||||
bus,
|
||||
activeId,
|
||||
hydrated,
|
||||
getOrCreateByteStore,
|
||||
getSessionVersion,
|
||||
bumpSessionVersion,
|
||||
}),
|
||||
[
|
||||
service,
|
||||
bus,
|
||||
activeId,
|
||||
hydrated,
|
||||
getOrCreateByteStore,
|
||||
getSessionVersion,
|
||||
bumpSessionVersion,
|
||||
],
|
||||
);
|
||||
|
||||
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
||||
}
|
||||
|
||||
export function useSessionService(): SessionService {
|
||||
const ctx = useContext(SessionContext);
|
||||
if (!ctx) throw new Error("useSessionService: missing SessionProvider");
|
||||
return ctx.service;
|
||||
}
|
||||
|
||||
export function useSessionBus(): EventBus {
|
||||
const ctx = useContext(SessionContext);
|
||||
if (!ctx) throw new Error("useSessionBus: missing SessionProvider");
|
||||
return ctx.bus;
|
||||
}
|
||||
|
||||
export function useActiveSessionId(): SessionId | null {
|
||||
const ctx = useContext(SessionContext);
|
||||
if (!ctx) throw new Error("useActiveSessionId: missing SessionProvider");
|
||||
return ctx.activeId;
|
||||
}
|
||||
|
||||
export function useActiveSession(): Session | null {
|
||||
const ctx = useContext(SessionContext);
|
||||
if (!ctx) throw new Error("useActiveSession: missing SessionProvider");
|
||||
return ctx.activeId ? ctx.service.get(ctx.activeId) : null;
|
||||
}
|
||||
|
||||
export function useSessionsHydrated(): boolean {
|
||||
const ctx = useContext(SessionContext);
|
||||
if (!ctx) throw new Error("useSessionsHydrated: missing SessionProvider");
|
||||
return ctx.hydrated;
|
||||
}
|
||||
|
||||
export function useSessionByteStore(sessionId: SessionId): PdfByteStore {
|
||||
const ctx = useContext(SessionContext);
|
||||
if (!ctx) throw new Error("useSessionByteStore: missing SessionProvider");
|
||||
return ctx.getOrCreateByteStore(sessionId);
|
||||
}
|
||||
|
||||
export function useSessionVersionBumper(): (sessionId: SessionId) => void {
|
||||
const ctx = useContext(SessionContext);
|
||||
if (!ctx) throw new Error("useSessionVersionBumper: missing SessionProvider");
|
||||
return ctx.bumpSessionVersion;
|
||||
}
|
||||
|
||||
export function useSessionVersion(sessionId: SessionId): number {
|
||||
const ctx = useContext(SessionContext);
|
||||
if (!ctx) throw new Error("useSessionVersion: missing SessionProvider");
|
||||
return ctx.getSessionVersion(sessionId);
|
||||
}
|
||||
|
||||
export function useSessionByteStoreRegistry(): {
|
||||
getOrCreateByteStore(sessionId: SessionId): PdfByteStore;
|
||||
} {
|
||||
const ctx = useContext(SessionContext);
|
||||
if (!ctx) throw new Error("useSessionByteStoreRegistry: missing SessionProvider");
|
||||
return { getOrCreateByteStore: ctx.getOrCreateByteStore };
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-render whenever the session list mutates. Returns a tick counter
|
||||
* that callers can use as a `useMemo`/`useEffect` dependency to read
|
||||
* `service.list()` lazily.
|
||||
*/
|
||||
export function useSessionListTick(): number {
|
||||
const ctx = useContext(SessionContext);
|
||||
if (!ctx) throw new Error("useSessionListTick: missing SessionProvider");
|
||||
const [tick, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const bump = () => setTick((t) => t + 1);
|
||||
const offs = [
|
||||
ctx.bus.on("SessionCreated", bump),
|
||||
ctx.bus.on("SessionRenamed", bump),
|
||||
ctx.bus.on("SessionDeleted", bump),
|
||||
];
|
||||
return () => {
|
||||
for (const off of offs) off();
|
||||
};
|
||||
}, [ctx.bus]);
|
||||
return tick;
|
||||
}
|
||||
27
src/work/SessionContextInternal.ts
Normal file
27
src/work/SessionContextInternal.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Internal: the React Context object backing `SessionProvider`.
|
||||
*
|
||||
* Lives in its own module so `EngineContext.tsx` can subscribe without
|
||||
* importing the full `SessionContext.tsx` (which would re-export the
|
||||
* EngineProvider via the same `@work` barrel and create a circular
|
||||
* dependency at module-init time).
|
||||
*/
|
||||
|
||||
import { createContext } from "react";
|
||||
|
||||
import type { SessionId } from "@shared/ids";
|
||||
|
||||
import type { EventBus, SessionService } from "@engine/index";
|
||||
import type { PdfByteStore } from "@source/index";
|
||||
|
||||
export interface SessionInternalContextValue {
|
||||
readonly service: SessionService;
|
||||
readonly bus: EventBus;
|
||||
readonly activeId: SessionId | null;
|
||||
readonly hydrated: boolean;
|
||||
getOrCreateByteStore(sessionId: SessionId): PdfByteStore;
|
||||
getSessionVersion(sessionId: SessionId): number;
|
||||
bumpSessionVersion(sessionId: SessionId): void;
|
||||
}
|
||||
|
||||
export const SessionInternalContext = createContext<SessionInternalContextValue | null>(null);
|
||||
@@ -1,4 +1,4 @@
|
||||
export { CollectionList } from "./CollectionList";
|
||||
export { CollectionList, type CollectionListProps } from "./CollectionList";
|
||||
export { ViewerShell } from "./ViewerShell";
|
||||
export { EvidenceSidebar, type EvidenceSidebarProps } from "./EvidenceSidebar";
|
||||
export {
|
||||
@@ -14,8 +14,23 @@ export {
|
||||
useActiveDocument,
|
||||
useActiveDocumentId,
|
||||
useEngineEventTick,
|
||||
useEngineRevision,
|
||||
useLastActivatedEvidence,
|
||||
usePdfByteStore,
|
||||
usePendingSelection,
|
||||
useScrollToAnnotation,
|
||||
type PendingSelection,
|
||||
} from "./EngineContext";
|
||||
export {
|
||||
SessionProvider,
|
||||
useActiveSession,
|
||||
useActiveSessionId,
|
||||
useSessionBus,
|
||||
useSessionByteStore,
|
||||
useSessionByteStoreRegistry,
|
||||
useSessionListTick,
|
||||
useSessionService,
|
||||
useSessionsHydrated,
|
||||
useSessionVersion,
|
||||
useSessionVersionBumper,
|
||||
} from "./SessionContext";
|
||||
|
||||
Reference in New Issue
Block a user