Add SFV panel: compute/verify CRC32/MD5/SHA1 checksums, .sfv/.md5/.sha1 sidecar files

Fully local feature - no network access, no credentials. New aurora-curator-core::sfv
module streams file hashing in 64KB chunks and handles both the traditional .sfv
format (CRC32, "name checksum" layout) and the GNU coreutils md5sum/sha1sum sidecar
convention (.md5/.sha1, "checksum  name" layout), dispatched on a shared ChecksumKind
enum. New src-tauri/src/sfv.rs wraps it in three Tauri commands - compute_checksums
(per-file error isolation), save_checksum_file (native Save dialog), and
verify_checksum_file (resolves each entry against the checksum file's own parent
directory, reports OK/Mismatch/Missing per entry). New SfvPanel.tsx/.css wires it all
into the sidebar's SFV screen - load files, pick a checksum type, Compute, Save, or
Verify against an existing checksum file.

14 new core-crate tests + 1 new src-tauri test, all passing (workspace: 160/160).
Verified end-to-end with a 22-check Playwright script against npm run dev.
This commit is contained in:
Renamr Scaffold
2026-08-29 15:59:05 +00:00
parent ed26391651
commit 316dbfc1ff
11 changed files with 940 additions and 8 deletions
+3
View File
@@ -4,6 +4,7 @@ import Header from "./components/Header";
import RenamePanel from "./components/RenamePanel";
import EpisodesPanel from "./components/EpisodesPanel";
import SubtitlesPanel from "./components/SubtitlesPanel";
import SfvPanel from "./components/SfvPanel";
import PanelStub from "./components/PanelStub";
export default function App() {
@@ -22,6 +23,8 @@ export default function App() {
<EpisodesPanel />
) : selected === "subtitles" ? (
<SubtitlesPanel />
) : selected === "sfv" ? (
<SfvPanel />
) : (
<PanelStub name={current.name} />
)}
+105
View File
@@ -0,0 +1,105 @@
.sfv-panel {
height: 100%;
min-width: 0;
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px;
overflow-y: auto;
}
.sfv-toolbar {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
background: var(--card);
border: 1px solid var(--surface-deep);
border-radius: 12px;
padding: 14px;
}
.sfv-loaded-count {
font-size: 12px;
color: var(--muted-text);
}
.sfv-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.sfv-results {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.sfv-row {
display: flex;
align-items: center;
gap: 12px;
background: var(--card);
border: 1px solid var(--surface-deep);
border-radius: 10px;
padding: 9px 12px;
}
.sfv-row-name {
flex: 1;
min-width: 0;
font-size: 12.5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sfv-row-checksum {
flex: 0 0 auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
color: var(--aurora-cyan);
}
.sfv-row-checksum-muted {
color: var(--muted-text);
}
.sfv-badge {
flex: 0 0 auto;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.04em;
border-radius: 999px;
padding: 3px 9px;
}
.sfv-badge-ok {
background: rgba(52, 211, 153, 0.16);
color: #34d399;
}
.sfv-badge-mismatch {
background: rgba(248, 113, 113, 0.16);
color: #f87171;
}
.sfv-badge-missing {
background: rgba(250, 204, 21, 0.16);
color: #facc15;
}
.sfv-empty-note {
color: var(--muted-text);
font-size: 12.5px;
padding: 8px 2px;
}
.sfv-status {
font-size: 12px;
color: var(--aurora-cyan);
}
+197
View File
@@ -0,0 +1,197 @@
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
interface FileEntry {
path: string;
name: string;
ext: string;
}
type ChecksumKind = "crc32" | "md5" | "sha1";
const KIND_OPTIONS: { value: ChecksumKind; label: string }[] = [
{ value: "crc32", label: "CRC32 (.sfv)" },
{ value: "md5", label: "MD5" },
{ value: "sha1", label: "SHA-1" },
];
interface ChecksumOutcome {
path: string;
name: string;
checksum: string | null;
error: string | null;
}
type VerifyStatus = "ok" | "mismatch" | "missing";
interface VerifyResult {
fileName: string;
expected: string;
actual: string | null;
status: VerifyStatus;
}
interface VerifyOutcome {
baseDir: string;
results: VerifyResult[];
}
const STATUS_LABELS: Record<VerifyStatus, string> = {
ok: "OK",
mismatch: "MISMATCH",
missing: "MISSING",
};
function defaultBaseName(files: FileEntry[]): string {
if (files.length === 0) return "checksums";
const dir = files[0].path.replace(/[\\/][^\\/]*$/, "");
const folderName = dir.split(/[\\/]/).pop();
return folderName && folderName.trim() ? folderName : "checksums";
}
export default function SfvPanel() {
const [files, setFiles] = useState<FileEntry[]>([]);
const [kind, setKind] = useState<ChecksumKind>("crc32");
const [computing, setComputing] = useState(false);
const [computeResults, setComputeResults] = useState<ChecksumOutcome[] | null>(null);
const [verifyOutcome, setVerifyOutcome] = useState<VerifyOutcome | null>(null);
const [verifying, setVerifying] = useState(false);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState<string | null>(null);
async function loadFiles() {
const picked = await invoke<FileEntry[]>("pick_files");
if (picked.length === 0) return;
setFiles(picked);
setComputeResults(null);
setVerifyOutcome(null);
setStatus(null);
}
async function runCompute() {
if (files.length === 0) {
setStatus("Load files first.");
return;
}
setComputing(true);
setVerifyOutcome(null);
try {
const results = await invoke<ChecksumOutcome[]>("compute_checksums", {
paths: files.map((f) => f.path),
kind,
});
setComputeResults(results);
const failed = results.filter((r) => r.error).length;
setStatus(failed > 0 ? `Computed ${results.length - failed} of ${results.length} (${failed} failed).` : `Computed ${results.length} checksum${results.length === 1 ? "" : "s"}.`);
} catch (err) {
setStatus(String(err));
} finally {
setComputing(false);
}
}
async function saveChecksumFile() {
if (!computeResults || computeResults.length === 0) {
setStatus("Compute checksums first.");
return;
}
const rows = computeResults.filter((r) => r.checksum).map((r) => ({ name: r.name, checksum: r.checksum as string }));
if (rows.length === 0) {
setStatus("No successfully computed checksums to save.");
return;
}
setSaving(true);
try {
const savedPath = await invoke<string | null>("save_checksum_file", {
defaultName: defaultBaseName(files),
rows,
kind,
});
setStatus(savedPath ? `Saved to ${savedPath}` : "Save cancelled.");
} catch (err) {
setStatus(String(err));
} finally {
setSaving(false);
}
}
async function verifyChecksumFile() {
const picked = await invoke<FileEntry[]>("pick_files");
if (picked.length === 0) return;
const checksumFile = picked[0];
setVerifying(true);
setComputeResults(null);
try {
const outcome = await invoke<VerifyOutcome>("verify_checksum_file", {
checksumFilePath: checksumFile.path,
kind,
});
setVerifyOutcome(outcome);
const ok = outcome.results.filter((r) => r.status === "ok").length;
const mismatch = outcome.results.filter((r) => r.status === "mismatch").length;
const missing = outcome.results.filter((r) => r.status === "missing").length;
setStatus(`${ok} OK, ${mismatch} mismatch${mismatch === 1 ? "" : "es"}, ${missing} missing.`);
} catch (err) {
setStatus(String(err));
} finally {
setVerifying(false);
}
}
return (
<div className="sfv-panel">
<div className="sfv-toolbar">
<button className="action-button" onClick={loadFiles}>
Load Files…
</button>
<span className="sfv-loaded-count">{files.length > 0 ? `${files.length} file${files.length === 1 ? "" : "s"} loaded` : "No files loaded"}</span>
<div className="action-segmented" role="radiogroup" aria-label="Checksum type">
{KIND_OPTIONS.map((opt) => (
<button key={opt.value} className={kind === opt.value ? "selected" : ""} onClick={() => setKind(opt.value)}>
{opt.label}
</button>
))}
</div>
</div>
<div className="sfv-actions">
<button className="action-button primary" onClick={runCompute} disabled={computing || files.length === 0}>
{computing ? "Computing…" : "Compute"}
</button>
<button className="action-button" onClick={saveChecksumFile} disabled={saving || !computeResults}>
{saving ? "Saving…" : "Save Checksum File…"}
</button>
<button className="action-button" onClick={verifyChecksumFile} disabled={verifying}>
{verifying ? "Verifying…" : "Verify Checksum File…"}
</button>
</div>
<div className="sfv-results">
{!computeResults && !verifyOutcome && <div className="sfv-empty-note">Load files and Compute, or pick an existing checksum file to Verify.</div>}
{computeResults &&
computeResults.map((r) => (
<div className="sfv-row" key={r.path}>
<span className="sfv-row-name">{r.name}</span>
{r.checksum ? <span className="sfv-row-checksum">{r.checksum}</span> : <span className="sfv-badge sfv-badge-missing">ERROR</span>}
</div>
))}
{verifyOutcome &&
verifyOutcome.results.map((r) => (
<div className="sfv-row" key={r.fileName}>
<span className="sfv-row-name">{r.fileName}</span>
{r.actual && r.status === "mismatch" && (
<span className="sfv-row-checksum sfv-row-checksum-muted">
{r.actual} ≠ {r.expected}
</span>
)}
<span className={`sfv-badge sfv-badge-${r.status}`}>{STATUS_LABELS[r.status]}</span>
</div>
))}
</div>
{status && <div className="sfv-status">{status}</div>}
</div>
);
}
+1
View File
@@ -8,6 +8,7 @@ import "./components/Header.css";
import "./components/RenamePanel.css";
import "./components/EpisodesPanel.css";
import "./components/SubtitlesPanel.css";
import "./components/SfvPanel.css";
import "./components/PanelStub.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(