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:
Renamr Scaffold
2026-08-29 03:15:21 +00:00
parent d1fc4039a7
commit f89589bc88
7 changed files with 240 additions and 22 deletions
+88 -15
View File
@@ -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>
)}