Add local season/episode matching and on-disk rename (skip-on-conflict, confirm-first)
This commit is contained in:
Generated
+1
@@ -2544,6 +2544,7 @@ dependencies = [
|
|||||||
name = "renamr-core"
|
name = "renamr-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"regex",
|
||||||
"rhai",
|
"rhai",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -3,9 +3,14 @@
|
|||||||
A fast, cross-platform media renamer — a ground-up rewrite of FileBot (via its FB-Mod
|
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.
|
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
|
**Status: early scaffold, but the core loop works end to end.** The naming-expression
|
||||||
matching engine, media metadata parsing, web datasource clients, rename engine, and the
|
engine is ported and tested, the faithfully-recreated two-pane UI is wired to real
|
||||||
real UI are not yet ported. See "Porting roadmap" below.
|
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
|
## Why a rewrite, and why this stack
|
||||||
|
|
||||||
@@ -44,14 +49,21 @@ translate directly.
|
|||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
```
|
```
|
||||||
crates/renamr-core/ Pure Rust core: naming-expression engine (ported), and — not yet
|
crates/renamr-core/ Pure Rust core: naming-expression engine (ported), local
|
||||||
ported — matching/similarity scoring, media metadata parsing,
|
filename-based season/episode matching (ported, `matching/`), and
|
||||||
web datasource clients (TMDB/TVDB/AniDB/etc), the rename engine.
|
the on-disk rename engine (ported for the same-directory case,
|
||||||
No Tauri dependency, so it builds and unit-tests standalone.
|
`rename/`) — skip-on-conflict, no move/organize-into-folders yet.
|
||||||
src-tauri/ Tauri app shell: exposes renamr-core as commands to the frontend,
|
Not yet ported: the fuzzy similarity engine, full media metadata
|
||||||
app metadata/icons/window config (tauri.conf.json).
|
bindings, web datasource clients (TMDB/TVDB/AniDB/etc). No Tauri
|
||||||
src/ React + TypeScript UI (Vite). Currently a single proof-of-concept
|
dependency, so it builds and unit-tests standalone.
|
||||||
screen wired to the live naming-expression preview.
|
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
|
scripts/gen_icons.py Regenerates src-tauri/icons/ (placeholder branding — swap the
|
||||||
colors/glyph in the script, or replace the icons directly, to
|
colors/glyph in the script, or replace the icons directly, to
|
||||||
change the look).
|
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
|
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
|
(`crates/renamr-core/src/format/functions.rs`); Rhai's built-ins cover some of the
|
||||||
rest already.
|
rest already.
|
||||||
3. **Matching/similarity engine** — `net.filebot.similarity.*`, especially
|
3. **Matching/similarity engine** — `net.filebot.similarity.*`. Local filename-based
|
||||||
`EpisodeMetrics.java` (849 lines, the core fuzzy-matching score cascade) and
|
season/episode detection is done (`crates/renamr-core/src/matching`, ported from the
|
||||||
`SeriesNameMatcher`/`SeasonEpisodeMatcher`/`DateMatcher`.
|
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
|
4. **Media detection** — `net.filebot.media.MediaDetection` (1356 lines): ties file-name
|
||||||
parsing + matching together to auto-identify what a file is.
|
parsing + matching together to auto-identify what a file is.
|
||||||
5. **Web datasource clients** — `net.filebot.web.*` (~40 files): TheMovieDB, TheTVDB,
|
5. **Web datasource clients** — `net.filebot.web.*` (~40 files): TheMovieDB, TheTVDB,
|
||||||
AniDB, OMDb, TVmaze, OpenSubtitles, etc. Use `reqwest` + `serde_json`/`serde`.
|
AniDB, OMDb, TVmaze, OpenSubtitles, etc. Use `reqwest` + `serde_json`/`serde`.
|
||||||
6. **Rename engine + CLI** — `net.filebot.cli.CmdlineOperations` (1235 lines) and
|
6. **Rename engine + CLI** — `net.filebot.cli.CmdlineOperations` (1235 lines) and
|
||||||
`ArgumentBean` (415 lines, the full flag surface: `-rename`, `--db`, `--format`,
|
`ArgumentBean` (415 lines, the full flag surface: `-rename`, `--db`, `--format`,
|
||||||
`--action`, `--conflict`, `-get-subtitles`, `-script`, etc.) — flags are catalogued
|
`--action`, `--conflict`, `-get-subtitles`, `-script`, etc.). The core same-directory
|
||||||
in project notes but not yet implemented against real logic.
|
rename operation is done (`crates/renamr-core/src/rename`, fixed skip-on-conflict
|
||||||
7. **Real UI** — file browser / drag-drop, rename preview table, settings, subtitle
|
policy, wired to the UI's Rename button behind a confirmation step). Not yet done:
|
||||||
panel. Current `src/App.tsx` is a single proof-of-concept screen.
|
the `--action` variants beyond rename-in-place (move/copy/symlink to a different
|
||||||
8. **Packaging** — code signing, auto-update, installers per platform via
|
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`.
|
`npm run tauri build`.
|
||||||
|
|||||||
@@ -15,4 +15,9 @@ thiserror = "1.0"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
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]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
//! parsing, web datasource clients, rename engine are not yet ported).
|
//! parsing, web datasource clients, rename engine are not yet ported).
|
||||||
|
|
||||||
pub mod format;
|
pub mod format;
|
||||||
|
pub mod matching;
|
||||||
|
pub mod rename;
|
||||||
|
|
||||||
use rhai::Dynamic;
|
use rhai::Dynamic;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
mod files;
|
mod files;
|
||||||
|
|
||||||
use renamr_core::format::ExpressionFormat;
|
use renamr_core::format::ExpressionFormat;
|
||||||
|
use renamr_core::matching::{self, EpisodeGuess};
|
||||||
|
use renamr_core::rename::{self, RenamePlanItem, RenameResult};
|
||||||
use renamr_core::string_bindings;
|
use renamr_core::string_bindings;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
@@ -15,12 +17,35 @@ fn render_expression(expression: String, bindings: HashMap<String, String>) -> R
|
|||||||
.map_err(|e| e.to_string())
|
.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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
render_expression,
|
render_expression,
|
||||||
|
guess_episode,
|
||||||
|
execute_rename,
|
||||||
files::pick_files,
|
files::pick_files,
|
||||||
files::pick_folder,
|
files::pick_folder,
|
||||||
files::expand_dropped_paths,
|
files::expand_dropped_paths,
|
||||||
|
|||||||
@@ -256,6 +256,112 @@
|
|||||||
line-height: 1.5;
|
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 {
|
.status-bar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 18px;
|
left: 18px;
|
||||||
|
|||||||
@@ -26,6 +26,46 @@ interface FileEntry {
|
|||||||
ext: string;
|
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
|
// 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.
|
// 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
|
// 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 [computedNames, setComputedNames] = useState<Record<string, string>>({});
|
||||||
const [computeError, setComputeError] = useState<string | null>(null);
|
const [computeError, setComputeError] = useState<string | null>(null);
|
||||||
const [status, setStatus] = 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
|
// 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.
|
// 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
|
// 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 = () => {
|
const removeSelected = () => {
|
||||||
if (!selected) return notImplemented("Excluding items");
|
if (!selected) return notImplemented("Excluding items");
|
||||||
setItems((prev) => {
|
setItems((prev) => {
|
||||||
@@ -317,10 +533,10 @@ export default function RenamePanel() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="rename-actions">
|
<div className="rename-actions">
|
||||||
<button className="action-button" onClick={() => notImplemented("Matching")}>
|
<button className="action-button" onClick={runMatching}>
|
||||||
Match
|
Match
|
||||||
</button>
|
</button>
|
||||||
<button className="action-button primary" onClick={() => notImplemented("Renaming")}>
|
<button className="action-button primary" onClick={prepareRename}>
|
||||||
Rename
|
Rename
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -355,14 +571,54 @@ export default function RenamePanel() {
|
|||||||
{computeError && <p className="format-error">{computeError}</p>}
|
{computeError && <p className="format-error">{computeError}</p>}
|
||||||
<p className="format-hint">
|
<p className="format-hint">
|
||||||
Live preview against {matchedCount} of {items.length} file{items.length === 1 ? "" : "s"}. Runs the real
|
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{" "}
|
Rust naming-expression engine (Rhai-backed) through Tauri. Loaded files start with only{" "}
|
||||||
<code>{"{name}"}</code>/<code>{"{ext}"}</code> (their own filename) since the matching engine that would
|
<code>{"{name}"}</code>/<code>{"{ext}"}</code> (their own filename) — click <strong>Match</strong> to scan
|
||||||
populate <code>{"{n}"}</code>/<code>{"{s00e00}"}</code>/etc. from online metadata isn't built yet.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status && <div className="status-bar">{status}</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user