Let the naming scheme organize files into subfolders

- rename::rename_one now resolves new_name relative to the source file's
  own directory, allowing / as a subdirectory separator (e.g. a naming
  scheme like {show name}/{episode name}) instead of rejecting it. Missing
  intermediate directories are created automatically via create_dir_all.
- New validate_relative_target() replaces the old is_plain_filename():
  still rejects anything that could escape the source directory (empty
  path segments - which also catches a leading-slash absolute path -
  and . / .. segments) plus backslashes (Renamr always treats / as the
  scheme's folder separator regardless of OS), but now allows a
  legitimate multi-segment relative path through.
- The "Unchanged" check now compares the full resolved target path to the
  source, not just the filename, since a computed name can now include
  directory components.
- 6 new tests: organizing into a new subfolder, nested subfolders that
  don't exist yet, conflict detection still works for organized targets,
  and rejection of backslashes / doubled / leading / trailing slashes.
- Frontend: naming-scheme editor hint explains the / convention, and the
  rename confirmation modal notes when a plan will create new subfolders.
- No changes needed in the naming-expression engine itself - it already
  treats / between two {} scriptlets as ordinary literal text.
This commit is contained in:
Renamr Scaffold
2026-08-29 03:36:06 +00:00
parent 855f9bea24
commit 658a235e05
3 changed files with 210 additions and 50 deletions
+40 -15
View File
@@ -9,10 +9,11 @@ native file/folder loading and drag-and-drop, local filename-based season/episod
detection ("Match") is working, real online metadata lookups against TMDB ("Fetch
Data") are working — including fuzzy show-name disambiguation, so a search that
returns several similarly-named shows doesn't just take TMDB's top-ranked hit — and the
actual on-disk rename operation is wired up (same-directory rename, skip-on-conflict,
with a confirmation step showing the full old-name/new-name plan before anything
touches disk — there is no undo yet). Full media-metadata bindings are not yet ported.
See "Porting roadmap" below.
actual on-disk rename operation is wired up (skip-on-conflict, with a confirmation step
showing the full old-name/new-name plan before anything touches disk — there is no undo
yet). Rename can also organize files into subfolders — including ones that don't exist
yet — when the naming scheme includes a `/`. Full media-metadata bindings are not yet
ported. See "Porting roadmap" below.
The naming scheme is fully user-editable from the app (the "Naming Scheme" button next
to the New Names list) — the default is `{show name} - {season number}x{episode
@@ -78,6 +79,28 @@ An undefined binding contributes an empty string rather than failing outright (s
`{n}{missing}` still renders); the overall expression only errors if the *total*
rendered output would be empty.
`/` between two `{ }` scriptlets (or anywhere in the literal text) needs no special
handling in the expression engine at all — it's just ordinary literal text, the same as
a space or a dash. It only becomes meaningful one layer up, at rename time (see
"Organizing into subfolders" below): a scheme like `{show name}/{episode name}` renders
to a name containing a real `/`, and the rename engine treats that as "put this file in
a subfolder" rather than rejecting it.
### Organizing into subfolders
A naming scheme that includes `/` (e.g. `{show name}/Season {season number}/{episode
number} - {episode name}`) moves the file into that subfolder as part of renaming,
creating any missing intermediate folders automatically
(`crates/renamr-core/src/rename/mod.rs::rename_one` → `fs::create_dir_all`). This is
still always scoped to the file's own directory tree — the target is resolved relative
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.
## Project layout
```
@@ -87,10 +110,11 @@ crates/renamr-core/ Pure Rust core: naming-expression engine (ported), local
(`similarity/`, partial `EpisodeMetrics` port), a TMDB metadata
client (`web/tmdb.rs` — TV search + episode lookup, disambiguated
via `similarity/`, tested against a mocked HTTP server via
`mockito`), and the on-disk rename engine (ported for the
same-directory case, `rename/`) — skip-on-conflict, no
move/organize-into-folders yet. Not yet ported: full media
metadata bindings, other web datasource clients (TVDB/AniDB/etc).
`mockito`), and the on-disk rename engine (`rename/`) —
skip-on-conflict, can also organize into (auto-created)
subfolders when the naming scheme includes `/`. Not yet ported:
full media metadata bindings, other web datasource clients
(TVDB/AniDB/etc).
No Tauri dependency, so it builds and unit-tests standalone.
src-tauri/ Tauri app shell: exposes renamr-core as commands to the frontend
(naming-scheme rendering, filename matching, TMDB metadata fetch,
@@ -184,13 +208,14 @@ ported from, for anyone picking this up:
OMDb, TVmaze, OpenSubtitles) are not yet started.
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 core same-directory
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). Not yet done:
the `--action` variants beyond rename-in-place (move/copy/symlink to a different
folder, which is what lets a naming scheme like `{n}/{s00e00}` organize files into
subfolders), the `--conflict` variants beyond skip (auto-number, override), and the
CLI flag surface itself.
`--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. Not yet done: the `copy`/`symlink` `--action` variants, 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,
local matching, confirmed rename) is built; the other five sidebar panels
(Episodes/Subtitles/SFV/Filter/List) are still stub screens, plus Align up/down
+163 -35
View File
@@ -1,20 +1,30 @@
//! The on-disk rename operation: given a planned `old path -> new filename` mapping,
//! actually renames the files.
//! The on-disk rename operation: given a planned `old path -> new relative target`
//! mapping, actually renames (and, when the target includes subfolders, moves) the
//! files.
//!
//! Deliberately scoped for v1: same-directory renames only (no moving files into a
//! different folder tree/organizing by naming scheme yet - that's a bigger feature with
//! its own directory-creation and cross-filesystem-move concerns). Conflict policy is
//! fixed at **skip**: if the computed target filename already exists and isn't the same
//! underlying file, that item is left alone and reported as skipped rather than
//! overwritten. There is no undo yet, so the UI is expected to show the caller a
//! confirmation of the full plan before calling [`execute_renames`].
//! Every rename stays rooted at the source file's own directory - `new_name` is always
//! resolved relative to `source`'s parent, never as an absolute path, and can never
//! escape that directory tree via `..`. Within that directory tree, though, `new_name`
//! may contain `/` to organize files into subfolders as part of renaming (e.g. a naming
//! scheme like `{show name}/{episode name}` - the naming-expression engine already
//! 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. There is no
//! undo yet, so the UI is expected to show the caller a confirmation of the full plan
//! before calling [`execute_renames`].
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
/// One planned rename: `source` is the file's current full path, `new_name` is the
/// filename (including extension) it should have afterward, in the same directory.
/// target it should have afterward, resolved relative to `source`'s own directory -
/// just a filename for a same-directory rename, or a `/`-separated relative path (e.g.
/// `"Breaking Bad/Ozymandias.mkv"`) to also move the file into a (possibly new)
/// subfolder.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RenamePlanItem {
@@ -42,16 +52,32 @@ pub struct RenameResult {
pub outcome: RenameOutcome,
}
/// Rejects filenames that could escape the source file's own directory (path
/// separators, `.`/`..`) or are empty. The rename engine only ever renames a file
/// within its existing directory, so a `new_name` that isn't a plain filename is a bug
/// upstream (or a hostile input) rather than something to interpret as a move.
fn is_plain_filename(name: &str) -> bool {
!name.is_empty()
&& name != "."
&& name != ".."
&& !name.contains('/')
&& !name.contains('\\')
/// Validates a computed target, relative to the source file's own directory. `/` is
/// allowed as a subdirectory separator - that's what lets a naming scheme like
/// `{show name}/{episode name}` organize files into subfolders - but every path
/// component is checked to make sure the target can never escape that directory tree:
/// no empty components (a leading, trailing, or doubled `/` - which also catches an
/// absolute-looking `/etc/passwd`-style path, since splitting it on `/` yields a
/// leading empty component), and no `.`/`..` components. Backslashes are rejected
/// outright rather than treated as an alternate separator - Renamr always uses `/` for
/// the naming scheme's folder separator regardless of OS, so a literal `\` in a
/// computed name is almost certainly a mistake, not an intentional Windows path.
fn validate_relative_target(name: &str) -> Result<(), &'static str> {
if name.is_empty() {
return Err("empty");
}
if name.contains('\\') {
return Err("backslashes aren't allowed - use / to organize into subfolders");
}
for component in name.split('/') {
if component.is_empty() {
return Err("contains an empty path segment (e.g. a leading, trailing, or doubled '/')");
}
if component == "." || component == ".." {
return Err("contains a '.' or '..' path segment");
}
}
Ok(())
}
/// True if `a` and `b` refer to the same underlying file (used to allow case-only
@@ -65,25 +91,12 @@ fn same_file(a: &Path, b: &Path) -> bool {
}
fn rename_one(source: &Path, new_name: &str) -> RenameOutcome {
if !is_plain_filename(new_name) {
if let Err(reason) = validate_relative_target(new_name) {
return RenameOutcome::Failed {
error: format!("\"{new_name}\" is not a valid filename"),
error: format!("\"{new_name}\" is not a valid target: {reason}"),
};
}
let current_name = match source.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => {
return RenameOutcome::Failed {
error: "source has no filename".to_string(),
}
}
};
if current_name == new_name {
return RenameOutcome::Unchanged;
}
let parent = match source.parent() {
Some(p) => p,
None => {
@@ -94,12 +107,27 @@ fn rename_one(source: &Path, new_name: &str) -> RenameOutcome {
};
let target = parent.join(new_name);
if target == source {
return RenameOutcome::Unchanged;
}
if target.exists() && !same_file(&target, source) {
return RenameOutcome::SkippedConflict {
existing_path: target.to_string_lossy().to_string(),
};
}
// `new_name` may include subdirectory components (organizing files into folders) -
// 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.
if let Some(target_parent) = target.parent() {
if let Err(e) = fs::create_dir_all(target_parent) {
return RenameOutcome::Failed {
error: format!("couldn't create destination folder: {e}"),
};
}
}
match fs::rename(source, &target) {
Ok(()) => RenameOutcome::Renamed {
new_path: target.to_string_lossy().to_string(),
@@ -246,4 +274,104 @@ mod tests {
fs::remove_dir_all(&dir).ok();
}
#[test]
fn organizes_into_a_new_subfolder() {
let (dir, file) = temp_file_setup("Breaking.Bad.S05E14.mkv", "video");
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "Breaking Bad/Ozymandias.mkv".to_string(),
}];
let results = execute_renames(&items);
match &results[0].outcome {
RenameOutcome::Renamed { new_path } => {
let expected = dir.join("Breaking Bad").join("Ozymandias.mkv");
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();
}
#[test]
fn organizes_into_nested_subfolders_that_dont_exist_yet() {
let (dir, file) = temp_file_setup("ep.mkv", "video");
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "Breaking Bad/Season 05/S05E14.mkv".to_string(),
}];
let results = execute_renames(&items);
let expected = dir.join("Breaking Bad").join("Season 05").join("S05E14.mkv");
match &results[0].outcome {
RenameOutcome::Renamed { new_path } => {
assert_eq!(Path::new(new_path), expected.as_path());
assert!(expected.exists());
}
other => panic!("expected Renamed, got {other:?}"),
}
fs::remove_dir_all(&dir).ok();
}
#[test]
fn skips_when_the_organized_target_already_exists() {
let (dir, file) = temp_file_setup("a.txt", "aaa");
fs::create_dir_all(dir.join("Sub")).unwrap();
fs::write(dir.join("Sub").join("b.txt"), "bbb").unwrap();
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "Sub/b.txt".to_string(),
}];
let results = execute_renames(&items);
assert!(matches!(
results[0].outcome,
RenameOutcome::SkippedConflict { .. }
));
assert!(file.exists());
assert_eq!(fs::read_to_string(dir.join("Sub").join("b.txt")).unwrap(), "bbb");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn rejects_backslashes_in_new_name() {
let (dir, file) = temp_file_setup("a.txt", "aaa");
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "Sub\\b.txt".to_string(),
}];
let results = execute_renames(&items);
assert!(matches!(results[0].outcome, RenameOutcome::Failed { .. }));
assert!(file.exists());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn rejects_doubled_and_leading_slashes() {
let (dir, file) = temp_file_setup("a.txt", "aaa");
for bad_name in ["Sub//b.txt", "/etc/passwd", "Sub/b.txt/"] {
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: bad_name.to_string(),
}];
let results = execute_renames(&items);
assert!(
matches!(results[0].outcome, RenameOutcome::Failed { .. }),
"expected {bad_name:?} to be rejected"
);
}
assert!(file.exists());
fs::remove_dir_all(&dir).ok();
}
}
+7
View File
@@ -810,6 +810,11 @@ export default function RenamePanel() {
set in <strong>TMDB Settings</strong> first. FileBot's own short field names (<code>{"{n}"}</code>,{" "}
<code>{"{s00e00}"}</code>, <code>{"{t}"}</code>, etc.) still work too, side by side with the names above.
</p>
<p className="format-hint">
Use <code>{"/"}</code> in the scheme to organize files into subfolders as part of renaming, e.g.{" "}
<code>{"{show name}/{episode name}"}</code> puts each show in its own folder. Missing folders are created
automatically; nothing outside the file's own folder tree is ever touched.
</p>
</div>
)}
@@ -824,6 +829,8 @@ export default function RenamePanel() {
There's no undo yet — review the list below before continuing.
{renamePlanSkipped > 0 &&
` (${renamePlanSkipped} other file${renamePlanSkipped === 1 ? "" : "s"} excluded: no valid preview.)`}
{renamePlan.some((p) => p.newName.includes("/")) &&
" Some of these also move the file into a new subfolder — any missing folders are created automatically."}
</p>
<div className="modal-plan-list">
{renamePlan.map((p) => (