feat(documents): let the picker bring back a deleted document

The document picker showed a document deleted in HealthLog as "Deleted
in HealthLog" and would not let it be ticked, so a person who deleted an
imported document by mistake met a dead end exactly where they would look
for it again.

Such a row can now be ticked, and the import sends revive: true for it.
A document still in Recently deleted is restored through the shared
restore, nothing is fetched, and the row reads "Restored from Recently
deleted". One the purge already removed is fetched and stored again, and
only once it is stored does the ledger forget its key, so a failed fetch
leaves the deletion standing. Without revive the route answers "deleted"
as before, and the token upload has no such switch: a Paperless workflow
or the import script re-sending a deleted document is still refused,
whether it is in Recently deleted or long purged.

The search names the document a deleted key still points at (documentId,
null once purged), so the row can say "In Recently deleted" rather than
"Deleted in HealthLog". The outcome gains "restored", sent only in answer
to revive. The row logic moves to picker-rows.ts.

Refs #1275
This commit is contained in:
Marc-André Bombeck committed 2026-10-24 20:55:19 +02:00
1 parent 4752115d7d
commit 52fa5a06b1
22 files changed
+562 -103

No files matched your search

@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import type { DocumentSourceResultDto } from "@/lib/documents/sources/types";
import {
deletedSourceIds,
heldBadgeLabel,
isPickable,
runLabel,
runOutcome,
} from "../picker-rows";
function row(
over: Partial<DocumentSourceResultDto> = {},
): DocumentSourceResultDto {
return {
sourceId: "41",
title: "Blood test",
date: null,
tags: [],
sizeBytes: null,
state: "new",
documentId: null,
...over,
};
}
const t = (key: string) => key;
describe("isPickable", () => {
it("ticks a new row and a deleted one (asking for it back)", () => {
expect(isPickable(row(), false)).toBe(true);
expect(isPickable(row({ state: "deleted", documentId: "d1" }), false)).toBe(
true,
);
expect(isPickable(row({ state: "deleted" }), false)).toBe(true);
});
it("ticks an imported row only when the pick links it or hands it on", () => {
const imported = row({ state: "imported", documentId: "d1" });
expect(isPickable(imported, false)).toBe(false);
expect(isPickable(imported, true)).toBe(true);
});
});
describe("deletedSourceIds", () => {
it("names exactly the rows held as deleted, which the import revives", () => {
const ids = deletedSourceIds([
row({ sourceId: "1" }),
row({ sourceId: "2", state: "deleted", documentId: "d2" }),
row({ sourceId: "3", state: "imported", documentId: "d3" }),
row({ sourceId: "4", state: "deleted" }),
]);
expect([...ids].sort()).toEqual(["2", "4"]);
});
});
describe("heldBadgeLabel", () => {
it("tells a document still in Recently deleted from one removed for good", () => {
expect(heldBadgeLabel(t, row({ state: "deleted", documentId: "d1" }))).toBe(
"documents.sourcePicker.stateInTrash",
);
expect(heldBadgeLabel(t, row({ state: "deleted" }))).toBe(
"documents.sourcePicker.stateDeleted",
);
expect(heldBadgeLabel(t, row({ state: "imported", documentId: "d" }))).toBe(
"documents.sourcePicker.stateImported",
);
});
});
describe("a restored row", () => {
it("reads as restored, linked or not", () => {
expect(runLabel(t, { state: "restored", linked: false })).toBe(
"documents.sourcePicker.result.restored",
);
expect(runLabel(t, { state: "restored", linked: true })).toBe(
"documents.sourcePicker.result.restoredLinked",
);
});
it("counts toward a successful run", () => {
expect(runOutcome({ imported: 1, present: 0, failed: 0 })).toBe("success");
});
});
@@ -9,7 +9,11 @@
* field opens focused and debounces into the source's own name search. Tag and
* date are filters beside it, not the entry point. Each result says what
* HealthLog already holds under that source key: already imported, deleted
* here (not offered again: a deletion was a decision), or new.
* here, or new. A deleted one can be ticked too, and that is the person
* deciding again: one still in Recently deleted is restored (nothing is
* fetched), one already removed for good is imported afresh. The import sends
* `revive` only for those rows; an importer's upload never can, so a deletion
* still holds against every automatic re-send.
*
* The import runs one document per request, in order, so every row can show
* its own progress and result, and one failure does not hide the others. A
@@ -59,7 +63,6 @@ import { apiGet, apiPost } from "@/lib/api/api-fetch";
import {
DOCUMENT_PICKER_MAX_SELECTION,
slugForSystem,
type DocumentImportOutcome,
type DocumentPickerLinkKind,
type DocumentPickerSystem,
type DocumentSourceImportDto,
@@ -73,7 +76,6 @@ import { cn } from "@/lib/utils";
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
import type { WrittenOutcome } from "@/lib/outcome/written-outcome";
import { formatBytes } from "@/lib/format/bytes";
import {
@@ -84,6 +86,14 @@ import {
import { Segmented } from "@/components/ui/segmented";
import { systemName } from "./use-document-sources";
import { InlineAction } from "@/components/ui/inline-action";
import {
deletedSourceIds,
heldBadgeLabel,
isPickable,
runLabel,
runOutcome,
type RowRun,
} from "./picker-rows";
export interface DocumentPickerLink {
kind: DocumentPickerLinkKind;
@@ -103,12 +113,6 @@ export interface DocumentSourcePickerProps {
onImported?: (documentIds: string[]) => void;
}
type RowRun =
| { state: "waiting" }
| { state: "working" }
| { state: DocumentImportOutcome; linked: boolean }
| { state: "failed"; message: string };
const SEARCH_DEBOUNCE_MS = 300;
const IMPORT_TIMEOUT_MS = 120_000;
@@ -242,11 +246,9 @@ export function DocumentSourcePicker({
return out;
}, [search.data]);
// An already imported document is worth picking only when the pick does
// something with it: link it, or hand it to the form.
const canTakeImported = Boolean(link || onImported);
const selectable = (row: DocumentSourceResultDto) =>
row.state === "new" || (row.state === "imported" && canTakeImported);
isPickable(row, canTakeImported);
const toggle = (row: DocumentSourceResultDto) => {
if (running || finished || !selectable(row)) return;
@@ -269,6 +271,7 @@ export function DocumentSourcePicker({
setRunning(true);
setStopMessage(null);
const order = [...selected.keys()];
const revive = deletedSourceIds(results);
setRuns(new Map(order.map((id) => [id, { state: "waiting" }])));
const handed: string[] = [];
const mark = (id: string, run: RowRun) => {
@@ -287,6 +290,7 @@ export function DocumentSourcePicker({
sourceId,
...(kind ? { kind } : {}),
...(link ? { link } : {}),
...(revive.has(sourceId) ? { revive: true } : {}),
},
// The server may take up to a minute to fetch a large file from
// the source; the default 15 s client timeout would abandon it.
@@ -324,7 +328,7 @@ export function DocumentSourcePicker({
let present = 0;
let failed = 0;
for (const run of runs.values()) {
if (run.state === "imported") imported += 1;
if (run.state === "imported" || run.state === "restored") imported += 1;
else if (run.state === "duplicate" || run.state === "deleted")
present += 1;
else if (run.state === "failed") failed += 1;
@@ -643,9 +647,7 @@ export function DocumentSourcePicker({
</span>
{row.state !== "new" && !run ? (
<Badge variant="outline" className="shrink-0">
{row.state === "imported"
? t("documents.sourcePicker.stateImported")
: t("documents.sourcePicker.stateDeleted")}
{heldBadgeLabel(t, row)}
</Badge>
) : null}
</button>
@@ -678,41 +680,6 @@ export function DocumentSourcePicker({
);
}
/** How the run went, in the shared outcome vocabulary. */
function runOutcome(counts: {
imported: number;
present: number;
failed: number;
}): WrittenOutcome {
const held = counts.imported + counts.present;
if (counts.failed === 0) return held > 0 ? "success" : "empty";
return held > 0 ? "partial" : "failed";
}
/** A row's result in words, or null while it has none yet. */
function runLabel(
t: (key: string, params?: Record<string, string | number>) => string,
run: RowRun,
): string | null {
switch (run.state) {
case "waiting":
case "working":
return null;
case "failed":
return run.message;
case "deleted":
return t("documents.sourcePicker.result.deleted");
case "imported":
return run.linked
? t("documents.sourcePicker.result.importedLinked")
: t("documents.sourcePicker.result.imported");
case "duplicate":
return run.linked
? t("documents.sourcePicker.result.duplicateLinked")
: t("documents.sourcePicker.result.duplicate");
}
}
/** One row's progress or result, under its title. */
function RunLine({ run }: { run: RowRun }) {
const { t } = useTranslations();
@@ -751,19 +718,24 @@ function RunLine({ run }: { run: RowRun }) {
);
case "imported":
case "duplicate":
case "restored":
return (
<span
className="text-success flex items-center gap-1.5 text-xs"
data-slot="document-source-run-done"
>
<CircleCheck className="size-3.5 shrink-0" aria-hidden />
{run.state === "imported"
{run.state === "restored"
? run.linked
? t("documents.sourcePicker.result.importedLinked")
: t("documents.sourcePicker.result.imported")
: run.linked
? t("documents.sourcePicker.result.duplicateLinked")
: t("documents.sourcePicker.result.duplicate")}
? t("documents.sourcePicker.result.restoredLinked")
: t("documents.sourcePicker.result.restored")
: run.state === "imported"
? run.linked
? t("documents.sourcePicker.result.importedLinked")
: t("documents.sourcePicker.result.imported")
: run.linked
? t("documents.sourcePicker.result.duplicateLinked")
: t("documents.sourcePicker.result.duplicate")}
</span>
);
}
@@ -0,0 +1,91 @@
/**
* The document picker's row logic, apart from its rendering: which rows can be
* ticked, which ask for a deleted document back, what a held row's badge and
* a finished row's result say, and how a whole run went.
*/
import type {
DocumentImportOutcome,
DocumentSourceResultDto,
} from "@/lib/documents/sources/types";
import type { WrittenOutcome } from "@/lib/outcome/written-outcome";
export type RowRun =
| { state: "waiting" }
| { state: "working" }
| { state: DocumentImportOutcome; linked: boolean }
| { state: "failed"; message: string };
/**
* Whether a result row can be ticked: a new one always; one deleted here too,
* since ticking it asks for it back (`revive`); an already imported one only
* when the pick does something with it: link it, or hand it to the form.
*/
export function isPickable(
row: DocumentSourceResultDto,
canTakeImported: boolean,
): boolean {
if (row.state === "imported") return canTakeImported;
return true;
}
/** The rows the vault holds as deleted: ticking one asks for it back. */
export function deletedSourceIds(rows: DocumentSourceResultDto[]): Set<string> {
return new Set(
rows.filter((r) => r.state === "deleted").map((r) => r.sourceId),
);
}
/**
* The badge on a row the vault already holds. A deleted one still in Recently
* deleted (the search names it) says so; one removed for good says deleted.
*/
export function heldBadgeLabel(
t: (key: string) => string,
row: DocumentSourceResultDto,
): string {
if (row.state === "imported") {
return t("documents.sourcePicker.stateImported");
}
return row.documentId
? t("documents.sourcePicker.stateInTrash")
: t("documents.sourcePicker.stateDeleted");
}
/** How the run went, in the shared outcome vocabulary. */
export function runOutcome(counts: {
imported: number;
present: number;
failed: number;
}): WrittenOutcome {
const held = counts.imported + counts.present;
if (counts.failed === 0) return held > 0 ? "success" : "empty";
return held > 0 ? "partial" : "failed";
}
/** A row's result in words, or null while it has none yet. */
export function runLabel(
t: (key: string, params?: Record<string, string | number>) => string,
run: RowRun,
): string | null {
switch (run.state) {
case "waiting":
case "working":
return null;
case "failed":
return run.message;
case "deleted":
return t("documents.sourcePicker.result.deleted");
case "restored":
return run.linked
? t("documents.sourcePicker.result.restoredLinked")
: t("documents.sourcePicker.result.restored");
case "imported":
return run.linked
? t("documents.sourcePicker.result.importedLinked")
: t("documents.sourcePicker.result.imported");
case "duplicate":
return run.linked
? t("documents.sourcePicker.result.duplicateLinked")
: t("documents.sourcePicker.result.duplicate");
}
}