diff --git a/README.md b/README.md index 020abdd..9816ad7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/aurora-curator-core/src/format/functions.rs b/crates/aurora-curator-core/src/format/functions.rs index 610633c..6a14fbc 100644 --- a/crates/aurora-curator-core/src/format/functions.rs +++ b/crates/aurora-curator-core/src/format/functions.rs @@ -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 `",
"`, 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::() + 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::() + 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::>().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::(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"); + } } diff --git a/crates/aurora-curator-core/tests/expression_format.rs b/crates/aurora-curator-core/tests/expression_format.rs index c7e5d9b..45c6769 100644 --- a/crates/aurora-curator-core/tests/expression_format.rs +++ b/crates/aurora-curator-core/tests/expression_format.rs @@ -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"); +}