From f89589bc88692aae52f317dd57b2a2117691034c Mon Sep 17 00:00:00 2001 From: Renamr Scaffold Date: Sat, 29 Aug 2026 03:15:21 +0000 Subject: [PATCH] Make the naming scheme fully user-editable, with the user's preferred format as default - ExpressionFormat::compile_scriptlet now falls back to treating a {token} as a literal multi-word binding key when it's neither a valid Rhai identifier nor valid Rhai script, but still looks like a plain human-typed field name (only letters/digits/underscores/spaces). This is what lets `{show name}` work without anyone needing to know Rhai, while real Rhai script (even containing spaces) still takes priority and genuine syntax errors still surface. - New default naming scheme app-wide: `{show name} - {season number}x {episode number} - {episode name}` (previously the terser {n} - {s00e00} - {t} form). Match/Fetch Data auto-upgrade targets updated to match, and sample items carry both the short FileBot-style keys and the new human-readable ones side by side. - Naming scheme editor UI rewritten: explains the field is fully editable, lists available fields grouped by when they become available (always / after Match / after Fetch Data), and clarifies Rhai script is also supported for anyone who wants it. - 3 new unit tests for the plain-field-name fallback (multi-word lookup works, real Rhai still takes priority, genuine syntax errors still error). --- README.md | 30 +++++ _to_delete/HEAD.lock | 0 _to_delete/index.lock | 0 crates/renamr-core/src/format/expression.rs | 43 ++++++-- crates/renamr-core/tests/expression_format.rs | 33 ++++++ src/components/RenamePanel.css | 53 +++++++++ src/components/RenamePanel.tsx | 103 +++++++++++++++--- 7 files changed, 240 insertions(+), 22 deletions(-) create mode 100644 _to_delete/HEAD.lock create mode 100644 _to_delete/index.lock diff --git a/README.md b/README.md index a886929..6cb936d 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,15 @@ plan before anything touches disk — there is no undo yet). Full media-metadata bindings and the fuzzy similarity/matching engine are not yet ported. See "Porting roadmap" below. +The naming scheme is fully user-editable from the app (the "Naming Scheme" button next +to the New Names list) — the default is `{show name} - {season number}x{episode +number} - {episode name}`, but it's just a starting point, not a fixed template. +Plain, human-readable, multi-word field names like `{show name}` work directly (no +Rhai knowledge needed), FileBot's own short field names (`{n}`, `{s00e00}`, `{t}`) +still work side by side with them, and real Rhai script is available for anyone who +wants it (e.g. `{show name.to_upper()}`). See "Naming scheme syntax" below for how +that's implemented. + ## Why a rewrite, and why this stack The original is a ~20k+ line Java/Swing app built with Ant. The goals were: faster @@ -47,6 +56,27 @@ identical. More complex chained-method expressions will need small syntax tweaks common string/array methods FileBot's expression helpers wrapped, so most patterns translate directly. +### Naming scheme syntax + +Every `{ }` token in an expression is resolved in this order +(`crates/renamr-core/src/format/expression.rs::compile_scriptlet`): + +1. **A single bare identifier** (`{n}`, `{season}`) is a direct binding lookup — the + fast path, and how FileBot's own short field names keep working unmodified. +2. Otherwise it's **compiled as Rhai script** (`{n.to_upper()}`, `{n + " " + t}`) — + real code, with every current binding available as a script variable. +3. If it's *neither* a valid identifier *nor* valid Rhai, but it still only contains + letters, digits, underscores and spaces (e.g. `{show name}`, `{episode number}`), + it falls back to a **literal multi-word binding key** instead of raising a syntax + error — this is what lets a human-typed field name with spaces work as a plain + lookup without anyone needing to know Rhai. Anything that isn't a plausible field + name (stray punctuation, unmatched brackets, etc.) still surfaces as a real syntax + error at that point, exactly as before. + +An undefined binding contributes an empty string rather than failing outright (so +`{n}{missing}` still renders); the overall expression only errors if the *total* +rendered output would be empty. + ## Project layout ``` diff --git a/_to_delete/HEAD.lock b/_to_delete/HEAD.lock new file mode 100644 index 0000000..e69de29 diff --git a/_to_delete/index.lock b/_to_delete/index.lock new file mode 100644 index 0000000..e69de29 diff --git a/crates/renamr-core/src/format/expression.rs b/crates/renamr-core/src/format/expression.rs index a374c23..0c8a183 100644 --- a/crates/renamr-core/src/format/expression.rs +++ b/crates/renamr-core/src/format/expression.rs @@ -109,13 +109,42 @@ fn compile_scriptlet(token: &str, expression: &str) -> Result Ok(Segment::Script(ast)), + Err(e) => { + // Not valid as a single bare identifier, and not valid as a Rhai script + // either - but if it otherwise looks like a plain, human-typed field name + // (e.g. "show name": just letters/digits/spaces/underscores), treat the + // whole thing as a literal binding key instead of surfacing a syntax + // error. This is what lets a naming scheme like + // `{show name} - {season number}x{episode number} - {episode name}` work + // without every such phrase needing to be a reserved single-word binding. + // Genuine script syntax errors (unmatched parens/quotes, bad operators, + // etc.) still get reported exactly as before. + let trimmed = token.trim(); + if is_plain_field_name(trimmed) { + Ok(Segment::Variable(trimmed.to_string())) + } else { + Err(ExpressionError::Syntax { + expression: expression.to_string(), + message: e.to_string(), + }) + } + } + } +} + +/// True for a token that reads like a plain (possibly multi-word) field name rather +/// than code: starts with a letter or underscore, and contains only letters, digits, +/// underscores, and internal spaces. Only consulted as a fallback after Rhai has +/// already rejected the token as a script. +fn is_plain_field_name(token: &str) -> bool { + let mut chars = token.chars(); + match chars.next() { + Some(c) if c.is_alphabetic() || c == '_' => {} + _ => return false, + } + chars.all(|c| c.is_alphanumeric() || c == '_' || c == ' ') } /// Splits an expression into literal text and `{ }` scriptlets, honoring nested braces diff --git a/crates/renamr-core/tests/expression_format.rs b/crates/renamr-core/tests/expression_format.rs index 6ae1b44..c49f28a 100644 --- a/crates/renamr-core/tests/expression_format.rs +++ b/crates/renamr-core/tests/expression_format.rs @@ -64,3 +64,36 @@ fn syntax_error_is_reported_with_the_offending_expression() { let message = err.to_string(); assert!(message.contains("syntax error"), "message was: {message}"); } + +#[test] +fn multi_word_plain_field_names_are_treated_as_literal_binding_keys() { + // "show name" etc. aren't valid Rhai (two bare identifiers with no operator between + // them), so this only works because compile_scriptlet falls back to a literal key + // lookup when a token looks like a plain human-typed field name rather than code. + let fmt = ExpressionFormat::compile("{show name} - {season number}x{episode number} - {episode name}").unwrap(); + let bindings = string_bindings([ + ("show name", "Reacher"), + ("season number", "1"), + ("episode number", "1"), + ("episode name", "Welcome to Margrave"), + ]); + assert_eq!(fmt.format(&bindings).unwrap(), "Reacher - 1x1 - Welcome to Margrave"); +} + +#[test] +fn rhai_expressions_still_take_priority_over_plain_field_name_fallback() { + // Contains spaces too, but it's valid Rhai (string concatenation) - should compile + // and run as a script, never falling through to the plain-field-name path. + let fmt = ExpressionFormat::compile(r#"{n + " " + t}"#).unwrap(); + let bindings = string_bindings([("n", "Breaking Bad"), ("t", "Ozymandias")]); + assert_eq!(fmt.format(&bindings).unwrap(), "Breaking Bad Ozymandias"); +} + +#[test] +fn genuinely_invalid_syntax_with_spaces_still_errors() { + // Has spaces like a plain field name might, but also punctuation that no field name + // would ever contain - should still be reported as a real syntax error, not + // silently treated as a (wrong) literal key. + let err = ExpressionFormat::compile("{show name (}").unwrap_err(); + assert!(err.to_string().contains("syntax error"), "message was: {err}"); +} diff --git a/src/components/RenamePanel.css b/src/components/RenamePanel.css index 65f334b..7bc814c 100644 --- a/src/components/RenamePanel.css +++ b/src/components/RenamePanel.css @@ -262,6 +262,59 @@ line-height: 1.5; } +.format-hint code, +.format-editor label code { + background: rgba(255, 255, 255, 0.06); + border-radius: 4px; + padding: 1px 5px; + font-family: ui-monospace, "SF Mono", monospace; + font-size: 10.5px; + color: var(--text); +} + +.format-hint a { + color: var(--aurora-cyan); +} + +.format-fields { + margin: 10px 0 0; + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.format-fields-group { + flex: 1 1 160px; + min-width: 140px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 5px; + padding: 8px 10px; + background: var(--surface); + border: 1px solid var(--surface-deep); + border-radius: 8px; +} + +.format-fields-group-title { + flex-basis: 100%; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--muted-text); + margin-bottom: 2px; +} + +.format-fields-group code { + background: rgba(255, 255, 255, 0.06); + border-radius: 4px; + padding: 2px 6px; + font-family: ui-monospace, "SF Mono", monospace; + font-size: 10.5px; + color: var(--aurora-green); +} + .modal-overlay { position: fixed; inset: 0; diff --git a/src/components/RenamePanel.tsx b/src/components/RenamePanel.tsx index 6845d80..38e6e4a 100644 --- a/src/components/RenamePanel.tsx +++ b/src/components/RenamePanel.tsx @@ -99,21 +99,45 @@ const SAMPLE_ITEMS: Item[] = [ id: "sample-1", originalName: "Breaking.Bad.S05E14.720p.HDTV.x264-KILLERS.mkv", ext: "mkv", - bindings: { n: "Breaking Bad", s00e00: "S05E14", t: "Ozymandias" }, + bindings: { + n: "Breaking Bad", + s00e00: "S05E14", + t: "Ozymandias", + "show name": "Breaking Bad", + "season number": "5", + "episode number": "14", + "episode name": "Ozymandias", + }, isSample: true, }, { id: "sample-2", originalName: "Breaking.Bad.S05E15.720p.HDTV.x264-KILLERS.mkv", ext: "mkv", - bindings: { n: "Breaking Bad", s00e00: "S05E15", t: "Granite State" }, + bindings: { + n: "Breaking Bad", + s00e00: "S05E15", + t: "Granite State", + "show name": "Breaking Bad", + "season number": "5", + "episode number": "15", + "episode name": "Granite State", + }, isSample: true, }, { id: "sample-3", originalName: "The.Office.US.S03E01.DVDRip.XviD-SAiNTS.avi", ext: "avi", - bindings: { n: "The Office (US)", s00e00: "S03E01", t: "Gay Witch Hunt" }, + bindings: { + n: "The Office (US)", + s00e00: "S03E01", + t: "Gay Witch Hunt", + "show name": "The Office (US)", + "season number": "3", + "episode number": "01", + "episode name": "Gay Witch Hunt", + }, isSample: true, }, { @@ -125,11 +149,20 @@ const SAMPLE_ITEMS: Item[] = [ }, ]; -const SAMPLE_EXPRESSION = "{n} - {s00e00} - {t}"; +// The user's own preferred naming scheme (their words: "I personally like {show name} - +// {season number}x{episode number} - {episode name}") - this is Renamr's default now, +// not just an example. `{show name}`/`{season number}`/etc. are multi-word plain-field +// binding names, which work via renamr-core's ExpressionFormat plain-field-name +// fallback (ExpressionError-free even though they aren't valid Rhai identifiers). +const SAMPLE_EXPRESSION = "{show name} - {season number}x{episode number} - {episode name}"; // The matching engine isn't ported yet, so real loaded files only have their own // filename-derived fields to preview with (mirrors the original's plain FileNameFormatter, // used whenever a file has no online match). const REAL_FILE_EXPRESSION = "{name}"; +// What Match alone can fill in (no episode title yet - that needs Fetch Data). +const MATCHED_EXPRESSION = "{show name} - {season number}x{episode number}"; +// What Match + Fetch Data together can fill in. +const FETCHED_EXPRESSION = "{show name} - {season number}x{episode number} - {episode name}"; function realFileToItem(entry: FileEntry): Item { return { @@ -369,14 +402,20 @@ export default function RenamePanel() { prev.map((item) => { const guess = guesses.get(item.id); if (!guess) return item; + const seriesName = guess.seriesName || item.bindings?.name || item.originalName; return { ...item, bindings: { ...item.bindings, - n: guess.seriesName || item.bindings?.name || item.originalName, + // FileBot-style short names, for compatibility with expressions that use them. + n: seriesName, s00e00: `S${pad2(guess.season)}E${pad2(guess.episode)}`, season: String(guess.season), episode: String(guess.episode), + // Human-readable aliases (this is what the default naming scheme uses). + "show name": seriesName, + "season number": String(guess.season), + "episode number": pad2(guess.episode), }, }; }) @@ -386,7 +425,7 @@ export default function RenamePanel() { // 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}"); + setExpression(MATCHED_EXPRESSION); } setStatus( @@ -597,9 +636,13 @@ export default function RenamePanel() { ...item, bindings: { ...item.bindings, + // FileBot-style short names, for compatibility. n: seriesName, t: episodeTitle, ...(airDate ? { airdate: airDate } : {}), + // Human-readable aliases. + "show name": seriesName, + "episode name": episodeTitle, }, }; }) @@ -607,8 +650,8 @@ export default function RenamePanel() { // Showcase the episode title field, but only the first time and only if the user // hasn't already customized the naming scheme away from the Match-set default. - if (expressionRef.current === "{n} - {s00e00}" && found > 0) { - setExpression("{n} - {s00e00} - {t}"); + if (expressionRef.current === MATCHED_EXPRESSION && found > 0) { + setExpression(FETCHED_EXPRESSION); } const parts = [`Fetched real metadata for ${found} of ${targets.length} file${targets.length === 1 ? "" : "s"}`]; @@ -729,13 +772,43 @@ 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 start with only{" "} - {"{name}"}/{"{ext}"} (their own filename) — click Match to scan - filenames locally for season/episode patterns and populate {"{n}"}/{"{s00e00}"}/ - {"{season}"}/{"{episode}"} (no network calls), then Fetch Data to - resolve those guesses against real TMDB metadata and populate {"{t}"} (episode title) and{" "} - {"{airdate}"} too — needs a free TMDB API token set in TMDB Settings first. + This field is fully yours to edit — type any combination of literal text and{" "} + {"{field}"} placeholders and the preview updates live against {matchedCount} of{" "} + {items.length} file{items.length === 1 ? "" : "s"} (the real Rust naming-expression engine, through + Tauri). Every field below works as a plain {"{field name}"}, spaces and all — no coding + required — plus the whole thing also accepts real{" "} + + Rhai + {" "} + script for anyone who wants it (e.g. {"{show name.to_upper()}"}). +

+
+
+ Always available + {"{name}"} + {"{ext}"} +
+
+ + After Match (local, offline) + + {"{show name}"} + {"{season number}"} + {"{episode number}"} +
+
+ + After Fetch Data (real TMDB metadata) + + {"{episode name}"} + {"{airdate}"} +
+
+

+ Match scans filenames locally for season/episode patterns (no network calls); Fetch Data resolves that + guess against real TMDB metadata for the real episode title and air date — needs a free TMDB API token + set in TMDB Settings first. FileBot's own short field names ({"{n}"},{" "} + {"{s00e00}"}, {"{t}"}, etc.) still work too, side by side with the names above.

)}