134 lines
5.3 KiB
Rust
134 lines
5.3 KiB
Rust
//! 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");
|
||
|
|
}
|
||
|
|
}
|