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
|
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
|
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
|
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
|
showing the full old-path/new-path plan before anything touches disk). Rename can also
|
||||||
yet). Rename can also organize files into subfolders — including ones that don't exist
|
organize files into subfolders — including ones that don't exist yet — when the naming
|
||||||
yet — when the naming scheme includes a `/`. Full media-metadata bindings are not yet
|
scheme includes a `/`. Every completed rename is logged to a history panel that can
|
||||||
ported. See "Porting roadmap" below.
|
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
|
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
|
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
|
(`similarity/`, partial `EpisodeMetrics` port), a TMDB metadata
|
||||||
client (`web/tmdb.rs` — TV search + episode lookup, disambiguated
|
client (`web/tmdb.rs` — TV search + episode lookup, disambiguated
|
||||||
via `similarity/`, tested against a mocked HTTP server via
|
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)
|
skip-on-conflict, can also organize into (auto-created)
|
||||||
subfolders when the naming scheme includes `/`. Not yet ported:
|
subfolders when the naming scheme includes `/` — and a rename
|
||||||
full media metadata bindings, other web datasource clients
|
history/undo log (`history/`, a partial `HistoryDialog` port).
|
||||||
(TVDB/AniDB/etc).
|
Not yet ported: full media metadata bindings, other web
|
||||||
|
datasource clients (TVDB/AniDB/etc).
|
||||||
No Tauri dependency, so it builds and unit-tests standalone.
|
No Tauri dependency, so it builds and unit-tests standalone.
|
||||||
src-tauri/ Tauri app shell: exposes renamr-core as commands to the frontend
|
src-tauri/ Tauri app shell: exposes renamr-core as commands to the frontend
|
||||||
(naming-scheme rendering, filename matching, TMDB metadata fetch,
|
(naming-scheme rendering, filename matching, TMDB metadata fetch,
|
||||||
executing a rename plan, native file/folder dialogs, drag-and-drop
|
executing a rename plan, native file/folder dialogs, drag-and-drop
|
||||||
expansion), plus `settings.rs` (TMDB token, stored as JSON in the
|
expansion), plus `settings.rs` (TMDB token) and `history.rs`
|
||||||
OS app-config directory), app metadata/icons/window config
|
(rename history/undo log) — both stored as JSON in the OS
|
||||||
|
app-config directory — app metadata/icons/window config
|
||||||
(tauri.conf.json).
|
(tauri.conf.json).
|
||||||
src/ React + TypeScript UI (Vite). Faithful recreation of the original
|
src/ React + TypeScript UI (Vite). Faithful recreation of the original
|
||||||
two-pane rename screen (Aurora theme) wired to real file loading,
|
two-pane rename screen (Aurora theme) wired to real file loading,
|
||||||
drag-and-drop, live naming-scheme preview, local matching, TMDB
|
drag-and-drop, live naming-scheme preview, local matching, TMDB
|
||||||
fetch (behind a Settings modal for the API token), and a
|
fetch (behind a Settings modal for the API token), a
|
||||||
confirm-before-touching-disk modal for the actual rename.
|
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
|
scripts/gen_icons.py Regenerates src-tauri/icons/ (placeholder branding — swap the
|
||||||
colors/glyph in the script, or replace the icons directly, to
|
colors/glyph in the script, or replace the icons directly, to
|
||||||
change the look).
|
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
|
`--conflict` variants beyond skip (auto-number, override), and the CLI flag surface
|
||||||
itself.
|
itself.
|
||||||
7. **The rest of the UI** — the Rename panel (file loading, drag-drop, live preview,
|
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
|
local matching, confirmed rename, rename history/undo) is built; the other five
|
||||||
(Episodes/Subtitles/SFV/Filter/List) are still stub screens, plus Align up/down
|
sidebar panels (Episodes/Subtitles/SFV/Filter/List) are still stub screens, plus
|
||||||
reordering, History, and Presets on the Rename panel itself are no-ops. There's also
|
Align up/down reordering and Presets on the Rename panel itself are still no-ops.
|
||||||
no per-item exclude inside the rename confirmation modal yet (it's all-or-nothing —
|
There's also no per-item exclude inside the rename confirmation modal yet (it's
|
||||||
use "Exclude Selected" on the file list beforehand to leave a file out entirely).
|
all-or-nothing — use "Exclude Selected" on the file list beforehand to leave a file
|
||||||
8. **Undo/history for renames** — there is no undo yet; the confirmation modal before
|
out entirely).
|
||||||
Rename executes is the only safety net right now. `net.filebot.HistoryDialog`/the
|
8. **Undo/history for renames** — done, as a flat JSON-backed undo stack rather than a
|
||||||
original's rename-history log is the reference for what a real undo would look like.
|
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
|
9. **Packaging** — code signing, auto-update, installers per platform via
|
||||||
`npm run tauri build`.
|
`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).
|
//! parsing, web datasource clients, rename engine are not yet ported).
|
||||||
|
|
||||||
pub mod format;
|
pub mod format;
|
||||||
|
pub mod history;
|
||||||
pub mod matching;
|
pub mod matching;
|
||||||
pub mod rename;
|
pub mod rename;
|
||||||
pub mod similarity;
|
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 files;
|
||||||
|
mod history;
|
||||||
mod metadata;
|
mod metadata;
|
||||||
mod settings;
|
mod settings;
|
||||||
|
|
||||||
@@ -7,6 +8,7 @@ use renamr_core::matching::{self, EpisodeGuess};
|
|||||||
use renamr_core::rename::{self, RenamePlanItem, RenameResult};
|
use renamr_core::rename::{self, RenamePlanItem, RenameResult};
|
||||||
use renamr_core::string_bindings;
|
use renamr_core::string_bindings;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use tauri::AppHandle;
|
||||||
|
|
||||||
/// Renders a naming-scheme expression against a set of binding values.
|
/// 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
|
/// 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)
|
matching::guess_episode(&filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Executes a batch of planned on-disk renames (same-directory rename only; conflicting
|
/// Executes a batch of planned on-disk renames (optionally moving files into
|
||||||
/// targets are skipped, never overwritten - see `renamr_core::rename` for the policy).
|
/// subfolders - see `renamr_core::rename`; conflicting targets are always skipped,
|
||||||
/// The UI is expected to have already shown the user the full old-name/new-name plan
|
/// never overwritten). The UI is expected to have already shown the user the full
|
||||||
/// and gotten explicit confirmation before calling this - there is no undo yet.
|
/// 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.
|
/// 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]
|
#[tauri::command]
|
||||||
async fn execute_rename(items: Vec<RenamePlanItem>) -> Vec<RenameResult> {
|
async fn execute_rename(app: AppHandle, items: Vec<RenamePlanItem>) -> Vec<RenameResult> {
|
||||||
tauri::async_runtime::spawn_blocking(move || rename::execute_renames(&items))
|
let results = tauri::async_runtime::spawn_blocking(move || rename::execute_renames(&items))
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default()
|
.unwrap_or_default();
|
||||||
|
history::record_batch(&app, &results);
|
||||||
|
results
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
@@ -51,6 +58,8 @@ pub fn run() {
|
|||||||
metadata::fetch_metadata,
|
metadata::fetch_metadata,
|
||||||
settings::get_tmdb_token,
|
settings::get_tmdb_token,
|
||||||
settings::save_tmdb_token,
|
settings::save_tmdb_token,
|
||||||
|
history::list_rename_history,
|
||||||
|
history::undo_rename_batch,
|
||||||
files::pick_files,
|
files::pick_files,
|
||||||
files::pick_folder,
|
files::pick_folder,
|
||||||
files::expand_dropped_paths,
|
files::expand_dropped_paths,
|
||||||
|
|||||||
@@ -454,6 +454,57 @@
|
|||||||
font-size: 12.5px;
|
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 {
|
.status-bar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 18px;
|
left: 18px;
|
||||||
|
|||||||
@@ -84,6 +84,30 @@ interface FetchResult {
|
|||||||
outcome: FetchOutcome;
|
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 {
|
function fileNameFromPath(path: string): string {
|
||||||
const parts = path.split(/[\\/]/);
|
const parts = path.split(/[\\/]/);
|
||||||
return parts[parts.length - 1] ?? path;
|
return parts[parts.length - 1] ?? path;
|
||||||
@@ -252,6 +276,11 @@ export default function RenamePanel() {
|
|||||||
const [tmdbTokenInput, setTmdbTokenInput] = useState("");
|
const [tmdbTokenInput, setTmdbTokenInput] = useState("");
|
||||||
const [savingSettings, setSavingSettings] = useState(false);
|
const [savingSettings, setSavingSettings] = useState(false);
|
||||||
const [fetching, setFetching] = 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(() => {
|
useEffect(() => {
|
||||||
invoke<string | null>("get_tmdb_token")
|
invoke<string | null>("get_tmdb_token")
|
||||||
@@ -544,6 +573,76 @@ export default function RenamePanel() {
|
|||||||
setRenamePlan(null);
|
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 = () => {
|
const openSettings = () => {
|
||||||
setTmdbTokenInput("");
|
setTmdbTokenInput("");
|
||||||
setSettingsOpen(true);
|
setSettingsOpen(true);
|
||||||
@@ -727,7 +826,7 @@ export default function RenamePanel() {
|
|||||||
<span className="list-button-gap" />
|
<span className="list-button-gap" />
|
||||||
<ListButton icon={<RemoveIcon />} label="Exclude Selected" onClick={removeSelected} />
|
<ListButton icon={<RemoveIcon />} label="Exclude Selected" onClick={removeSelected} />
|
||||||
<ListButton icon={<ClearIcon />} label="Clear" onClick={clearAll} />
|
<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")} />
|
<ListButton icon={<PresetsIcon />} label="Presets" onClick={() => notImplemented("Presets")} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -906,6 +1005,62 @@ export default function RenamePanel() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user