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:
1 parent
5f895cab6a
commit
e8c534a3ae
6 files changed
+435
-6
No files matched your search
@@ -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 (
|
||||
|
||||
Reference in new issue
Block a user