Expanded scripting/naming helpers: pad, sort_name/sort_initial, upper/lower_initial, acronym, validate_file_name, space/space_with, colon/colon_with

Ports the next slice of FileBot's ExpressionFormatMethods.java as Rhai-callable
methods usable inside any { } naming-scheme expression, alongside the existing
any/all_of/concat helpers:

- pad/pad_with: left-pad a value's string form (default '0'), non-mutating,
  non-truncating - deliberately a different Rhai method name for the 3-arg
  custom-char form (pad_with) since Rhai's own built-in pad(length, char)
  right-pads and mutates in place, which collides in behavior (not API) with
  what FileBot's zero-padded season/episode numbers need.
- sort_name/sort_initial: move a leading The/A/An article to the end for
  alphabetical sorting, e.g. "The Office" -> "Office, The".
- upper_initial/lower_initial: case-convert only the first character.
- acronym: first letter of each word, uppercased.
- validate_file_name: strip characters illegal in filenames, collapse
  whitespace runs, trim trailing dots.
- space/space_with, colon/colon_with: separator and colon normalization.

16 new unit tests in functions.rs (143 lib tests total, up from 126) plus 3
new integration tests in expression_format.rs demonstrating the helpers used
inside real naming-scheme expressions (14 total, up from 11). Verified via
cargo test --workspace, cargo check --workspace, and npm run build, all
clean. Updated README (status paragraph and porting-roadmap item 2).
This commit is contained in:
Renamr Scaffold
2026-08-30 03:24:19 +00:00
parent 1291ae086d
commit 23c9ca609f
3 changed files with 320 additions and 8 deletions
+18 -4
View File
@@ -64,7 +64,12 @@ file via a native Save dialog — this closes out the full original panel set (R
Episodes/Subtitles/SFV/Filter/List); see "List" below. Real media-file probing (real
resolution, frame rate, duration, and codecs read straight from a video file's own
container header, not guessed from its filename) is wired into the Rename panel's
"Match" step — see "Real media-file probing" below. See "Porting roadmap" below.
"Match" step — see "Real media-file probing" below. The naming-expression engine's Rhai
scripting layer also gained a batch of FileBot-style string helpers — `.pad()`,
`.sort_name()`/`.sort_initial()`, `.upper_initial()`/`.lower_initial()`, `.acronym()`,
`.validate_file_name()`, `.space()`/`.space_with()`, `.colon()`/`.colon_with()` — callable
inside any `{ }` naming-scheme expression, e.g. `{n.sort_name()}/S{s.pad(2)}E{e.pad(2)}`;
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
@@ -593,9 +598,18 @@ ported from, for anyone picking this up:
wiring probed bindings into the List/Filter panels (currently Rename-panel-only).
2. **String/expression helper methods** — `net.filebot.format.ExpressionFormatMethods`
(636 lines): FileBot-specific Groovy string extensions like `.pad()`, `.sortName()`.
Only a small subset (`any`, `all_of`, `concat`) is ported so far
(`crates/aurora-curator-core/src/format/functions.rs`); Rhai's built-ins cover some of the
rest already.
Ported so far (`crates/aurora-curator-core/src/format/functions.rs`): `any`, `all_of`,
`concat`, plus a newer batch of naming/scripting helpers — `pad`/`pad_with`
(left-pad, non-mutating; note this is a deliberately different name collision-avoidance
from Rhai's own built-in `pad(length, char)`, which right-pads and mutates in place —
not a fit for FileBot's zero-padded season/episode numbers), `sort_name`/`sort_initial`
(moves a leading "The"/"A"/"An" article to the end, e.g. `{n.sort_name()}` turns
`"The Office"` into `"Office, The"`), `upper_initial`/`lower_initial`, `acronym`,
`validate_file_name` (strips characters illegal in filenames, collapses whitespace,
trims trailing dots), and `space`/`space_with`/`colon`/`colon_with` (separator/colon
normalization). Rhai's built-ins cover some of the rest already. Still not ported:
FileBot's CSV/file-reading and shell-quoting helpers (`csv`, `read_lines`,
`quote_bash`, `quote_powershell`, etc.).
3. **Matching/similarity engine** — `net.filebot.similarity.*`. Local filename-based
season/episode detection is done (`crates/aurora-curator-core/src/matching`, ported from the
pattern-matching half of `SeasonEpisodeMatcher.java`, adapted to Rust's `regex` crate
@@ -1,10 +1,18 @@
//! Global helper functions available inside every `{ }` expression, e.g. `{any([x, y])}`.
//!
//! Starter subset ported from FileBot's `ExpressionFormatFunctions.java`. Rhai already
//! provides `to_upper`, `to_lower`, `trim`, `sub_string`, `len`, `contains`, `pad` and
//! friends as built-in string methods, so this module only adds the FileBot-specific
//! helpers that don't already have a Rhai equivalent. More helpers (csv/read_lines/
//! quote_bash/quote_powershell, etc.) are tracked in the porting backlog.
//! provides `to_upper`, `to_lower`, `trim`, `sub_string`, `len`, `contains` and friends as
//! built-in string methods, so this module only adds the FileBot-specific helpers that
//! don't already have a Rhai equivalent. More helpers (csv/read_lines/quote_bash/
//! quote_powershell, etc.) are tracked in the porting backlog.
//!
//! **`pad` note**: Rhai *does* register a built-in `pad(length, char)`, but it mutates
//! the string in place and pads on the RIGHT (`"5".pad(3, "0")` -> `"500"`). FileBot's
//! naming-scheme `.pad(n)` zero-pads a short number on the LEFT and returns a new value
//! (`"5".pad(2)` -> `"05"`, for e.g. `{s.pad(2)}` season numbers) - different enough that
//! it's re-implemented here under the same name; Rhai resolves by argument
//! count/type, so this doesn't collide with the built-in as long as call sites use the
//! arities registered below.
use rhai::{Array, Dynamic, Engine};
@@ -44,9 +52,270 @@ fn concat(parts: Array) -> String {
.join("")
}
/// Left-pads `value`'s string form with `'0'` up to `width` characters. A value already
/// at or past `width` is returned unchanged (never truncated).
fn pad(value: Dynamic, width: i64) -> String {
pad_with(value, width, "0".to_string())
}
/// Same as [`pad`], but with a caller-chosen pad character (only its first `char` is
/// used; an empty string falls back to `'0'`).
fn pad_with(value: Dynamic, width: i64, pad_char: String) -> String {
let s = value.to_string();
let pad_char = pad_char.chars().next().unwrap_or('0');
let width = width.max(0) as usize;
let len = s.chars().count();
if len >= width {
return s;
}
let mut result: String = std::iter::repeat(pad_char).take(width - len).collect();
result.push_str(&s);
result
}
/// The three English articles FileBot's `sortName`/`sortInitial` recognize and move to
/// the end for alphabetical sorting purposes.
const LEADING_ARTICLES: [&str; 3] = ["The", "An", "A"];
/// If `s` starts with one of [`LEADING_ARTICLES`] (case-insensitively) followed by a
/// space, returns `(article-as-written-in-s, rest-of-string)`. `"An"` is checked before
/// `"A"` so `"An Officer"` doesn't wrongly match just the leading `"A"`.
fn split_leading_article(s: &str) -> Option<(&str, &str)> {
for article in LEADING_ARTICLES {
if s.len() > article.len() {
let (head, tail) = s.split_at(article.len());
if head.eq_ignore_ascii_case(article) && tail.starts_with(' ') {
return Some((head, &tail[1..]));
}
}
}
None
}
/// Moves a leading "The"/"A"/"An" to the end as `", <Article>"`, e.g. `"The Office"` ->
/// `"Office, The"`. A title with no leading article is returned unchanged.
fn sort_name(value: Dynamic) -> String {
let s = value.to_string();
match split_leading_article(&s) {
Some((article, rest)) => format!("{rest}, {article}"),
None => s,
}
}
/// The single uppercase character `sort_name` would alphabetize this title under, i.e.
/// the first letter of the string with any leading article stripped first.
fn sort_initial(value: Dynamic) -> String {
let s = value.to_string();
let base = split_leading_article(&s).map(|(_, rest)| rest).unwrap_or(&s);
base.chars().next().map(|c| c.to_uppercase().to_string()).unwrap_or_default()
}
/// Uppercases just the first character; the rest of the string is untouched.
fn upper_initial(value: Dynamic) -> String {
let s = value.to_string();
let mut chars = s.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => s,
}
}
/// Lowercases just the first character; the rest of the string is untouched.
fn lower_initial(value: Dynamic) -> String {
let s = value.to_string();
let mut chars = s.chars();
match chars.next() {
Some(first) => first.to_lowercase().collect::<String>() + chars.as_str(),
None => s,
}
}
/// Builds an acronym from the first alphanumeric character of each whitespace-separated
/// word, uppercased, e.g. `"Attack on Titan"` -> `"AOT"`. A simplified reading of
/// FileBot's original (which preserves the case of minor words in some cases) - Aurora
/// Curator always uppercases the whole acronym, which is the more common naming-scheme
/// use case and avoids depending on an English stop-word list.
fn acronym(value: Dynamic) -> String {
value
.to_string()
.split_whitespace()
.filter_map(|word| word.chars().find(|c| c.is_alphanumeric()))
.map(|c| c.to_ascii_uppercase())
.collect()
}
/// True for a character illegal (or reserved) in a filename on at least one of
/// Windows/macOS/Linux: the Windows-reserved set plus the NUL/control range and the
/// path separators. Doesn't reject `/` on its own here - callers that need a full *path*
/// rather than a single filename component should not route through this function.
fn is_illegal_filename_char(c: char) -> bool {
matches!(c, '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|') || (c as u32) < 0x20
}
/// Makes `value`'s string form safe to use as a filename: illegal characters (see
/// [`is_illegal_filename_char`]) become a space, runs of whitespace collapse to one
/// space, and the result is trimmed - including trailing dots, which Windows silently
/// strips from filenames (so leaving them in would make the planned name and the
/// actually-written name disagree).
fn validate_file_name(value: Dynamic) -> String {
let replaced: String =
value.to_string().chars().map(|c| if is_illegal_filename_char(c) { ' ' } else { c }).collect();
replaced.split_whitespace().collect::<Vec<_>>().join(" ").trim_end_matches('.').to_string()
}
/// True for a character FileBot's `.space()` treats as a normalizable word separator.
fn is_separator_char(c: char) -> bool {
c.is_whitespace() || c == '.' || c == '_'
}
/// Normalizes every run of separator characters (whitespace, `.`, `_`) to a single
/// space, then trims. Equivalent to `space_with(value, " ")`.
fn space(value: Dynamic) -> String {
space_with(value, " ".to_string())
}
/// Same as [`space`], but replacing each separator run with `replacement` (only its
/// first `char` is used; an empty string falls back to a space) instead of a literal
/// space - e.g. `{n.space_with(".")}` turns `"Breaking Bad"` into `"Breaking.Bad"`.
fn space_with(value: Dynamic, replacement: String) -> String {
let replacement = replacement.chars().next().unwrap_or(' ');
let s = value.to_string();
let mut result = String::new();
let mut last_was_sep = false;
for c in s.chars() {
if is_separator_char(c) {
if !last_was_sep {
result.push(replacement);
}
last_was_sep = true;
} else {
result.push(c);
last_was_sep = false;
}
}
result.trim_matches(replacement).to_string()
}
/// Replaces every `:` with `" -"` - colons are illegal in Windows filenames but common
/// in TV/movie titles (`"Show: Subtitle"`).
fn colon(value: Dynamic) -> String {
colon_with(value, " -".to_string())
}
/// Same as [`colon`], but replacing `:` with a caller-chosen string instead of `" -"`.
fn colon_with(value: Dynamic, replacement: String) -> String {
value.to_string().replace(':', &replacement)
}
pub fn register(engine: &mut Engine) {
engine.register_fn("is_empty_value", is_empty_value);
engine.register_fn("any", any_of);
engine.register_fn("all_of", all_of);
engine.register_fn("concat", concat);
engine.register_fn("pad", pad);
engine.register_fn("pad_with", pad_with);
engine.register_fn("sort_name", sort_name);
engine.register_fn("sort_initial", sort_initial);
engine.register_fn("upper_initial", upper_initial);
engine.register_fn("lower_initial", lower_initial);
engine.register_fn("acronym", acronym);
engine.register_fn("validate_file_name", validate_file_name);
engine.register_fn("space", space);
engine.register_fn("space_with", space_with);
engine.register_fn("colon", colon);
engine.register_fn("colon_with", colon_with);
}
#[cfg(test)]
mod tests {
use super::*;
fn eval(expr: &str) -> String {
let mut engine = Engine::new();
register(&mut engine);
engine.eval::<Dynamic>(expr).unwrap().to_string()
}
#[test]
fn pad_left_pads_with_zero_by_default() {
assert_eq!(eval(r#""5".pad(2)"#), "05");
assert_eq!(eval(r#""5".pad(3)"#), "005");
}
#[test]
fn pad_does_not_truncate_a_value_already_at_or_past_width() {
assert_eq!(eval(r#""123".pad(2)"#), "123");
assert_eq!(eval(r#""12".pad(2)"#), "12");
}
#[test]
fn pad_accepts_a_custom_pad_character() {
assert_eq!(eval(r#""7".pad_with(3, "-")"#), "--7");
}
#[test]
fn sort_name_moves_a_leading_article_to_the_end() {
assert_eq!(eval(r#""The Office".sort_name()"#), "Office, The");
assert_eq!(eval(r#""A Beautiful Mind".sort_name()"#), "Beautiful Mind, A");
assert_eq!(eval(r#""An Officer and a Gentleman".sort_name()"#), "Officer and a Gentleman, An");
}
#[test]
fn sort_name_leaves_a_title_with_no_leading_article_unchanged() {
assert_eq!(eval(r#""Deadpool".sort_name()"#), "Deadpool");
// "A-Team" is not "A " + rest - no space after the article, so it doesn't match.
assert_eq!(eval(r#""A-Team".sort_name()"#), "A-Team");
}
#[test]
fn sort_initial_strips_the_article_before_taking_the_first_letter() {
assert_eq!(eval(r#""The Office".sort_initial()"#), "O");
assert_eq!(eval(r#""Deadpool".sort_initial()"#), "D");
}
#[test]
fn upper_and_lower_initial_only_touch_the_first_character() {
assert_eq!(eval(r#""breaking Bad".upper_initial()"#), "Breaking Bad");
assert_eq!(eval(r#""Breaking Bad".lower_initial()"#), "breaking Bad");
}
#[test]
fn acronym_takes_the_first_letter_of_each_word_uppercased() {
assert_eq!(eval(r#""Attack on Titan".acronym()"#), "AOT");
assert_eq!(eval(r#""Full Metal Alchemist Brotherhood".acronym()"#), "FMAB");
}
#[test]
fn validate_file_name_replaces_illegal_characters_with_a_space() {
assert_eq!(eval(r#""Who Framed Roger Rabbit?".validate_file_name()"#), "Who Framed Roger Rabbit");
assert_eq!(eval(r#""Aliens: Special Edition".validate_file_name()"#), "Aliens Special Edition");
}
#[test]
fn validate_file_name_collapses_whitespace_and_strips_trailing_dots() {
assert_eq!(eval(r#""Weird: /Path\\Bits*".validate_file_name()"#), "Weird Path Bits");
assert_eq!(eval(r#""Trailing dot.".validate_file_name()"#), "Trailing dot");
}
#[test]
fn space_normalizes_separator_runs_to_a_single_space() {
assert_eq!(eval(r#""Breaking___Bad".space()"#), "Breaking Bad");
assert_eq!(eval(r#""Breaking.Bad.2008".space()"#), "Breaking Bad 2008");
}
#[test]
fn space_with_uses_a_custom_replacement_character() {
assert_eq!(eval(r#""Breaking Bad".space_with(".")"#), "Breaking.Bad");
}
#[test]
fn colon_replaces_with_a_dash_by_default() {
assert_eq!(eval(r#""Aliens: Special Edition".colon()"#), "Aliens - Special Edition");
}
#[test]
fn colon_with_uses_a_custom_replacement() {
assert_eq!(eval(r#""Aliens: Special Edition".colon_with(" -")"#), "Aliens - Special Edition");
assert_eq!(eval(r#""Aliens:Special".colon_with("")"#), "AliensSpecial");
}
}
@@ -97,3 +97,32 @@ fn genuinely_invalid_syntax_with_spaces_still_errors() {
let err = ExpressionFormat::compile("{show name (}").unwrap_err();
assert!(err.to_string().contains("syntax error"), "message was: {err}");
}
#[test]
fn scripting_helpers_pad_and_sort_name_work_in_a_naming_scheme() {
// A realistic naming scheme leaning on the newly-ported scripting/naming helpers:
// zero-padded season/episode numbers plus a sort-friendly show name for a "by title"
// library layout.
let fmt = ExpressionFormat::compile("{n.sort_name()}/S{s.pad(2)}E{e.pad(2)} - {t}").unwrap();
let bindings = string_bindings([
("n", "The Office"),
("s", "5"),
("e", "14"),
("t", "Stress Relief"),
]);
assert_eq!(fmt.format(&bindings).unwrap(), "Office, The/S05E14 - Stress Relief");
}
#[test]
fn scripting_helper_validate_file_name_sanitizes_a_title_with_illegal_characters() {
let fmt = ExpressionFormat::compile("{t.colon().validate_file_name()}").unwrap();
let bindings = string_bindings([("t", "Aliens: Special Edition?")]);
assert_eq!(fmt.format(&bindings).unwrap(), "Aliens - Special Edition");
}
#[test]
fn scripting_helper_acronym_and_space_with_combine_in_an_expression() {
let fmt = ExpressionFormat::compile(r#"{n.acronym()}_{n.space_with("_")}"#).unwrap();
let bindings = string_bindings([("n", "Attack on Titan")]);
assert_eq!(fmt.format(&bindings).unwrap(), "AOT_Attack_on_Titan");
}