Add conflict policy options: Skip / Auto-number / Override
New ConflictPolicy enum in rename/ (Skip = original/default behavior,
AutoNumber = save as "Name (1).ext" counting up to the first free slot,
Override = replace the conflicting file outright), threaded through
execute_renames/rename_one alongside RenameAction. Override never deletes
a conflicting directory (falls back to Skip for that case); AutoNumber
sidesteps directories the same way by just trying the next number.
execute_rename now takes a conflict argument; undo/history are
deliberately NOT conflict-aware and always use their own fixed
skip-on-conflict policy regardless of what the original rename used.
Frontend: a second segmented selector in the rename confirmation modal
("On conflict: Skip / Auto-number / Override"), with Override styled in
red and its warning copy calling out that it can't be undone.
This commit is contained in:
@@ -9,14 +9,19 @@ 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
|
||||
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
|
||||
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
|
||||
before anything touches disk, a per-row ✕ to drop an individual file from that plan at
|
||||
the last step, and a Move/Copy/Symlink selector for what to actually do at each
|
||||
target). It can also organize files into subfolders — including ones that don't exist
|
||||
yet — when the naming scheme includes a `/`. Every completed batch is logged to a
|
||||
history panel that can undo it (moving files back for Move, or just removing what was
|
||||
created for Copy/Symlink). A single loaded batch can freely mix TV episodes and movies
|
||||
just take TMDB's top-ranked hit — and the actual on-disk operation is wired up (with a
|
||||
confirmation step showing the full old-path/new-path plan before anything touches disk,
|
||||
a per-row ✕ to drop an individual file from that plan at the last step, a Move/Copy/
|
||||
Symlink selector for what to actually do at each target, and — independently — a Skip/
|
||||
Auto-number/Override selector for what happens when the computed name already exists:
|
||||
leave it alone (the original, and still default, behavior), save alongside it as
|
||||
"Name (1).ext", or replace it outright). It can also organize files into subfolders —
|
||||
including ones that don't exist yet — when the naming scheme includes a `/`. Every
|
||||
completed batch is logged to a history panel that can undo it (moving files back for
|
||||
Move, or just removing what was created for Copy/Symlink — undoing always uses its own
|
||||
skip-on-conflict policy regardless of what the original rename used, so an undo can
|
||||
never overwrite something else as a side effect). A single loaded batch can freely mix
|
||||
TV episodes and movies
|
||||
— each file only fills in the fields that apply to it. "Match" also parses release-tag
|
||||
text straight out of the filename — resolution, source (BluRay/HDTV/WEB-DL/…),
|
||||
video/audio codec, release group — so naming schemes can reference
|
||||
@@ -110,9 +115,10 @@ to the source file's parent directory, never as an absolute path, and every path
|
||||
component is validated (`validate_relative_target`) to reject empty segments (a
|
||||
leading, trailing, or doubled `/` — which also catches an absolute-looking
|
||||
`/etc/passwd`-style path), `.`/`..` segments, and backslashes (Renamr always uses `/`
|
||||
for the naming scheme's folder separator regardless of OS). Conflict policy is
|
||||
unchanged: if the resolved target already exists and isn't the same underlying file,
|
||||
that item is skipped rather than overwritten.
|
||||
for the naming scheme's folder separator regardless of OS). If the resolved target
|
||||
already exists and isn't the same underlying file, what happens next is up to the
|
||||
chosen conflict policy — Skip (default), Auto-number, or Override; see item 6 of the
|
||||
porting roadmap below for the full behavior of each.
|
||||
|
||||
## Project layout
|
||||
|
||||
@@ -127,8 +133,9 @@ crates/renamr-core/ Pure Rust core: naming-expression engine (ported), local
|
||||
search + episode lookup
|
||||
and movie search + lookup, both disambiguated via `similarity/`,
|
||||
tested against a mocked HTTP server via `mockito`), the on-disk
|
||||
rename engine (`rename/` — skip-on-conflict, Move/Copy/Symlink
|
||||
actions, can also organize into (auto-created) subfolders when
|
||||
rename engine (`rename/` — Move/Copy/Symlink actions,
|
||||
Skip/Auto-number/Override conflict policies, can also organize
|
||||
into (auto-created) subfolders when
|
||||
the naming scheme includes `/`) — and a rename history/undo log
|
||||
(`history/`, a partial `HistoryDialog` port, undo-aware of which
|
||||
action produced each batch). Not yet ported: full media metadata
|
||||
@@ -244,17 +251,22 @@ ported from, for anyone picking this up:
|
||||
6. **Rename engine + CLI** — `net.filebot.cli.CmdlineOperations` (1235 lines) and
|
||||
`ArgumentBean` (415 lines, the full flag surface: `-rename`, `--db`, `--format`,
|
||||
`--action`, `--conflict`, `-get-subtitles`, `-script`, etc.). The rename operation is
|
||||
done (`crates/renamr-core/src/rename`, fixed skip-on-conflict policy, wired to the
|
||||
UI's Rename button behind a confirmation step) including the `move`-into-subfolder
|
||||
`--action` variant — a naming scheme like `{n}/{s00e00}` organizes files into
|
||||
subfolders, creating any missing folders automatically, always scoped to the file's
|
||||
own directory tree. The `copy`/`symlink` `--action` variants are done too — an action
|
||||
selector in the confirmation modal picks Move/Copy/Symlink for the whole batch; Copy
|
||||
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.
|
||||
done (`crates/renamr-core/src/rename`, wired to the UI's Rename button behind a
|
||||
confirmation step) including the `move`-into-subfolder `--action` variant — a naming
|
||||
scheme like `{n}/{s00e00}` organizes files into subfolders, creating any missing
|
||||
folders automatically, always scoped to the file's own directory tree. The
|
||||
`copy`/`symlink` `--action` variants are done too — an action selector in the
|
||||
confirmation modal picks Move/Copy/Symlink for the whole batch; Copy 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). The `--conflict`
|
||||
variants are done too — a second selector in the same modal picks Skip (default,
|
||||
leaves the conflicting file alone), Auto-number (`"Name (1).ext"`, counting up to the
|
||||
first free slot), or Override (replaces the conflicting file outright — the one
|
||||
irreversible option, called out with red styling in the UI; undoing that batch later
|
||||
restores the *source*, never what got overwritten, since undo always uses its own
|
||||
fixed skip-on-conflict policy no matter which policy the original rename used). Not
|
||||
yet done: the CLI flag surface itself.
|
||||
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,
|
||||
Align Up/Down reordering, naming-scheme Presets with `1`-`9` hotkeys) is built; the
|
||||
|
||||
@@ -10,17 +10,22 @@
|
||||
//! treats `/` between two `{ }` scriptlets as ordinary literal text, so no changes were
|
||||
//! needed there; this is purely about the rename engine trusting a `/` in the computed
|
||||
//! 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
|
||||
//! **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. The UI is
|
||||
//! expected to show the caller a confirmation of the full plan (and let them choose a
|
||||
//! [`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.
|
||||
//! intermediate directories are created automatically. What happens when the computed
|
||||
//! target already exists is chosen per batch via [`ConflictPolicy`] - skip (the
|
||||
//! original, and still default, behavior), auto-number, or override - a port of
|
||||
//! `net.filebot.cli.CmdlineOperations`'s `--conflict` flag. The UI is expected to show
|
||||
//! the caller a confirmation of the full plan (and let them choose both a
|
||||
//! [`RenameAction`] and a [`ConflictPolicy`]) 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. Undoing
|
||||
//! always uses skip-on-conflict regardless of what policy the original rename used
|
||||
//! (see `history::revert_one`) - overwriting an unrelated file as a side effect of an
|
||||
//! *undo* would be a much worse surprise than leaving it for the user to sort out by
|
||||
//! hand.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// One planned rename: `source` is the file's current full path, `new_name` is the
|
||||
/// target it should have afterward, resolved relative to `source`'s own directory -
|
||||
@@ -61,6 +66,31 @@ impl Default for RenameAction {
|
||||
}
|
||||
}
|
||||
|
||||
/// What to do when a computed target already exists (and isn't just the source file
|
||||
/// itself under a different case) - a port of `CmdlineOperations`'s `--conflict` flag.
|
||||
/// Applies to a whole batch at once, same as [`RenameAction`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ConflictPolicy {
|
||||
/// Leave the existing file alone and report the item as skipped. The original (and
|
||||
/// still default) behavior - never destroys anything.
|
||||
Skip,
|
||||
/// Save alongside the conflicting file instead, as `"Name (1).ext"`, `"Name
|
||||
/// (2).ext"`, etc. - the first name in that sequence that isn't already taken in
|
||||
/// the target directory (tries up to 999 before giving up).
|
||||
AutoNumber,
|
||||
/// Replace whatever's at the target path. Irreversible for the file that gets
|
||||
/// overwritten - undo can restore what the *source* becomes, never what conflict
|
||||
/// resolution destroyed at the target.
|
||||
Override,
|
||||
}
|
||||
|
||||
impl Default for ConflictPolicy {
|
||||
fn default() -> Self {
|
||||
ConflictPolicy::Skip
|
||||
}
|
||||
}
|
||||
|
||||
/// What happened to one planned rename.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "status", rename_all = "camelCase")]
|
||||
@@ -137,7 +167,75 @@ fn create_symlink(_source: &Path, _target: &Path) -> std::io::Result<()> {
|
||||
))
|
||||
}
|
||||
|
||||
fn rename_one(source: &Path, new_name: &str, action: RenameAction) -> RenameOutcome {
|
||||
/// Finds the first `"<stem> (N).<ext>"` (or `"<stem> (N)"` with no extension) in
|
||||
/// `target`'s own directory that isn't already taken, starting at N=1. Tries up to 999
|
||||
/// before giving up (`None`) - a directory with 999+ same-named conflicts is almost
|
||||
/// certainly a sign something else is wrong, not a case to loop on forever.
|
||||
fn next_available_name(target: &Path) -> Option<PathBuf> {
|
||||
let parent = target.parent()?;
|
||||
let stem = target.file_stem()?.to_string_lossy().to_string();
|
||||
let ext = target.extension().map(|e| e.to_string_lossy().to_string());
|
||||
|
||||
for n in 1..=999 {
|
||||
let candidate_name = match &ext {
|
||||
Some(e) => format!("{stem} ({n}).{e}"),
|
||||
None => format!("{stem} ({n})"),
|
||||
};
|
||||
let candidate = parent.join(candidate_name);
|
||||
if !candidate.exists() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolves a conflict at `target` (which the caller has already confirmed exists and
|
||||
/// isn't `source` under a different case) according to `conflict`. Returns the target
|
||||
/// path to actually use, or the terminal outcome to report instead (skip, or a failure
|
||||
/// - e.g. auto-numbering exhausted its attempts, or removing an existing file ahead of
|
||||
/// an Override-Symlink failed).
|
||||
///
|
||||
/// A conflicting target that's a *directory* is only ever a special case for Override
|
||||
/// (which falls back to skipping it) - Override is for replacing one file with
|
||||
/// another, never for deleting a whole directory tree out from under the caller. Skip
|
||||
/// treats it like any other conflict already, and AutoNumber doesn't need to touch it
|
||||
/// at all - it just tries the next number instead.
|
||||
fn resolve_conflict(target: &Path, action: RenameAction, conflict: ConflictPolicy) -> Result<PathBuf, RenameOutcome> {
|
||||
match conflict {
|
||||
ConflictPolicy::Skip => Err(RenameOutcome::SkippedConflict {
|
||||
existing_path: target.to_string_lossy().to_string(),
|
||||
}),
|
||||
// Doesn't matter whether the conflicting entry is a file or a directory here -
|
||||
// auto-numbering never touches it either way, it just tries the next name.
|
||||
ConflictPolicy::AutoNumber => next_available_name(target).ok_or_else(|| RenameOutcome::Failed {
|
||||
error: "couldn't find a free auto-numbered name (tried up to 999)".to_string(),
|
||||
}),
|
||||
ConflictPolicy::Override => {
|
||||
// Override is for replacing one file with another - never for deleting a
|
||||
// whole directory tree out from under the caller. Falls back to Skip's
|
||||
// behavior for that one case.
|
||||
if target.is_dir() {
|
||||
return Err(RenameOutcome::SkippedConflict {
|
||||
existing_path: target.to_string_lossy().to_string(),
|
||||
});
|
||||
}
|
||||
// fs::rename and fs::copy both already replace an existing regular file at
|
||||
// the destination on their own - only Symlink's underlying syscall fails
|
||||
// outright when the target path already exists, so that's the one case that
|
||||
// needs the old file removed first.
|
||||
if action == RenameAction::Symlink {
|
||||
if let Err(e) = fs::remove_file(target) {
|
||||
return Err(RenameOutcome::Failed {
|
||||
error: format!("couldn't remove the existing file before overriding it: {e}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(target.to_path_buf())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rename_one(source: &Path, new_name: &str, action: RenameAction, conflict: ConflictPolicy) -> RenameOutcome {
|
||||
if let Err(reason) = validate_relative_target(new_name) {
|
||||
return RenameOutcome::Failed {
|
||||
error: format!("\"{new_name}\" is not a valid target: {reason}"),
|
||||
@@ -161,11 +259,14 @@ fn rename_one(source: &Path, new_name: &str, action: RenameAction) -> RenameOutc
|
||||
return RenameOutcome::Unchanged;
|
||||
}
|
||||
|
||||
if target.exists() && !same_file(&target, source) {
|
||||
return RenameOutcome::SkippedConflict {
|
||||
existing_path: target.to_string_lossy().to_string(),
|
||||
};
|
||||
}
|
||||
let target = if target.exists() && !same_file(&target, source) {
|
||||
match resolve_conflict(&target, action, conflict) {
|
||||
Ok(resolved) => resolved,
|
||||
Err(outcome) => return outcome,
|
||||
}
|
||||
} else {
|
||||
target
|
||||
};
|
||||
|
||||
// `new_name` may include subdirectory components (organizing files into folders) -
|
||||
// make sure the destination directory tree exists before attempting the actual
|
||||
@@ -194,17 +295,18 @@ fn rename_one(source: &Path, new_name: &str, action: RenameAction) -> RenameOutc
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes a batch of planned renames in order, all under the same [`RenameAction`].
|
||||
/// Items are processed sequentially (not in parallel) so that two items whose computed
|
||||
/// names collide with each other resolve the same way an on-disk collision would: the
|
||||
/// first one through wins, the second is reported as a skipped conflict rather than
|
||||
/// silently overwriting it.
|
||||
pub fn execute_renames(items: &[RenamePlanItem], action: RenameAction) -> Vec<RenameResult> {
|
||||
/// Executes a batch of planned renames in order, all under the same [`RenameAction`]
|
||||
/// and [`ConflictPolicy`]. Items are processed sequentially (not in parallel) so that
|
||||
/// two items whose computed names collide with each other resolve the same way an
|
||||
/// on-disk collision would: under Skip, the first one through wins and the second is
|
||||
/// reported as a skipped conflict; under AutoNumber, the second gets bumped to " (1)";
|
||||
/// under Override, the second replaces what the first just wrote.
|
||||
pub fn execute_renames(items: &[RenamePlanItem], action: RenameAction, conflict: ConflictPolicy) -> Vec<RenameResult> {
|
||||
items
|
||||
.iter()
|
||||
.map(|item| RenameResult {
|
||||
source: item.source.clone(),
|
||||
outcome: rename_one(Path::new(&item.source), &item.new_name, action),
|
||||
outcome: rename_one(Path::new(&item.source), &item.new_name, action, conflict),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -235,7 +337,7 @@ mod tests {
|
||||
new_name: "new.txt".to_string(),
|
||||
}];
|
||||
|
||||
let results = execute_renames(&items, RenameAction::Move);
|
||||
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
|
||||
assert_eq!(results.len(), 1);
|
||||
match &results[0].outcome {
|
||||
RenameOutcome::Renamed { new_path } => {
|
||||
@@ -257,7 +359,7 @@ mod tests {
|
||||
new_name: "same.txt".to_string(),
|
||||
}];
|
||||
|
||||
let results = execute_renames(&items, RenameAction::Move);
|
||||
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
|
||||
assert_eq!(results[0].outcome, RenameOutcome::Unchanged);
|
||||
assert!(file.exists());
|
||||
|
||||
@@ -274,7 +376,7 @@ mod tests {
|
||||
new_name: "b.txt".to_string(),
|
||||
}];
|
||||
|
||||
let results = execute_renames(&items, RenameAction::Move);
|
||||
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
|
||||
match &results[0].outcome {
|
||||
RenameOutcome::SkippedConflict { existing_path } => {
|
||||
assert!(existing_path.ends_with("b.txt"));
|
||||
@@ -305,7 +407,7 @@ mod tests {
|
||||
},
|
||||
];
|
||||
|
||||
let results = execute_renames(&items, RenameAction::Move);
|
||||
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
|
||||
assert!(matches!(results[0].outcome, RenameOutcome::Renamed { .. }));
|
||||
assert!(matches!(
|
||||
results[1].outcome,
|
||||
@@ -325,7 +427,7 @@ mod tests {
|
||||
new_name: "../escaped.txt".to_string(),
|
||||
}];
|
||||
|
||||
let results = execute_renames(&items, RenameAction::Move);
|
||||
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
|
||||
assert!(matches!(results[0].outcome, RenameOutcome::Failed { .. }));
|
||||
assert!(file.exists());
|
||||
|
||||
@@ -340,7 +442,7 @@ mod tests {
|
||||
new_name: "Breaking Bad/Ozymandias.mkv".to_string(),
|
||||
}];
|
||||
|
||||
let results = execute_renames(&items, RenameAction::Move);
|
||||
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
|
||||
match &results[0].outcome {
|
||||
RenameOutcome::Renamed { new_path } => {
|
||||
let expected = dir.join("Breaking Bad").join("Ozymandias.mkv");
|
||||
@@ -362,7 +464,7 @@ mod tests {
|
||||
new_name: "Breaking Bad/Season 05/S05E14.mkv".to_string(),
|
||||
}];
|
||||
|
||||
let results = execute_renames(&items, RenameAction::Move);
|
||||
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
|
||||
let expected = dir.join("Breaking Bad").join("Season 05").join("S05E14.mkv");
|
||||
match &results[0].outcome {
|
||||
RenameOutcome::Renamed { new_path } => {
|
||||
@@ -386,7 +488,7 @@ mod tests {
|
||||
new_name: "Sub/b.txt".to_string(),
|
||||
}];
|
||||
|
||||
let results = execute_renames(&items, RenameAction::Move);
|
||||
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
|
||||
assert!(matches!(
|
||||
results[0].outcome,
|
||||
RenameOutcome::SkippedConflict { .. }
|
||||
@@ -405,7 +507,7 @@ mod tests {
|
||||
new_name: "Sub\\b.txt".to_string(),
|
||||
}];
|
||||
|
||||
let results = execute_renames(&items, RenameAction::Move);
|
||||
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
|
||||
assert!(matches!(results[0].outcome, RenameOutcome::Failed { .. }));
|
||||
assert!(file.exists());
|
||||
|
||||
@@ -421,7 +523,7 @@ mod tests {
|
||||
source: file.to_string_lossy().to_string(),
|
||||
new_name: bad_name.to_string(),
|
||||
}];
|
||||
let results = execute_renames(&items, RenameAction::Move);
|
||||
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
|
||||
assert!(
|
||||
matches!(results[0].outcome, RenameOutcome::Failed { .. }),
|
||||
"expected {bad_name:?} to be rejected"
|
||||
@@ -440,7 +542,7 @@ mod tests {
|
||||
new_name: "copied.txt".to_string(),
|
||||
}];
|
||||
|
||||
let results = execute_renames(&items, RenameAction::Copy);
|
||||
let results = execute_renames(&items, RenameAction::Copy, ConflictPolicy::Skip);
|
||||
match &results[0].outcome {
|
||||
RenameOutcome::Renamed { new_path } => {
|
||||
assert!(new_path.ends_with("copied.txt"));
|
||||
@@ -464,7 +566,7 @@ mod tests {
|
||||
new_name: "Sub/copied.txt".to_string(),
|
||||
}];
|
||||
|
||||
let results = execute_renames(&items, RenameAction::Copy);
|
||||
let results = execute_renames(&items, RenameAction::Copy, ConflictPolicy::Skip);
|
||||
let expected = dir.join("Sub").join("copied.txt");
|
||||
match &results[0].outcome {
|
||||
RenameOutcome::Renamed { new_path } => {
|
||||
@@ -487,7 +589,7 @@ mod tests {
|
||||
new_name: "linked.txt".to_string(),
|
||||
}];
|
||||
|
||||
let results = execute_renames(&items, RenameAction::Symlink);
|
||||
let results = execute_renames(&items, RenameAction::Symlink, ConflictPolicy::Skip);
|
||||
match &results[0].outcome {
|
||||
RenameOutcome::Renamed { new_path } => {
|
||||
let link = Path::new(new_path);
|
||||
@@ -515,7 +617,7 @@ mod tests {
|
||||
source: file.to_string_lossy().to_string(),
|
||||
new_name: "b.txt".to_string(),
|
||||
}];
|
||||
let results = execute_renames(&items, action);
|
||||
let results = execute_renames(&items, action, ConflictPolicy::Skip);
|
||||
match &results[0].outcome {
|
||||
RenameOutcome::SkippedConflict { existing_path } => {
|
||||
assert!(existing_path.ends_with("b.txt"));
|
||||
@@ -539,7 +641,7 @@ mod tests {
|
||||
source: file.to_string_lossy().to_string(),
|
||||
new_name: "same.txt".to_string(),
|
||||
}];
|
||||
let results = execute_renames(&items, action);
|
||||
let results = execute_renames(&items, action, ConflictPolicy::Skip);
|
||||
assert_eq!(
|
||||
results[0].outcome,
|
||||
RenameOutcome::Unchanged,
|
||||
@@ -550,4 +652,198 @@ mod tests {
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_number_saves_alongside_the_conflicting_file_instead_of_skipping() {
|
||||
let (dir, file) = temp_file_setup("a.txt", "aaa");
|
||||
fs::write(dir.join("b.txt"), "bbb").unwrap();
|
||||
|
||||
let items = vec![RenamePlanItem {
|
||||
source: file.to_string_lossy().to_string(),
|
||||
new_name: "b.txt".to_string(),
|
||||
}];
|
||||
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::AutoNumber);
|
||||
match &results[0].outcome {
|
||||
RenameOutcome::Renamed { new_path } => {
|
||||
assert_eq!(Path::new(new_path), dir.join("b (1).txt"));
|
||||
assert!(Path::new(new_path).exists());
|
||||
}
|
||||
other => panic!("expected Renamed, got {other:?}"),
|
||||
}
|
||||
// The original conflicting file must still be exactly as it was.
|
||||
assert_eq!(fs::read_to_string(dir.join("b.txt")).unwrap(), "bbb");
|
||||
assert!(!file.exists()); // Move: the source is gone, relocated to "b (1).txt"
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_number_keeps_counting_past_names_that_are_also_taken() {
|
||||
let (dir, file) = temp_file_setup("a.txt", "aaa");
|
||||
fs::write(dir.join("b.txt"), "bbb").unwrap();
|
||||
fs::write(dir.join("b (1).txt"), "existing 1").unwrap();
|
||||
fs::write(dir.join("b (2).txt"), "existing 2").unwrap();
|
||||
|
||||
let items = vec![RenamePlanItem {
|
||||
source: file.to_string_lossy().to_string(),
|
||||
new_name: "b.txt".to_string(),
|
||||
}];
|
||||
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::AutoNumber);
|
||||
match &results[0].outcome {
|
||||
RenameOutcome::Renamed { new_path } => {
|
||||
assert_eq!(Path::new(new_path), dir.join("b (3).txt"));
|
||||
}
|
||||
other => panic!("expected Renamed, got {other:?}"),
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_number_handles_extensionless_names_and_organized_subfolders() {
|
||||
let (dir, file) = temp_file_setup("a", "aaa");
|
||||
fs::write(dir.join("README"), "existing").unwrap();
|
||||
|
||||
let items = vec![RenamePlanItem {
|
||||
source: file.to_string_lossy().to_string(),
|
||||
new_name: "README".to_string(),
|
||||
}];
|
||||
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::AutoNumber);
|
||||
match &results[0].outcome {
|
||||
RenameOutcome::Renamed { new_path } => {
|
||||
assert_eq!(Path::new(new_path), dir.join("README (1)"));
|
||||
}
|
||||
other => panic!("expected Renamed, got {other:?}"),
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_item_in_batch_auto_numbers_when_it_collides_with_the_first() {
|
||||
let (dir, file_a) = temp_file_setup("a.txt", "aaa");
|
||||
let file_b = dir.join("c.txt");
|
||||
fs::write(&file_b, "ccc").unwrap();
|
||||
|
||||
let items = vec![
|
||||
RenamePlanItem {
|
||||
source: file_a.to_string_lossy().to_string(),
|
||||
new_name: "target.txt".to_string(),
|
||||
},
|
||||
RenamePlanItem {
|
||||
source: file_b.to_string_lossy().to_string(),
|
||||
new_name: "target.txt".to_string(),
|
||||
},
|
||||
];
|
||||
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::AutoNumber);
|
||||
assert_eq!(
|
||||
results[0].outcome,
|
||||
RenameOutcome::Renamed {
|
||||
new_path: dir.join("target.txt").to_string_lossy().to_string()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
results[1].outcome,
|
||||
RenameOutcome::Renamed {
|
||||
new_path: dir.join("target (1).txt").to_string_lossy().to_string()
|
||||
}
|
||||
);
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_replaces_the_conflicting_file_for_move_and_copy() {
|
||||
for action in [RenameAction::Move, RenameAction::Copy] {
|
||||
let (dir, file) = temp_file_setup("a.txt", "new content");
|
||||
fs::write(dir.join("b.txt"), "old content").unwrap();
|
||||
|
||||
let items = vec![RenamePlanItem {
|
||||
source: file.to_string_lossy().to_string(),
|
||||
new_name: "b.txt".to_string(),
|
||||
}];
|
||||
let results = execute_renames(&items, action, ConflictPolicy::Override);
|
||||
match &results[0].outcome {
|
||||
RenameOutcome::Renamed { new_path } => {
|
||||
assert_eq!(Path::new(new_path), dir.join("b.txt"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(new_path).unwrap(),
|
||||
"new content",
|
||||
"expected b.txt to now hold the source's content for {action:?}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Renamed for {action:?}, got {other:?}"),
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn override_removes_an_existing_file_before_creating_a_symlink_there() {
|
||||
let (dir, file) = temp_file_setup("a.txt", "hello");
|
||||
fs::write(dir.join("b.txt"), "old content").unwrap();
|
||||
|
||||
let items = vec![RenamePlanItem {
|
||||
source: file.to_string_lossy().to_string(),
|
||||
new_name: "b.txt".to_string(),
|
||||
}];
|
||||
let results = execute_renames(&items, RenameAction::Symlink, ConflictPolicy::Override);
|
||||
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_to_string(link).unwrap(), "hello");
|
||||
}
|
||||
other => panic!("expected Renamed, got {other:?}"),
|
||||
}
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_never_deletes_a_conflicting_directory() {
|
||||
let (dir, file) = temp_file_setup("a.txt", "aaa");
|
||||
fs::create_dir_all(dir.join("b.txt")).unwrap(); // a DIRECTORY named "b.txt"
|
||||
|
||||
let items = vec![RenamePlanItem {
|
||||
source: file.to_string_lossy().to_string(),
|
||||
new_name: "b.txt".to_string(),
|
||||
}];
|
||||
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Override);
|
||||
match &results[0].outcome {
|
||||
RenameOutcome::SkippedConflict { existing_path } => {
|
||||
assert!(existing_path.ends_with("b.txt"));
|
||||
}
|
||||
other => panic!("expected SkippedConflict (never delete a directory), got {other:?}"),
|
||||
}
|
||||
assert!(dir.join("b.txt").is_dir());
|
||||
assert!(file.exists());
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_number_also_never_deletes_a_conflicting_directory() {
|
||||
let (dir, file) = temp_file_setup("a.txt", "aaa");
|
||||
fs::create_dir_all(dir.join("b.txt")).unwrap();
|
||||
|
||||
let items = vec![RenamePlanItem {
|
||||
source: file.to_string_lossy().to_string(),
|
||||
new_name: "b.txt".to_string(),
|
||||
}];
|
||||
// AutoNumber should sidestep the directory entirely by picking "b (1).txt"
|
||||
// rather than touching "b.txt" at all.
|
||||
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::AutoNumber);
|
||||
match &results[0].outcome {
|
||||
RenameOutcome::Renamed { new_path } => {
|
||||
assert_eq!(Path::new(new_path), dir.join("b (1).txt"));
|
||||
}
|
||||
other => panic!("expected Renamed, got {other:?}"),
|
||||
}
|
||||
assert!(dir.join("b.txt").is_dir());
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
|
||||
+16
-9
@@ -6,7 +6,7 @@ mod settings;
|
||||
|
||||
use renamr_core::format::ExpressionFormat;
|
||||
use renamr_core::matching::{self, EpisodeGuess, MovieGuess, ReleaseInfo};
|
||||
use renamr_core::rename::{self, RenameAction, RenamePlanItem, RenameResult};
|
||||
use renamr_core::rename::{self, ConflictPolicy, RenameAction, RenamePlanItem, RenameResult};
|
||||
use renamr_core::string_bindings;
|
||||
use std::collections::HashMap;
|
||||
use tauri::AppHandle;
|
||||
@@ -53,19 +53,26 @@ fn guess_release_info(filename: String) -> Option<ReleaseInfo> {
|
||||
}
|
||||
|
||||
/// Executes a batch of planned on-disk rename/copy/symlink operations (optionally
|
||||
/// moving/copying files into subfolders - see `renamr_core::rename`; conflicting
|
||||
/// targets are always skipped, never overwritten). `action` picks which of the three
|
||||
/// happens - the UI is expected to have already shown the user the full old-path/
|
||||
/// 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.
|
||||
/// moving/copying files into subfolders - see `renamr_core::rename`). `action` picks
|
||||
/// which of the three happens; `conflict` picks what happens when a computed target
|
||||
/// already exists (skip it, save alongside it with an auto-numbered name, or replace
|
||||
/// it) - the UI is expected to have already shown the user the full old-path/new-path
|
||||
/// plan and both choices, and gotten explicit confirmation before calling this. Runs
|
||||
/// off the main thread since real filesystem I/O shouldn't block the UI.
|
||||
///
|
||||
/// Every successfully-produced item is recorded to the rename history log afterward
|
||||
/// (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.
|
||||
/// removes what was created. `conflict` is NOT recorded - undoing always uses its own
|
||||
/// fixed skip-on-conflict policy regardless of what the original rename used.
|
||||
#[tauri::command]
|
||||
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, action))
|
||||
async fn execute_rename(
|
||||
app: AppHandle,
|
||||
items: Vec<RenamePlanItem>,
|
||||
action: RenameAction,
|
||||
conflict: ConflictPolicy,
|
||||
) -> Vec<RenameResult> {
|
||||
let results = tauri::async_runtime::spawn_blocking(move || rename::execute_renames(&items, action, conflict))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
history::record_batch(&app, &results, action);
|
||||
|
||||
@@ -497,6 +497,19 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.action-segmented-option-danger.selected {
|
||||
background: #ff8a8a;
|
||||
color: var(--dock-deep);
|
||||
}
|
||||
|
||||
.action-segmented-option-danger:not(.selected):hover {
|
||||
color: #ff8a8a;
|
||||
}
|
||||
|
||||
.modal-note-danger {
|
||||
color: #ff8a8a;
|
||||
}
|
||||
|
||||
.modal-field-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
|
||||
@@ -98,6 +98,12 @@ interface RenameResult {
|
||||
// confirmRename below).
|
||||
type RenameAction = "move" | "copy" | "symlink";
|
||||
|
||||
// Mirrors renamr-core's `ConflictPolicy` - what happens when a computed target already
|
||||
// exists. "skip" (the default, and the only behavior Renamr had before) leaves the
|
||||
// conflicting file alone; "autoNumber" saves alongside it as "Name (1).ext" instead;
|
||||
// "override" replaces it outright (irreversible for whatever gets overwritten).
|
||||
type ConflictPolicy = "skip" | "autoNumber" | "override";
|
||||
|
||||
// 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
|
||||
// Match already produces offline, which is why Fetch Data requires Match to have run
|
||||
@@ -287,6 +293,25 @@ const RENAME_ACTION_LABELS: Record<
|
||||
},
|
||||
};
|
||||
|
||||
// Copy for each ConflictPolicy, used in the confirmation modal's second segmented
|
||||
// selector and the modal's conflict-behavior note.
|
||||
const CONFLICT_POLICY_LABELS: Record<ConflictPolicy, { button: string; description: string }> = {
|
||||
skip: {
|
||||
button: "Skip",
|
||||
description: "If a computed name already exists on disk, that file is left alone and skipped.",
|
||||
},
|
||||
autoNumber: {
|
||||
button: "Auto-number",
|
||||
description:
|
||||
'If a computed name already exists on disk, the new one is saved alongside it as "Name (1).ext" instead (counting up until a free name is found).',
|
||||
},
|
||||
override: {
|
||||
button: "Override",
|
||||
description:
|
||||
"If a computed name already exists on disk, it's replaced outright. This can't be undone — undoing this batch later can restore the source file, but not whatever got overwritten.",
|
||||
},
|
||||
};
|
||||
|
||||
function realFileToItem(entry: FileEntry): Item {
|
||||
return {
|
||||
id: entry.path,
|
||||
@@ -372,6 +397,10 @@ export default function RenamePanel() {
|
||||
// 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");
|
||||
// What to do when a computed target already exists - same remembered-across-opens
|
||||
// pattern as renameAction, defaults to "skip" (Renamr's original and only behavior
|
||||
// before auto-number/override existed).
|
||||
const [conflictPolicy, setConflictPolicy] = useState<ConflictPolicy>("skip");
|
||||
// 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
|
||||
// the frontend after saving - see Settings modal below).
|
||||
@@ -707,6 +736,7 @@ export default function RenamePanel() {
|
||||
const results = await invoke<RenameResult[]>("execute_rename", {
|
||||
items: renamePlan.map((p) => ({ source: p.source, newName: p.newName })),
|
||||
action: renameAction,
|
||||
conflict: conflictPolicy,
|
||||
});
|
||||
const bySource = new Map(results.map((r) => [r.source, r] as const));
|
||||
// Compute counts from `results` directly, not inside the setItems updater below:
|
||||
@@ -1316,6 +1346,26 @@ export default function RenamePanel() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="modal-field-label">On conflict</span>
|
||||
<div
|
||||
className="action-segmented"
|
||||
role="radiogroup"
|
||||
aria-label="What to do when the computed name already exists"
|
||||
>
|
||||
{(["skip", "autoNumber", "override"] as const).map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={`action-segmented-option${conflictPolicy === c ? " selected" : ""}${
|
||||
c === "override" ? " action-segmented-option-danger" : ""
|
||||
}`}
|
||||
onClick={() => setConflictPolicy(c)}
|
||||
disabled={renaming}
|
||||
>
|
||||
{CONFLICT_POLICY_LABELS[c].button}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="modal-subtitle">
|
||||
This will {RENAME_ACTION_LABELS[renameAction].verb} {renamePlan.length} file
|
||||
{renamePlan.length === 1 ? "" : "s"} on disk right now. Review the list below before continuing — use
|
||||
@@ -1355,8 +1405,8 @@ export default function RenamePanel() {
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<p className="modal-note">
|
||||
If a computed name already exists on disk, that file is skipped rather than overwritten.
|
||||
<p className={`modal-note${conflictPolicy === "override" ? " modal-note-danger" : ""}`}>
|
||||
{CONFLICT_POLICY_LABELS[conflictPolicy].description}
|
||||
</p>
|
||||
<div className="modal-actions">
|
||||
<button className="action-button" onClick={cancelRename} disabled={renaming}>
|
||||
|
||||
Reference in New Issue
Block a user