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
+133
View File
@@ -0,0 +1,133 @@
//! The Tauri-facing side of the SFV panel: compute checksums for a set of loaded
//! files, save them as a sidecar checksum file (`.sfv`/`.md5`/`.sha1`), and verify a
//! set of files against an existing checksum file. Wraps `aurora_curator_core::sfv`,
//! which does all the actual algorithm/format work - this module is just Tauri
//! commands, native file dialogs, and the async/off-main-thread plumbing. Fully local:
//! unlike every other panel added this session, SFV needs no network access and no
//! credentials at all.
use std::path::{Path, PathBuf};
use aurora_curator_core::sfv::{self, ChecksumEntry, ChecksumKind, VerifyResult};
use serde::Serialize;
use tauri::AppHandle;
use tauri_plugin_dialog::DialogExt;
/// One computed checksum, or the error hashing that particular file hit (a file
/// disappearing between being loaded and being hashed, a permissions problem, etc) -
/// kept per-file so one bad file doesn't abort the whole batch.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ChecksumOutcome {
pub path: String,
pub name: String,
pub checksum: Option<String>,
pub error: Option<String>,
}
fn file_name_of(path: &str) -> String {
Path::new(path)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| path.to_string())
}
/// Computes a checksum for every path in `paths`, using the algorithm `kind` selects.
/// Runs the whole batch on a blocking thread (real file I/O, potentially large video
/// files) rather than the async command-invocation path.
#[tauri::command]
pub async fn compute_checksums(paths: Vec<String>, kind: ChecksumKind) -> Vec<ChecksumOutcome> {
tauri::async_runtime::spawn_blocking(move || {
paths
.into_iter()
.map(|path| {
let name = file_name_of(&path);
match sfv::compute_checksum(Path::new(&path), kind) {
Ok(checksum) => ChecksumOutcome { path, name, checksum: Some(checksum), error: None },
Err(err) => ChecksumOutcome { path, name, checksum: None, error: Some(err.to_string()) },
}
})
.collect()
})
.await
.unwrap_or_default()
}
/// One row the frontend already has (a computed checksum for a loaded file) - the
/// input shape `save_checksum_file` builds a sidecar file's text content from.
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChecksumFileRow {
pub name: String,
pub checksum: String,
}
/// Opens a native "Save File" dialog defaulted to `<default_name>.<kind's extension>`,
/// and if the user doesn't cancel, writes the built checksum-file text there. Returns
/// the saved path, or `None` if the user cancelled the dialog (not an error).
#[tauri::command]
pub async fn save_checksum_file(app: AppHandle, default_name: String, rows: Vec<ChecksumFileRow>, kind: ChecksumKind) -> Result<Option<String>, String> {
let entries: Vec<ChecksumEntry> = rows.into_iter().map(|r| ChecksumEntry { file_name: r.name, checksum: r.checksum }).collect();
let content = sfv::build_checksum_file(&entries, kind);
let (tx, rx) = tokio::sync::oneshot::channel();
app.dialog()
.file()
.set_file_name(format!("{default_name}.{}", kind.extension()))
.save_file(move |result| {
let _ = tx.send(result);
});
let chosen = rx.await.map_err(|e| e.to_string())?;
let Some(file_path) = chosen else {
return Ok(None);
};
let path = file_path.into_path().map_err(|e| e.to_string())?;
tauri::async_runtime::spawn_blocking({
let path = path.clone();
move || std::fs::write(&path, content)
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
Ok(Some(path.to_string_lossy().to_string()))
}
/// What verifying one checksum file produced: the file's own directory (every entry is
/// resolved relative to it) and the per-entry OK/mismatch/missing results.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct VerifyOutcome {
pub base_dir: String,
pub results: Vec<VerifyResult>,
}
/// Reads and parses `checksum_file_path` (a `.sfv`/`.md5`/`.sha1` file the user picked)
/// and verifies every entry it lists against the real files on disk, resolved relative
/// to the checksum file's own parent directory.
#[tauri::command]
pub async fn verify_checksum_file(checksum_file_path: String, kind: ChecksumKind) -> Result<VerifyOutcome, String> {
tauri::async_runtime::spawn_blocking(move || {
let path = PathBuf::from(&checksum_file_path);
let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
let entries = sfv::parse_checksum_file(&content, kind);
let base_dir = path.parent().map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from("."));
let results = sfv::verify_entries(&entries, &base_dir, kind);
Ok(VerifyOutcome { base_dir: base_dir.to_string_lossy().to_string(), results })
})
.await
.map_err(|e| e.to_string())?
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn file_name_of_strips_the_directory_portion() {
assert_eq!(file_name_of("/a/b/c.mkv"), "c.mkv");
assert_eq!(file_name_of("just-a-name.mkv"), "just-a-name.mkv");
}
}