Rematch documents by content fingerprint on upload and session open.
Re-uploading the same PDF attaches bytes to the existing document (keeping evidence) instead of creating a twin. On restore, merge duplicate fingerprint groups and migrate annotations onto the evidence-bearing primary.
This commit is contained in:
parent
a73d45bfac
commit
1fb1841173
7 changed files with 464 additions and 14 deletions
|
|
@ -38,6 +38,7 @@ import { CaptureLinkPersister } from "./forms/CaptureLinkPersister";
|
||||||
import { loadCaptureState } from "./forms/capture-persistence";
|
import { loadCaptureState } from "./forms/capture-persistence";
|
||||||
import { FormsApp } from "./forms/FormsApp";
|
import { FormsApp } from "./forms/FormsApp";
|
||||||
import { ReviewLayout } from "./ReviewLayout";
|
import { ReviewLayout } from "./ReviewLayout";
|
||||||
|
import { DocumentFingerprintReconciler } from "./sessions/DocumentFingerprintReconciler";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CreateFirstSession,
|
CreateFirstSession,
|
||||||
|
|
@ -176,6 +177,7 @@ function SessionScopedTree({ mode }: { mode: AppMode }) {
|
||||||
? { initialLinks: restoredCapture.evidenceLinks }
|
? { initialLinks: restoredCapture.evidenceLinks }
|
||||||
: {})}
|
: {})}
|
||||||
>
|
>
|
||||||
|
<DocumentFingerprintReconciler />
|
||||||
<CaptureLinkPersister sessionId={sessionId} />
|
<CaptureLinkPersister sessionId={sessionId} />
|
||||||
{mode === "forms" ? (
|
{mode === "forms" ? (
|
||||||
<FormsApp
|
<FormsApp
|
||||||
|
|
|
||||||
50
src/app/sessions/DocumentFingerprintReconciler.tsx
Normal file
50
src/app/sessions/DocumentFingerprintReconciler.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
/**
|
||||||
|
* On session mount / engine restore, collapse documents that share a content
|
||||||
|
* fingerprint (e.g. evidence-bearing record + re-upload twin after a reload
|
||||||
|
* lost bytes). Retargets the active document when it was a discarded twin.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useActiveDocumentId,
|
||||||
|
useEngine,
|
||||||
|
useEngineRevision,
|
||||||
|
usePdfByteStore,
|
||||||
|
} from "@work/index";
|
||||||
|
|
||||||
|
import { mergeDuplicateDocumentsByFingerprint } from "./document-rematch";
|
||||||
|
|
||||||
|
export function DocumentFingerprintReconciler() {
|
||||||
|
const engine = useEngine();
|
||||||
|
const byteStore = usePdfByteStore();
|
||||||
|
const revision = useEngineRevision();
|
||||||
|
const { id: activeId, setId } = useActiveDocumentId();
|
||||||
|
const ranForRevision = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Run once after each engine restore/hydrate revision bump.
|
||||||
|
if (ranForRevision.current === revision) return;
|
||||||
|
ranForRevision.current = revision;
|
||||||
|
|
||||||
|
const { remappedIds, documentsRemoved } =
|
||||||
|
mergeDuplicateDocumentsByFingerprint(engine, byteStore);
|
||||||
|
|
||||||
|
if (activeId && remappedIds.has(activeId)) {
|
||||||
|
setId(remappedIds.get(activeId)!);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bytes may have moved onto the still-active primary; force the viewer
|
||||||
|
// to re-resolve the blob URL (same id alone may not remount).
|
||||||
|
if (documentsRemoved > 0 && activeId && byteStore.has(activeId)) {
|
||||||
|
const keep = activeId;
|
||||||
|
setId(null);
|
||||||
|
queueMicrotask(() => setId(keep));
|
||||||
|
}
|
||||||
|
// Intentionally omit activeId from deps: we only reconcile on revision.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- see above
|
||||||
|
}, [engine, byteStore, revision, setId]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,8 @@ import {
|
||||||
usePdfByteStore,
|
usePdfByteStore,
|
||||||
} from "@work/index";
|
} from "@work/index";
|
||||||
|
|
||||||
|
import { registerOrRematchDocument } from "./document-rematch";
|
||||||
|
|
||||||
import manifest from "../../../fixtures/pdfs/manifest.json";
|
import manifest from "../../../fixtures/pdfs/manifest.json";
|
||||||
|
|
||||||
interface Fixture {
|
interface Fixture {
|
||||||
|
|
@ -60,16 +62,15 @@ export function SampleSessions() {
|
||||||
const { document, representation } = await ingestPdf(bytes, {
|
const { document, representation } = await ingestPdf(bytes, {
|
||||||
filename: fixture.filename,
|
filename: fixture.filename,
|
||||||
});
|
});
|
||||||
// Push the bytes into the byte store so the viewer can mount them via
|
// Same blob URL machinery as the upload path, with fingerprint rematch
|
||||||
// the same blob URL machinery used by the upload path. The document
|
// so reloading a sample reattaches to an existing session document.
|
||||||
// record carries the blob URL on `uri` for the viewer adapter.
|
|
||||||
const record = byteStore.put(document.id, bytes);
|
const record = byteStore.put(document.id, bytes);
|
||||||
engine.documents.register({
|
const outcome = registerOrRematchDocument(engine, byteStore, {
|
||||||
document: { ...document, uri: record.blobUrl },
|
document: { ...document, uri: record.blobUrl },
|
||||||
representation,
|
representation,
|
||||||
});
|
});
|
||||||
setByFixture((prev) => ({ ...prev, [fixture.id]: document.id }));
|
setByFixture((prev) => ({ ...prev, [fixture.id]: outcome.documentId }));
|
||||||
setId(document.id);
|
setId(outcome.documentId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@
|
||||||
* 1. read each File as bytes,
|
* 1. read each File as bytes,
|
||||||
* 2. run the source-layer `ingestPdfFromFile` (mints the blob URL
|
* 2. run the source-layer `ingestPdfFromFile` (mints the blob URL
|
||||||
* via the session's `PdfByteStore`),
|
* via the session's `PdfByteStore`),
|
||||||
* 3. register the resulting `{document, representation}` with the
|
* 3. register — or rematch by content fingerprint to an existing
|
||||||
* engine,
|
* document (so re-upload reattaches PDF bytes to prior evidence),
|
||||||
* 4. activate the most-recently-uploaded document.
|
* 4. activate the most-recently-uploaded / rematched document.
|
||||||
*
|
*
|
||||||
* Failures (non-PDFs, ingest errors) are surfaced inline above the
|
* Failures (non-PDFs, ingest errors) are surfaced inline above the
|
||||||
* dropzone; the caller doesn't need a separate toast for them.
|
* dropzone; the caller doesn't need a separate toast for them.
|
||||||
|
|
@ -23,10 +23,14 @@ import {
|
||||||
usePdfByteStore,
|
usePdfByteStore,
|
||||||
} from "@work/index";
|
} from "@work/index";
|
||||||
|
|
||||||
|
import { registerOrRematchDocument } from "./document-rematch";
|
||||||
|
|
||||||
interface UploadEntry {
|
interface UploadEntry {
|
||||||
readonly file: File;
|
readonly file: File;
|
||||||
status: "queued" | "uploading" | "done" | "error";
|
status: "queued" | "uploading" | "done" | "error";
|
||||||
error?: string;
|
error?: string;
|
||||||
|
/** True when upload reattached bytes to an existing fingerprint match. */
|
||||||
|
rematched?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UploadDropzoneProps {
|
export interface UploadDropzoneProps {
|
||||||
|
|
@ -67,10 +71,18 @@ export function UploadDropzone({ onUploaded }: UploadDropzoneProps) {
|
||||||
entry.file,
|
entry.file,
|
||||||
byteStore,
|
byteStore,
|
||||||
);
|
);
|
||||||
engine.documents.register({ document, representation });
|
const outcome = registerOrRematchDocument(engine, byteStore, {
|
||||||
|
document,
|
||||||
|
representation,
|
||||||
|
});
|
||||||
entry.status = "done";
|
entry.status = "done";
|
||||||
lastDocumentId = document.id;
|
if (outcome.kind === "rematched") {
|
||||||
onUploaded?.(document.id);
|
entry.error = undefined;
|
||||||
|
// Surface rematch in the progress list without treating it as failure.
|
||||||
|
(entry as UploadEntry & { rematched?: boolean }).rematched = true;
|
||||||
|
}
|
||||||
|
lastDocumentId = outcome.documentId;
|
||||||
|
onUploaded?.(outcome.documentId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
entry.status = "error";
|
entry.status = "error";
|
||||||
entry.error = err instanceof Error ? err.message : String(err);
|
entry.error = err instanceof Error ? err.message : String(err);
|
||||||
|
|
@ -178,7 +190,10 @@ export function UploadDropzone({ onUploaded }: UploadDropzoneProps) {
|
||||||
: "#333",
|
: "#333",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{entry.file.name} — {entry.status}
|
{entry.file.name} —{" "}
|
||||||
|
{entry.status === "done" && entry.rematched
|
||||||
|
? "done (matched existing document by content hash)"
|
||||||
|
: entry.status}
|
||||||
{entry.error ? `: ${entry.error}` : ""}
|
{entry.error ? `: ${entry.error}` : ""}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
150
src/app/sessions/document-rematch.test.ts
Normal file
150
src/app/sessions/document-rematch.test.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { Document, DocumentRepresentation } from "@shared/document";
|
||||||
|
import type { DocumentId, RepresentationId } from "@shared/ids";
|
||||||
|
import { createEngine } from "@engine/index";
|
||||||
|
import { createPdfByteStore } from "@source/index";
|
||||||
|
|
||||||
|
import {
|
||||||
|
mergeDuplicateDocumentsByFingerprint,
|
||||||
|
pickPrimaryDocument,
|
||||||
|
registerOrRematchDocument,
|
||||||
|
rekeyByteStore,
|
||||||
|
} from "./document-rematch";
|
||||||
|
|
||||||
|
function makeDoc(
|
||||||
|
id: string,
|
||||||
|
fingerprint: string,
|
||||||
|
title = "doc.pdf",
|
||||||
|
): Document {
|
||||||
|
const now = "2026-07-30T00:00:00.000Z";
|
||||||
|
return {
|
||||||
|
id: id as DocumentId,
|
||||||
|
mediaType: "application/pdf",
|
||||||
|
title,
|
||||||
|
fingerprint,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRep(docId: string, repId: string): DocumentRepresentation {
|
||||||
|
return {
|
||||||
|
id: repId as RepresentationId,
|
||||||
|
documentId: docId as DocumentId,
|
||||||
|
representationType: "pdf-text",
|
||||||
|
contentHash: "hash",
|
||||||
|
canonicalText: "hello",
|
||||||
|
pageMap: [{ page: 1, width: 1, height: 1 }],
|
||||||
|
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 5, pageLength: 5 }],
|
||||||
|
generatedAt: "2026-07-30T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("document-rematch", () => {
|
||||||
|
it("rekeys byte store from new id onto existing id", () => {
|
||||||
|
const store = createPdfByteStore({
|
||||||
|
createObjectURL: () => "blob:x",
|
||||||
|
revokeObjectURL: () => {},
|
||||||
|
});
|
||||||
|
const from = "doc_new" as DocumentId;
|
||||||
|
const to = "doc_old" as DocumentId;
|
||||||
|
store.put(from, new Uint8Array([1, 2, 3]));
|
||||||
|
expect(rekeyByteStore(store, from, to)).toBe(true);
|
||||||
|
expect(store.has(from)).toBe(false);
|
||||||
|
expect(store.get(to)?.bytes).toEqual(new Uint8Array([1, 2, 3]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registerOrRematch attaches bytes to existing fingerprint and skips twin", () => {
|
||||||
|
const engine = createEngine();
|
||||||
|
const store = createPdfByteStore({
|
||||||
|
createObjectURL: vi.fn(() => `blob:${Math.random()}`),
|
||||||
|
revokeObjectURL: () => {},
|
||||||
|
});
|
||||||
|
const existing = makeDoc("doc_old", "fp-aaa", "old.pdf");
|
||||||
|
engine.documents.register({
|
||||||
|
document: existing,
|
||||||
|
representation: makeRep("doc_old", "rep_old"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const fresh = makeDoc("doc_new", "fp-aaa", "reupload.pdf");
|
||||||
|
store.put("doc_new" as DocumentId, new Uint8Array([9, 9]));
|
||||||
|
const result = registerOrRematchDocument(engine, store, {
|
||||||
|
document: { ...fresh, uri: "blob:new" },
|
||||||
|
representation: makeRep("doc_new", "rep_new"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.kind).toBe("rematched");
|
||||||
|
if (result.kind !== "rematched") return;
|
||||||
|
expect(result.documentId).toBe("doc_old");
|
||||||
|
expect(engine.documents.list()).toHaveLength(1);
|
||||||
|
expect(store.has("doc_new" as DocumentId)).toBe(false);
|
||||||
|
expect(store.get("doc_old" as DocumentId)?.bytes).toEqual(
|
||||||
|
new Uint8Array([9, 9]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registerOrRematch registers when fingerprint is new", () => {
|
||||||
|
const engine = createEngine();
|
||||||
|
const store = createPdfByteStore({
|
||||||
|
createObjectURL: () => "blob:y",
|
||||||
|
revokeObjectURL: () => {},
|
||||||
|
});
|
||||||
|
const doc = makeDoc("doc_1", "fp-new");
|
||||||
|
store.put("doc_1" as DocumentId, new Uint8Array([1]));
|
||||||
|
const result = registerOrRematchDocument(engine, store, {
|
||||||
|
document: doc,
|
||||||
|
representation: makeRep("doc_1", "rep_1"),
|
||||||
|
});
|
||||||
|
expect(result.kind).toBe("registered");
|
||||||
|
expect(engine.documents.list()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mergeDuplicateDocumentsByFingerprint prefers evidence-bearing primary", () => {
|
||||||
|
const engine = createEngine();
|
||||||
|
const store = createPdfByteStore({
|
||||||
|
createObjectURL: vi.fn(() => `blob:${Math.random()}`),
|
||||||
|
revokeObjectURL: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const withEvidence = makeDoc("doc_a", "fp-same", "a.pdf");
|
||||||
|
const withBytesOnly = makeDoc("doc_b", "fp-same", "b.pdf");
|
||||||
|
engine.documents.register({
|
||||||
|
document: withEvidence,
|
||||||
|
representation: makeRep("doc_a", "rep_a"),
|
||||||
|
});
|
||||||
|
engine.documents.register({
|
||||||
|
document: withBytesOnly,
|
||||||
|
representation: makeRep("doc_b", "rep_b"),
|
||||||
|
});
|
||||||
|
store.put("doc_b" as DocumentId, new Uint8Array([7, 7, 7]));
|
||||||
|
|
||||||
|
const ann = engine.annotations.create({
|
||||||
|
documentId: "doc_a" as DocumentId,
|
||||||
|
selectors: [{ type: "TextQuoteSelector", exact: "hello" }],
|
||||||
|
quote: "hello",
|
||||||
|
});
|
||||||
|
engine.evidence.create({
|
||||||
|
annotationIds: [ann.id],
|
||||||
|
commentary: "note",
|
||||||
|
});
|
||||||
|
|
||||||
|
const primary = pickPrimaryDocument(engine, store, [
|
||||||
|
withBytesOnly,
|
||||||
|
withEvidence,
|
||||||
|
]);
|
||||||
|
expect(primary.id).toBe("doc_a");
|
||||||
|
|
||||||
|
const stats = mergeDuplicateDocumentsByFingerprint(engine, store);
|
||||||
|
expect(stats.groupsMerged).toBe(1);
|
||||||
|
expect(stats.documentsRemoved).toBe(1);
|
||||||
|
expect(engine.documents.list().map((d) => d.id)).toEqual(["doc_a"]);
|
||||||
|
expect(store.get("doc_a" as DocumentId)?.bytes).toEqual(
|
||||||
|
new Uint8Array([7, 7, 7]),
|
||||||
|
);
|
||||||
|
expect(engine.annotations.listByDocument("doc_a" as DocumentId)).toHaveLength(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(engine.evidence.listByDocument("doc_a" as DocumentId)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
213
src/app/sessions/document-rematch.ts
Normal file
213
src/app/sessions/document-rematch.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
/**
|
||||||
|
* Document identity by content fingerprint (SHA-256 of source bytes).
|
||||||
|
*
|
||||||
|
* PDFs and evidence are stored under different lifecycle concerns (bytes in
|
||||||
|
* the byte store / IndexedDB; annotations + evidence in the engine snapshot).
|
||||||
|
* They re-join via `Document.fingerprint` so a re-upload of the same file
|
||||||
|
* attaches bytes to the existing document instead of creating a twin.
|
||||||
|
*
|
||||||
|
* Also collapses accidental duplicates (e.g. upload after a reload lost
|
||||||
|
* bytes but kept the evidence-bearing document record).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Document, DocumentRepresentation } from "@shared/document";
|
||||||
|
import type { DocumentId } from "@shared/ids";
|
||||||
|
import type { Engine } from "@engine/index";
|
||||||
|
import type { PdfByteStore } from "@source/index";
|
||||||
|
|
||||||
|
export type RegisterOrRematchResult =
|
||||||
|
| {
|
||||||
|
readonly kind: "registered";
|
||||||
|
readonly documentId: DocumentId;
|
||||||
|
readonly fingerprint: string | undefined;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly kind: "rematched";
|
||||||
|
readonly documentId: DocumentId;
|
||||||
|
readonly fingerprint: string;
|
||||||
|
readonly discardedDocumentId: DocumentId;
|
||||||
|
};
|
||||||
|
|
||||||
|
function evidenceCount(engine: Engine, documentId: DocumentId): number {
|
||||||
|
return engine.evidence.listByDocument(documentId).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function annotationCount(engine: Engine, documentId: DocumentId): number {
|
||||||
|
return engine.annotations.listByDocument(documentId).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefer the document that already carries user work (evidence / annotations),
|
||||||
|
* then one that already has bytes, then the first seen.
|
||||||
|
*/
|
||||||
|
export function pickPrimaryDocument(
|
||||||
|
engine: Engine,
|
||||||
|
byteStore: PdfByteStore,
|
||||||
|
candidates: readonly Document[],
|
||||||
|
): Document {
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
throw new Error("pickPrimaryDocument: empty candidates");
|
||||||
|
}
|
||||||
|
if (candidates.length === 1) return candidates[0]!;
|
||||||
|
|
||||||
|
const scored = candidates.map((doc) => ({
|
||||||
|
doc,
|
||||||
|
evidence: evidenceCount(engine, doc.id),
|
||||||
|
annotations: annotationCount(engine, doc.id),
|
||||||
|
hasBytes: byteStore.has(doc.id) ? 1 : 0,
|
||||||
|
}));
|
||||||
|
scored.sort((a, b) => {
|
||||||
|
if (b.evidence !== a.evidence) return b.evidence - a.evidence;
|
||||||
|
if (b.annotations !== a.annotations) return b.annotations - a.annotations;
|
||||||
|
if (b.hasBytes !== a.hasBytes) return b.hasBytes - a.hasBytes;
|
||||||
|
return a.doc.createdAt.localeCompare(b.doc.createdAt);
|
||||||
|
});
|
||||||
|
return scored[0]!.doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findDocumentsByFingerprint(
|
||||||
|
engine: Engine,
|
||||||
|
fingerprint: string,
|
||||||
|
): readonly Document[] {
|
||||||
|
if (!fingerprint) return [];
|
||||||
|
return engine.documents.list().filter((d) => d.fingerprint === fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move in-memory / durable bytes from `fromId` onto `toId` and drop `fromId`.
|
||||||
|
*/
|
||||||
|
export function rekeyByteStore(
|
||||||
|
byteStore: PdfByteStore,
|
||||||
|
fromId: DocumentId,
|
||||||
|
toId: DocumentId,
|
||||||
|
): boolean {
|
||||||
|
if (fromId === toId) return byteStore.has(toId);
|
||||||
|
const record = byteStore.get(fromId);
|
||||||
|
if (!record) return byteStore.has(toId);
|
||||||
|
byteStore.put(toId, record.bytes);
|
||||||
|
byteStore.delete(fromId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After ingest, either register a new document or attach bytes to an existing
|
||||||
|
* one with the same fingerprint (evidence stays on the existing id).
|
||||||
|
*/
|
||||||
|
export function registerOrRematchDocument(
|
||||||
|
engine: Engine,
|
||||||
|
byteStore: PdfByteStore,
|
||||||
|
input: {
|
||||||
|
readonly document: Document;
|
||||||
|
readonly representation: DocumentRepresentation;
|
||||||
|
},
|
||||||
|
): RegisterOrRematchResult {
|
||||||
|
const fingerprint = input.document.fingerprint;
|
||||||
|
if (fingerprint) {
|
||||||
|
const matches = findDocumentsByFingerprint(engine, fingerprint);
|
||||||
|
if (matches.length > 0) {
|
||||||
|
const primary = pickPrimaryDocument(engine, byteStore, matches);
|
||||||
|
rekeyByteStore(byteStore, input.document.id, primary.id);
|
||||||
|
|
||||||
|
// Refresh title/uri on the primary when useful (viewer prefers byte store).
|
||||||
|
const live = byteStore.get(primary.id);
|
||||||
|
if (live || input.document.title) {
|
||||||
|
engine.repos.documents.update({
|
||||||
|
...primary,
|
||||||
|
...(input.document.title !== undefined
|
||||||
|
? { title: input.document.title }
|
||||||
|
: {}),
|
||||||
|
...(live ? { uri: live.blobUrl } : {}),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never register the discarded twin.
|
||||||
|
return {
|
||||||
|
kind: "rematched",
|
||||||
|
documentId: primary.id,
|
||||||
|
fingerprint,
|
||||||
|
discardedDocumentId: input.document.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.documents.register({
|
||||||
|
document: input.document,
|
||||||
|
representation: input.representation,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
kind: "registered",
|
||||||
|
documentId: input.document.id,
|
||||||
|
fingerprint,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse documents that share a fingerprint into one primary record.
|
||||||
|
* Migrates annotations onto the primary and copies PDF bytes when present.
|
||||||
|
*/
|
||||||
|
export function mergeDuplicateDocumentsByFingerprint(
|
||||||
|
engine: Engine,
|
||||||
|
byteStore: PdfByteStore,
|
||||||
|
): {
|
||||||
|
readonly groupsMerged: number;
|
||||||
|
readonly documentsRemoved: number;
|
||||||
|
/** secondary documentId → primary documentId */
|
||||||
|
readonly remappedIds: ReadonlyMap<DocumentId, DocumentId>;
|
||||||
|
} {
|
||||||
|
const byFp = new Map<string, Document[]>();
|
||||||
|
for (const doc of engine.documents.list()) {
|
||||||
|
if (!doc.fingerprint) continue;
|
||||||
|
const list = byFp.get(doc.fingerprint) ?? [];
|
||||||
|
list.push(doc);
|
||||||
|
byFp.set(doc.fingerprint, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
let groupsMerged = 0;
|
||||||
|
let documentsRemoved = 0;
|
||||||
|
const remappedIds = new Map<DocumentId, DocumentId>();
|
||||||
|
|
||||||
|
for (const [, group] of byFp) {
|
||||||
|
if (group.length < 2) continue;
|
||||||
|
groupsMerged += 1;
|
||||||
|
const primary = pickPrimaryDocument(engine, byteStore, group);
|
||||||
|
const primaryRep =
|
||||||
|
engine.documents.listRepresentations(primary.id)[0] ?? null;
|
||||||
|
|
||||||
|
for (const secondary of group) {
|
||||||
|
if (secondary.id === primary.id) continue;
|
||||||
|
|
||||||
|
rekeyByteStore(byteStore, secondary.id, primary.id);
|
||||||
|
remappedIds.set(secondary.id, primary.id);
|
||||||
|
|
||||||
|
for (const ann of engine.annotations.listByDocument(secondary.id)) {
|
||||||
|
engine.repos.annotations.update({
|
||||||
|
...ann,
|
||||||
|
documentId: primary.id,
|
||||||
|
...(primaryRep
|
||||||
|
? { representationId: primaryRep.id }
|
||||||
|
: ann.representationId !== undefined
|
||||||
|
? { representationId: ann.representationId }
|
||||||
|
: {}),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop secondary record (and its representations). Evidence items stay:
|
||||||
|
// they key off annotation ids, which now point at primary.
|
||||||
|
engine.documents.remove(secondary.id);
|
||||||
|
documentsRemoved += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const live = byteStore.get(primary.id);
|
||||||
|
if (live) {
|
||||||
|
engine.repos.documents.update({
|
||||||
|
...engine.documents.get(primary.id)!,
|
||||||
|
uri: live.blobUrl,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { groupsMerged, documentsRemoved, remappedIds };
|
||||||
|
}
|
||||||
|
|
@ -154,10 +154,29 @@ export async function importSessionZip(
|
||||||
const targetByteStore = services.getOrCreateByteStore(targetSessionId);
|
const targetByteStore = services.getOrCreateByteStore(targetSessionId);
|
||||||
|
|
||||||
// 5. Build the document remap.
|
// 5. Build the document remap.
|
||||||
|
// Prefer fingerprint targets that already hold annotations/evidence so a
|
||||||
|
// re-import reattaches to the "work" document, not an empty twin.
|
||||||
const docRemap = new Map<DocumentId, DocumentId>();
|
const docRemap = new Map<DocumentId, DocumentId>();
|
||||||
const existingByFingerprint = new Map<string, DocumentId>();
|
const existingByFingerprint = new Map<string, DocumentId>();
|
||||||
|
const fingerprintCandidates = new Map<string, Document[]>();
|
||||||
for (const doc of targetEngine.documents.list()) {
|
for (const doc of targetEngine.documents.list()) {
|
||||||
if (doc.fingerprint) existingByFingerprint.set(doc.fingerprint, doc.id);
|
if (!doc.fingerprint) continue;
|
||||||
|
const list = fingerprintCandidates.get(doc.fingerprint) ?? [];
|
||||||
|
list.push(doc);
|
||||||
|
fingerprintCandidates.set(doc.fingerprint, list);
|
||||||
|
}
|
||||||
|
for (const [fp, candidates] of fingerprintCandidates) {
|
||||||
|
const scored = candidates.map((doc) => ({
|
||||||
|
id: doc.id,
|
||||||
|
evidence: targetEngine.evidence.listByDocument(doc.id).length,
|
||||||
|
annotations: targetEngine.annotations.listByDocument(doc.id).length,
|
||||||
|
}));
|
||||||
|
scored.sort((a, b) => {
|
||||||
|
if (b.evidence !== a.evidence) return b.evidence - a.evidence;
|
||||||
|
if (b.annotations !== a.annotations) return b.annotations - a.annotations;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
existingByFingerprint.set(fp, scored[0]!.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
let documentsAdded = 0;
|
let documentsAdded = 0;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue