Initial Renamr scaffold: Tauri (Rust core + React/TS UI), naming-expression engine ported+tested
@@ -0,0 +1,7 @@
|
|||||||
|
/target
|
||||||
|
/node_modules
|
||||||
|
/dist
|
||||||
|
/src-tauri/target
|
||||||
|
/src-tauri/gen
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = ["crates/renamr-core", "src-tauri"]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "MIT"
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Renamr
|
||||||
|
|
||||||
|
A fast, cross-platform media renamer — a ground-up rewrite of FileBot (via its FB-Mod
|
||||||
|
fork), built from scratch as its own project: new stack, new architecture, new branding.
|
||||||
|
|
||||||
|
**Status: early scaffold.** The naming-expression engine is ported and tested; the
|
||||||
|
matching engine, media metadata parsing, web datasource clients, rename engine, and the
|
||||||
|
real UI are not yet ported. See "Porting roadmap" below.
|
||||||
|
|
||||||
|
## Why a rewrite, and why this stack
|
||||||
|
|
||||||
|
The original is a ~20k+ line Java/Swing app built with Ant. The goals were: faster
|
||||||
|
startup and UI, faster matching/renaming, a codebase that's actually pleasant to work
|
||||||
|
in, and a genuine rebrand — not a coat of paint.
|
||||||
|
|
||||||
|
Renamr is **Tauri**: a Rust core (`crates/renamr-core`, `src-tauri`) plus a
|
||||||
|
TypeScript/React frontend (`src`), compiled to a small native binary per platform
|
||||||
|
(Windows/macOS/Linux) with a system webview instead of shipping a bundled browser
|
||||||
|
engine. That's the concrete "faster" win over both the original JVM/Swing app and
|
||||||
|
an Electron-style rewrite: quick cold start, small binary, real OS-native windowing.
|
||||||
|
|
||||||
|
An earlier attempt used Kotlin + Compose Multiplatform to stay on the JVM and reuse
|
||||||
|
FileBot's Java logic directly. That was abandoned (nothing was salvaged into this repo)
|
||||||
|
after discovering the sandbox this was originally built in blocks Maven Central and the
|
||||||
|
Gradle Plugin Portal, making it impossible to compile or test. Tauri was chosen as the
|
||||||
|
replacement because both halves — Cargo via crates.io, npm via the public npm registry
|
||||||
|
— are reachable, so every piece described as "done" below is actually compiled/tested,
|
||||||
|
not just written and hoped for.
|
||||||
|
|
||||||
|
### Compatibility notes
|
||||||
|
|
||||||
|
Goal: keep CLI flags, config file locations, and the *outer* `{ }` naming-scheme
|
||||||
|
template syntax compatible with FileBot conventions, so existing automation mostly
|
||||||
|
keeps working.
|
||||||
|
|
||||||
|
One deliberate gap: FileBot's `{ }` expressions ran arbitrary **Groovy** code
|
||||||
|
(JVM-only). Renamr's Rust core has no Groovy engine, so expressions are evaluated with
|
||||||
|
[Rhai](https://rhai.rs/) instead. Simple bindings (`{n}`, `{s00e00}`, `{t}`) are
|
||||||
|
identical. More complex chained-method expressions will need small syntax tweaks
|
||||||
|
(e.g. Groovy's `.toUpperCase()` → Rhai's `.to_upper()`) — Rhai already ships most of the
|
||||||
|
common string/array methods FileBot's expression helpers wrapped, so most patterns
|
||||||
|
translate directly.
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
crates/renamr-core/ Pure Rust core: naming-expression engine (ported), and — not yet
|
||||||
|
ported — matching/similarity scoring, media metadata parsing,
|
||||||
|
web datasource clients (TMDB/TVDB/AniDB/etc), the rename engine.
|
||||||
|
No Tauri dependency, so it builds and unit-tests standalone.
|
||||||
|
src-tauri/ Tauri app shell: exposes renamr-core as commands to the frontend,
|
||||||
|
app metadata/icons/window config (tauri.conf.json).
|
||||||
|
src/ React + TypeScript UI (Vite). Currently a single proof-of-concept
|
||||||
|
screen wired to the live naming-expression preview.
|
||||||
|
scripts/gen_icons.py Regenerates src-tauri/icons/ (placeholder branding — swap the
|
||||||
|
colors/glyph in the script, or replace the icons directly, to
|
||||||
|
change the look).
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
Prerequisites: Rust (stable) + Cargo, Node.js 18+, and Tauri's platform prerequisites
|
||||||
|
(see https://v2.tauri.app/start/prerequisites/ — on Linux this means
|
||||||
|
`libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev` and a C
|
||||||
|
toolchain; macOS/Windows need Xcode / the VS Build Tools + WebView2 respectively).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Rust core (no UI deps needed):
|
||||||
|
cargo test -p renamr-core
|
||||||
|
|
||||||
|
# Full app in dev mode (opens a native window):
|
||||||
|
npm install
|
||||||
|
npm run tauri dev
|
||||||
|
|
||||||
|
# Production build (installer/bundle for your current OS):
|
||||||
|
npm run tauri build
|
||||||
|
```
|
||||||
|
|
||||||
|
Verified in the environment this was built in: `cargo test -p renamr-core` (all
|
||||||
|
passing), `cargo check -p renamr` (compiles clean against real Tauri + GTK/WebKit
|
||||||
|
system libraries), and `npm run build` (TypeScript type-checks, Vite bundles). The
|
||||||
|
actual GUI window has **not** been visually verified anywhere yet — that sandbox has no
|
||||||
|
display. Run `npm run tauri dev` on your machine to see and click through it for the
|
||||||
|
first time.
|
||||||
|
|
||||||
|
## Porting roadmap (not yet done)
|
||||||
|
|
||||||
|
Roughly in priority order — each references the original FileBot source file it's
|
||||||
|
ported from, for anyone picking this up:
|
||||||
|
|
||||||
|
1. **Full naming-scheme bindings** — `net.filebot.format.MediaBindingBean` (1354 lines):
|
||||||
|
all the `{n}`, `{s00e00}`, `{airdate}`, `{vf}` (video format), etc. binding
|
||||||
|
properties. Currently only whatever bindings you pass in manually are available.
|
||||||
|
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/renamr-core/src/format/functions.rs`); Rhai's built-ins cover some of the
|
||||||
|
rest already.
|
||||||
|
3. **Matching/similarity engine** — `net.filebot.similarity.*`, especially
|
||||||
|
`EpisodeMetrics.java` (849 lines, the core fuzzy-matching score cascade) and
|
||||||
|
`SeriesNameMatcher`/`SeasonEpisodeMatcher`/`DateMatcher`.
|
||||||
|
4. **Media detection** — `net.filebot.media.MediaDetection` (1356 lines): ties file-name
|
||||||
|
parsing + matching together to auto-identify what a file is.
|
||||||
|
5. **Web datasource clients** — `net.filebot.web.*` (~40 files): TheMovieDB, TheTVDB,
|
||||||
|
AniDB, OMDb, TVmaze, OpenSubtitles, etc. Use `reqwest` + `serde_json`/`serde`.
|
||||||
|
6. **Rename engine + CLI** — `net.filebot.cli.CmdlineOperations` (1235 lines) and
|
||||||
|
`ArgumentBean` (415 lines, the full flag surface: `-rename`, `--db`, `--format`,
|
||||||
|
`--action`, `--conflict`, `-get-subtitles`, `-script`, etc.) — flags are catalogued
|
||||||
|
in project notes but not yet implemented against real logic.
|
||||||
|
7. **Real UI** — file browser / drag-drop, rename preview table, settings, subtitle
|
||||||
|
panel. Current `src/App.tsx` is a single proof-of-concept screen.
|
||||||
|
8. **Packaging** — code signing, auto-update, installers per platform via
|
||||||
|
`npm run tauri build`.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "renamr-core"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
description = "Renamr core: naming-expression engine, media matching, and rename engine (UI-agnostic)."
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# Embeddable scripting engine powering the {expression} naming-scheme language.
|
||||||
|
# Stands in for the Groovy JSR-223 engine FileBot used (Groovy is JVM-only, not available
|
||||||
|
# to a Rust core) while keeping the same outer `{ }` template syntax.
|
||||||
|
rhai = { version = "1.19", features = ["sync"] }
|
||||||
|
|
||||||
|
thiserror = "1.0"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
use rhai::Engine;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use super::functions;
|
||||||
|
|
||||||
|
/// Shared Rhai engine, built once. Engines are relatively expensive to construct
|
||||||
|
/// (loading the standard package), so every [`crate::format::ExpressionFormat`]
|
||||||
|
/// compiles against this single shared instance.
|
||||||
|
pub(super) fn shared() -> &'static Engine {
|
||||||
|
static ENGINE: OnceLock<Engine> = OnceLock::new();
|
||||||
|
ENGINE.get_or_init(|| {
|
||||||
|
let mut engine = Engine::new();
|
||||||
|
functions::register(&mut engine);
|
||||||
|
engine
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
use rhai::{Dynamic, Scope, AST};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use super::engine;
|
||||||
|
|
||||||
|
/// The variables available inside a `{ }` expression, e.g. `n` -> show name.
|
||||||
|
pub type Bindings = HashMap<String, Dynamic>;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ExpressionError {
|
||||||
|
#[error("syntax error in expression `{expression}`: {message}")]
|
||||||
|
Syntax { expression: String, message: String },
|
||||||
|
#[error("expression yields empty value: {0}")]
|
||||||
|
Empty(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Segment {
|
||||||
|
Literal(String),
|
||||||
|
/// Fast path for bare identifiers like `{n}` or `{s00e00}`: skips the script engine
|
||||||
|
/// entirely, both for speed and so an undefined binding fails predictably.
|
||||||
|
Variable(String),
|
||||||
|
Script(AST),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A compiled naming-scheme expression, e.g. `"{n} - {s00e00} - {t}"`.
|
||||||
|
///
|
||||||
|
/// Compilation is separate from formatting (like `java.text.Format`/FileBot's
|
||||||
|
/// `ExpressionFormat`) so a pattern is parsed once and then applied to many files.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ExpressionFormat {
|
||||||
|
expression: String,
|
||||||
|
segments: Vec<Segment>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for Segment {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Segment::Literal(s) => write!(f, "Literal({s:?})"),
|
||||||
|
Segment::Variable(s) => write!(f, "Variable({s:?})"),
|
||||||
|
Segment::Script(_) => write!(f, "Script(..)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExpressionFormat {
|
||||||
|
pub fn compile(expression: &str) -> Result<Self, ExpressionError> {
|
||||||
|
let segments = parse(expression)?;
|
||||||
|
Ok(Self {
|
||||||
|
expression: expression.to_string(),
|
||||||
|
segments,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expression(&self) -> &str {
|
||||||
|
&self.expression
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders this pattern against the given bindings. Mirrors FileBot's semantics:
|
||||||
|
/// a scriptlet that fails to evaluate (e.g. references an undefined/empty binding)
|
||||||
|
/// simply contributes nothing, but the *overall* result must be non-empty.
|
||||||
|
pub fn format(&self, bindings: &Bindings) -> Result<String, ExpressionError> {
|
||||||
|
let engine = engine::shared();
|
||||||
|
let mut out = String::new();
|
||||||
|
|
||||||
|
for segment in &self.segments {
|
||||||
|
match segment {
|
||||||
|
Segment::Literal(text) => out.push_str(text),
|
||||||
|
Segment::Variable(name) => {
|
||||||
|
if let Some(value) = bindings.get(name) {
|
||||||
|
if !value.is_unit() {
|
||||||
|
out.push_str(&value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Segment::Script(ast) => {
|
||||||
|
let mut scope = Scope::new();
|
||||||
|
for (key, value) in bindings {
|
||||||
|
scope.push_dynamic(key.clone(), value.clone());
|
||||||
|
}
|
||||||
|
if let Ok(value) = engine.eval_ast_with_scope::<Dynamic>(&mut scope, ast) {
|
||||||
|
if !value.is_unit() {
|
||||||
|
out.push_str(&value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.is_empty() {
|
||||||
|
return Err(ExpressionError::Empty(self.expression.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_identifier(token: &str) -> bool {
|
||||||
|
let mut chars = token.chars();
|
||||||
|
match chars.next() {
|
||||||
|
Some(c) if c.is_alphabetic() || c == '_' => {}
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
chars.all(|c| c.is_alphanumeric() || c == '_')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compile_scriptlet(token: &str, expression: &str) -> Result<Segment, ExpressionError> {
|
||||||
|
if is_identifier(token) {
|
||||||
|
return Ok(Segment::Variable(token.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
engine::shared()
|
||||||
|
.compile(token)
|
||||||
|
.map(Segment::Script)
|
||||||
|
.map_err(|e| ExpressionError::Syntax {
|
||||||
|
expression: expression.to_string(),
|
||||||
|
message: e.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splits an expression into literal text and `{ }` scriptlets, honoring nested braces
|
||||||
|
/// (so a scriptlet may itself contain `{ }`, e.g. a map literal or block).
|
||||||
|
fn parse(expression: &str) -> Result<Vec<Segment>, ExpressionError> {
|
||||||
|
let mut segments = Vec::new();
|
||||||
|
let mut token = String::new();
|
||||||
|
let mut level = 0i32;
|
||||||
|
|
||||||
|
for c in expression.chars() {
|
||||||
|
match c {
|
||||||
|
'{' => {
|
||||||
|
if level == 0 {
|
||||||
|
if !token.is_empty() {
|
||||||
|
segments.push(Segment::Literal(std::mem::take(&mut token)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
token.push(c);
|
||||||
|
}
|
||||||
|
level += 1;
|
||||||
|
}
|
||||||
|
'}' => {
|
||||||
|
level -= 1;
|
||||||
|
if level < 0 {
|
||||||
|
return Err(ExpressionError::Syntax {
|
||||||
|
expression: expression.to_string(),
|
||||||
|
message: "unexpected '}'".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if level == 0 {
|
||||||
|
if !token.is_empty() {
|
||||||
|
segments.push(compile_scriptlet(&std::mem::take(&mut token), expression)?);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
token.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => token.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if level != 0 {
|
||||||
|
return Err(ExpressionError::Syntax {
|
||||||
|
expression: expression.to_string(),
|
||||||
|
message: "missing '}'".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !token.is_empty() {
|
||||||
|
segments.push(Segment::Literal(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(segments)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
//! 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.
|
||||||
|
|
||||||
|
use rhai::{Array, Dynamic, Engine};
|
||||||
|
|
||||||
|
fn is_empty_value(value: &Dynamic) -> bool {
|
||||||
|
if value.is_unit() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if let Some(s) = value.clone().try_cast::<rhai::ImmutableString>() {
|
||||||
|
return s.is_empty();
|
||||||
|
}
|
||||||
|
if let Some(arr) = value.clone().try_cast::<Array>() {
|
||||||
|
return arr.is_empty();
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the first array element that isn't empty/unit, or `()` if all are empty.
|
||||||
|
fn any_of(candidates: Array) -> Dynamic {
|
||||||
|
candidates
|
||||||
|
.into_iter()
|
||||||
|
.find(|v| !is_empty_value(v))
|
||||||
|
.unwrap_or(Dynamic::UNIT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all non-empty elements, preserving order.
|
||||||
|
fn all_of(candidates: Array) -> Array {
|
||||||
|
candidates.into_iter().filter(|v| !is_empty_value(v)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Concatenates all non-empty elements' string forms with no separator.
|
||||||
|
fn concat(parts: Array) -> String {
|
||||||
|
parts
|
||||||
|
.into_iter()
|
||||||
|
.filter(|v| !is_empty_value(v))
|
||||||
|
.map(|v| v.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
//! Renamr's naming-scheme expression language.
|
||||||
|
//!
|
||||||
|
//! A format string is literal text with `{ ... }` scriptlets spliced in, e.g.
|
||||||
|
//! `"{n} - {s00e00} - {t}"`
|
||||||
|
//! Everything inside `{ }` is evaluated as an expression against a set of "bindings"
|
||||||
|
//! (the variables available to the expression, e.g. `n` = show name).
|
||||||
|
//!
|
||||||
|
//! This keeps FileBot's outer `{ }` template syntax and simple-binding behavior
|
||||||
|
//! (`{n}`, `{s00e00}`, ...), so existing simple rename patterns keep working unchanged.
|
||||||
|
//! The inner expression *language* is Rhai rather than Groovy (Groovy is JVM-only and
|
||||||
|
//! this core is plain Rust) -- see the crate README for the compatibility notes on more
|
||||||
|
//! complex chained-method expressions.
|
||||||
|
|
||||||
|
mod engine;
|
||||||
|
mod expression;
|
||||||
|
mod functions;
|
||||||
|
|
||||||
|
pub use expression::{ExpressionError, ExpressionFormat};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
//! Renamr core: the UI-agnostic engine behind the Renamr media renamer.
|
||||||
|
//!
|
||||||
|
//! This crate has no Tauri/UI dependency so it can be built and unit-tested standalone.
|
||||||
|
//! It's the piece being ported first from the original FileBot (Java) codebase; see
|
||||||
|
//! the workspace README for the porting roadmap (matching engine, media metadata
|
||||||
|
//! parsing, web datasource clients, rename engine are not yet ported).
|
||||||
|
|
||||||
|
pub mod format;
|
||||||
|
|
||||||
|
use rhai::Dynamic;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Convenience constructor for [`format::ExpressionFormat`] bindings from plain strings,
|
||||||
|
/// which is the common case (binding values are almost always show/movie/episode text).
|
||||||
|
pub fn string_bindings<I, K, V>(pairs: I) -> HashMap<String, Dynamic>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = (K, V)>,
|
||||||
|
K: Into<String>,
|
||||||
|
V: Into<String>,
|
||||||
|
{
|
||||||
|
pairs
|
||||||
|
.into_iter()
|
||||||
|
.map(|(k, v)| (k.into(), Dynamic::from(v.into())))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
use renamr_core::format::ExpressionFormat;
|
||||||
|
use renamr_core::string_bindings;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn simple_variable_substitution() {
|
||||||
|
let fmt = ExpressionFormat::compile("{n} - {s00e00} - {t}").unwrap();
|
||||||
|
let bindings = string_bindings([
|
||||||
|
("n", "Breaking Bad"),
|
||||||
|
("s00e00", "S05E14"),
|
||||||
|
("t", "Ozymandias"),
|
||||||
|
]);
|
||||||
|
assert_eq!(fmt.format(&bindings).unwrap(), "Breaking Bad - S05E14 - Ozymandias");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scripted_expression_evaluation() {
|
||||||
|
let fmt = ExpressionFormat::compile("{n.to_upper()}").unwrap();
|
||||||
|
let bindings = string_bindings([("n", "Breaking Bad")]);
|
||||||
|
assert_eq!(fmt.format(&bindings).unwrap(), "BREAKING BAD");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn literal_text_passes_through() {
|
||||||
|
let fmt = ExpressionFormat::compile("plain text, no expressions").unwrap();
|
||||||
|
assert_eq!(fmt.format(&Default::default()).unwrap(), "plain text, no expressions");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn global_helper_function_any() {
|
||||||
|
let fmt = ExpressionFormat::compile("{any([x, y])}").unwrap();
|
||||||
|
let bindings = string_bindings([("y", "fallback")]);
|
||||||
|
// `x` isn't bound at all; referencing it inside the array literal would normally
|
||||||
|
// error, so real usage is any([x_or_default, y]) - here we just prove `any` picks
|
||||||
|
// the first non-empty value among ones that DO evaluate.
|
||||||
|
let bindings2 = string_bindings([("x", ""), ("y", "fallback")]);
|
||||||
|
let _ = bindings; // silence unused warning for the simplified case above
|
||||||
|
assert_eq!(fmt.format(&bindings2).unwrap(), "fallback");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn undefined_binding_alone_yields_error() {
|
||||||
|
let fmt = ExpressionFormat::compile("{missing}").unwrap();
|
||||||
|
let result = fmt.format(&Default::default());
|
||||||
|
assert!(result.is_err(), "expected empty-result error, got {:?}", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn undefined_binding_is_skipped_when_other_content_exists() {
|
||||||
|
let fmt = ExpressionFormat::compile("{n}{missing}").unwrap();
|
||||||
|
let bindings = string_bindings([("n", "Show")]);
|
||||||
|
assert_eq!(fmt.format(&bindings).unwrap(), "Show");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_braces_inside_a_scriptlet_are_preserved() {
|
||||||
|
// Rhai object map literal: #{ key: value }
|
||||||
|
let fmt = ExpressionFormat::compile("{ (#{a: 1, b: 2}).a }").unwrap();
|
||||||
|
assert_eq!(fmt.format(&Default::default()).unwrap(), "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn syntax_error_is_reported_with_the_offending_expression() {
|
||||||
|
let err = ExpressionFormat::compile("{this is not valid rhai +++ }").unwrap_err();
|
||||||
|
let message = err.to_string();
|
||||||
|
assert!(message.contains("syntax error"), "message was: {message}");
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Renamr</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "renamr",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"tauri": "tauri"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"@tauri-apps/api": "^2.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.1.0",
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^5.4.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generates Renamr's app icon set (placeholder branding, easy to swap later).
|
||||||
|
|
||||||
|
Produces the sizes Tauri's bundler expects in src-tauri/icons/.
|
||||||
|
"""
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
import os
|
||||||
|
|
||||||
|
OUT = os.path.join(os.path.dirname(__file__), "..", "src-tauri", "icons")
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
|
||||||
|
SIZE = 1024
|
||||||
|
BG_TOP = (91, 76, 245) # indigo
|
||||||
|
BG_BOTTOM = (56, 189, 178) # teal
|
||||||
|
FG = (255, 255, 255)
|
||||||
|
|
||||||
|
def make_master():
|
||||||
|
img = Image.new("RGB", (SIZE, SIZE), BG_TOP)
|
||||||
|
px = img.load()
|
||||||
|
for y in range(SIZE):
|
||||||
|
t = y / SIZE
|
||||||
|
r = int(BG_TOP[0] * (1 - t) + BG_BOTTOM[0] * t)
|
||||||
|
g = int(BG_TOP[1] * (1 - t) + BG_BOTTOM[1] * t)
|
||||||
|
b = int(BG_TOP[2] * (1 - t) + BG_BOTTOM[2] * t)
|
||||||
|
for x in range(SIZE):
|
||||||
|
px[x, y] = (r, g, b)
|
||||||
|
|
||||||
|
# rounded-square mask
|
||||||
|
mask = Image.new("L", (SIZE, SIZE), 0)
|
||||||
|
ImageDraw.Draw(mask).rounded_rectangle([0, 0, SIZE - 1, SIZE - 1], radius=SIZE // 5, fill=255)
|
||||||
|
canvas = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
|
||||||
|
canvas.paste(img, (0, 0), mask)
|
||||||
|
|
||||||
|
draw = ImageDraw.Draw(canvas)
|
||||||
|
font = ImageFont.truetype("/usr/share/fonts/truetype/google-fonts/Poppins-Bold.ttf", int(SIZE * 0.56))
|
||||||
|
text = "R"
|
||||||
|
bbox = draw.textbbox((0, 0), text, font=font)
|
||||||
|
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||||
|
draw.text(((SIZE - tw) / 2 - bbox[0], (SIZE - th) / 2 - bbox[1] - SIZE * 0.02), text, font=font, fill=FG)
|
||||||
|
|
||||||
|
return canvas
|
||||||
|
|
||||||
|
master = make_master()
|
||||||
|
master.save(os.path.join(OUT, "icon-master.png"))
|
||||||
|
|
||||||
|
sizes = [32, 128, 256, 512, 1024]
|
||||||
|
for s in sizes:
|
||||||
|
master.resize((s, s), Image.LANCZOS).save(os.path.join(OUT, f"{s}x{s}.png"))
|
||||||
|
|
||||||
|
# Tauri's expected filenames
|
||||||
|
master.resize((32, 32), Image.LANCZOS).save(os.path.join(OUT, "32x32.png"))
|
||||||
|
master.resize((128, 128), Image.LANCZOS).save(os.path.join(OUT, "128x128.png"))
|
||||||
|
master.resize((256, 256), Image.LANCZOS).save(os.path.join(OUT, "[email protected]"))
|
||||||
|
|
||||||
|
# Windows .ico (multi-resolution)
|
||||||
|
ico_sizes = [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)]
|
||||||
|
master.save(os.path.join(OUT, "icon.ico"), sizes=ico_sizes)
|
||||||
|
|
||||||
|
# macOS .icns
|
||||||
|
try:
|
||||||
|
master.save(os.path.join(OUT, "icon.icns"), format="ICNS")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"WARN: could not write icon.icns via Pillow ({e}); leaving it out")
|
||||||
|
|
||||||
|
print("done:", os.listdir(OUT))
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "renamr"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "renamr_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
renamr-core = { path = "../crates/renamr-core" }
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
use renamr_core::format::ExpressionFormat;
|
||||||
|
use renamr_core::string_bindings;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Renders a naming-scheme expression against a set of binding values.
|
||||||
|
/// This is the Tauri-facing entry point into the core format engine, used by the
|
||||||
|
/// rename-preview panel in the UI so users see live results as they edit a pattern.
|
||||||
|
#[tauri::command]
|
||||||
|
fn render_expression(expression: String, bindings: HashMap<String, String>) -> Result<String, String> {
|
||||||
|
let compiled = ExpressionFormat::compile(&expression).map_err(|e| e.to_string())?;
|
||||||
|
compiled
|
||||||
|
.format(&string_bindings(bindings))
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.invoke_handler(tauri::generate_handler![render_expression])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running Renamr");
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Prevents an additional console window on Windows in release builds.
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
renamr_lib::run();
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "Renamr",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "com.renamr.app",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "npm run dev",
|
||||||
|
"beforeBuildCommand": "npm run build",
|
||||||
|
"devUrl": "http://localhost:5173",
|
||||||
|
"frontendDist": "../dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "Renamr",
|
||||||
|
"width": 1100,
|
||||||
|
"height": 720,
|
||||||
|
"minWidth": 720,
|
||||||
|
"minHeight": 480
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/[email protected]",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
const APP_NAME = "Renamr";
|
||||||
|
const APP_VERSION = "0.1.0";
|
||||||
|
|
||||||
|
const DEFAULT_EXPRESSION = "{n} - {s00e00} - {t}";
|
||||||
|
const DEFAULT_BINDINGS: Record<string, string> = {
|
||||||
|
n: "Example Show",
|
||||||
|
s00e00: "S01E01",
|
||||||
|
t: "Pilot",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [expression, setExpression] = useState(DEFAULT_EXPRESSION);
|
||||||
|
const [preview, setPreview] = useState<string>("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
invoke<string>("render_expression", { expression, bindings: DEFAULT_BINDINGS })
|
||||||
|
.then((result) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setPreview(result);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setPreview("");
|
||||||
|
setError(String(err));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [expression]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="shell">
|
||||||
|
<header>
|
||||||
|
<h1>{APP_NAME}</h1>
|
||||||
|
<p className="tagline">A fast, cross-platform media renamer.</p>
|
||||||
|
<p className="version">v{APP_VERSION}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<label htmlFor="expression">Naming scheme</label>
|
||||||
|
<input
|
||||||
|
id="expression"
|
||||||
|
value={expression}
|
||||||
|
onChange={(e) => setExpression(e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="preview">
|
||||||
|
<span className="preview-label">Preview</span>
|
||||||
|
{error ? <span className="preview-error">{error}</span> : <span className="preview-value">{preview}</span>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
font-family: "Poppins", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #0f1222;
|
||||||
|
color: #f4f5fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 48px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 2.25rem;
|
||||||
|
margin: 0;
|
||||||
|
background: linear-gradient(135deg, #5b4cf5, #38bdb2);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
margin-top: 32px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
color: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-value {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-error {
|
||||||
|
color: #ff8080;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2021",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
// Tauri expects a fixed dev server port and relative asset paths.
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
clearScreen: false,
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
envPrefix: ["VITE_", "TAURI_"],
|
||||||
|
build: {
|
||||||
|
target: "esnext",
|
||||||
|
outDir: "dist",
|
||||||
|
},
|
||||||
|
});
|
||||||