Add rename history with undo
- New crates/renamr-core/src/history module: HistoryBatch/HistoryEntry records, revert_batch() moves files back to their original paths (skip-on-conflict, same policy as rename::execute_renames; recreates a deleted destination folder defensively), push_batch() maintains a most-recent-first log capped at 50 batches. A partial port of the idea behind net.filebot.HistoryDialog, as a flat undo stack rather than a full session/element log. 6 new tests. - New src-tauri/src/history.rs: persists the log as history.json in the OS app-config directory (same location as settings.json). execute_rename now takes an AppHandle and calls history::record_batch after every rename, recording only the items that actually got renamed. Two new commands: list_rename_history and undo_rename_batch. - Frontend: the History button (previously a no-op) now opens a panel listing past rename batches with a timestamp, file count, and the old-name/new-name pairs, each with an Undo button. Undoing reconciles any currently-loaded item back to its pre-rename path/name, same pattern as post-rename reconciliation. - Verified via cargo test (45 total)/check + npm build + a full Playwright pass: loaded mocked files, renamed them, opened History, undid the batch, and confirmed both the UI and the (mocked) backend state matched at every step.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
//! The Tauri-facing side of rename history/undo: persists the log
|
||||
//! `renamr_core::history` produces to disk (plain JSON in the OS app-config
|
||||
//! directory, alongside `settings.json`) and exposes it to the UI as two commands -
|
||||
//! list the log, and undo one batch from it.
|
||||
|
||||
use renamr_core::history::{self, HistoryBatch, HistoryEntry, RevertResult};
|
||||
use renamr_core::rename::{RenameOutcome, RenameResult};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
fn history_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.map_err(|e| format!("couldn't resolve the app config directory: {e}"))?;
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("couldn't create {}: {e}", dir.display()))?;
|
||||
Ok(dir.join("history.json"))
|
||||
}
|
||||
|
||||
fn load(app: &AppHandle) -> Vec<HistoryBatch> {
|
||||
let Ok(path) = history_path(app) else {
|
||||
return Vec::new();
|
||||
};
|
||||
fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|contents| serde_json::from_str(&contents).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn save(app: &AppHandle, batches: &[HistoryBatch]) -> Result<(), String> {
|
||||
let path = history_path(app)?;
|
||||
let json = serde_json::to_string_pretty(batches).map_err(|e| e.to_string())?;
|
||||
fs::write(&path, json).map_err(|e| format!("couldn't write {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
fn now_millis() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Called right after a rename batch executes (see `execute_rename` in `lib.rs`) -
|
||||
/// records only the entries that were actually renamed; a file that was unchanged,
|
||||
/// skipped, or failed to rename has nothing to undo. Best-effort: if persisting the
|
||||
/// history fails for some reason, the rename itself already succeeded and shouldn't be
|
||||
/// reported as failed just because its undo record couldn't be saved, so errors here
|
||||
/// are swallowed rather than surfaced to the caller.
|
||||
pub fn record_batch(app: &AppHandle, results: &[RenameResult]) {
|
||||
let entries: Vec<HistoryEntry> = results
|
||||
.iter()
|
||||
.filter_map(|r| match &r.outcome {
|
||||
RenameOutcome::Renamed { new_path } => Some(HistoryEntry {
|
||||
source: r.source.clone(),
|
||||
new_path: new_path.clone(),
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let batch = HistoryBatch {
|
||||
id: format!("batch-{}", now_millis()),
|
||||
timestamp: now_millis(),
|
||||
entries,
|
||||
};
|
||||
|
||||
let mut batches = load(app);
|
||||
history::push_batch(&mut batches, batch);
|
||||
let _ = save(app, &batches);
|
||||
}
|
||||
|
||||
/// Lists every recorded rename batch, most-recent-first.
|
||||
#[tauri::command]
|
||||
pub fn list_rename_history(app: AppHandle) -> Vec<HistoryBatch> {
|
||||
load(&app)
|
||||
}
|
||||
|
||||
/// Reverts one batch (by id) and removes it from the history log - a batch is only
|
||||
/// ever undone once. Reverting is best-effort per file (see `RevertOutcome`): a file
|
||||
/// that's no longer where the history says, or whose original path now has something
|
||||
/// else at it, is reported rather than silently skipped, but doesn't block the rest of
|
||||
/// the batch from being reverted.
|
||||
#[tauri::command]
|
||||
pub fn undo_rename_batch(app: AppHandle, batch_id: String) -> Result<Vec<RevertResult>, String> {
|
||||
let mut batches = load(&app);
|
||||
let idx = batches
|
||||
.iter()
|
||||
.position(|b| b.id == batch_id)
|
||||
.ok_or_else(|| "That rename batch isn't in the history log anymore.".to_string())?;
|
||||
let batch = batches.remove(idx);
|
||||
|
||||
let results = history::revert_batch(&batch);
|
||||
save(&app, &batches)?;
|
||||
Ok(results)
|
||||
}
|
||||
+16
-7
@@ -1,4 +1,5 @@
|
||||
mod files;
|
||||
mod history;
|
||||
mod metadata;
|
||||
mod settings;
|
||||
|
||||
@@ -7,6 +8,7 @@ use renamr_core::matching::{self, EpisodeGuess};
|
||||
use renamr_core::rename::{self, RenamePlanItem, RenameResult};
|
||||
use renamr_core::string_bindings;
|
||||
use std::collections::HashMap;
|
||||
use tauri::AppHandle;
|
||||
|
||||
/// Renders a naming-scheme expression against a set of binding values.
|
||||
/// This is the Tauri-facing entry point into the core format engine, used by the
|
||||
@@ -28,16 +30,21 @@ fn guess_episode(filename: String) -> Option<EpisodeGuess> {
|
||||
matching::guess_episode(&filename)
|
||||
}
|
||||
|
||||
/// Executes a batch of planned on-disk renames (same-directory rename only; conflicting
|
||||
/// targets are skipped, never overwritten - see `renamr_core::rename` for the policy).
|
||||
/// The UI is expected to have already shown the user the full old-name/new-name plan
|
||||
/// and gotten explicit confirmation before calling this - there is no undo yet.
|
||||
/// Executes a batch of planned on-disk renames (optionally moving files into
|
||||
/// subfolders - see `renamr_core::rename`; conflicting targets are always skipped,
|
||||
/// never overwritten). The UI is expected to have already shown the user the full
|
||||
/// old-path/new-path plan and gotten explicit confirmation before calling this.
|
||||
/// Runs off the main thread since real filesystem I/O shouldn't block the UI.
|
||||
///
|
||||
/// Every successfully-renamed item is recorded to the rename history log afterward
|
||||
/// (see `history::record_batch`) so it can be undone later from the History panel.
|
||||
#[tauri::command]
|
||||
async fn execute_rename(items: Vec<RenamePlanItem>) -> Vec<RenameResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || rename::execute_renames(&items))
|
||||
async fn execute_rename(app: AppHandle, items: Vec<RenamePlanItem>) -> Vec<RenameResult> {
|
||||
let results = tauri::async_runtime::spawn_blocking(move || rename::execute_renames(&items))
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default();
|
||||
history::record_batch(&app, &results);
|
||||
results
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
@@ -51,6 +58,8 @@ pub fn run() {
|
||||
metadata::fetch_metadata,
|
||||
settings::get_tmdb_token,
|
||||
settings::save_tmdb_token,
|
||||
history::list_rename_history,
|
||||
history::undo_rename_batch,
|
||||
files::pick_files,
|
||||
files::pick_folder,
|
||||
files::expand_dropped_paths,
|
||||
|
||||
Reference in New Issue
Block a user