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).
This commit is contained in:
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -109,13 +109,42 @@ fn compile_scriptlet(token: &str, expression: &str) -> Result<Segment, Expressio
|
||||
return Ok(Segment::Variable(token.to_string()));
|
||||
}
|
||||
|
||||
engine::shared()
|
||||
.compile(token)
|
||||
.map(Segment::Script)
|
||||
.map_err(|e| ExpressionError::Syntax {
|
||||
expression: expression.to_string(),
|
||||
message: e.to_string(),
|
||||
})
|
||||
match engine::shared().compile(token) {
|
||||
Ok(ast) => 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
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 && <p className="format-error">{computeError}</p>}
|
||||
<p className="format-hint">
|
||||
Live preview against {matchedCount} of {items.length} file{items.length === 1 ? "" : "s"}. Runs the real
|
||||
Rust naming-expression engine (Rhai-backed) through Tauri. Loaded files start with only{" "}
|
||||
<code>{"{name}"}</code>/<code>{"{ext}"}</code> (their own filename) — click <strong>Match</strong> to scan
|
||||
filenames locally for season/episode patterns and populate <code>{"{n}"}</code>/<code>{"{s00e00}"}</code>/
|
||||
<code>{"{season}"}</code>/<code>{"{episode}"}</code> (no network calls), then <strong>Fetch Data</strong> to
|
||||
resolve those guesses against real TMDB metadata and populate <code>{"{t}"}</code> (episode title) and{" "}
|
||||
<code>{"{airdate}"}</code> too — needs a free TMDB API token set in <strong>TMDB Settings</strong> first.
|
||||
This field is fully yours to edit — type any combination of literal text and{" "}
|
||||
<code>{"{field}"}</code> 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 <code>{"{field name}"}</code>, spaces and all — no coding
|
||||
required — plus the whole thing also accepts real{" "}
|
||||
<a href="https://rhai.rs/" target="_blank" rel="noreferrer">
|
||||
Rhai
|
||||
</a>{" "}
|
||||
script for anyone who wants it (e.g. <code>{"{show name.to_upper()}"}</code>).
|
||||
</p>
|
||||
<div className="format-fields">
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">Always available</span>
|
||||
<code>{"{name}"}</code>
|
||||
<code>{"{ext}"}</code>
|
||||
</div>
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">
|
||||
After <strong>Match</strong> (local, offline)
|
||||
</span>
|
||||
<code>{"{show name}"}</code>
|
||||
<code>{"{season number}"}</code>
|
||||
<code>{"{episode number}"}</code>
|
||||
</div>
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">
|
||||
After <strong>Fetch Data</strong> (real TMDB metadata)
|
||||
</span>
|
||||
<code>{"{episode name}"}</code>
|
||||
<code>{"{airdate}"}</code>
|
||||
</div>
|
||||
</div>
|
||||
<p className="format-hint">
|
||||
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 <strong>TMDB Settings</strong> first. FileBot's own short field names (<code>{"{n}"}</code>,{" "}
|
||||
<code>{"{s00e00}"}</code>, <code>{"{t}"}</code>, etc.) still work too, side by side with the names above.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user