"use client"; /** * The document picker (#1038): search a connected Paperless-ngx or Papra by * document name, narrow by tag and date, tick the documents to bring over, * import them. * * Name first, because that is how the person looks for a letter: the search * 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. * * 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 * refusal that will answer every following document the same way (the hourly * upload allowance, a full vault, a refused token) stops the run and says so. * * Where it was opened from decides what an import does besides storing: * * - `link`: the record already exists (a condition's page), so the server * files the document against it as part of the import; * - `onImported`: a form that owns its selection (a visit, a vaccination) * gets the new ids and adds them itself. Linking on the server there would * be undone by the form's own save, which replaces its links. * * With either, a document already in HealthLog can be picked too: it is not * stored twice, it is linked or handed to the form. */ import { useCalendarDate } from "@/hooks/use-calendar-date"; import { useInfiniteQuery, useQuery, useQueryClient, } from "@tanstack/react-query"; import { Check, CircleAlert, CircleCheck, Loader2, Search, SearchX, X, } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; import { DateField } from "@/components/ui/date-field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; import { QueryErrorRow } from "@/components/ui/query-error-row"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Skeleton } from "@/components/ui/skeleton"; import { TagChip } from "@/components/ui/tag-chip"; import { apiGet, apiPost } from "@/lib/api/api-fetch"; import { DOCUMENT_PICKER_MAX_SELECTION, slugForSystem, type DocumentImportOutcome, type DocumentPickerLinkKind, type DocumentPickerSystem, type DocumentSourceImportDto, type DocumentSourceResultDto, type DocumentSourceSearchDto, type DocumentSourceTagDto, } from "@/lib/documents/sources/types"; import { useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, queryKeys } from "@/lib/query-keys"; 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 "../vault-utils"; import { errorCodeOf, isStoppingError, sourceErrorMessage, } from "./source-errors"; import { SegmentedChoice } from "./segmented-choice"; import { systemName } from "./use-document-sources"; export interface DocumentPickerLink { kind: DocumentPickerLinkKind; id: string; } export interface DocumentSourcePickerProps { open: boolean; onOpenChange: (open: boolean) => void; /** Connected systems, in the order the switch shows them. */ systems: DocumentPickerSystem[]; /** File the imported documents against this existing record. */ link?: DocumentPickerLink; /** Document type for everything imported (default OTHER). */ kind?: InboundDocumentKindValue; /** Hand the vault ids to a form that links them on its own save. */ 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; export function DocumentSourcePicker({ open, onOpenChange, systems, link, kind, onImported, }: DocumentSourcePickerProps) { const { t, locale } = useTranslations(); const calendarDate = useCalendarDate(); const queryClient = useQueryClient(); const [system, setSystem] = useState(null); const active = system && systems.includes(system) ? system : (systems[0] ?? null); const slug = active ? slugForSystem(active) : null; const [searchDraft, setSearchDraft] = useState(""); const [q, setQ] = useState(""); const [tag, setTag] = useState(""); const [from, setFrom] = useState(""); const [to, setTo] = useState(""); const [selected, setSelected] = useState>(new Map()); const [runs, setRuns] = useState>(new Map()); const [running, setRunning] = useState(false); const [finished, setFinished] = useState(false); const [stopMessage, setStopMessage] = useState(null); const [atMax, setAtMax] = useState(false); /** The latest per-row result, for screen readers (aria-live below). */ const [announcement, setAnnouncement] = useState(""); // A new search or filter starts over: a selection made in one result list // must not ride along unseen into another, and a finished run's result // lines belong to the list they were made in. const startOver = () => { setSelected(new Map()); setRuns(new Map()); setFinished(false); setStopMessage(null); setAtMax(false); }; const debounceRef = useRef | null>(null); useEffect( () => () => { if (debounceRef.current) clearTimeout(debounceRef.current); }, [], ); const onSearchChange = (value: string) => { setSearchDraft(value); if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { const next = value.trim(); if (next !== q) { startOver(); setQ(next); } }, SEARCH_DEBOUNCE_MS); }; const filterSetter = (set: (value: string) => void) => (value: string): void => { startOver(); set(value); }; const reset = () => { setSearchDraft(""); setQ(""); setTag(""); setFrom(""); setTo(""); startOver(); }; const switchSystem = (next: DocumentPickerSystem) => { if (next === active || running) return; reset(); setSystem(next); }; const tags = useQuery({ queryKey: queryKeys.documentSourceTags(slug ?? ""), enabled: open && slug !== null, queryFn: () => apiGet<{ tags: DocumentSourceTagDto[] }>( `/api/documents/sources/${slug}/tags`, ), staleTime: 5 * 60_000, }); const facets = { q, ...(tag ? { tag } : {}), ...(from ? { from } : {}), ...(to ? { to } : {}), }; const rangeInvalid = Boolean(from && to && from > to); const search = useInfiniteQuery({ queryKey: queryKeys.documentSourceSearch(slug ?? "", facets), enabled: open && slug !== null && !rangeInvalid, initialPageParam: 1, queryFn: ({ pageParam }) => { const sp = new URLSearchParams({ page: String(pageParam) }); if (q) sp.set("q", q); if (tag) sp.set("tag", tag); if (from) sp.set("from", from); if (to) sp.set("to", to); return apiGet( `/api/documents/sources/${slug}/search?${sp.toString()}`, ); }, getNextPageParam: (last) => (last.hasMore ? last.page + 1 : undefined), staleTime: 30_000, }); const results = useMemo(() => { const seen = new Set(); const out: DocumentSourceResultDto[] = []; for (const page of search.data?.pages ?? []) { for (const row of page.results) { if (seen.has(row.sourceId)) continue; seen.add(row.sourceId); out.push(row); } } 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); const toggle = (row: DocumentSourceResultDto) => { if (running || finished || !selectable(row)) return; const next = new Map(selected); if (next.has(row.sourceId)) { next.delete(row.sourceId); setAtMax(false); } else if (next.size < DOCUMENT_PICKER_MAX_SELECTION) { next.set(row.sourceId, row.title); } else { // Said rather than silently ignored: the tick that did not appear. setAtMax(true); return; } setSelected(next); }; async function runImport() { if (!slug || selected.size === 0) return; setRunning(true); setStopMessage(null); const order = [...selected.keys()]; setRuns(new Map(order.map((id) => [id, { state: "waiting" }]))); const handed: string[] = []; const mark = (id: string, run: RowRun) => { setRuns((prev) => new Map(prev).set(id, run)); const title = selected.get(id) ?? id; const said = runLabel(t, run); if (said) setAnnouncement(`${title}: ${said}`); }; for (const sourceId of order) { mark(sourceId, { state: "working" }); try { const result = await apiPost( `/api/documents/sources/${slug}/import`, { sourceId, ...(kind ? { kind } : {}), ...(link ? { link } : {}), }, // 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. { signal: AbortSignal.timeout(IMPORT_TIMEOUT_MS) }, ); mark(sourceId, { state: result.outcome, linked: result.linked }); if (result.outcome !== "deleted" && result.documentId) { handed.push(result.documentId); } } catch (err) { const code = errorCodeOf(err); const message = sourceErrorMessage(t, code); mark(sourceId, { state: "failed", message }); if (isStoppingError(code)) { setStopMessage(message); break; } } } setRunning(false); setFinished(true); setSelected(new Map()); // The vault, its pickers and the record's own lists read documents // through the `["documents"]` prefix; the picker's states through its own. void invalidateKeys(queryClient, [ queryKeys.documents(), queryKeys.documentSources(), ]); if (handed.length > 0) onImported?.(handed); } const counts = useMemo(() => { let imported = 0; let present = 0; let failed = 0; for (const run of runs.values()) { if (run.state === "imported") imported += 1; else if (run.state === "duplicate" || run.state === "deleted") present += 1; else if (run.state === "failed") failed += 1; } return { imported, present, failed }; }, [runs]); const name = active ? systemName(active) : ""; const hasFilters = Boolean(tag || from || to); const footer = (

