Add CSV export of scan results

The Review tab gets an Export CSV menu: "This view" exports the current
status filter and search, "Entire scan" exports every scanned slave file.

Each row carries status, match type, a recommendation, slave/master paths,
the link target, the match reason, sizes, codec details for both sides,
the quality verdict, notes, errors and timestamps. Rows are streamed in
pages of 1000 so a large export never holds the database connection a
running scan needs. The file starts with a UTF-8 BOM for Excel, and cells
a spreadsheet would evaluate as formulas are prefixed with an apostrophe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-22 17:47:28 -07:00
1 parent 5f895cab6a
commit e8c534a3ae
6 files changed
+435 -6

No files matched your search

+42 -4
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, ArrowRight, ArrowUpCircle, ChevronDown, ChevronRight, CopyCheck, EyeOff, Eye, Link2, Search, Crown } from "lucide-react";
import { AlertTriangle, ArrowRight, ArrowUpCircle, Download, ChevronDown, ChevronRight, CopyCheck, EyeOff, Eye, Link2, Search, Crown } from "lucide-react";
import { ApiError, api, type Match, type Page, type Pair, type ProbeInfo } from "../api";
import { Alert, Badge, Button, Card, Empty, Modal, Spinner, cx, formatBytes, formatDuration, inputCls, statusTone } from "../ui";
import { useToast } from "../toast";
@@ -86,9 +86,12 @@ export function Review({ pair }: { pair: Pair }) {
{count(f.id) !== undefined && <span className="tabular-nums opacity-70">{count(f.id)}</span>}
</button>
))}
<div className="relative ml-auto w-full sm:w-64">
<Search className="absolute left-2.5 top-2.5 size-4 text-muted" />
<input className={inputCls + " pl-8"} placeholder="Filter by path…" value={q} onChange={(e) => setQ(e.target.value)} />
<div className="ml-auto flex w-full items-center gap-2 sm:w-auto">
<div className="relative flex-1 sm:w-64">
<Search className="absolute left-2.5 top-2.5 size-4 text-muted" />
<input className={inputCls + " pl-8"} placeholder="Filter by path…" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<ExportMenu pairId={pair.id} status={status} query={debounced} viewCount={list.data?.total} filterLabel={filters.find((f) => f.id === status)?.label ?? "All"} />
</div>
</div>
@@ -152,6 +155,41 @@ export function Review({ pair }: { pair: Pair }) {
);
}
function ExportMenu({ pairId, status, query, viewCount, filterLabel }: { pairId: number; status: string; query: string; viewCount?: number; filterLabel: string }) {
const [open, setOpen] = useState(false);
useEffect(() => {
if (!open) return;
const close = () => setOpen(false);
window.addEventListener("click", close);
return () => window.removeEventListener("click", close);
}, [open]);
const href = (s: string, q: string) => `/api/pairs/${pairId}/export.csv?status=${encodeURIComponent(s)}&q=${encodeURIComponent(q)}`;
const item = "flex w-full flex-col items-start rounded-md px-3 py-2 text-left hover:bg-panel-2";
return (
<div className="relative" onClick={(e) => e.stopPropagation()}>
<Button onClick={() => setOpen(!open)} title="Export scan results as CSV">
<Download className="size-4" /> <span className="hidden md:inline">Export CSV</span>
</Button>
{open && (
<div className="absolute right-0 z-30 mt-1 w-64 rounded-lg border border-line bg-panel p-1 shadow-xl">
<a className={item} href={href(status, query)} download onClick={() => setOpen(false)}>
<span className="text-sm">This view</span>
<span className="text-xs text-muted">
{filterLabel}
{query && <> · “{query}”</>}
{viewCount !== undefined && <> · {viewCount} rows</>}
</span>
</a>
<a className={item} href={href("", "")} download onClick={() => setOpen(false)}>
<span className="text-sm">Entire scan</span>
<span className="text-xs text-muted">Every scanned slave file, including linked and slave-only</span>
</a>
</div>
)}
</div>
);
}
function PathLine({ m }: { m: Match }) {
const same = m.rel_master === m.rel_slave || !m.rel_master;
return (