Naming scheme editor: clickable field tokens + persist across restarts
Two usability fixes to the naming-scheme editor, both reported/requested
directly: the field-reference list ({show name}, {episode number}, etc.)
was plain unclickable text, so building a scheme meant typing every field
name out by hand; and the editor's text always reset to the built-in
sample expression on every launch, since nothing ever wrote it to disk -
only the explicit 1-9 preset slots (presets.rs) were persisted.
Field list -> clickable buttons: each field reference is now a real
<button> (.format-field-chip) wired to insertField(), which reads the
input's real DOM selectionStart/selectionEnd and inserts the token at
the caret, then restores focus and moves the caret to just after the
inserted text so clicking several fields in a row builds up
left-to-right. Purely a frontend change - no new Tauri commands needed.
Persistence: added last_naming_scheme to settings.rs's StoredSettings
(same settings.json already used for tv/movie source, credentials,
etc.), plus get_last_naming_scheme/save_last_naming_scheme commands.
RenamePanel.tsx loads it once on mount (restoring the saved scheme
before the user can interact with anything) and saves it back debounced
(600ms after typing settles) as the user edits - gated behind a
schemeLoaded flag so the save effect can never fire before the load
completes and clobber a saved scheme with the not-yet-replaced sample
default. An empty/blank saved value (fresh install) leaves the built-in
sample expression in place, same fallback behavior as every other
optional setting in this file.
Verified via cargo test --workspace (191 -> 193, two new settings.rs
tests: schema-evolution for the new field, and the blank-clears-to-None
behavior) + npm run build (clean) + a 7-check Playwright script against
npm run dev (mocking window.__TAURI_INTERNALS__.invoke): confirmed the
editor restores a mocked saved scheme on launch, clicking a field chip
inserts its token at a caret placed via setSelectionRange (not just
appended), a second click lands immediately after the first rather than
at the old position, and save_last_naming_scheme fires once (debounced)
with the exact edited text rather than on every keystroke.
Updated README's naming-scheme paragraph to describe both.
This commit is contained in:
@@ -80,7 +80,12 @@ Plain, human-readable, multi-word field names like `{show name}` work directly (
|
||||
Rhai knowledge needed), FileBot's own short field names (`{n}`, `{s00e00}`, `{t}`)
|
||||
still work side by side with them, and real Rhai script is available for anyone who
|
||||
wants it (e.g. `{show name.to_upper()}`). See "Naming scheme syntax" below for how
|
||||
that's implemented.
|
||||
that's implemented. Every field name shown in the editor is also a clickable button —
|
||||
click one to insert it at the cursor instead of typing it out by hand. Whatever's
|
||||
currently in the editor is saved to disk as you type (debounced) and restored the next
|
||||
time the app opens, so a custom scheme survives a restart instead of resetting to the
|
||||
sample expression above; the 1-9 preset slots (a separate, explicitly-named save) are
|
||||
still there alongside this for switching between several schemes on purpose.
|
||||
|
||||
## Why a rewrite, and why this stack
|
||||
|
||||
|
||||
@@ -119,6 +119,8 @@ pub fn run() {
|
||||
settings::get_opensubtitles_username,
|
||||
settings::has_opensubtitles_password,
|
||||
settings::save_opensubtitles_login,
|
||||
settings::get_last_naming_scheme,
|
||||
settings::save_last_naming_scheme,
|
||||
subtitles::search_subtitles,
|
||||
subtitles::download_subtitle,
|
||||
episodes::browse_episodes,
|
||||
|
||||
@@ -83,6 +83,13 @@ struct StoredSettings {
|
||||
/// [`has_opensubtitles_password`], which only reports whether one is set.
|
||||
#[serde(default)]
|
||||
opensubtitles_password: Option<String>,
|
||||
/// The naming-scheme expression the Rename panel's editor last held - saved on
|
||||
/// every edit (debounced on the frontend) and restored on startup, so a custom
|
||||
/// scheme survives closing and reopening the app instead of resetting to the
|
||||
/// built-in sample expression every time. Distinct from `presets.rs`'s 1-9 saved
|
||||
/// slots: this is the *current* scheme, not an explicitly named save.
|
||||
#[serde(default)]
|
||||
last_naming_scheme: Option<String>,
|
||||
}
|
||||
|
||||
fn settings_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
@@ -294,6 +301,32 @@ pub fn save_opensubtitles_login(app: AppHandle, username: String, password: Stri
|
||||
save(&app, &settings)
|
||||
}
|
||||
|
||||
/// Returns the last-saved naming-scheme expression, or `None` if nothing (or only
|
||||
/// blank text) has been saved yet - the frontend falls back to its own built-in
|
||||
/// sample expression in that case.
|
||||
pub fn last_naming_scheme(app: &AppHandle) -> Option<String> {
|
||||
load(app).last_naming_scheme.filter(|t| !t.trim().is_empty())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_last_naming_scheme(app: AppHandle) -> Option<String> {
|
||||
last_naming_scheme(&app)
|
||||
}
|
||||
|
||||
/// Saves the naming-scheme editor's current text so it survives an app restart. The
|
||||
/// frontend debounces calls to this while the user is actively typing (see
|
||||
/// `RenamePanel.tsx`) rather than saving on every keystroke. Unlike
|
||||
/// `presets::save_preset`, an empty expression is accepted here (it just clears the
|
||||
/// saved value, same as every other credential field in this module) rather than
|
||||
/// rejected - the editor's text field can legitimately be emptied while editing.
|
||||
#[tauri::command]
|
||||
pub fn save_last_naming_scheme(app: AppHandle, expression: String) -> Result<(), String> {
|
||||
let mut settings = load(&app);
|
||||
let trimmed = expression.trim().to_string();
|
||||
settings.last_naming_scheme = if trimmed.is_empty() { None } else { Some(expression) };
|
||||
save(&app, &settings)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -372,6 +405,37 @@ mod tests {
|
||||
assert_eq!(parsed.opensubtitles_password, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_settings_without_a_last_naming_scheme_field_still_deserializes() {
|
||||
// Same schema-evolution guarantee as the OpenSubtitles test above, for the
|
||||
// field this naming-scheme-persistence fix added.
|
||||
let old_json = r#"{"tmdb_token":"abc123"}"#;
|
||||
let parsed: StoredSettings = serde_json::from_str(old_json).unwrap();
|
||||
assert_eq!(parsed.last_naming_scheme, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_naming_scheme_round_trips_and_ignores_pure_whitespace() {
|
||||
// Pure merge logic (load/save both need a real AppHandle, exercised end to end
|
||||
// by the Playwright pass instead - same reasoning as every other credential
|
||||
// test in this file): a saved non-blank expression comes back unchanged,
|
||||
// preserving internal spacing exactly (naming schemes can have meaningful
|
||||
// literal spaces, e.g. " - " between fields), and a whitespace-only save reads
|
||||
// back as unset, matching the empty-string behavior of every other field here.
|
||||
let mut settings = StoredSettings::default();
|
||||
settings.last_naming_scheme = Some("{show name} - {episode name}".to_string());
|
||||
assert_eq!(
|
||||
settings.last_naming_scheme.as_deref().filter(|t| !t.trim().is_empty()),
|
||||
Some("{show name} - {episode name}")
|
||||
);
|
||||
|
||||
settings.last_naming_scheme = Some(" ".to_string());
|
||||
assert_eq!(
|
||||
settings.last_naming_scheme.as_deref().filter(|t| !t.trim().is_empty()),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_login_update_keeps_the_other_field_when_one_is_blank() {
|
||||
// Updating just the password shouldn't silently clear a previously-saved
|
||||
|
||||
@@ -306,13 +306,26 @@
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.format-fields-group code {
|
||||
.format-field-chip {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
font-family: ui-monospace, "SF Mono", monospace;
|
||||
font-size: 10.5px;
|
||||
color: var(--aurora-green);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, color 0.12s ease, transform 0.06s ease;
|
||||
}
|
||||
|
||||
.format-field-chip:hover {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.format-field-chip:active {
|
||||
background: var(--aurora-green);
|
||||
color: var(--surface);
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
|
||||
@@ -452,6 +452,10 @@ function LoadMenuButton({ onFiles, onFolder }: { onFiles: () => void; onFolder:
|
||||
|
||||
export default function RenamePanel() {
|
||||
const [expression, setExpression] = useState(SAMPLE_EXPRESSION);
|
||||
// Whether the one-time "restore the last-saved naming scheme" load (see the effect
|
||||
// below) has finished - gates the debounced save effect so it can never fire before
|
||||
// the load does and stomp a saved scheme with the SAMPLE_EXPRESSION default.
|
||||
const [schemeLoaded, setSchemeLoaded] = useState(false);
|
||||
const [editingFormat, setEditingFormat] = useState(false);
|
||||
const [items, setItems] = useState<Item[]>(SAMPLE_ITEMS);
|
||||
const [selected, setSelected] = useState<string | null>(SAMPLE_ITEMS[0].id);
|
||||
@@ -543,6 +547,34 @@ export default function RenamePanel() {
|
||||
expressionRef.current = expression;
|
||||
}, [expression]);
|
||||
|
||||
// Restores the naming-scheme editor's text from the last time the app was open (see
|
||||
// `src-tauri/src/settings.rs`'s `last_naming_scheme`) - without this, the editor
|
||||
// always reset to SAMPLE_EXPRESSION on every launch, silently discarding whatever
|
||||
// custom scheme the user had built. A missing/blank saved value (first run, or
|
||||
// nothing ever customized) leaves the built-in sample expression in place.
|
||||
// `schemeLoaded` only flips to true once this settles, so the save effect below can
|
||||
// never fire first and overwrite a saved scheme with the not-yet-replaced default.
|
||||
useEffect(() => {
|
||||
invoke<string | null>("get_last_naming_scheme")
|
||||
.then((saved) => {
|
||||
if (saved && saved.trim()) setExpression(saved);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setSchemeLoaded(true));
|
||||
}, []);
|
||||
|
||||
// Persists the naming-scheme editor's text as the user edits it, debounced so a
|
||||
// fast typist doesn't hit disk on every keystroke. Deliberately separate from the
|
||||
// explicit 1-9 preset slots (`presets.rs`) - this tracks whatever's live in the
|
||||
// editor right now, not a named save.
|
||||
useEffect(() => {
|
||||
if (!schemeLoaded) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
invoke("save_last_naming_scheme", { expression }).catch(() => {});
|
||||
}, 600);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [schemeLoaded, expression]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -1405,6 +1437,47 @@ export default function RenamePanel() {
|
||||
);
|
||||
};
|
||||
|
||||
// So clicking a field reference below (e.g. "{show name}") can insert it into the
|
||||
// naming-scheme input at the caret instead of the user typing the exact text out by
|
||||
// hand every time.
|
||||
const expressionInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const insertField = (token: string) => {
|
||||
const el = expressionInputRef.current;
|
||||
if (!el) {
|
||||
// No live input to read a caret position from (shouldn't normally happen, since
|
||||
// this is only reachable while the editor is open) - fall back to appending.
|
||||
setExpression((prev) => prev + token);
|
||||
return;
|
||||
}
|
||||
const start = el.selectionStart ?? el.value.length;
|
||||
const end = el.selectionEnd ?? el.value.length;
|
||||
const next = el.value.slice(0, start) + token + el.value.slice(end);
|
||||
setExpression(next);
|
||||
// Restore focus and put the caret right after what was just inserted, so clicking
|
||||
// several fields in a row inserts them left-to-right rather than all piling up at
|
||||
// the same spot. Deferred a tick so this runs after React re-renders the input
|
||||
// with the new value.
|
||||
const caret = start + token.length;
|
||||
requestAnimationFrame(() => {
|
||||
el.focus();
|
||||
el.setSelectionRange(caret, caret);
|
||||
});
|
||||
};
|
||||
|
||||
// One clickable field-reference button, styled like the plain `<code>` tags this
|
||||
// replaced (see RenamePanel.css's `.format-field-chip`) but wired to `insertField`.
|
||||
const FieldChip = ({ token }: { token: string }) => (
|
||||
<button
|
||||
type="button"
|
||||
className="format-field-chip"
|
||||
title={`Insert ${token} at the cursor`}
|
||||
onClick={() => insertField(token)}
|
||||
>
|
||||
{token}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rename-panel">
|
||||
<div className="rename-lists">
|
||||
@@ -1460,6 +1533,7 @@ export default function RenamePanel() {
|
||||
<label htmlFor="expression">Naming scheme</label>
|
||||
<input
|
||||
id="expression"
|
||||
ref={expressionInputRef}
|
||||
value={expression}
|
||||
spellCheck={false}
|
||||
onChange={(e) => setExpression(e.target.value)}
|
||||
@@ -1474,56 +1548,57 @@ export default function RenamePanel() {
|
||||
<a href="https://rhai.rs/" target="_blank" rel="noreferrer">
|
||||
Rhai
|
||||
</a>{" "}
|
||||
script for anyone who wants it (e.g. <code>{"{show name.to_upper()}"}</code>).
|
||||
script for anyone who wants it (e.g. <code>{"{show name.to_upper()}"}</code>). Click any field below to
|
||||
insert it at the cursor instead of typing it out.
|
||||
</p>
|
||||
<div className="format-fields">
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">Always available</span>
|
||||
<code>{"{name}"}</code>
|
||||
<code>{"{ext}"}</code>
|
||||
<FieldChip token="{name}" />
|
||||
<FieldChip token="{ext}" />
|
||||
</div>
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">
|
||||
TV, after <strong>Match</strong> (local, offline)
|
||||
</span>
|
||||
<code>{"{show name}"}</code>
|
||||
<code>{"{season number}"}</code>
|
||||
<code>{"{episode number}"}</code>
|
||||
<FieldChip token="{show name}" />
|
||||
<FieldChip token="{season number}" />
|
||||
<FieldChip token="{episode number}" />
|
||||
</div>
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">
|
||||
TV, after <strong>Fetch Data</strong> (real TMDB metadata)
|
||||
</span>
|
||||
<code>{"{episode name}"}</code>
|
||||
<code>{"{airdate}"}</code>
|
||||
<FieldChip token="{episode name}" />
|
||||
<FieldChip token="{airdate}" />
|
||||
</div>
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">
|
||||
Movies, after <strong>Match</strong> or <strong>Fetch Data</strong>
|
||||
</span>
|
||||
<code>{"{movie name}"}</code>
|
||||
<code>{"{year}"}</code>
|
||||
<code>{"{airdate}"}</code>
|
||||
<FieldChip token="{movie name}" />
|
||||
<FieldChip token="{year}" />
|
||||
<FieldChip token="{airdate}" />
|
||||
</div>
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">
|
||||
Release tags, after <strong>Match</strong> (TV or movies, local, offline)
|
||||
</span>
|
||||
<code>{"{resolution}"}</code>
|
||||
<code>{"{source}"}</code>
|
||||
<code>{"{video codec}"}</code>
|
||||
<code>{"{audio codec}"}</code>
|
||||
<code>{"{release group}"}</code>
|
||||
<FieldChip token="{resolution}" />
|
||||
<FieldChip token="{source}" />
|
||||
<FieldChip token="{video codec}" />
|
||||
<FieldChip token="{audio codec}" />
|
||||
<FieldChip token="{release group}" />
|
||||
</div>
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">
|
||||
Real file probing, after <strong>Match</strong> (reads the actual file, local, offline)
|
||||
</span>
|
||||
<code>{"{width}"}</code>
|
||||
<code>{"{height}"}</code>
|
||||
<code>{"{fps}"}</code>
|
||||
<code>{"{duration}"}</code>
|
||||
<code>{"{file size}"}</code>
|
||||
<FieldChip token="{width}" />
|
||||
<FieldChip token="{height}" />
|
||||
<FieldChip token="{fps}" />
|
||||
<FieldChip token="{duration}" />
|
||||
<FieldChip token="{file size}" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="format-hint">
|
||||
|
||||
Reference in New Issue
Block a user