{finished ? null : running ? t("documents.sourcePicker.importing") : t("documents.sourcePicker.selectedCount", { count: selected.size, max: DOCUMENT_PICKER_MAX_SELECTION, })}

{finished ? (
) : ( )}
); return ( { // A run in progress finishes before the sheet can close; its rows // are the only place the outcome is shown. if (!next && running) return; if (!next) reset(); onOpenChange(next); }} title={t("documents.sourcePicker.title", { name })} description={t("documents.sourcePicker.description")} contentWidth="lg" footer={footer} >
{systems.length > 1 && active ? ( ) : null}
onSearchChange(e.target.value)} placeholder={t("documents.sourcePicker.searchPlaceholder")} className="pl-9" maxLength={200} autoComplete="off" data-slot="document-source-search" />
filterSetter(setTag)(e.target.value)} disabled={tags.isPending && !tags.isError} > {(tags.data?.tags ?? []).map((option) => ( ))}
{hasFilters ? ( ) : null} {finished ? (

{t("documents.sourcePicker.finishedHint")}

) : null} {atMax ? (

{t("documents.sourcePicker.maxReached", { max: DOCUMENT_PICKER_MAX_SELECTION, })}

) : null} {/* Each row's result as it lands, for a screen reader. */}
{announcement}
{stopMessage ? (

{t("documents.sourcePicker.stopped", { reason: stopMessage })}

) : null} {rangeInvalid ? (

{t("documents.sourcePicker.rangeInvalid")}

) : search.isPending ? (
{Array.from({ length: 4 }, (_, i) => ( ))}
) : search.isError ? ( void search.refetch()} slot="document-source-error" /> ) : results.length === 0 ? ( } title={ q || hasFilters ? t("documents.sourcePicker.noMatch") : t("documents.sourcePicker.empty", { name }) } description={ q || hasFilters ? t("documents.sourcePicker.noMatchHint") : undefined } /> ) : (
    {results.map((row) => { const on = selected.has(row.sourceId); const run = runs.get(row.sourceId); const allowed = selectable(row); const meta = [ row.date ? calendarDate(row.date) : null, row.sizeBytes !== null ? formatBytes(row.sizeBytes, locale) : null, ] .filter(Boolean) .join(", "); return (
  • ); })}
)} {search.hasNextPage && !search.isError ? ( ) : null}
); } /** 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, 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(); switch (run.state) { case "waiting": return ( {t("documents.sourcePicker.result.waiting")} ); case "working": return ( {t("documents.sourcePicker.result.working")} ); case "failed": return ( {run.message} ); case "deleted": return ( {t("documents.sourcePicker.result.deleted")} ); case "imported": case "duplicate": return ( {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")} ); } }