Add local season/episode matching and on-disk rename (skip-on-conflict, confirm-first)

This commit is contained in:
Renamr Scaffold
2026-08-29 02:42:31 +00:00
parent d0c29048be
commit bab9bfa1ec
9 changed files with 851 additions and 24 deletions
Generated
+1
View File
@@ -2544,6 +2544,7 @@ dependencies = [
name = "renamr-core"
version = "0.1.0"
dependencies = [
"regex",
"rhai",
"serde",
"serde_json",
+46 -19
View File
@@ -3,9 +3,14 @@
A fast, cross-platform media renamer — a ground-up rewrite of FileBot (via its FB-Mod
fork), built from scratch as its own project: new stack, new architecture, new branding.
**Status: early scaffold.** The naming-expression engine is ported and tested; the
matching engine, media metadata parsing, web datasource clients, rename engine, and the
real UI are not yet ported. See "Porting roadmap" below.
**Status: early scaffold, but the core loop works end to end.** The naming-expression
engine is ported and tested, the faithfully-recreated two-pane UI is wired to real
native file/folder loading and drag-and-drop, local filename-based season/episode
detection ("Match") is working, 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, the fuzzy similarity/matching engine, and online metadata
lookups are not yet ported. See "Porting roadmap" below.
## Why a rewrite, and why this stack
@@ -44,14 +49,21 @@ translate directly.
## Project layout
```
crates/renamr-core/ Pure Rust core: naming-expression engine (ported), and — not yet
ported — matching/similarity scoring, media metadata parsing,
web datasource clients (TMDB/TVDB/AniDB/etc), the rename engine.
No Tauri dependency, so it builds and unit-tests standalone.
src-tauri/ Tauri app shell: exposes renamr-core as commands to the frontend,
app metadata/icons/window config (tauri.conf.json).
src/ React + TypeScript UI (Vite). Currently a single proof-of-concept
screen wired to the live naming-expression preview.
crates/renamr-core/ Pure Rust core: naming-expression engine (ported), local
filename-based season/episode matching (ported, `matching/`), 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: the fuzzy similarity engine, full media metadata
bindings, web datasource clients (TMDB/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, executing a rename
plan, native file/folder dialogs, drag-and-drop expansion), app
metadata/icons/window config (tauri.conf.json).
src/ React + TypeScript UI (Vite). Faithful recreation of the original
two-pane rename screen (Aurora theme) wired to real file loading,
drag-and-drop, live naming-scheme preview, local matching, and a
confirm-before-touching-disk modal for the actual rename.
scripts/gen_icons.py Regenerates src-tauri/icons/ (placeholder branding — swap the
colors/glyph in the script, or replace the icons directly, to
change the look).
@@ -96,18 +108,33 @@ ported from, for anyone picking this up:
Only a small subset (`any`, `all_of`, `concat`) is ported so far
(`crates/renamr-core/src/format/functions.rs`); Rhai's built-ins cover some of the
rest already.
3. **Matching/similarity engine** — `net.filebot.similarity.*`, especially
`EpisodeMetrics.java` (849 lines, the core fuzzy-matching score cascade) and
`SeriesNameMatcher`/`SeasonEpisodeMatcher`/`DateMatcher`.
3. **Matching/similarity engine** — `net.filebot.similarity.*`. Local filename-based
season/episode detection is done (`crates/renamr-core/src/matching`, ported from the
pattern-matching half of `SeasonEpisodeMatcher.java`, adapted to Rust's `regex` crate
which lacks lookahead/lookbehind — wired to the "Match" button). Still not ported:
`EpisodeMetrics.java` (849 lines, the fuzzy title-matching score cascade used once
there's an actual metadata source to score candidates against) and `DateMatcher`.
4. **Media detection** — `net.filebot.media.MediaDetection` (1356 lines): ties file-name
parsing + matching together to auto-identify what a file is.
5. **Web datasource clients** — `net.filebot.web.*` (~40 files): TheMovieDB, TheTVDB,
AniDB, OMDb, TVmaze, OpenSubtitles, etc. Use `reqwest` + `serde_json`/`serde`.
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.) — flags are catalogued
in project notes but not yet implemented against real logic.
7. **Real UI** — file browser / drag-drop, rename preview table, settings, subtitle
panel. Current `src/App.tsx` is a single proof-of-concept screen.
8. **Packaging** — code signing, auto-update, installers per platform via
`--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.
7. **The rest of the UI** — the Rename panel (file loading, drag-drop, live preview,
local matching, confirmed rename) is built; the other five sidebar panels
(Episodes/Subtitles/SFV/Filter/List) are still stub screens, plus Align up/down
reordering, History, and Presets on the Rename panel itself are no-ops. There's also
no per-item exclude inside the rename confirmation modal yet (it's all-or-nothing —
use "Exclude Selected" on the file list beforehand to leave a file out entirely).
8. **Undo/history for renames** — there is no undo yet; the confirmation modal before
Rename executes is the only safety net right now. `net.filebot.HistoryDialog`/the
original's rename-history log is the reference for what a real undo would look like.
9. **Packaging** — code signing, auto-update, installers per platform via
`npm run tauri build`.
+5
View File
@@ -15,4 +15,9 @@ thiserror = "1.0"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Filename-based season/episode detection (local, no lookahead/lookbehind support
# unlike Java's regex engine used by the original SeasonEpisodeMatcher, so patterns
# are adapted to rely on \b word boundaries instead).
regex = "1"
[dev-dependencies]
+2
View File
@@ -6,6 +6,8 @@
//! parsing, web datasource clients, rename engine are not yet ported).
pub mod format;
pub mod matching;
pub mod rename;
use rhai::Dynamic;
use std::collections::HashMap;
+156
View File
@@ -0,0 +1,156 @@
//! Local, filename-based season/episode detection.
//!
//! This ports the pattern-matching half of the original FileBot's
//! `similarity/SeasonEpisodeMatcher.java`: given a filename, guess a season/episode
//! number pair and the series-name text that precedes it. Unlike the original (which
//! runs on Java's regex engine and leans on lookahead/lookbehind), Rust's `regex` crate
//! has neither, so every pattern here is rewritten to rely on `\b` word boundaries and
//! character classes instead.
//!
//! Deliberately scoped to *local* filename parsing only - there is no metadata lookup
//! here, no online API calls, and no fuzzy title matching against a show database (that
//! is the much larger `EpisodeMetrics`-based engine, not yet ported). This is the
//! "works offline the moment you load real files" increment.
use regex::Regex;
use serde::Serialize;
use std::sync::OnceLock;
/// A season/episode number pair guessed from a filename, plus the raw series-name text
/// that appeared before the match.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EpisodeGuess {
pub series_name: String,
pub season: i32,
pub episode: i32,
}
impl EpisodeGuess {
/// Formats as FileBot's conventional `{s00e00}` binding value, e.g. `S01E01`.
pub fn season_episode_label(&self) -> String {
format!("S{:02}E{:02}", self.season, self.episode)
}
}
fn season_episode_pattern() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"(?i)\bseason\s*(\d{1,4})\D{0,5}episode\s*(\d{1,3})\b").unwrap()
})
}
fn s00e00_pattern() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"(?i)\bs(\d{1,2})[^a-zA-Z0-9]{0,3}e(\d{1,3})\b").unwrap())
}
fn sxe_pattern() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"\b(\d{1,2})x(\d{2,3})\b").unwrap())
}
/// Cleans up the raw text preceding a season/episode match into a plausible series
/// name: `.`/`_` used as word separators become spaces, and leading/trailing
/// whitespace/dash/dot clutter is trimmed.
fn clean_series_name(raw: &str) -> String {
let replaced: String = raw
.chars()
.map(|c| if c == '.' || c == '_' { ' ' } else { c })
.collect();
let trimmed = replaced.trim_matches(|c: char| c.is_whitespace() || c == '-' || c == '.');
trimmed.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// Tries each season/episode pattern in priority order (most explicit/specific first)
/// and returns the first match, with the series name guessed from the text before it.
///
/// Returns `None` if no pattern matches - this is expected for movies, already-clean
/// filenames, or anything that just doesn't carry a season/episode marker in its name.
pub fn guess_episode(filename: &str) -> Option<EpisodeGuess> {
// `_` counts as a word character to regex's `\b`, so "show_name_s02e03" would
// otherwise fail to find a boundary right before the `s`. Match against a
// space-normalized copy instead (same length, so match byte-offsets still line up
// with the original string for slicing out the series-name prefix).
let normalized: String = filename
.chars()
.map(|c| if c == '_' { ' ' } else { c })
.collect();
let patterns = [season_episode_pattern(), s00e00_pattern(), sxe_pattern()];
for pattern in patterns {
if let Some(caps) = pattern.captures(&normalized) {
let whole = caps.get(0).unwrap();
let season: i32 = caps.get(1)?.as_str().parse().ok()?;
let episode: i32 = caps.get(2)?.as_str().parse().ok()?;
let prefix = &filename[..whole.start()];
let series_name = clean_series_name(prefix);
return Some(EpisodeGuess {
series_name,
season,
episode,
});
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matches_dotted_s00e00_style() {
let guess = guess_episode("Breaking.Bad.S05E14.720p.HDTV.x264-KILLERS").unwrap();
assert_eq!(guess.series_name, "Breaking Bad");
assert_eq!(guess.season, 5);
assert_eq!(guess.episode, 14);
assert_eq!(guess.season_episode_label(), "S05E14");
}
#[test]
fn matches_dashed_sxe_style() {
let guess = guess_episode("Reacher - 1x01 - Welcome to Margrave").unwrap();
assert_eq!(guess.series_name, "Reacher");
assert_eq!(guess.season, 1);
assert_eq!(guess.episode, 1);
assert_eq!(guess.season_episode_label(), "S01E01");
}
#[test]
fn matches_verbose_season_episode_words() {
let guess = guess_episode("The Office Season 4 Episode 9 - Dinner Party").unwrap();
assert_eq!(guess.series_name, "The Office");
assert_eq!(guess.season, 4);
assert_eq!(guess.episode, 9);
}
#[test]
fn matches_underscored_prefix() {
let guess = guess_episode("some_show_name_s02e03_episode_title").unwrap();
assert_eq!(guess.series_name, "some show name");
assert_eq!(guess.season, 2);
assert_eq!(guess.episode, 3);
}
#[test]
fn returns_none_for_movie_filenames() {
assert!(guess_episode("Inception.2010.1080p.BluRay.x264").is_none());
assert!(guess_episode("The Matrix (1999)").is_none());
}
#[test]
fn prefers_more_specific_pattern_over_sxe() {
// s00e00 pattern should win over the looser 1x01-style pattern when both could
// technically apply to fragments of the same string.
let guess = guess_episode("Show.Name.S01E02.1080p").unwrap();
assert_eq!(guess.season, 1);
assert_eq!(guess.episode, 2);
}
}
+249
View File
@@ -0,0 +1,249 @@
//! The on-disk rename operation: given a planned `old path -> new filename` mapping,
//! actually renames 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`].
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.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RenamePlanItem {
pub source: String,
pub new_name: String,
}
/// What happened to one planned rename.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "status", rename_all = "camelCase")]
pub enum RenameOutcome {
/// Renamed successfully; `new_path` is the file's new full path.
Renamed { new_path: String },
/// The computed name was already the file's current name - nothing to do.
Unchanged,
/// A different file already exists at the target path; left untouched.
SkippedConflict { existing_path: String },
/// The rename was rejected or the OS reported an error.
Failed { error: String },
}
#[derive(Debug, Clone, Serialize)]
pub struct RenameResult {
pub source: String,
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('\\')
}
/// True if `a` and `b` refer to the same underlying file (used to allow case-only
/// renames on case-insensitive filesystems, where the target "already exists" check
/// would otherwise find the source file itself).
fn same_file(a: &Path, b: &Path) -> bool {
match (fs::canonicalize(a), fs::canonicalize(b)) {
(Ok(pa), Ok(pb)) => pa == pb,
_ => false,
}
}
fn rename_one(source: &Path, new_name: &str) -> RenameOutcome {
if !is_plain_filename(new_name) {
return RenameOutcome::Failed {
error: format!("\"{new_name}\" is not a valid filename"),
};
}
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 => {
return RenameOutcome::Failed {
error: "source has no parent directory".to_string(),
}
}
};
let target = parent.join(new_name);
if target.exists() && !same_file(&target, source) {
return RenameOutcome::SkippedConflict {
existing_path: target.to_string_lossy().to_string(),
};
}
match fs::rename(source, &target) {
Ok(()) => RenameOutcome::Renamed {
new_path: target.to_string_lossy().to_string(),
},
Err(e) => RenameOutcome::Failed {
error: e.to_string(),
},
}
}
/// Executes a batch of planned renames in order. 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]) -> Vec<RenameResult> {
items
.iter()
.map(|item| RenameResult {
source: item.source.clone(),
outcome: rename_one(Path::new(&item.source), &item.new_name),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
/// Creates a fresh temp directory with one file in it, for tests that need to touch
/// the real filesystem. Returns (dir, file_path).
fn temp_file_setup(file_name: &str, contents: &str) -> (std::path::PathBuf, std::path::PathBuf) {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("renamr_rename_test_{}_{}", std::process::id(), n));
fs::create_dir_all(&dir).unwrap();
let file = dir.join(file_name);
fs::write(&file, contents).unwrap();
(dir, file)
}
#[test]
fn renames_a_file_successfully() {
let (dir, file) = temp_file_setup("old.txt", "hello");
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "new.txt".to_string(),
}];
let results = execute_renames(&items);
assert_eq!(results.len(), 1);
match &results[0].outcome {
RenameOutcome::Renamed { new_path } => {
assert!(new_path.ends_with("new.txt"));
assert!(Path::new(new_path).exists());
assert!(!file.exists());
}
other => panic!("expected Renamed, got {other:?}"),
}
fs::remove_dir_all(&dir).ok();
}
#[test]
fn reports_unchanged_when_name_is_already_correct() {
let (dir, file) = temp_file_setup("same.txt", "hello");
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "same.txt".to_string(),
}];
let results = execute_renames(&items);
assert_eq!(results[0].outcome, RenameOutcome::Unchanged);
assert!(file.exists());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn skips_when_target_already_exists() {
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);
match &results[0].outcome {
RenameOutcome::SkippedConflict { existing_path } => {
assert!(existing_path.ends_with("b.txt"));
}
other => panic!("expected SkippedConflict, got {other:?}"),
}
// Neither file should have been touched.
assert!(file.exists());
assert_eq!(fs::read_to_string(dir.join("b.txt")).unwrap(), "bbb");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn second_item_in_batch_skips_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);
assert!(matches!(results[0].outcome, RenameOutcome::Renamed { .. }));
assert!(matches!(
results[1].outcome,
RenameOutcome::SkippedConflict { .. }
));
// The second file's original content should be untouched.
assert_eq!(fs::read_to_string(&file_b).unwrap(), "ccc");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn rejects_path_traversal_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: "../escaped.txt".to_string(),
}];
let results = execute_renames(&items);
assert!(matches!(results[0].outcome, RenameOutcome::Failed { .. }));
assert!(file.exists());
fs::remove_dir_all(&dir).ok();
}
}
+25
View File
@@ -1,6 +1,8 @@
mod files;
use renamr_core::format::ExpressionFormat;
use renamr_core::matching::{self, EpisodeGuess};
use renamr_core::rename::{self, RenamePlanItem, RenameResult};
use renamr_core::string_bindings;
use std::collections::HashMap;
@@ -15,12 +17,35 @@ fn render_expression(expression: String, bindings: HashMap<String, String>) -> R
.map_err(|e| e.to_string())
}
/// Guesses a season/episode number pair (and series name) from a filename using local
/// regex patterns only - no network calls, no metadata lookup. Returns `None` when the
/// filename doesn't carry a recognizable season/episode marker (movies, already-clean
/// names, etc).
#[tauri::command]
fn guess_episode(filename: String) -> Option<EpisodeGuess> {
matching::guess_episode(&filename)
}
/// Executes a batch of planned on-disk renames (same-directory rename only; conflicting
/// targets are skipped, never overwritten - see `renamr_core::rename` for the policy).
/// The UI is expected to have already shown the user the full old-name/new-name plan
/// and gotten explicit confirmation before calling this - there is no undo yet.
/// Runs off the main thread since real filesystem I/O shouldn't block the UI.
#[tauri::command]
async fn execute_rename(items: Vec<RenamePlanItem>) -> Vec<RenameResult> {
tauri::async_runtime::spawn_blocking(move || rename::execute_renames(&items))
.await
.unwrap_or_default()
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![
render_expression,
guess_episode,
execute_rename,
files::pick_files,
files::pick_folder,
files::expand_dropped_paths,
+106
View File
@@ -256,6 +256,112 @@
line-height: 1.5;
}
.modal-overlay {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
background: rgba(3, 10, 13, 0.65);
backdrop-filter: blur(2px);
}
.modal-card {
width: min(560px, calc(100vw - 48px));
max-height: min(560px, calc(100vh - 64px));
display: flex;
flex-direction: column;
background: var(--card);
border: 1px solid var(--surface-deep);
border-radius: 14px;
padding: 20px 22px;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
}
.modal-title {
margin: 0 0 8px;
font-size: 16px;
font-weight: 700;
color: var(--text);
}
.modal-subtitle {
margin: 0 0 14px;
font-size: 12px;
line-height: 1.5;
color: var(--muted-text);
}
.modal-plan-list {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
background: var(--surface);
border: 1px solid var(--surface-deep);
border-radius: 10px;
padding: 6px;
}
.modal-plan-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
padding: 6px 8px;
border-radius: 6px;
font-size: 12px;
}
.modal-plan-row:hover {
background: rgba(255, 255, 255, 0.03);
}
.modal-plan-old {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--muted-text);
}
.modal-plan-arrow {
flex: 0 0 auto;
color: var(--aurora-cyan);
}
.modal-plan-new {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
font-weight: 600;
}
.modal-note {
margin: 10px 0 0;
font-size: 11px;
color: var(--muted-text);
}
.modal-actions {
margin-top: 16px;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.modal-actions .action-button {
width: auto;
padding: 0 18px;
}
.status-bar {
position: absolute;
left: 18px;
+261 -5
View File
@@ -26,6 +26,46 @@ interface FileEntry {
ext: string;
}
// Result of the local, filename-only season/episode matcher (renamr-core's
// `matching` module, exposed via the `guess_episode` Tauri command). No network calls -
// this only recognizes patterns already present in the filename text itself.
interface EpisodeGuess {
seriesName: string;
season: number;
episode: number;
}
function pad2(n: number): string {
return n.toString().padStart(2, "0");
}
// One row of the on-disk rename plan shown in the confirmation modal before anything
// touches the filesystem. `id`/`source` are the file's current full path.
interface RenamePlanEntry {
id: string;
source: string;
currentName: string;
newName: string;
}
// Mirrors renamr-core's `RenameOutcome` (serde-tagged on "status"). Conflict policy is
// fixed at skip - a colliding target is never overwritten, just reported.
type RenameOutcome =
| { status: "renamed"; newPath: string }
| { status: "unchanged" }
| { status: "skippedConflict"; existingPath: string }
| { status: "failed"; error: string };
interface RenameResult {
source: string;
outcome: RenameOutcome;
}
function fileNameFromPath(path: string): string {
const parts = path.split(/[\\/]/);
return parts[parts.length - 1] ?? path;
}
// Shown until the user loads real files, so the two-pane layout and live naming-scheme
// preview are visible immediately. Item 4 demonstrates the "no data" muted row style.
// This is what full metadata-matched renaming will look like once the matching engine
@@ -142,6 +182,10 @@ export default function RenamePanel() {
const [computedNames, setComputedNames] = useState<Record<string, string>>({});
const [computeError, setComputeError] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null);
// Non-null while the "confirm before touching disk" modal is open.
const [renamePlan, setRenamePlan] = useState<RenamePlanEntry[] | null>(null);
const [renamePlanSkipped, setRenamePlanSkipped] = useState(0);
const [renaming, setRenaming] = useState(false);
// Refs so the drag-and-drop listener (registered once on mount) always sees the
// latest expression/items instead of the stale values from its first render.
@@ -250,6 +294,178 @@ export default function RenamePanel() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Local, filename-based season/episode detection (renamr-core's `matching` module).
// Populates `{n}`/`{s00e00}`/`{season}`/`{episode}` bindings from patterns already
// present in the filename text - no network calls, no metadata lookup, so it's
// distinct from (and much cheaper than) "Fetch Data" online matching, which still
// isn't built.
const runMatching = async () => {
const targets = items.filter((i) => !i.isSample);
if (targets.length === 0) {
setStatus("Load some files first, then Match will scan their names for season/episode info.");
window.clearTimeout((runMatching as any)._t);
(runMatching as any)._t = window.setTimeout(() => setStatus(null), 3200);
return;
}
const results = await Promise.all(
targets.map(async (item) => {
try {
const guess = await invoke<EpisodeGuess | null>("guess_episode", {
filename: item.originalName,
});
return [item.id, guess] as const;
} catch {
return [item.id, null] as const;
}
})
);
const guesses = new Map(results);
// Count matches from the results directly, not inside the setItems updater below:
// React doesn't run a functional updater synchronously, so a counter mutated in
// there wouldn't be ready yet for the status message/auto-upgrade check right after.
const matched = results.filter(([, guess]) => guess !== null).length;
setItems((prev) =>
prev.map((item) => {
const guess = guesses.get(item.id);
if (!guess) return item;
return {
...item,
bindings: {
...item.bindings,
n: guess.seriesName || item.bindings?.name || item.originalName,
s00e00: `S${pad2(guess.season)}E${pad2(guess.episode)}`,
season: String(guess.season),
episode: String(guess.episode),
},
};
})
);
// Showcase the newly-populated fields, but only the first time and only if the
// user hasn't already customized the naming scheme away from the plain-filename
// default.
if (expressionRef.current === REAL_FILE_EXPRESSION && matched > 0) {
setExpression("{n} - {s00e00}");
}
setStatus(
matched > 0
? `Matched season/episode on ${matched} of ${targets.length} file${targets.length === 1 ? "" : "s"} (filename-based, local only).`
: "No season/episode pattern recognized in the loaded filenames."
);
window.clearTimeout((runMatching as any)._t);
(runMatching as any)._t = window.setTimeout(() => setStatus(null), 4000);
};
// Step 1 of Rename: build the plan (old path -> new filename) from the live preview
// that's already on screen, and open the confirmation modal. Nothing on disk is
// touched here - only `confirmRename` below actually calls the Rust rename command,
// and only after the user explicitly approves the plan they can see.
const prepareRename = () => {
const targets = items.filter((i) => !i.isSample);
if (targets.length === 0) {
setStatus("Load some real files first — Rename needs files on disk to work with.");
window.clearTimeout((prepareRename as any)._t);
(prepareRename as any)._t = window.setTimeout(() => setStatus(null), 3200);
return;
}
const plan: RenamePlanEntry[] = [];
let skippedNoPreview = 0;
for (const item of targets) {
const newName = computedNames[item.id];
if (!newName) {
skippedNoPreview += 1;
continue;
}
if (newName === item.originalName) continue; // already correct, nothing to do
plan.push({ id: item.id, source: item.id, currentName: item.originalName, newName });
}
if (plan.length === 0) {
setStatus(
skippedNoPreview > 0
? "Nothing to rename yet — the naming scheme couldn't be computed for those files (check the naming scheme for errors)."
: "Nothing to rename — the computed names already match the current filenames."
);
window.clearTimeout((prepareRename as any)._t);
(prepareRename as any)._t = window.setTimeout(() => setStatus(null), 4000);
return;
}
setRenamePlanSkipped(skippedNoPreview);
setRenamePlan(plan);
};
// Step 2: the user approved the plan shown in the modal - actually rename the files
// on disk via the Rust `execute_rename` command, then reconcile local state (a
// renamed file's id, which is its path, changes) and report the outcome.
const confirmRename = async () => {
if (!renamePlan) return;
setRenaming(true);
try {
const results = await invoke<RenameResult[]>("execute_rename", {
items: renamePlan.map((p) => ({ source: p.source, newName: p.newName })),
});
const bySource = new Map(results.map((r) => [r.source, r] as const));
// Compute counts from `results` directly, not inside the setItems updater below:
// 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
// runMatching earlier - same fix applies here).
const renamed = results.filter((r) => r.outcome.status === "renamed").length;
const skipped = results.filter((r) => r.outcome.status === "skippedConflict").length;
const failed = results.filter((r) => r.outcome.status === "failed").length;
setItems((prev) =>
prev.map((item) => {
const result = bySource.get(item.id);
if (!result) return item;
if (result.outcome.status === "renamed") {
const newFileName = fileNameFromPath(result.outcome.newPath);
const [base, ext] = splitExt(newFileName);
return {
...item,
id: result.outcome.newPath,
originalName: newFileName,
ext,
bindings: item.bindings ? { ...item.bindings, name: base, ext } : item.bindings,
};
}
return item;
})
);
// Keep the current selection pointing at the same logical file if its id (path)
// changed underneath it.
setSelected((prevSelected) => {
if (!prevSelected) return prevSelected;
const result = bySource.get(prevSelected);
return result?.outcome.status === "renamed" ? result.outcome.newPath : prevSelected;
});
const parts = [`Renamed ${renamed} file${renamed === 1 ? "" : "s"}`];
if (skipped > 0) parts.push(`${skipped} skipped (name already exists)`);
if (failed > 0) parts.push(`${failed} failed`);
setStatus(parts.join(", ") + ".");
} catch (err) {
setStatus(`Rename failed: ${err}`);
} finally {
setRenaming(false);
setRenamePlan(null);
window.clearTimeout((confirmRename as any)._t);
(confirmRename as any)._t = window.setTimeout(() => setStatus(null), 5000);
}
};
const cancelRename = () => {
if (renaming) return;
setRenamePlan(null);
};
const removeSelected = () => {
if (!selected) return notImplemented("Excluding items");
setItems((prev) => {
@@ -317,10 +533,10 @@ export default function RenamePanel() {
/>
<div className="rename-actions">
<button className="action-button" onClick={() => notImplemented("Matching")}>
<button className="action-button" onClick={runMatching}>
Match
</button>
<button className="action-button primary" onClick={() => notImplemented("Renaming")}>
<button className="action-button primary" onClick={prepareRename}>
Rename
</button>
</div>
@@ -355,14 +571,54 @@ export default function RenamePanel() {
{computeError && <p className="format-error">{computeError}</p>}
<p className="format-hint">
Live preview against {matchedCount} of {items.length} file{items.length === 1 ? "" : "s"}. Runs the real
Rust naming-expression engine (Rhai-backed) through Tauri. Loaded files only expose{" "}
<code>{"{name}"}</code>/<code>{"{ext}"}</code> (their own filename) since the matching engine that would
populate <code>{"{n}"}</code>/<code>{"{s00e00}"}</code>/etc. from online metadata isn't built yet.
Rust naming-expression engine (Rhai-backed) through Tauri. Loaded files start with only{" "}
<code>{"{name}"}</code>/<code>{"{ext}"}</code> (their own filename) — click <strong>Match</strong> to scan
filenames locally for season/episode patterns and populate <code>{"{n}"}</code>/<code>{"{s00e00}"}</code>/
<code>{"{season}"}</code>/<code>{"{episode}"}</code>. That's filename parsing only, no network calls;{" "}
<strong>Fetch Data</strong> (online metadata matching) isn't built yet.
</p>
</div>
)}
{status && <div className="status-bar">{status}</div>}
{renamePlan && (
<div className="modal-overlay" onClick={cancelRename}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<h2 className="modal-title">Confirm Rename</h2>
<p className="modal-subtitle">
This will rename {renamePlan.length} file{renamePlan.length === 1 ? "" : "s"} on disk right now.
There's no undo yet — review the list below before continuing.
{renamePlanSkipped > 0 &&
` (${renamePlanSkipped} other file${renamePlanSkipped === 1 ? "" : "s"} excluded: no valid preview.)`}
</p>
<div className="modal-plan-list">
{renamePlan.map((p) => (
<div className="modal-plan-row" key={p.id}>
<span className="modal-plan-old" title={p.currentName}>
{p.currentName}
</span>
<span className="modal-plan-arrow">→</span>
<span className="modal-plan-new" title={p.newName}>
{p.newName}
</span>
</div>
))}
</div>
<p className="modal-note">
If a computed name already exists on disk, that file is skipped rather than overwritten.
</p>
<div className="modal-actions">
<button className="action-button" onClick={cancelRename} disabled={renaming}>
Cancel
</button>
<button className="action-button primary" onClick={confirmRename} disabled={renaming}>
{renaming ? "Renaming…" : `Rename ${renamePlan.length} File${renamePlan.length === 1 ? "" : "s"}`}
</button>
</div>
</div>
</div>
)}
</div>
);
}