Add Move/Copy/Symlink rename actions, with undo support for all three
- renamr-core::rename: new RenameAction enum (Move/Copy/Symlink, default Move, mirroring net.filebot.cli.CmdlineOperations's --action modes). execute_renames() now takes an action applied to the whole batch. Copy uses fs::copy (source untouched); Symlink uses std::os::unix::fs::symlink / std::os::windows::fs::symlink_file behind a cfg(unix)/cfg(windows) split. Same conflict policy (skip, never overwrite) and auto-created destination folders apply to all three actions. 8 new tests. - renamr-core::history: HistoryBatch now records which action produced it (#[serde(default)] so old history.json entries - all implicitly Move - still deserialize). revert_one() branches on it: Move still moves the file back; Copy/ Symlink undo by deleting the copy/symlink (using symlink_metadata so a broken link is still recognized as present and removable) - the original was never touched by those actions, so there's nothing to move back. 3 new tests. - src-tauri: execute_rename command takes the chosen action and threads it through to both execute_renames and record_batch. - Frontend: a Move/Copy/Symlink segmented selector in the "Confirm Rename" modal (remembered across modal opens, defaults to Move), action-aware subtitle/button copy, and a status message using the right verb (Renamed/Copied/Symlinked). Only Move reconciles a loaded item's id/name/bindings to the new path afterward, since Copy/ Symlink leave the source file exactly where it was. History panel now shows a Copy/ Symlink badge on non-Move batches and explains what Undo does for each. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
aab8e28246
commit
fd4e6f66f0
@@ -9,14 +9,15 @@ native file/folder loading and drag-and-drop, local filename-based season/episod
|
|||||||
movie title/year* detection ("Match") is working, real online metadata lookups against
|
movie title/year* detection ("Match") is working, real online metadata lookups against
|
||||||
TMDB ("Fetch Data") are working for both TV episodes and movies — including fuzzy
|
TMDB ("Fetch Data") are working for both TV episodes and movies — including fuzzy
|
||||||
title disambiguation, so a search that returns several similarly-named results doesn't
|
title disambiguation, so a search that returns several similarly-named results doesn't
|
||||||
just take TMDB's top-ranked hit — and the actual on-disk rename operation is wired up
|
just take TMDB's top-ranked hit — and the actual on-disk operation is wired up
|
||||||
(skip-on-conflict, with a confirmation step showing the full old-path/new-path plan
|
(skip-on-conflict, with a confirmation step showing the full old-path/new-path plan
|
||||||
before anything touches disk, and a per-row ✕ to drop an individual file from that plan
|
before anything touches disk, a per-row ✕ to drop an individual file from that plan at
|
||||||
at the last step). Rename can also organize files into subfolders —
|
the last step, and a Move/Copy/Symlink selector for what to actually do at each
|
||||||
including ones that don't exist yet — when the naming scheme includes a `/`. Every
|
target). It can also organize files into subfolders — including ones that don't exist
|
||||||
completed rename is logged to a history panel that can undo it, moving files back to
|
yet — when the naming scheme includes a `/`. Every completed batch is logged to a
|
||||||
their original paths. A single loaded batch can freely mix TV episodes and movies —
|
history panel that can undo it (moving files back for Move, or just removing what was
|
||||||
each file only fills in the fields that apply to it. Full media-metadata bindings are
|
created for Copy/Symlink). A single loaded batch can freely mix TV episodes and movies
|
||||||
|
— each file only fills in the fields that apply to it. Full media-metadata bindings are
|
||||||
not yet ported. See "Porting roadmap" below.
|
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
|
||||||
@@ -115,12 +116,12 @@ crates/renamr-core/ Pure Rust core: naming-expression engine (ported), local
|
|||||||
TMDB metadata client (`web/tmdb.rs` — TV search + episode lookup
|
TMDB metadata client (`web/tmdb.rs` — TV search + episode lookup
|
||||||
and movie search + lookup, both disambiguated via `similarity/`,
|
and movie search + lookup, both disambiguated via `similarity/`,
|
||||||
tested against a mocked HTTP server via `mockito`), the on-disk
|
tested against a mocked HTTP server via `mockito`), the on-disk
|
||||||
rename engine (`rename/`) —
|
rename engine (`rename/` — skip-on-conflict, Move/Copy/Symlink
|
||||||
skip-on-conflict, can also organize into (auto-created)
|
actions, can also organize into (auto-created) subfolders when
|
||||||
subfolders when the naming scheme includes `/` — and a rename
|
the naming scheme includes `/`) — and a rename history/undo log
|
||||||
history/undo log (`history/`, a partial `HistoryDialog` port).
|
(`history/`, a partial `HistoryDialog` port, undo-aware of which
|
||||||
Not yet ported: full media metadata bindings, other web
|
action produced each batch). Not yet ported: full media metadata
|
||||||
datasource clients (TVDB/AniDB/etc).
|
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 for both TV and
|
(naming-scheme rendering, filename matching for both TV and
|
||||||
@@ -227,9 +228,13 @@ ported from, for anyone picking this up:
|
|||||||
UI's Rename button behind a confirmation step) including the `move`-into-subfolder
|
UI's Rename button behind a confirmation step) including the `move`-into-subfolder
|
||||||
`--action` variant — a naming scheme like `{n}/{s00e00}` organizes files into
|
`--action` variant — a naming scheme like `{n}/{s00e00}` organizes files into
|
||||||
subfolders, creating any missing folders automatically, always scoped to the file's
|
subfolders, creating any missing folders automatically, always scoped to the file's
|
||||||
own directory tree. Not yet done: the `copy`/`symlink` `--action` variants, the
|
own directory tree. The `copy`/`symlink` `--action` variants are done too — an action
|
||||||
`--conflict` variants beyond skip (auto-number, override), and the CLI flag surface
|
selector in the confirmation modal picks Move/Copy/Symlink for the whole batch; Copy
|
||||||
itself.
|
and Symlink both leave the source file untouched and produce a new file/link at the
|
||||||
|
computed target, and both are undoable from the History panel the same as Move (undo
|
||||||
|
for Copy/Symlink just removes what was created, since there's nothing to move back).
|
||||||
|
Not yet done: the `--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,
|
7. **The rest of the UI** — the Rename panel (file loading, drag-drop, live preview,
|
||||||
local TV/movie matching, confirmed rename with per-item exclude, rename history/undo)
|
local TV/movie matching, confirmed rename with per-item exclude, rename history/undo)
|
||||||
is built; the other five sidebar panels (Episodes/Subtitles/SFV/Filter/List) are
|
is built; the other five sidebar panels (Episodes/Subtitles/SFV/Filter/List) are
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
//! log is persisted to disk is a Tauri-layer concern (`src-tauri/src/history.rs`), same
|
//! 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.
|
//! split as `rename` (pure logic here) vs. the Tauri command that calls it.
|
||||||
|
|
||||||
|
use crate::rename::RenameAction;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
@@ -22,7 +23,8 @@ pub struct HistoryEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One executed rename operation - everything renamed together in a single "Rename N
|
/// One executed rename operation - everything renamed together in a single "Rename N
|
||||||
/// Files" confirmation - undoable as a unit.
|
/// Files" confirmation - undoable as a unit. All entries in a batch share one
|
||||||
|
/// [`RenameAction`], since the UI picks a single action per confirmation, not per file.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct HistoryBatch {
|
pub struct HistoryBatch {
|
||||||
@@ -31,6 +33,11 @@ pub struct HistoryBatch {
|
|||||||
/// (no date/time dependency needed in this crate) so the frontend can format it
|
/// (no date/time dependency needed in this crate) so the frontend can format it
|
||||||
/// however it likes, e.g. `new Date(timestamp).toLocaleString()`.
|
/// however it likes, e.g. `new Date(timestamp).toLocaleString()`.
|
||||||
pub timestamp: u64,
|
pub timestamp: u64,
|
||||||
|
/// What was actually done to produce `entries.new_path` from `entries.source`.
|
||||||
|
/// `#[serde(default)]` so a `history.json` written before this field existed (every
|
||||||
|
/// batch back then was necessarily a Move) still deserializes cleanly.
|
||||||
|
#[serde(default)]
|
||||||
|
pub action: RenameAction,
|
||||||
pub entries: Vec<HistoryEntry>,
|
pub entries: Vec<HistoryEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,13 +45,18 @@ pub struct HistoryBatch {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
#[serde(tag = "status", rename_all = "camelCase")]
|
#[serde(tag = "status", rename_all = "camelCase")]
|
||||||
pub enum RevertOutcome {
|
pub enum RevertOutcome {
|
||||||
/// Successfully moved back to its original path.
|
/// For a Move batch: successfully moved back to its original path. For a Copy or
|
||||||
|
/// Symlink batch: the copy/symlink that was created has been removed - the original
|
||||||
|
/// file was never touched in the first place, so `restored_path` here just names it
|
||||||
|
/// for the caller rather than describing something that was actually moved.
|
||||||
Reverted { restored_path: String },
|
Reverted { restored_path: String },
|
||||||
/// A different file already exists at the original path; left untouched - the same
|
/// A different file already exists at the original path; left untouched - the same
|
||||||
/// skip-never-overwrite policy `rename::execute_renames` uses.
|
/// skip-never-overwrite policy `rename::execute_renames` uses. Only possible for a
|
||||||
|
/// Move revert; Copy/Symlink reverts never touch the original path at all.
|
||||||
SkippedConflict { existing_path: String },
|
SkippedConflict { existing_path: String },
|
||||||
/// The renamed file isn't where the history says it should be anymore (moved,
|
/// The renamed/copied/linked file isn't where the history says it should be
|
||||||
/// deleted, or already reverted some other way), or the OS reported an error.
|
/// anymore (moved, deleted, or already reverted some other way), or the OS reported
|
||||||
|
/// an error.
|
||||||
Failed { error: String },
|
Failed { error: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,10 +75,12 @@ fn same_file(a: &Path, b: &Path) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn revert_one(entry: &HistoryEntry) -> RevertOutcome {
|
fn revert_one(entry: &HistoryEntry, action: RenameAction) -> RevertOutcome {
|
||||||
let current = Path::new(&entry.new_path);
|
let current = Path::new(&entry.new_path);
|
||||||
let original = Path::new(&entry.source);
|
let original = Path::new(&entry.source);
|
||||||
|
|
||||||
|
match action {
|
||||||
|
RenameAction::Move => {
|
||||||
if !current.exists() {
|
if !current.exists() {
|
||||||
return RevertOutcome::Failed {
|
return RevertOutcome::Failed {
|
||||||
error: "the renamed file is no longer at its recorded location".to_string(),
|
error: "the renamed file is no longer at its recorded location".to_string(),
|
||||||
@@ -79,9 +93,10 @@ fn revert_one(entry: &HistoryEntry) -> RevertOutcome {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// The original's parent directory should still exist (nothing in Renamr deletes
|
// The original's parent directory should still exist (nothing in Renamr
|
||||||
// directories), but recreate it defensively in case it was removed by hand since -
|
// deletes directories), but recreate it defensively in case it was removed
|
||||||
// cheap, and `create_dir_all` is a no-op when it's already there.
|
// 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 Some(parent) = original.parent() {
|
||||||
if let Err(e) = fs::create_dir_all(parent) {
|
if let Err(e) = fs::create_dir_all(parent) {
|
||||||
return RevertOutcome::Failed {
|
return RevertOutcome::Failed {
|
||||||
@@ -97,17 +112,39 @@ fn revert_one(entry: &HistoryEntry) -> RevertOutcome {
|
|||||||
Err(e) => RevertOutcome::Failed { error: e.to_string() },
|
Err(e) => RevertOutcome::Failed { error: e.to_string() },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
RenameAction::Copy | RenameAction::Symlink => {
|
||||||
|
// Copy/Symlink never touched `original` in the first place - the file that
|
||||||
|
// was created lives only at `current`, so undoing is just removing it.
|
||||||
|
// `symlink_metadata` (rather than `exists`, which follows links) is used so
|
||||||
|
// a broken symlink - its target moved or deleted separately - still counts
|
||||||
|
// as present and removable, instead of being reported as already gone.
|
||||||
|
if fs::symlink_metadata(current).is_err() {
|
||||||
|
return RevertOutcome::Failed {
|
||||||
|
error: "the copy/symlink is no longer at its recorded location".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// Reverts every entry in a batch, in order. Like [`crate::rename::execute_renames`],
|
match fs::remove_file(current) {
|
||||||
/// entries are processed sequentially (not in parallel) so a collision between two
|
Ok(()) => RevertOutcome::Reverted {
|
||||||
/// entries in the same batch resolves deterministically rather than racing.
|
restored_path: original.to_string_lossy().to_string(),
|
||||||
|
},
|
||||||
|
Err(e) => RevertOutcome::Failed { error: e.to_string() },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reverts every entry in a batch, in order, all under the batch's recorded
|
||||||
|
/// [`RenameAction`]. 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> {
|
pub fn revert_batch(batch: &HistoryBatch) -> Vec<RevertResult> {
|
||||||
batch
|
batch
|
||||||
.entries
|
.entries
|
||||||
.iter()
|
.iter()
|
||||||
.map(|entry| RevertResult {
|
.map(|entry| RevertResult {
|
||||||
source: entry.new_path.clone(),
|
source: entry.new_path.clone(),
|
||||||
outcome: revert_one(entry),
|
outcome: revert_one(entry, batch.action),
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -152,6 +189,7 @@ mod tests {
|
|||||||
|
|
||||||
let batch = HistoryBatch {
|
let batch = HistoryBatch {
|
||||||
id: "b1".to_string(),
|
id: "b1".to_string(),
|
||||||
|
action: RenameAction::Move,
|
||||||
timestamp: 0,
|
timestamp: 0,
|
||||||
entries: vec![HistoryEntry {
|
entries: vec![HistoryEntry {
|
||||||
source: original.to_string_lossy().to_string(),
|
source: original.to_string_lossy().to_string(),
|
||||||
@@ -185,6 +223,7 @@ mod tests {
|
|||||||
|
|
||||||
let batch = HistoryBatch {
|
let batch = HistoryBatch {
|
||||||
id: "b1".to_string(),
|
id: "b1".to_string(),
|
||||||
|
action: RenameAction::Move,
|
||||||
timestamp: 0,
|
timestamp: 0,
|
||||||
entries: vec![HistoryEntry {
|
entries: vec![HistoryEntry {
|
||||||
source: original.to_string_lossy().to_string(),
|
source: original.to_string_lossy().to_string(),
|
||||||
@@ -209,6 +248,7 @@ mod tests {
|
|||||||
|
|
||||||
let batch = HistoryBatch {
|
let batch = HistoryBatch {
|
||||||
id: "b1".to_string(),
|
id: "b1".to_string(),
|
||||||
|
action: RenameAction::Move,
|
||||||
timestamp: 0,
|
timestamp: 0,
|
||||||
entries: vec![HistoryEntry {
|
entries: vec![HistoryEntry {
|
||||||
source: original.to_string_lossy().to_string(),
|
source: original.to_string_lossy().to_string(),
|
||||||
@@ -236,6 +276,7 @@ mod tests {
|
|||||||
|
|
||||||
let batch = HistoryBatch {
|
let batch = HistoryBatch {
|
||||||
id: "b1".to_string(),
|
id: "b1".to_string(),
|
||||||
|
action: RenameAction::Move,
|
||||||
timestamp: 0,
|
timestamp: 0,
|
||||||
entries: vec![HistoryEntry {
|
entries: vec![HistoryEntry {
|
||||||
source: original.to_string_lossy().to_string(),
|
source: original.to_string_lossy().to_string(),
|
||||||
@@ -257,6 +298,7 @@ mod tests {
|
|||||||
HistoryBatch {
|
HistoryBatch {
|
||||||
id: "empty".to_string(),
|
id: "empty".to_string(),
|
||||||
timestamp: 0,
|
timestamp: 0,
|
||||||
|
action: RenameAction::Move,
|
||||||
entries: vec![],
|
entries: vec![],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -272,6 +314,7 @@ mod tests {
|
|||||||
HistoryBatch {
|
HistoryBatch {
|
||||||
id: format!("batch-{i}"),
|
id: format!("batch-{i}"),
|
||||||
timestamp: i as u64,
|
timestamp: i as u64,
|
||||||
|
action: RenameAction::Move,
|
||||||
entries: vec![HistoryEntry {
|
entries: vec![HistoryEntry {
|
||||||
source: "a".to_string(),
|
source: "a".to_string(),
|
||||||
new_path: "b".to_string(),
|
new_path: "b".to_string(),
|
||||||
@@ -286,4 +329,93 @@ mod tests {
|
|||||||
// The oldest batches (0..5) were dropped to stay under the cap.
|
// The oldest batches (0..5) were dropped to stay under the cap.
|
||||||
assert!(!history.iter().any(|b| b.id == "batch-0"));
|
assert!(!history.iter().any(|b| b.id == "batch-0"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reverting_a_copy_batch_deletes_the_copy_and_leaves_the_original_untouched() {
|
||||||
|
let dir = temp_dir();
|
||||||
|
let original = dir.join("original.txt");
|
||||||
|
fs::write(&original, "hello").unwrap();
|
||||||
|
let copy = dir.join("copied.txt");
|
||||||
|
fs::write(©, "hello").unwrap();
|
||||||
|
|
||||||
|
let batch = HistoryBatch {
|
||||||
|
id: "b1".to_string(),
|
||||||
|
timestamp: 0,
|
||||||
|
action: RenameAction::Copy,
|
||||||
|
entries: vec![HistoryEntry {
|
||||||
|
source: original.to_string_lossy().to_string(),
|
||||||
|
new_path: copy.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());
|
||||||
|
}
|
||||||
|
other => panic!("expected Reverted, got {other:?}"),
|
||||||
|
}
|
||||||
|
// The copy is gone...
|
||||||
|
assert!(!copy.exists());
|
||||||
|
// ...but the original was never touched in the first place.
|
||||||
|
assert!(original.exists());
|
||||||
|
assert_eq!(fs::read_to_string(&original).unwrap(), "hello");
|
||||||
|
|
||||||
|
fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn reverting_a_symlink_batch_removes_the_link_not_the_file_it_points_to() {
|
||||||
|
let dir = temp_dir();
|
||||||
|
let original = dir.join("original.txt");
|
||||||
|
fs::write(&original, "hello").unwrap();
|
||||||
|
let link = dir.join("linked.txt");
|
||||||
|
std::os::unix::fs::symlink(&original, &link).unwrap();
|
||||||
|
|
||||||
|
let batch = HistoryBatch {
|
||||||
|
id: "b1".to_string(),
|
||||||
|
timestamp: 0,
|
||||||
|
action: RenameAction::Symlink,
|
||||||
|
entries: vec![HistoryEntry {
|
||||||
|
source: original.to_string_lossy().to_string(),
|
||||||
|
new_path: link.to_string_lossy().to_string(),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
let results = revert_batch(&batch);
|
||||||
|
assert!(matches!(results[0].outcome, RevertOutcome::Reverted { .. }));
|
||||||
|
// The symlink is gone...
|
||||||
|
assert!(fs::symlink_metadata(&link).is_err());
|
||||||
|
// ...but the real file it pointed at is completely unaffected.
|
||||||
|
assert!(original.exists());
|
||||||
|
assert_eq!(fs::read_to_string(&original).unwrap(), "hello");
|
||||||
|
|
||||||
|
fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reverting_a_copy_batch_fails_gracefully_when_the_copy_is_already_gone() {
|
||||||
|
let dir = temp_dir();
|
||||||
|
let original = dir.join("original.txt");
|
||||||
|
fs::write(&original, "hello").unwrap();
|
||||||
|
let copy = dir.join("copied.txt"); // never created
|
||||||
|
|
||||||
|
let batch = HistoryBatch {
|
||||||
|
id: "b1".to_string(),
|
||||||
|
timestamp: 0,
|
||||||
|
action: RenameAction::Copy,
|
||||||
|
entries: vec![HistoryEntry {
|
||||||
|
source: original.to_string_lossy().to_string(),
|
||||||
|
new_path: copy.to_string_lossy().to_string(),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
let results = revert_batch(&batch);
|
||||||
|
assert!(matches!(results[0].outcome, RevertOutcome::Failed { .. }));
|
||||||
|
// The original should still be completely untouched.
|
||||||
|
assert!(original.exists());
|
||||||
|
|
||||||
|
fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,11 @@
|
|||||||
//! name to mean "put it in a subfolder" instead of rejecting it outright). Any missing
|
//! name to mean "put it in a subfolder" instead of rejecting it outright). Any missing
|
||||||
//! intermediate directories are created automatically. Conflict policy is fixed at
|
//! intermediate directories are created automatically. Conflict policy is fixed at
|
||||||
//! **skip**: if the computed target already exists and isn't the same underlying file,
|
//! **skip**: if the computed target already exists and isn't the same underlying file,
|
||||||
//! that item is left alone and reported as skipped rather than overwritten. There is no
|
//! that item is left alone and reported as skipped rather than overwritten. The UI is
|
||||||
//! undo yet, so the UI is expected to show the caller a confirmation of the full plan
|
//! expected to show the caller a confirmation of the full plan (and let them choose a
|
||||||
//! before calling [`execute_renames`].
|
//! [`RenameAction`]) before calling [`execute_renames`]; every batch that action
|
||||||
|
//! actually touches a file for is also recorded to the rename-history log one layer up
|
||||||
|
//! (`renamr_core::history`), so it can be undone from there.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -32,6 +34,33 @@ pub struct RenamePlanItem {
|
|||||||
pub new_name: String,
|
pub new_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What to actually do at the target path once it's been resolved and cleared of
|
||||||
|
/// conflicts - a port of the handful of `--action` modes `net.filebot.cli
|
||||||
|
/// .CmdlineOperations` supports (`move`, the default, plus `copy`/`symlink`; `keeplink`
|
||||||
|
/// and `test` are not ported). Applies to a whole batch at once - the UI picks one
|
||||||
|
/// action per "Rename" confirmation, not per file.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum RenameAction {
|
||||||
|
/// Renames/moves the file in place - the only action Renamr had before this. The
|
||||||
|
/// source file no longer exists afterward; the item now lives at the target path.
|
||||||
|
Move,
|
||||||
|
/// Copies the file's contents to the target path, leaving the source untouched.
|
||||||
|
Copy,
|
||||||
|
/// Creates a symlink at the target path pointing back at the (untouched) source
|
||||||
|
/// file. Unix: `std::os::unix::fs::symlink`. Windows: `symlink_file` (creating a
|
||||||
|
/// file symlink there normally requires Developer Mode or an elevated process -
|
||||||
|
/// surfaced to the caller as an ordinary `Failed` outcome if it's rejected, not a
|
||||||
|
/// special case).
|
||||||
|
Symlink,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RenameAction {
|
||||||
|
fn default() -> Self {
|
||||||
|
RenameAction::Move
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What happened to one planned rename.
|
/// What happened to one planned rename.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
#[serde(tag = "status", rename_all = "camelCase")]
|
#[serde(tag = "status", rename_all = "camelCase")]
|
||||||
@@ -90,7 +119,25 @@ fn same_file(a: &Path, b: &Path) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rename_one(source: &Path, new_name: &str) -> RenameOutcome {
|
#[cfg(unix)]
|
||||||
|
fn create_symlink(source: &Path, target: &Path) -> std::io::Result<()> {
|
||||||
|
std::os::unix::fs::symlink(source, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn create_symlink(source: &Path, target: &Path) -> std::io::Result<()> {
|
||||||
|
std::os::windows::fs::symlink_file(source, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(unix, windows)))]
|
||||||
|
fn create_symlink(_source: &Path, _target: &Path) -> std::io::Result<()> {
|
||||||
|
Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Unsupported,
|
||||||
|
"symlinks aren't supported on this platform",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rename_one(source: &Path, new_name: &str, action: RenameAction) -> RenameOutcome {
|
||||||
if let Err(reason) = validate_relative_target(new_name) {
|
if let Err(reason) = validate_relative_target(new_name) {
|
||||||
return RenameOutcome::Failed {
|
return RenameOutcome::Failed {
|
||||||
error: format!("\"{new_name}\" is not a valid target: {reason}"),
|
error: format!("\"{new_name}\" is not a valid target: {reason}"),
|
||||||
@@ -108,6 +155,9 @@ fn rename_one(source: &Path, new_name: &str) -> RenameOutcome {
|
|||||||
let target = parent.join(new_name);
|
let target = parent.join(new_name);
|
||||||
|
|
||||||
if target == source {
|
if target == source {
|
||||||
|
// Nothing meaningful to do for any action - Move has nothing to move, and
|
||||||
|
// Copy/Symlink onto the source's own path would just be a no-op self-reference
|
||||||
|
// at best (or an outright error at worst, e.g. `fs::copy` onto the same path).
|
||||||
return RenameOutcome::Unchanged;
|
return RenameOutcome::Unchanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +169,7 @@ fn rename_one(source: &Path, new_name: &str) -> RenameOutcome {
|
|||||||
|
|
||||||
// `new_name` may include subdirectory components (organizing files into folders) -
|
// `new_name` may include subdirectory components (organizing files into folders) -
|
||||||
// make sure the destination directory tree exists before attempting the actual
|
// make sure the destination directory tree exists before attempting the actual
|
||||||
// rename/move. `create_dir_all` is a no-op (Ok) when the directory already exists.
|
// operation. `create_dir_all` is a no-op (Ok) when the directory already exists.
|
||||||
if let Some(target_parent) = target.parent() {
|
if let Some(target_parent) = target.parent() {
|
||||||
if let Err(e) = fs::create_dir_all(target_parent) {
|
if let Err(e) = fs::create_dir_all(target_parent) {
|
||||||
return RenameOutcome::Failed {
|
return RenameOutcome::Failed {
|
||||||
@@ -128,7 +178,13 @@ fn rename_one(source: &Path, new_name: &str) -> RenameOutcome {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
match fs::rename(source, &target) {
|
let result = match action {
|
||||||
|
RenameAction::Move => fs::rename(source, &target),
|
||||||
|
RenameAction::Copy => fs::copy(source, &target).map(|_bytes| ()),
|
||||||
|
RenameAction::Symlink => create_symlink(source, &target),
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
Ok(()) => RenameOutcome::Renamed {
|
Ok(()) => RenameOutcome::Renamed {
|
||||||
new_path: target.to_string_lossy().to_string(),
|
new_path: target.to_string_lossy().to_string(),
|
||||||
},
|
},
|
||||||
@@ -138,16 +194,17 @@ fn rename_one(source: &Path, new_name: &str) -> RenameOutcome {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Executes a batch of planned renames in order. Items are processed sequentially (not
|
/// Executes a batch of planned renames in order, all under the same [`RenameAction`].
|
||||||
/// in parallel) so that two items whose computed names collide with each other resolve
|
/// Items are processed sequentially (not in parallel) so that two items whose computed
|
||||||
/// the same way an on-disk collision would: the first one through wins, the second is
|
/// names collide with each other resolve the same way an on-disk collision would: the
|
||||||
/// reported as a skipped conflict rather than silently overwriting it.
|
/// first one through wins, the second is reported as a skipped conflict rather than
|
||||||
pub fn execute_renames(items: &[RenamePlanItem]) -> Vec<RenameResult> {
|
/// silently overwriting it.
|
||||||
|
pub fn execute_renames(items: &[RenamePlanItem], action: RenameAction) -> Vec<RenameResult> {
|
||||||
items
|
items
|
||||||
.iter()
|
.iter()
|
||||||
.map(|item| RenameResult {
|
.map(|item| RenameResult {
|
||||||
source: item.source.clone(),
|
source: item.source.clone(),
|
||||||
outcome: rename_one(Path::new(&item.source), &item.new_name),
|
outcome: rename_one(Path::new(&item.source), &item.new_name, action),
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -178,7 +235,7 @@ mod tests {
|
|||||||
new_name: "new.txt".to_string(),
|
new_name: "new.txt".to_string(),
|
||||||
}];
|
}];
|
||||||
|
|
||||||
let results = execute_renames(&items);
|
let results = execute_renames(&items, RenameAction::Move);
|
||||||
assert_eq!(results.len(), 1);
|
assert_eq!(results.len(), 1);
|
||||||
match &results[0].outcome {
|
match &results[0].outcome {
|
||||||
RenameOutcome::Renamed { new_path } => {
|
RenameOutcome::Renamed { new_path } => {
|
||||||
@@ -200,7 +257,7 @@ mod tests {
|
|||||||
new_name: "same.txt".to_string(),
|
new_name: "same.txt".to_string(),
|
||||||
}];
|
}];
|
||||||
|
|
||||||
let results = execute_renames(&items);
|
let results = execute_renames(&items, RenameAction::Move);
|
||||||
assert_eq!(results[0].outcome, RenameOutcome::Unchanged);
|
assert_eq!(results[0].outcome, RenameOutcome::Unchanged);
|
||||||
assert!(file.exists());
|
assert!(file.exists());
|
||||||
|
|
||||||
@@ -217,7 +274,7 @@ mod tests {
|
|||||||
new_name: "b.txt".to_string(),
|
new_name: "b.txt".to_string(),
|
||||||
}];
|
}];
|
||||||
|
|
||||||
let results = execute_renames(&items);
|
let results = execute_renames(&items, RenameAction::Move);
|
||||||
match &results[0].outcome {
|
match &results[0].outcome {
|
||||||
RenameOutcome::SkippedConflict { existing_path } => {
|
RenameOutcome::SkippedConflict { existing_path } => {
|
||||||
assert!(existing_path.ends_with("b.txt"));
|
assert!(existing_path.ends_with("b.txt"));
|
||||||
@@ -248,7 +305,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
let results = execute_renames(&items);
|
let results = execute_renames(&items, RenameAction::Move);
|
||||||
assert!(matches!(results[0].outcome, RenameOutcome::Renamed { .. }));
|
assert!(matches!(results[0].outcome, RenameOutcome::Renamed { .. }));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
results[1].outcome,
|
results[1].outcome,
|
||||||
@@ -268,7 +325,7 @@ mod tests {
|
|||||||
new_name: "../escaped.txt".to_string(),
|
new_name: "../escaped.txt".to_string(),
|
||||||
}];
|
}];
|
||||||
|
|
||||||
let results = execute_renames(&items);
|
let results = execute_renames(&items, RenameAction::Move);
|
||||||
assert!(matches!(results[0].outcome, RenameOutcome::Failed { .. }));
|
assert!(matches!(results[0].outcome, RenameOutcome::Failed { .. }));
|
||||||
assert!(file.exists());
|
assert!(file.exists());
|
||||||
|
|
||||||
@@ -283,7 +340,7 @@ mod tests {
|
|||||||
new_name: "Breaking Bad/Ozymandias.mkv".to_string(),
|
new_name: "Breaking Bad/Ozymandias.mkv".to_string(),
|
||||||
}];
|
}];
|
||||||
|
|
||||||
let results = execute_renames(&items);
|
let results = execute_renames(&items, RenameAction::Move);
|
||||||
match &results[0].outcome {
|
match &results[0].outcome {
|
||||||
RenameOutcome::Renamed { new_path } => {
|
RenameOutcome::Renamed { new_path } => {
|
||||||
let expected = dir.join("Breaking Bad").join("Ozymandias.mkv");
|
let expected = dir.join("Breaking Bad").join("Ozymandias.mkv");
|
||||||
@@ -305,7 +362,7 @@ mod tests {
|
|||||||
new_name: "Breaking Bad/Season 05/S05E14.mkv".to_string(),
|
new_name: "Breaking Bad/Season 05/S05E14.mkv".to_string(),
|
||||||
}];
|
}];
|
||||||
|
|
||||||
let results = execute_renames(&items);
|
let results = execute_renames(&items, RenameAction::Move);
|
||||||
let expected = dir.join("Breaking Bad").join("Season 05").join("S05E14.mkv");
|
let expected = dir.join("Breaking Bad").join("Season 05").join("S05E14.mkv");
|
||||||
match &results[0].outcome {
|
match &results[0].outcome {
|
||||||
RenameOutcome::Renamed { new_path } => {
|
RenameOutcome::Renamed { new_path } => {
|
||||||
@@ -329,7 +386,7 @@ mod tests {
|
|||||||
new_name: "Sub/b.txt".to_string(),
|
new_name: "Sub/b.txt".to_string(),
|
||||||
}];
|
}];
|
||||||
|
|
||||||
let results = execute_renames(&items);
|
let results = execute_renames(&items, RenameAction::Move);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
results[0].outcome,
|
results[0].outcome,
|
||||||
RenameOutcome::SkippedConflict { .. }
|
RenameOutcome::SkippedConflict { .. }
|
||||||
@@ -348,7 +405,7 @@ mod tests {
|
|||||||
new_name: "Sub\\b.txt".to_string(),
|
new_name: "Sub\\b.txt".to_string(),
|
||||||
}];
|
}];
|
||||||
|
|
||||||
let results = execute_renames(&items);
|
let results = execute_renames(&items, RenameAction::Move);
|
||||||
assert!(matches!(results[0].outcome, RenameOutcome::Failed { .. }));
|
assert!(matches!(results[0].outcome, RenameOutcome::Failed { .. }));
|
||||||
assert!(file.exists());
|
assert!(file.exists());
|
||||||
|
|
||||||
@@ -364,7 +421,7 @@ mod tests {
|
|||||||
source: file.to_string_lossy().to_string(),
|
source: file.to_string_lossy().to_string(),
|
||||||
new_name: bad_name.to_string(),
|
new_name: bad_name.to_string(),
|
||||||
}];
|
}];
|
||||||
let results = execute_renames(&items);
|
let results = execute_renames(&items, RenameAction::Move);
|
||||||
assert!(
|
assert!(
|
||||||
matches!(results[0].outcome, RenameOutcome::Failed { .. }),
|
matches!(results[0].outcome, RenameOutcome::Failed { .. }),
|
||||||
"expected {bad_name:?} to be rejected"
|
"expected {bad_name:?} to be rejected"
|
||||||
@@ -374,4 +431,123 @@ mod tests {
|
|||||||
|
|
||||||
fs::remove_dir_all(&dir).ok();
|
fs::remove_dir_all(&dir).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn copy_action_leaves_the_source_file_in_place() {
|
||||||
|
let (dir, file) = temp_file_setup("original.txt", "hello");
|
||||||
|
let items = vec![RenamePlanItem {
|
||||||
|
source: file.to_string_lossy().to_string(),
|
||||||
|
new_name: "copied.txt".to_string(),
|
||||||
|
}];
|
||||||
|
|
||||||
|
let results = execute_renames(&items, RenameAction::Copy);
|
||||||
|
match &results[0].outcome {
|
||||||
|
RenameOutcome::Renamed { new_path } => {
|
||||||
|
assert!(new_path.ends_with("copied.txt"));
|
||||||
|
assert!(Path::new(new_path).exists());
|
||||||
|
assert_eq!(fs::read_to_string(new_path).unwrap(), "hello");
|
||||||
|
// The whole point of Copy: the source is untouched.
|
||||||
|
assert!(file.exists());
|
||||||
|
assert_eq!(fs::read_to_string(&file).unwrap(), "hello");
|
||||||
|
}
|
||||||
|
other => panic!("expected Renamed, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn copy_action_can_also_organize_into_a_new_subfolder() {
|
||||||
|
let (dir, file) = temp_file_setup("original.txt", "hello");
|
||||||
|
let items = vec![RenamePlanItem {
|
||||||
|
source: file.to_string_lossy().to_string(),
|
||||||
|
new_name: "Sub/copied.txt".to_string(),
|
||||||
|
}];
|
||||||
|
|
||||||
|
let results = execute_renames(&items, RenameAction::Copy);
|
||||||
|
let expected = dir.join("Sub").join("copied.txt");
|
||||||
|
match &results[0].outcome {
|
||||||
|
RenameOutcome::Renamed { new_path } => {
|
||||||
|
assert_eq!(Path::new(new_path), expected.as_path());
|
||||||
|
assert!(expected.exists());
|
||||||
|
assert!(file.exists());
|
||||||
|
}
|
||||||
|
other => panic!("expected Renamed, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn symlink_action_leaves_the_source_file_in_place_and_points_back_at_it() {
|
||||||
|
let (dir, file) = temp_file_setup("original.txt", "hello");
|
||||||
|
let items = vec![RenamePlanItem {
|
||||||
|
source: file.to_string_lossy().to_string(),
|
||||||
|
new_name: "linked.txt".to_string(),
|
||||||
|
}];
|
||||||
|
|
||||||
|
let results = execute_renames(&items, RenameAction::Symlink);
|
||||||
|
match &results[0].outcome {
|
||||||
|
RenameOutcome::Renamed { new_path } => {
|
||||||
|
let link = Path::new(new_path);
|
||||||
|
assert!(fs::symlink_metadata(link).unwrap().file_type().is_symlink());
|
||||||
|
assert_eq!(fs::read_link(link).unwrap(), file);
|
||||||
|
// Reading through the link gets the source's real content...
|
||||||
|
assert_eq!(fs::read_to_string(link).unwrap(), "hello");
|
||||||
|
// ...and the source itself is completely untouched.
|
||||||
|
assert!(file.exists());
|
||||||
|
assert!(!fs::symlink_metadata(&file).unwrap().file_type().is_symlink());
|
||||||
|
}
|
||||||
|
other => panic!("expected Renamed, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conflict_policy_applies_the_same_way_to_copy_and_symlink() {
|
||||||
|
let (dir, file) = temp_file_setup("a.txt", "aaa");
|
||||||
|
fs::write(dir.join("b.txt"), "bbb").unwrap();
|
||||||
|
|
||||||
|
for action in [RenameAction::Copy, RenameAction::Symlink] {
|
||||||
|
let items = vec![RenamePlanItem {
|
||||||
|
source: file.to_string_lossy().to_string(),
|
||||||
|
new_name: "b.txt".to_string(),
|
||||||
|
}];
|
||||||
|
let results = execute_renames(&items, action);
|
||||||
|
match &results[0].outcome {
|
||||||
|
RenameOutcome::SkippedConflict { existing_path } => {
|
||||||
|
assert!(existing_path.ends_with("b.txt"));
|
||||||
|
}
|
||||||
|
other => panic!("expected SkippedConflict for {action:?}, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Neither original file nor the pre-existing conflicting file should be touched.
|
||||||
|
assert!(file.exists());
|
||||||
|
assert_eq!(fs::read_to_string(dir.join("b.txt")).unwrap(), "bbb");
|
||||||
|
|
||||||
|
fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn copy_and_symlink_report_unchanged_when_the_computed_name_is_already_the_source() {
|
||||||
|
let (dir, file) = temp_file_setup("same.txt", "hello");
|
||||||
|
|
||||||
|
for action in [RenameAction::Copy, RenameAction::Symlink] {
|
||||||
|
let items = vec![RenamePlanItem {
|
||||||
|
source: file.to_string_lossy().to_string(),
|
||||||
|
new_name: "same.txt".to_string(),
|
||||||
|
}];
|
||||||
|
let results = execute_renames(&items, action);
|
||||||
|
assert_eq!(
|
||||||
|
results[0].outcome,
|
||||||
|
RenameOutcome::Unchanged,
|
||||||
|
"expected Unchanged for {action:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(file.exists());
|
||||||
|
|
||||||
|
fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//! list the log, and undo one batch from it.
|
//! list the log, and undo one batch from it.
|
||||||
|
|
||||||
use renamr_core::history::{self, HistoryBatch, HistoryEntry, RevertResult};
|
use renamr_core::history::{self, HistoryBatch, HistoryEntry, RevertResult};
|
||||||
use renamr_core::rename::{RenameOutcome, RenameResult};
|
use renamr_core::rename::{RenameAction, RenameOutcome, RenameResult};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
@@ -43,12 +43,14 @@ fn now_millis() -> u64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Called right after a rename batch executes (see `execute_rename` in `lib.rs`) -
|
/// 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,
|
/// records only the entries that were actually renamed/copied/linked, and the action
|
||||||
/// skipped, or failed to rename has nothing to undo. Best-effort: if persisting the
|
/// that was used to do it (so `undo_rename_batch` later knows whether to move the
|
||||||
/// history fails for some reason, the rename itself already succeeded and shouldn't be
|
/// result back or just delete it - see `renamr_core::history::revert_one`); a file that
|
||||||
/// reported as failed just because its undo record couldn't be saved, so errors here
|
/// was unchanged, skipped, or failed has nothing to undo. Best-effort: if persisting the
|
||||||
|
/// history fails for some reason, the operation 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.
|
/// are swallowed rather than surfaced to the caller.
|
||||||
pub fn record_batch(app: &AppHandle, results: &[RenameResult]) {
|
pub fn record_batch(app: &AppHandle, results: &[RenameResult], action: RenameAction) {
|
||||||
let entries: Vec<HistoryEntry> = results
|
let entries: Vec<HistoryEntry> = results
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|r| match &r.outcome {
|
.filter_map(|r| match &r.outcome {
|
||||||
@@ -67,6 +69,7 @@ pub fn record_batch(app: &AppHandle, results: &[RenameResult]) {
|
|||||||
let batch = HistoryBatch {
|
let batch = HistoryBatch {
|
||||||
id: format!("batch-{}", now_millis()),
|
id: format!("batch-{}", now_millis()),
|
||||||
timestamp: now_millis(),
|
timestamp: now_millis(),
|
||||||
|
action,
|
||||||
entries,
|
entries,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+14
-11
@@ -5,7 +5,7 @@ mod settings;
|
|||||||
|
|
||||||
use renamr_core::format::ExpressionFormat;
|
use renamr_core::format::ExpressionFormat;
|
||||||
use renamr_core::matching::{self, EpisodeGuess, MovieGuess};
|
use renamr_core::matching::{self, EpisodeGuess, MovieGuess};
|
||||||
use renamr_core::rename::{self, RenamePlanItem, RenameResult};
|
use renamr_core::rename::{self, RenameAction, RenamePlanItem, RenameResult};
|
||||||
use renamr_core::string_bindings;
|
use renamr_core::string_bindings;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
@@ -39,20 +39,23 @@ fn guess_movie(filename: String) -> Option<MovieGuess> {
|
|||||||
matching::guess_movie(&filename)
|
matching::guess_movie(&filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Executes a batch of planned on-disk renames (optionally moving files into
|
/// Executes a batch of planned on-disk rename/copy/symlink operations (optionally
|
||||||
/// subfolders - see `renamr_core::rename`; conflicting targets are always skipped,
|
/// moving/copying files into subfolders - see `renamr_core::rename`; conflicting
|
||||||
/// never overwritten). The UI is expected to have already shown the user the full
|
/// targets are always skipped, never overwritten). `action` picks which of the three
|
||||||
/// old-path/new-path plan and gotten explicit confirmation before calling this.
|
/// happens - the UI is expected to have already shown the user the full old-path/
|
||||||
/// Runs off the main thread since real filesystem I/O shouldn't block the UI.
|
/// new-path plan, the chosen action, 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
|
/// Every successfully-produced item is recorded to the rename history log afterward
|
||||||
/// (see `history::record_batch`) so it can be undone later from the History panel.
|
/// (see `history::record_batch`), tagged with `action`, so it can be undone later from
|
||||||
|
/// the History panel - a Move undo moves the file back, a Copy/Symlink undo just
|
||||||
|
/// removes what was created.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn execute_rename(app: AppHandle, items: Vec<RenamePlanItem>) -> Vec<RenameResult> {
|
async fn execute_rename(app: AppHandle, items: Vec<RenamePlanItem>, action: RenameAction) -> Vec<RenameResult> {
|
||||||
let results = tauri::async_runtime::spawn_blocking(move || rename::execute_renames(&items))
|
let results = tauri::async_runtime::spawn_blocking(move || rename::execute_renames(&items, action))
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
history::record_batch(&app, &results);
|
history::record_batch(&app, &results, action);
|
||||||
results
|
results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -459,6 +459,44 @@
|
|||||||
color: var(--aurora-cyan);
|
color: var(--aurora-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-segmented {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 2px 0 12px;
|
||||||
|
padding: 3px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--surface-deep);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-segmented-option {
|
||||||
|
all: unset;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex: 1 1 0;
|
||||||
|
text-align: center;
|
||||||
|
padding: 7px 0;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted-text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-segmented-option:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-segmented-option.selected {
|
||||||
|
background: var(--aurora-green);
|
||||||
|
color: var(--dock-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-segmented-option:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-field-label {
|
.modal-field-label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|||||||
@@ -78,6 +78,13 @@ interface RenameResult {
|
|||||||
outcome: RenameOutcome;
|
outcome: RenameOutcome;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mirrors renamr-core's `RenameAction` - what actually happens at each computed target.
|
||||||
|
// "move" relocates the file in place (the only action Renamr had before); "copy" and
|
||||||
|
// "symlink" both leave the source file exactly where it is and produce a new file
|
||||||
|
// elsewhere, which is why only "move" updates the loaded item's own path/name (see
|
||||||
|
// confirmRename below).
|
||||||
|
type RenameAction = "move" | "copy" | "symlink";
|
||||||
|
|
||||||
// Sent to `fetch_metadata`: one file's local guess (from Match) to resolve against
|
// Sent to `fetch_metadata`: one file's local guess (from Match) to resolve against
|
||||||
// real TMDB data. Needs a series name + season/episode to search with - exactly what
|
// real TMDB data. Needs a series name + season/episode to search with - exactly what
|
||||||
// Match already produces offline, which is why Fetch Data requires Match to have run
|
// Match already produces offline, which is why Fetch Data requires Match to have run
|
||||||
@@ -131,6 +138,10 @@ interface HistoryBatch {
|
|||||||
id: string;
|
id: string;
|
||||||
/// Unix epoch milliseconds - formatted client-side with `Date`.
|
/// Unix epoch milliseconds - formatted client-side with `Date`.
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
|
// What action produced every entry in this batch. Older history entries recorded
|
||||||
|
// before this field existed come back as "move" (the Rust side's serde default),
|
||||||
|
// which is also correct - that was the only action that ever existed back then.
|
||||||
|
action: RenameAction;
|
||||||
entries: HistoryEntry[];
|
entries: HistoryEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,6 +239,22 @@ const MATCHED_EXPRESSION = "{show name} - {season number}x{episode number}";
|
|||||||
// What Match + Fetch Data together can fill in.
|
// What Match + Fetch Data together can fill in.
|
||||||
const FETCHED_EXPRESSION = "{show name} - {season number}x{episode number} - {episode name}";
|
const FETCHED_EXPRESSION = "{show name} - {season number}x{episode number} - {episode name}";
|
||||||
|
|
||||||
|
// Copy for each RenameAction, used in the confirmation modal's segmented selector,
|
||||||
|
// subtitle, confirm-button label, and the post-confirm status message.
|
||||||
|
const RENAME_ACTION_LABELS: Record<
|
||||||
|
RenameAction,
|
||||||
|
{ button: string; verb: string; pastTense: string; progressive: string }
|
||||||
|
> = {
|
||||||
|
move: { button: "Rename", verb: "rename", pastTense: "Renamed", progressive: "Renaming…" },
|
||||||
|
copy: { button: "Copy", verb: "copy", pastTense: "Copied", progressive: "Copying…" },
|
||||||
|
symlink: {
|
||||||
|
button: "Symlink",
|
||||||
|
verb: "create symlinks for",
|
||||||
|
pastTense: "Symlinked",
|
||||||
|
progressive: "Creating symlinks…",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
function realFileToItem(entry: FileEntry): Item {
|
function realFileToItem(entry: FileEntry): Item {
|
||||||
return {
|
return {
|
||||||
id: entry.path,
|
id: entry.path,
|
||||||
@@ -309,6 +336,10 @@ export default function RenamePanel() {
|
|||||||
const [renamePlan, setRenamePlan] = useState<RenamePlanEntry[] | null>(null);
|
const [renamePlan, setRenamePlan] = useState<RenamePlanEntry[] | null>(null);
|
||||||
const [renamePlanSkipped, setRenamePlanSkipped] = useState(0);
|
const [renamePlanSkipped, setRenamePlanSkipped] = useState(0);
|
||||||
const [renaming, setRenaming] = useState(false);
|
const [renaming, setRenaming] = useState(false);
|
||||||
|
// What to actually do at each computed target - chosen in the confirmation modal,
|
||||||
|
// remembered across modal opens within the session (defaults to "move", matching
|
||||||
|
// Renamr's original and only behavior before copy/symlink existed).
|
||||||
|
const [renameAction, setRenameAction] = useState<RenameAction>("move");
|
||||||
// TMDB token state for "Fetch Data". `null` = not checked yet, `false` = checked and
|
// TMDB token state for "Fetch Data". `null` = not checked yet, `false` = checked and
|
||||||
// nothing saved, `true` = a token is on file (the value itself never comes back to
|
// nothing saved, `true` = a token is on file (the value itself never comes back to
|
||||||
// the frontend after saving - see Settings modal below).
|
// the frontend after saving - see Settings modal below).
|
||||||
@@ -582,9 +613,12 @@ export default function RenamePanel() {
|
|||||||
setRenamePlan(plan);
|
setRenamePlan(plan);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Step 2: the user approved the plan shown in the modal - actually rename the files
|
// Step 2: the user approved the plan (and chose an action) shown in the modal -
|
||||||
// on disk via the Rust `execute_rename` command, then reconcile local state (a
|
// actually run it on disk via the Rust `execute_rename` command, then reconcile
|
||||||
// renamed file's id, which is its path, changes) and report the outcome.
|
// local state and report the outcome. Only "move" relocates the source file, so only
|
||||||
|
// "move" updates a loaded item's own id/name/bindings to point at the new path -
|
||||||
|
// "copy"/"symlink" leave the source exactly where it was and produce an additional
|
||||||
|
// file elsewhere that Renamr doesn't separately track as a loaded item.
|
||||||
const confirmRename = async () => {
|
const confirmRename = async () => {
|
||||||
if (!renamePlan || renamePlan.length === 0) return;
|
if (!renamePlan || renamePlan.length === 0) return;
|
||||||
setRenaming(true);
|
setRenaming(true);
|
||||||
@@ -592,16 +626,18 @@ export default function RenamePanel() {
|
|||||||
try {
|
try {
|
||||||
const results = await invoke<RenameResult[]>("execute_rename", {
|
const results = await invoke<RenameResult[]>("execute_rename", {
|
||||||
items: renamePlan.map((p) => ({ source: p.source, newName: p.newName })),
|
items: renamePlan.map((p) => ({ source: p.source, newName: p.newName })),
|
||||||
|
action: renameAction,
|
||||||
});
|
});
|
||||||
const bySource = new Map(results.map((r) => [r.source, r] as const));
|
const bySource = new Map(results.map((r) => [r.source, r] as const));
|
||||||
// Compute counts from `results` directly, not inside the setItems updater below:
|
// Compute counts from `results` directly, not inside the setItems updater below:
|
||||||
// React doesn't run a functional updater synchronously, so counters mutated in
|
// React doesn't run a functional updater synchronously, so counters mutated in
|
||||||
// there aren't ready yet when building the status message right after (this bit
|
// there aren't ready yet when building the status message right after (this bit
|
||||||
// runMatching earlier - same fix applies here).
|
// runMatching earlier - same fix applies here).
|
||||||
const renamed = results.filter((r) => r.outcome.status === "renamed").length;
|
const succeeded = results.filter((r) => r.outcome.status === "renamed").length;
|
||||||
const skipped = results.filter((r) => r.outcome.status === "skippedConflict").length;
|
const skipped = results.filter((r) => r.outcome.status === "skippedConflict").length;
|
||||||
const failed = results.filter((r) => r.outcome.status === "failed").length;
|
const failed = results.filter((r) => r.outcome.status === "failed").length;
|
||||||
|
|
||||||
|
if (renameAction === "move") {
|
||||||
setItems((prev) =>
|
setItems((prev) =>
|
||||||
prev.map((item) => {
|
prev.map((item) => {
|
||||||
const result = bySource.get(item.id);
|
const result = bySource.get(item.id);
|
||||||
@@ -622,15 +658,16 @@ export default function RenamePanel() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Keep the current selection pointing at the same logical file if its id (path)
|
// Keep the current selection pointing at the same logical file if its id
|
||||||
// changed underneath it.
|
// (path) changed underneath it.
|
||||||
setSelected((prevSelected) => {
|
setSelected((prevSelected) => {
|
||||||
if (!prevSelected) return prevSelected;
|
if (!prevSelected) return prevSelected;
|
||||||
const result = bySource.get(prevSelected);
|
const result = bySource.get(prevSelected);
|
||||||
return result?.outcome.status === "renamed" ? result.outcome.newPath : prevSelected;
|
return result?.outcome.status === "renamed" ? result.outcome.newPath : prevSelected;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const parts = [`Renamed ${renamed} file${renamed === 1 ? "" : "s"}`];
|
const parts = [`${RENAME_ACTION_LABELS[renameAction].pastTense} ${succeeded} file${succeeded === 1 ? "" : "s"}`];
|
||||||
if (skipped > 0) parts.push(`${skipped} skipped (name already exists)`);
|
if (skipped > 0) parts.push(`${skipped} skipped (name already exists)`);
|
||||||
if (failed > 0) parts.push(`${failed} failed`);
|
if (failed > 0) parts.push(`${failed} failed`);
|
||||||
setStatus(parts.join(", ") + ".");
|
setStatus(parts.join(", ") + ".");
|
||||||
@@ -1080,14 +1117,32 @@ export default function RenamePanel() {
|
|||||||
<div className="modal-overlay" onClick={cancelRename}>
|
<div className="modal-overlay" onClick={cancelRename}>
|
||||||
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
||||||
<h2 className="modal-title">Confirm Rename</h2>
|
<h2 className="modal-title">Confirm Rename</h2>
|
||||||
|
<div className="action-segmented" role="radiogroup" aria-label="What to do with these files">
|
||||||
|
{(["move", "copy", "symlink"] as const).map((a) => (
|
||||||
|
<button
|
||||||
|
key={a}
|
||||||
|
type="button"
|
||||||
|
className={`action-segmented-option${renameAction === a ? " selected" : ""}`}
|
||||||
|
onClick={() => setRenameAction(a)}
|
||||||
|
disabled={renaming}
|
||||||
|
>
|
||||||
|
{RENAME_ACTION_LABELS[a].button}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<p className="modal-subtitle">
|
<p className="modal-subtitle">
|
||||||
This will rename {renamePlan.length} file{renamePlan.length === 1 ? "" : "s"} on disk right now.
|
This will {RENAME_ACTION_LABELS[renameAction].verb} {renamePlan.length} file
|
||||||
There's no undo yet — review the list below before continuing, and use the ✕ on any row to drop just
|
{renamePlan.length === 1 ? "" : "s"} on disk right now. Review the list below before continuing — use
|
||||||
that file from this batch.
|
the ✕ on any row to drop just that file from this batch, or undo the whole batch afterward from the
|
||||||
|
History panel.
|
||||||
|
{renameAction !== "move" &&
|
||||||
|
" The original file is left exactly where it is; only the new copy/symlink is created."}
|
||||||
{renamePlanSkipped > 0 &&
|
{renamePlanSkipped > 0 &&
|
||||||
` (${renamePlanSkipped} other file${renamePlanSkipped === 1 ? "" : "s"} excluded: no valid preview.)`}
|
` (${renamePlanSkipped} other file${renamePlanSkipped === 1 ? "" : "s"} excluded: no valid preview.)`}
|
||||||
{renamePlan.some((p) => p.newName.includes("/")) &&
|
{renamePlan.some((p) => p.newName.includes("/")) &&
|
||||||
" Some of these also move the file into a new subfolder — any missing folders are created automatically."}
|
` Some of these also ${
|
||||||
|
renameAction === "move" ? "move" : "place"
|
||||||
|
} the file into a new subfolder — any missing folders are created automatically.`}
|
||||||
</p>
|
</p>
|
||||||
<div className="modal-plan-list">
|
<div className="modal-plan-list">
|
||||||
{renamePlan.length === 0 ? (
|
{renamePlan.length === 0 ? (
|
||||||
@@ -1126,7 +1181,11 @@ export default function RenamePanel() {
|
|||||||
onClick={confirmRename}
|
onClick={confirmRename}
|
||||||
disabled={renaming || renamePlan.length === 0}
|
disabled={renaming || renamePlan.length === 0}
|
||||||
>
|
>
|
||||||
{renaming ? "Renaming…" : `Rename ${renamePlan.length} File${renamePlan.length === 1 ? "" : "s"}`}
|
{renaming
|
||||||
|
? RENAME_ACTION_LABELS[renameAction].progressive
|
||||||
|
: `${RENAME_ACTION_LABELS[renameAction].button} ${renamePlan.length} File${
|
||||||
|
renamePlan.length === 1 ? "" : "s"
|
||||||
|
}`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1185,9 +1244,11 @@ export default function RenamePanel() {
|
|||||||
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
||||||
<h2 className="modal-title">Rename History</h2>
|
<h2 className="modal-title">Rename History</h2>
|
||||||
<p className="modal-subtitle">
|
<p className="modal-subtitle">
|
||||||
Every completed rename is logged here so it can be undone. Undoing moves each file back to its
|
Every completed batch is logged here so it can be undone. For a Move batch, undoing moves each file
|
||||||
original path — if something else now exists there, that one file is skipped rather than overwritten.
|
back to its original path — if something else now exists there, that one file is skipped rather than
|
||||||
The log keeps the most recent 50 rename operations.
|
overwritten. For a Copy or Symlink batch, undoing just removes the copy/symlink that was created; the
|
||||||
|
original file was never touched, so there's nothing to move back. The log keeps the most recent 50
|
||||||
|
operations.
|
||||||
</p>
|
</p>
|
||||||
{historyBatches === null ? (
|
{historyBatches === null ? (
|
||||||
<p className="modal-note">Loading…</p>
|
<p className="modal-note">Loading…</p>
|
||||||
@@ -1201,6 +1262,7 @@ export default function RenamePanel() {
|
|||||||
<span className="history-batch-when">{new Date(batch.timestamp).toLocaleString()}</span>
|
<span className="history-batch-when">{new Date(batch.timestamp).toLocaleString()}</span>
|
||||||
<span className="history-batch-count">
|
<span className="history-batch-count">
|
||||||
{batch.entries.length} file{batch.entries.length === 1 ? "" : "s"}
|
{batch.entries.length} file{batch.entries.length === 1 ? "" : "s"}
|
||||||
|
{batch.action !== "move" && ` · ${RENAME_ACTION_LABELS[batch.action].button}`}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
className="action-button history-undo-button"
|
className="action-button history-undo-button"
|
||||||
|
|||||||
Reference in New Issue
Block a user