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:
@@ -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");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user