From bab9bfa1ecfd13b998598f5a8f987afbc9fec0c9 Mon Sep 17 00:00:00 2001 From: Renamr Scaffold Date: Sat, 29 Aug 2026 02:42:31 +0000 Subject: [PATCH] Add local season/episode matching and on-disk rename (skip-on-conflict, confirm-first) --- Cargo.lock | 1 + README.md | 65 ++++-- crates/renamr-core/Cargo.toml | 5 + crates/renamr-core/src/lib.rs | 2 + crates/renamr-core/src/matching/mod.rs | 156 +++++++++++++++ crates/renamr-core/src/rename/mod.rs | 249 +++++++++++++++++++++++ src-tauri/src/lib.rs | 25 +++ src/components/RenamePanel.css | 106 ++++++++++ src/components/RenamePanel.tsx | 266 ++++++++++++++++++++++++- 9 files changed, 851 insertions(+), 24 deletions(-) create mode 100644 crates/renamr-core/src/matching/mod.rs create mode 100644 crates/renamr-core/src/rename/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 9e0c52d..6c9c45d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2544,6 +2544,7 @@ dependencies = [ name = "renamr-core" version = "0.1.0" dependencies = [ + "regex", "rhai", "serde", "serde_json", diff --git a/README.md b/README.md index 9ef1f5f..17ef39a 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/crates/renamr-core/Cargo.toml b/crates/renamr-core/Cargo.toml index 7a7c8a4..d52098e 100644 --- a/crates/renamr-core/Cargo.toml +++ b/crates/renamr-core/Cargo.toml @@ -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] diff --git a/crates/renamr-core/src/lib.rs b/crates/renamr-core/src/lib.rs index 453a107..b15b594 100644 --- a/crates/renamr-core/src/lib.rs +++ b/crates/renamr-core/src/lib.rs @@ -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; diff --git a/crates/renamr-core/src/matching/mod.rs b/crates/renamr-core/src/matching/mod.rs new file mode 100644 index 0000000..72f799d --- /dev/null +++ b/crates/renamr-core/src/matching/mod.rs @@ -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 = 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 = 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 = 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::>().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 { + // `_` 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); + } +} diff --git a/crates/renamr-core/src/rename/mod.rs b/crates/renamr-core/src/rename/mod.rs new file mode 100644 index 0000000..6dadb42 --- /dev/null +++ b/crates/renamr-core/src/rename/mod.rs @@ -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 { + 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(); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 84e9d49..e3fea05 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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) -> 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 { + 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) -> Vec { + 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, diff --git a/src/components/RenamePanel.css b/src/components/RenamePanel.css index 060c753..0f64ef4 100644 --- a/src/components/RenamePanel.css +++ b/src/components/RenamePanel.css @@ -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; diff --git a/src/components/RenamePanel.tsx b/src/components/RenamePanel.tsx index 0c32f0c..f7597f3 100644 --- a/src/components/RenamePanel.tsx +++ b/src/components/RenamePanel.tsx @@ -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>({}); const [computeError, setComputeError] = useState(null); const [status, setStatus] = useState(null); + // Non-null while the "confirm before touching disk" modal is open. + const [renamePlan, setRenamePlan] = useState(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("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("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() { />
- -
@@ -355,14 +571,54 @@ export default function RenamePanel() { {computeError &&

{computeError}

}

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{" "} - {"{name}"}/{"{ext}"} (their own filename) since the matching engine that would - populate {"{n}"}/{"{s00e00}"}/etc. from online metadata isn't built yet. + Rust naming-expression engine (Rhai-backed) through Tauri. Loaded files start with only{" "} + {"{name}"}/{"{ext}"} (their own filename) — click Match to scan + filenames locally for season/episode patterns and populate {"{n}"}/{"{s00e00}"}/ + {"{season}"}/{"{episode}"}. That's filename parsing only, no network calls;{" "} + Fetch Data (online metadata matching) isn't built yet.

)} {status &&
{status}
} + + {renamePlan && ( +
+
e.stopPropagation()}> +

Confirm Rename

+

+ 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.)`} +

+
+ {renamePlan.map((p) => ( +
+ + {p.currentName} + + → + + {p.newName} + +
+ ))} +
+

+ If a computed name already exists on disk, that file is skipped rather than overwritten. +

+
+ + +
+
+
+ )} ); }