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:
@@ -10,10 +10,11 @@ detection ("Match") is working, real online metadata lookups against TMDB ("Fetc
|
||||
Data") are working — including fuzzy show-name disambiguation, so a search that
|
||||
returns several similarly-named shows doesn't just take TMDB's top-ranked hit — and the
|
||||
actual on-disk rename operation is wired up (skip-on-conflict, with a confirmation step
|
||||
showing the full old-name/new-name plan before anything touches disk — there is no undo
|
||||
yet). Rename can also organize files into subfolders — including ones that don't exist
|
||||
yet — when the naming scheme includes a `/`. Full media-metadata bindings are not yet
|
||||
ported. See "Porting roadmap" below.
|
||||
showing the full old-path/new-path plan before anything touches disk). Rename can also
|
||||
organize files into subfolders — including ones that don't exist yet — when the naming
|
||||
scheme includes a `/`. Every completed rename is logged to a history panel that can
|
||||
undo it, moving files back to their original paths. Full media-metadata bindings are
|
||||
not yet ported. See "Porting roadmap" below.
|
||||
|
||||
The naming scheme is fully user-editable from the app (the "Naming Scheme" button next
|
||||
to the New Names list) — the default is `{show name} - {season number}x{episode
|
||||
@@ -110,23 +111,26 @@ crates/renamr-core/ Pure Rust core: naming-expression engine (ported), local
|
||||
(`similarity/`, partial `EpisodeMetrics` port), a TMDB metadata
|
||||
client (`web/tmdb.rs` — TV search + episode lookup, disambiguated
|
||||
via `similarity/`, tested against a mocked HTTP server via
|
||||
`mockito`), and the on-disk rename engine (`rename/`) —
|
||||
`mockito`), the on-disk rename engine (`rename/`) —
|
||||
skip-on-conflict, can also organize into (auto-created)
|
||||
subfolders when the naming scheme includes `/`. Not yet ported:
|
||||
full media metadata bindings, other web datasource clients
|
||||
(TVDB/AniDB/etc).
|
||||
subfolders when the naming scheme includes `/` — and a rename
|
||||
history/undo log (`history/`, a partial `HistoryDialog` port).
|
||||
Not yet ported: full media metadata bindings, other web
|
||||
datasource clients (TVDB/AniDB/etc).
|
||||
No Tauri dependency, so it builds and unit-tests standalone.
|
||||
src-tauri/ Tauri app shell: exposes renamr-core as commands to the frontend
|
||||
(naming-scheme rendering, filename matching, TMDB metadata fetch,
|
||||
executing a rename plan, native file/folder dialogs, drag-and-drop
|
||||
expansion), plus `settings.rs` (TMDB token, stored as JSON in the
|
||||
OS app-config directory), app metadata/icons/window config
|
||||
expansion), plus `settings.rs` (TMDB token) and `history.rs`
|
||||
(rename history/undo log) — both stored as JSON in the OS
|
||||
app-config directory — app metadata/icons/window config
|
||||
(tauri.conf.json).
|
||||
src/ React + TypeScript UI (Vite). Faithful recreation of the original
|
||||
two-pane rename screen (Aurora theme) wired to real file loading,
|
||||
drag-and-drop, live naming-scheme preview, local matching, TMDB
|
||||
fetch (behind a Settings modal for the API token), and a
|
||||
confirm-before-touching-disk modal for the actual rename.
|
||||
fetch (behind a Settings modal for the API token), a
|
||||
confirm-before-touching-disk modal for the actual rename, and a
|
||||
History panel that can undo a past rename.
|
||||
scripts/gen_icons.py Regenerates src-tauri/icons/ (placeholder branding — swap the
|
||||
colors/glyph in the script, or replace the icons directly, to
|
||||
change the look).
|
||||
@@ -217,13 +221,18 @@ ported from, for anyone picking this up:
|
||||
`--conflict` variants beyond skip (auto-number, override), and the CLI flag surface
|
||||
itself.
|
||||
7. **The rest of the UI** — the Rename panel (file loading, drag-drop, live preview,
|
||||
local matching, confirmed rename) is built; the other five sidebar panels
|
||||
(Episodes/Subtitles/SFV/Filter/List) are still stub screens, plus Align up/down
|
||||
reordering, History, and Presets on the Rename panel itself are no-ops. There's also
|
||||
no per-item exclude inside the rename confirmation modal yet (it's all-or-nothing —
|
||||
use "Exclude Selected" on the file list beforehand to leave a file out entirely).
|
||||
8. **Undo/history for renames** — there is no undo yet; the confirmation modal before
|
||||
Rename executes is the only safety net right now. `net.filebot.HistoryDialog`/the
|
||||
original's rename-history log is the reference for what a real undo would look like.
|
||||
local matching, confirmed rename, rename history/undo) is built; the other five
|
||||
sidebar panels (Episodes/Subtitles/SFV/Filter/List) are still stub screens, plus
|
||||
Align up/down reordering and Presets on the Rename panel itself are still no-ops.
|
||||
There's also no per-item exclude inside the rename confirmation modal yet (it's
|
||||
all-or-nothing — use "Exclude Selected" on the file list beforehand to leave a file
|
||||
out entirely).
|
||||
8. **Undo/history for renames** — done, as a flat JSON-backed undo stack rather than a
|
||||
full port of `net.filebot.HistoryDialog`'s session/element log
|
||||
(`crates/renamr-core/src/history`, `src-tauri/src/history.rs`). Every successful
|
||||
rename is recorded to a `history.json` in the OS app-config directory (same location
|
||||
as `settings.json`); the History panel lists past batches and can revert one back to
|
||||
its original paths, skipping (never overwriting) any file whose original path now
|
||||
has something else at it. Capped at the 50 most recent batches.
|
||||
9. **Packaging** — code signing, auto-update, installers per platform via
|
||||
`npm run tauri build`.
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
//! Rename history: a persisted log of past rename batches (what changed, from what to
|
||||
//! what) and the ability to revert one - Renamr's only safety net beyond the
|
||||
//! confirmation-before-execute step, since a plain filesystem rename has no OS-level
|
||||
//! undo. Loosely inspired by `net.filebot.HistoryDialog`/the original's rename-history
|
||||
//! log, but as a flat JSON-backed undo stack (see [`push_batch`]) rather than a full
|
||||
//! session/element log - simpler, and all this app actually needs.
|
||||
//!
|
||||
//! This module only implements the *logic* (recording, trimming, reverting); where the
|
||||
//! log is persisted to disk is a Tauri-layer concern (`src-tauri/src/history.rs`), same
|
||||
//! split as `rename` (pure logic here) vs. the Tauri command that calls it.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// One file's old path -> new path, as recorded right after a successful rename.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HistoryEntry {
|
||||
pub source: String,
|
||||
pub new_path: String,
|
||||
}
|
||||
|
||||
/// One executed rename operation - everything renamed together in a single "Rename N
|
||||
/// Files" confirmation - undoable as a unit.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HistoryBatch {
|
||||
pub id: String,
|
||||
/// Unix epoch milliseconds when the batch was executed. Kept as a plain number
|
||||
/// (no date/time dependency needed in this crate) so the frontend can format it
|
||||
/// however it likes, e.g. `new Date(timestamp).toLocaleString()`.
|
||||
pub timestamp: u64,
|
||||
pub entries: Vec<HistoryEntry>,
|
||||
}
|
||||
|
||||
/// What happened when reverting one entry of a batch.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "status", rename_all = "camelCase")]
|
||||
pub enum RevertOutcome {
|
||||
/// Successfully moved back to its original path.
|
||||
Reverted { restored_path: String },
|
||||
/// A different file already exists at the original path; left untouched - the same
|
||||
/// skip-never-overwrite policy `rename::execute_renames` uses.
|
||||
SkippedConflict { existing_path: String },
|
||||
/// The renamed file isn't where the history says it should be anymore (moved,
|
||||
/// deleted, or already reverted some other way), or the OS reported an error.
|
||||
Failed { error: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RevertResult {
|
||||
/// The entry's `new_path` (i.e. where the file was expected to be found, before
|
||||
/// reverting) - lets the caller map a result back to a specific history entry.
|
||||
pub source: String,
|
||||
pub outcome: RevertOutcome,
|
||||
}
|
||||
|
||||
fn same_file(a: &Path, b: &Path) -> bool {
|
||||
match (fs::canonicalize(a), fs::canonicalize(b)) {
|
||||
(Ok(pa), Ok(pb)) => pa == pb,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn revert_one(entry: &HistoryEntry) -> RevertOutcome {
|
||||
let current = Path::new(&entry.new_path);
|
||||
let original = Path::new(&entry.source);
|
||||
|
||||
if !current.exists() {
|
||||
return RevertOutcome::Failed {
|
||||
error: "the renamed file is no longer at its recorded location".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
if original.exists() && !same_file(original, current) {
|
||||
return RevertOutcome::SkippedConflict {
|
||||
existing_path: original.to_string_lossy().to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
// The original's parent directory should still exist (nothing in Renamr deletes
|
||||
// directories), but recreate it defensively in case it was removed by hand since -
|
||||
// cheap, and `create_dir_all` is a no-op when it's already there.
|
||||
if let Some(parent) = original.parent() {
|
||||
if let Err(e) = fs::create_dir_all(parent) {
|
||||
return RevertOutcome::Failed {
|
||||
error: format!("couldn't recreate the original folder: {e}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
match fs::rename(current, original) {
|
||||
Ok(()) => RevertOutcome::Reverted {
|
||||
restored_path: original.to_string_lossy().to_string(),
|
||||
},
|
||||
Err(e) => RevertOutcome::Failed { error: e.to_string() },
|
||||
}
|
||||
}
|
||||
|
||||
/// Reverts every entry in a batch, in order. Like [`crate::rename::execute_renames`],
|
||||
/// entries are processed sequentially (not in parallel) so a collision between two
|
||||
/// entries in the same batch resolves deterministically rather than racing.
|
||||
pub fn revert_batch(batch: &HistoryBatch) -> Vec<RevertResult> {
|
||||
batch
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| RevertResult {
|
||||
source: entry.new_path.clone(),
|
||||
outcome: revert_one(entry),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Maximum number of batches kept in the persisted history - once the log grows past
|
||||
/// this, the oldest batches are dropped so the history file can't grow unbounded over
|
||||
/// a long-lived install.
|
||||
pub const MAX_HISTORY_BATCHES: usize = 50;
|
||||
|
||||
/// Adds a new batch to the front of `history` (most-recent-first) and trims the log
|
||||
/// back down to [`MAX_HISTORY_BATCHES`]. A batch with no entries is not recorded at
|
||||
/// all - a rename operation where every item was unchanged/skipped/failed has nothing
|
||||
/// to undo, so it shouldn't clutter the history.
|
||||
pub fn push_batch(history: &mut Vec<HistoryBatch>, batch: HistoryBatch) {
|
||||
if batch.entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
history.insert(0, batch);
|
||||
history.truncate(MAX_HISTORY_BATCHES);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
static COUNTER: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
fn temp_dir() -> std::path::PathBuf {
|
||||
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
|
||||
let dir = std::env::temp_dir().join(format!("renamr_history_test_{}_{}", std::process::id(), n));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverts_a_renamed_file_back_to_its_original_path() {
|
||||
let dir = temp_dir();
|
||||
let original = dir.join("Breaking.Bad.S05E14.mkv");
|
||||
let renamed = dir.join("Ozymandias.mkv");
|
||||
fs::write(&renamed, "video").unwrap();
|
||||
|
||||
let batch = HistoryBatch {
|
||||
id: "b1".to_string(),
|
||||
timestamp: 0,
|
||||
entries: vec![HistoryEntry {
|
||||
source: original.to_string_lossy().to_string(),
|
||||
new_path: renamed.to_string_lossy().to_string(),
|
||||
}],
|
||||
};
|
||||
|
||||
let results = revert_batch(&batch);
|
||||
match &results[0].outcome {
|
||||
RevertOutcome::Reverted { restored_path } => {
|
||||
assert_eq!(Path::new(restored_path), original.as_path());
|
||||
assert!(original.exists());
|
||||
assert!(!renamed.exists());
|
||||
}
|
||||
other => panic!("expected Reverted, got {other:?}"),
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverting_into_a_subfolder_target_recreates_a_deleted_original_folder() {
|
||||
let dir = temp_dir();
|
||||
let sub = dir.join("Breaking Bad");
|
||||
fs::create_dir_all(&sub).unwrap();
|
||||
let original = sub.join("Ozymandias.mkv");
|
||||
let renamed = dir.join("flat.mkv");
|
||||
fs::write(&renamed, "video").unwrap();
|
||||
// Simulate the organized-into subfolder having been removed since the rename.
|
||||
fs::remove_dir_all(&sub).unwrap();
|
||||
|
||||
let batch = HistoryBatch {
|
||||
id: "b1".to_string(),
|
||||
timestamp: 0,
|
||||
entries: vec![HistoryEntry {
|
||||
source: original.to_string_lossy().to_string(),
|
||||
new_path: renamed.to_string_lossy().to_string(),
|
||||
}],
|
||||
};
|
||||
|
||||
let results = revert_batch(&batch);
|
||||
assert!(matches!(results[0].outcome, RevertOutcome::Reverted { .. }));
|
||||
assert!(original.exists());
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_revert_when_something_else_now_occupies_the_original_path() {
|
||||
let dir = temp_dir();
|
||||
let original = dir.join("original.mkv");
|
||||
fs::write(&original, "a new unrelated file").unwrap();
|
||||
let renamed = dir.join("renamed.mkv");
|
||||
fs::write(&renamed, "video").unwrap();
|
||||
|
||||
let batch = HistoryBatch {
|
||||
id: "b1".to_string(),
|
||||
timestamp: 0,
|
||||
entries: vec![HistoryEntry {
|
||||
source: original.to_string_lossy().to_string(),
|
||||
new_path: renamed.to_string_lossy().to_string(),
|
||||
}],
|
||||
};
|
||||
|
||||
let results = revert_batch(&batch);
|
||||
assert!(matches!(
|
||||
results[0].outcome,
|
||||
RevertOutcome::SkippedConflict { .. }
|
||||
));
|
||||
// Neither file should have been touched.
|
||||
assert_eq!(fs::read_to_string(&original).unwrap(), "a new unrelated file");
|
||||
assert_eq!(fs::read_to_string(&renamed).unwrap(), "video");
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fails_gracefully_when_the_renamed_file_is_no_longer_there() {
|
||||
let dir = temp_dir();
|
||||
let original = dir.join("original.mkv");
|
||||
let renamed = dir.join("renamed.mkv"); // never created
|
||||
|
||||
let batch = HistoryBatch {
|
||||
id: "b1".to_string(),
|
||||
timestamp: 0,
|
||||
entries: vec![HistoryEntry {
|
||||
source: original.to_string_lossy().to_string(),
|
||||
new_path: renamed.to_string_lossy().to_string(),
|
||||
}],
|
||||
};
|
||||
|
||||
let results = revert_batch(&batch);
|
||||
assert!(matches!(results[0].outcome, RevertOutcome::Failed { .. }));
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_batch_ignores_empty_batches() {
|
||||
let mut history = Vec::new();
|
||||
push_batch(
|
||||
&mut history,
|
||||
HistoryBatch {
|
||||
id: "empty".to_string(),
|
||||
timestamp: 0,
|
||||
entries: vec![],
|
||||
},
|
||||
);
|
||||
assert!(history.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_batch_orders_most_recent_first_and_trims_to_the_cap() {
|
||||
let mut history = Vec::new();
|
||||
for i in 0..(MAX_HISTORY_BATCHES + 5) {
|
||||
push_batch(
|
||||
&mut history,
|
||||
HistoryBatch {
|
||||
id: format!("batch-{i}"),
|
||||
timestamp: i as u64,
|
||||
entries: vec![HistoryEntry {
|
||||
source: "a".to_string(),
|
||||
new_path: "b".to_string(),
|
||||
}],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(history.len(), MAX_HISTORY_BATCHES);
|
||||
// Most recently pushed batch is first.
|
||||
assert_eq!(history[0].id, format!("batch-{}", MAX_HISTORY_BATCHES + 4));
|
||||
// The oldest batches (0..5) were dropped to stay under the cap.
|
||||
assert!(!history.iter().any(|b| b.id == "batch-0"));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
//! parsing, web datasource clients, rename engine are not yet ported).
|
||||
|
||||
pub mod format;
|
||||
pub mod history;
|
||||
pub mod matching;
|
||||
pub mod rename;
|
||||
pub mod similarity;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -454,6 +454,57 @@
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.history-batch-list {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.history-batch {
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.history-batch-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 2px 4px 6px;
|
||||
}
|
||||
|
||||
.history-batch-when {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.history-batch-count {
|
||||
flex: 0 0 auto;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
color: var(--muted-text);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.history-undo-button {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
height: 26px;
|
||||
padding: 0 12px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.history-batch-entries {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
|
||||
@@ -84,6 +84,30 @@ interface FetchResult {
|
||||
outcome: FetchOutcome;
|
||||
}
|
||||
|
||||
// Rename history / undo. Mirrors renamr-core's `history` module (serde-tagged on
|
||||
// "status" for the outcome, same convention as RenameOutcome/FetchOutcome above).
|
||||
interface HistoryEntry {
|
||||
source: string;
|
||||
newPath: string;
|
||||
}
|
||||
|
||||
interface HistoryBatch {
|
||||
id: string;
|
||||
/// Unix epoch milliseconds - formatted client-side with `Date`.
|
||||
timestamp: number;
|
||||
entries: HistoryEntry[];
|
||||
}
|
||||
|
||||
type RevertOutcome =
|
||||
| { status: "reverted"; restoredPath: string }
|
||||
| { status: "skippedConflict"; existingPath: string }
|
||||
| { status: "failed"; error: string };
|
||||
|
||||
interface RevertResult {
|
||||
source: string;
|
||||
outcome: RevertOutcome;
|
||||
}
|
||||
|
||||
function fileNameFromPath(path: string): string {
|
||||
const parts = path.split(/[\\/]/);
|
||||
return parts[parts.length - 1] ?? path;
|
||||
@@ -252,6 +276,11 @@ export default function RenamePanel() {
|
||||
const [tmdbTokenInput, setTmdbTokenInput] = useState("");
|
||||
const [savingSettings, setSavingSettings] = useState(false);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
// Rename history / undo. `null` batches = not loaded yet (also used to show a
|
||||
// loading state the first time the History panel is opened).
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [historyBatches, setHistoryBatches] = useState<HistoryBatch[] | null>(null);
|
||||
const [undoingBatchId, setUndoingBatchId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
invoke<string | null>("get_tmdb_token")
|
||||
@@ -544,6 +573,76 @@ export default function RenamePanel() {
|
||||
setRenamePlan(null);
|
||||
};
|
||||
|
||||
// Rename history / undo - Renamr's only safety net beyond the confirm-before-execute
|
||||
// step, since a filesystem rename has no OS-level undo. `execute_rename` records a
|
||||
// batch of every successfully-renamed file after each Rename; this panel lists those
|
||||
// batches and can revert one back to its original paths.
|
||||
const openHistory = () => {
|
||||
setHistoryOpen(true);
|
||||
invoke<HistoryBatch[]>("list_rename_history")
|
||||
.then(setHistoryBatches)
|
||||
.catch((err) => {
|
||||
setHistoryBatches([]);
|
||||
setStatus(`Couldn't load rename history: ${err}`);
|
||||
});
|
||||
};
|
||||
|
||||
const closeHistory = () => {
|
||||
if (undoingBatchId) return;
|
||||
setHistoryOpen(false);
|
||||
};
|
||||
|
||||
const undoBatch = async (batch: HistoryBatch) => {
|
||||
setUndoingBatchId(batch.id);
|
||||
try {
|
||||
const results = await invoke<RevertResult[]>("undo_rename_batch", { batchId: batch.id });
|
||||
// Compute counts from `results` directly, not inside the setItems updater below -
|
||||
// same rule as runMatching/confirmRename/runFetch above (React doesn't run a
|
||||
// functional updater synchronously, so a counter mutated in there wouldn't be
|
||||
// ready yet when building the status message right after).
|
||||
const bySource = new Map(results.map((r) => [r.source, r] as const));
|
||||
const reverted = results.filter((r) => r.outcome.status === "reverted").length;
|
||||
const skipped = results.filter((r) => r.outcome.status === "skippedConflict").length;
|
||||
const failed = results.filter((r) => r.outcome.status === "failed").length;
|
||||
|
||||
// Reconcile any currently-loaded item that was part of this batch back to its
|
||||
// pre-rename path/filename - mirrors confirmRename's post-rename reconciliation.
|
||||
setItems((prev) =>
|
||||
prev.map((item) => {
|
||||
const result = bySource.get(item.id);
|
||||
if (!result || result.outcome.status !== "reverted") return item;
|
||||
const restoredFileName = fileNameFromPath(result.outcome.restoredPath);
|
||||
const [base, ext] = splitExt(restoredFileName);
|
||||
return {
|
||||
...item,
|
||||
id: result.outcome.restoredPath,
|
||||
originalName: restoredFileName,
|
||||
ext,
|
||||
bindings: item.bindings ? { ...item.bindings, name: base, ext } : item.bindings,
|
||||
};
|
||||
})
|
||||
);
|
||||
setSelected((prevSelected) => {
|
||||
if (!prevSelected) return prevSelected;
|
||||
const result = bySource.get(prevSelected);
|
||||
return result?.outcome.status === "reverted" ? result.outcome.restoredPath : prevSelected;
|
||||
});
|
||||
|
||||
setHistoryBatches((prev) => (prev ? prev.filter((b) => b.id !== batch.id) : prev));
|
||||
|
||||
const parts = [`Reverted ${reverted} file${reverted === 1 ? "" : "s"}`];
|
||||
if (skipped > 0) parts.push(`${skipped} skipped (something else is there now)`);
|
||||
if (failed > 0) parts.push(`${failed} failed`);
|
||||
setStatus(parts.join(", ") + ".");
|
||||
} catch (err) {
|
||||
setStatus(`Undo failed: ${err}`);
|
||||
} finally {
|
||||
setUndoingBatchId(null);
|
||||
window.clearTimeout((undoBatch as any)._t);
|
||||
(undoBatch as any)._t = window.setTimeout(() => setStatus(null), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const openSettings = () => {
|
||||
setTmdbTokenInput("");
|
||||
setSettingsOpen(true);
|
||||
@@ -727,7 +826,7 @@ export default function RenamePanel() {
|
||||
<span className="list-button-gap" />
|
||||
<ListButton icon={<RemoveIcon />} label="Exclude Selected" onClick={removeSelected} />
|
||||
<ListButton icon={<ClearIcon />} label="Clear" onClick={clearAll} />
|
||||
<ListButton icon={<HistoryIcon />} label="History" onClick={() => notImplemented("Rename history")} />
|
||||
<ListButton icon={<HistoryIcon />} label="History" onClick={openHistory} />
|
||||
<ListButton icon={<PresetsIcon />} label="Presets" onClick={() => notImplemented("Presets")} />
|
||||
</>
|
||||
}
|
||||
@@ -906,6 +1005,62 @@ export default function RenamePanel() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{historyOpen && (
|
||||
<div className="modal-overlay" onClick={closeHistory}>
|
||||
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="modal-title">Rename History</h2>
|
||||
<p className="modal-subtitle">
|
||||
Every completed rename is logged here so it can be undone. Undoing moves each file back to its
|
||||
original path — if something else now exists there, that one file is skipped rather than overwritten.
|
||||
The log keeps the most recent 50 rename operations.
|
||||
</p>
|
||||
{historyBatches === null ? (
|
||||
<p className="modal-note">Loading…</p>
|
||||
) : historyBatches.length === 0 ? (
|
||||
<p className="modal-note">No renames yet — this fills in after you use Rename.</p>
|
||||
) : (
|
||||
<div className="modal-plan-list history-batch-list">
|
||||
{historyBatches.map((batch) => (
|
||||
<div className="history-batch" key={batch.id}>
|
||||
<div className="history-batch-header">
|
||||
<span className="history-batch-when">{new Date(batch.timestamp).toLocaleString()}</span>
|
||||
<span className="history-batch-count">
|
||||
{batch.entries.length} file{batch.entries.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
<button
|
||||
className="action-button history-undo-button"
|
||||
onClick={() => undoBatch(batch)}
|
||||
disabled={undoingBatchId !== null}
|
||||
>
|
||||
{undoingBatchId === batch.id ? "Undoing…" : "Undo"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="history-batch-entries">
|
||||
{batch.entries.map((entry, i) => (
|
||||
<div className="modal-plan-row" key={i}>
|
||||
<span className="modal-plan-old" title={entry.newPath}>
|
||||
{fileNameFromPath(entry.newPath)}
|
||||
</span>
|
||||
<span className="modal-plan-arrow">→</span>
|
||||
<span className="modal-plan-new" title={entry.source}>
|
||||
{fileNameFromPath(entry.source)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="modal-actions">
|
||||
<button className="action-button" onClick={closeHistory} disabled={undoingBatchId !== null}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user