diff --git a/README.md b/README.md index c4dd7c7..8d5a8d9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bcc2e7d..c69b75c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 28ad9b6..7292acf 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -83,6 +83,13 @@ struct StoredSettings { /// [`has_opensubtitles_password`], which only reports whether one is set. #[serde(default)] opensubtitles_password: Option, + /// 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, } fn settings_path(app: &AppHandle) -> Result { @@ -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 { + load(app).last_naming_scheme.filter(|t| !t.trim().is_empty()) +} + +#[tauri::command] +pub fn get_last_naming_scheme(app: AppHandle) -> Option { + 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 diff --git a/src/components/RenamePanel.css b/src/components/RenamePanel.css index 042b214..5374d86 100644 --- a/src/components/RenamePanel.css +++ b/src/components/RenamePanel.css @@ -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 { diff --git a/src/components/RenamePanel.tsx b/src/components/RenamePanel.tsx index 90b18d7..7d0cb3a 100644 --- a/src/components/RenamePanel.tsx +++ b/src/components/RenamePanel.tsx @@ -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(SAMPLE_ITEMS); const [selected, setSelected] = useState(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("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(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 `` tags this + // replaced (see RenamePanel.css's `.format-field-chip`) but wired to `insertField`. + const FieldChip = ({ token }: { token: string }) => ( + + ); + return (
@@ -1460,6 +1533,7 @@ export default function RenamePanel() { setExpression(e.target.value)} @@ -1474,56 +1548,57 @@ export default function RenamePanel() { Rhai {" "} - script for anyone who wants it (e.g. {"{show name.to_upper()}"}). + script for anyone who wants it (e.g. {"{show name.to_upper()}"}). Click any field below to + insert it at the cursor instead of typing it out.

Always available - {"{name}"} - {"{ext}"} + +
TV, after Match (local, offline) - {"{show name}"} - {"{season number}"} - {"{episode number}"} + + +
TV, after Fetch Data (real TMDB metadata) - {"{episode name}"} - {"{airdate}"} + +
Movies, after Match or Fetch Data - {"{movie name}"} - {"{year}"} - {"{airdate}"} + + +
Release tags, after Match (TV or movies, local, offline) - {"{resolution}"} - {"{source}"} - {"{video codec}"} - {"{audio codec}"} - {"{release group}"} + + + + +
Real file probing, after Match (reads the actual file, local, offline) - {"{width}"} - {"{height}"} - {"{fps}"} - {"{duration}"} - {"{file size}"} + + + + +