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 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 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 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 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 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). wiring probed bindings into the List/Filter panels (currently Rename-panel-only).
2. **String/expression helper methods** — `net.filebot.format.ExpressionFormatMethods` 2. **String/expression helper methods** — `net.filebot.format.ExpressionFormatMethods`
(636 lines): FileBot-specific Groovy string extensions like `.pad()`, `.sortName()`. (636 lines): FileBot-specific Groovy string extensions like `.pad()`, `.sortName()`.
Only a small subset (`any`, `all_of`, `concat`) is ported so far Ported so far (`crates/aurora-curator-core/src/format/functions.rs`): `any`, `all_of`,
(`crates/aurora-curator-core/src/format/functions.rs`); Rhai's built-ins cover some of the `concat`, plus a newer batch of naming/scripting helpers — `pad`/`pad_with`
rest already. (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 3. **Matching/similarity engine** — `net.filebot.similarity.*`. Local filename-based
season/episode detection is done (`crates/aurora-curator-core/src/matching`, ported from the 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 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])}`. //! Global helper functions available inside every `{ }` expression, e.g. `{any([x, y])}`.
//! //!
//! Starter subset ported from FileBot's `ExpressionFormatFunctions.java`. Rhai already //! Starter subset ported from FileBot's `ExpressionFormatFunctions.java`. Rhai already
//! provides `to_upper`, `to_lower`, `trim`, `sub_string`, `len`, `contains`, `pad` and //! provides `to_upper`, `to_lower`, `trim`, `sub_string`, `len`, `contains` and friends as
//! friends as built-in string methods, so this module only adds the FileBot-specific //! built-in string methods, so this module only adds the FileBot-specific helpers that
//! helpers that don't already have a Rhai equivalent. More helpers (csv/read_lines/ //! don't already have a Rhai equivalent. More helpers (csv/read_lines/quote_bash/
//! quote_bash/quote_powershell, etc.) are tracked in the porting backlog. //! 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}; use rhai::{Array, Dynamic, Engine};
@@ -44,9 +52,270 @@ fn concat(parts: Array) -> String {
.join("") .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) { pub fn register(engine: &mut Engine) {
engine.register_fn("is_empty_value", is_empty_value); engine.register_fn("is_empty_value", is_empty_value);
engine.register_fn("any", any_of); engine.register_fn("any", any_of);
engine.register_fn("all_of", all_of); engine.register_fn("all_of", all_of);
engine.register_fn("concat", concat); 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(); let err = ExpressionFormat::compile("{show name (}").unwrap_err();
assert!(err.to_string().contains("syntax error"), "message was: {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");
}