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:
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user