diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 319b996..2e49bf3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -121,6 +121,10 @@ pub fn run() { settings::save_opensubtitles_login, settings::get_last_naming_scheme, settings::save_last_naming_scheme, + settings::get_last_movie_naming_scheme, + settings::save_last_movie_naming_scheme, + settings::get_theme, + settings::save_theme, subtitles::search_subtitles, subtitles::download_subtitle, episodes::browse_episodes, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 7292acf..80a1077 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -88,8 +88,27 @@ struct StoredSettings { /// 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. + /// + /// Used for TV episodes specifically - see [`last_movie_naming_scheme`] for the + /// separate scheme movies use. The two are independent because TV and movie items + /// carry entirely different binding vocabularies (season/episode numbers and an + /// episode title vs. just a movie title and year); a single shared scheme text + /// can only ever render one of the two kinds correctly; a batch that mixes TV and + /// movies would leave the other kind's fields blank. #[serde(default)] last_naming_scheme: Option, + /// The naming-scheme expression the Rename panel's editor last held while in + /// "Movies" mode - same persistence behavior as [`last_naming_scheme`], just for + /// movie items. See that field's doc for why these are two separate settings. + #[serde(default)] + last_movie_naming_scheme: Option, + /// Which visual theme the app renders with - a plain string id ("aurora"/"light"/ + /// "ocean"/"sunset") matching a `[data-theme="..."]` block in `src/theme.css` on + /// the frontend. Kept as an opaque string here rather than a Rust enum so a new + /// theme can be added purely in CSS/TS without touching this file too; an + /// unrecognized or missing value just falls back to the frontend's own default. + #[serde(default)] + theme: Option, } fn settings_path(app: &AppHandle) -> Result { @@ -327,6 +346,49 @@ pub fn save_last_naming_scheme(app: AppHandle, expression: String) -> Result<(), save(&app, &settings) } +/// Returns the last-saved *movie* naming-scheme expression, or `None` if nothing (or +/// only blank text) has been saved yet - the frontend falls back to its own built-in +/// movie default in that case. See [`last_naming_scheme`] for why movies get their +/// own separate saved scheme instead of sharing the TV one. +pub fn last_movie_naming_scheme(app: &AppHandle) -> Option { + load(app).last_movie_naming_scheme.filter(|t| !t.trim().is_empty()) +} + +#[tauri::command] +pub fn get_last_movie_naming_scheme(app: AppHandle) -> Option { + last_movie_naming_scheme(&app) +} + +/// Saves the movie naming-scheme editor's current text so it survives an app restart. +/// Mirrors [`save_last_naming_scheme`] exactly, just for the separate movie scheme. +#[tauri::command] +pub fn save_last_movie_naming_scheme(app: AppHandle, expression: String) -> Result<(), String> { + let mut settings = load(&app); + let trimmed = expression.trim().to_string(); + settings.last_movie_naming_scheme = if trimmed.is_empty() { None } else { Some(expression) }; + save(&app, &settings) +} + +/// Returns the stored theme id, or `None` if nothing's been saved yet (the frontend +/// then falls back to its own built-in default theme). +pub fn theme(app: &AppHandle) -> Option { + load(app).theme.filter(|t| !t.trim().is_empty()) +} + +#[tauri::command] +pub fn get_theme(app: AppHandle) -> Option { + theme(&app) +} + +/// Saves which theme id the user picked in Settings. +#[tauri::command] +pub fn save_theme(app: AppHandle, theme: String) -> Result<(), String> { + let mut settings = load(&app); + let trimmed = theme.trim().to_string(); + settings.theme = if trimmed.is_empty() { None } else { Some(trimmed) }; + save(&app, &settings) +} + #[cfg(test)] mod tests { use super::*; @@ -414,6 +476,45 @@ mod tests { assert_eq!(parsed.last_naming_scheme, None); } + #[test] + fn stored_settings_without_movie_scheme_or_theme_fields_still_deserializes() { + // Same schema-evolution guarantee, for the two fields the movie-naming-scheme + // split and the theming feature added. + let old_json = r#"{"tmdb_token":"abc123","last_naming_scheme":"{show name}"}"#; + let parsed: StoredSettings = serde_json::from_str(old_json).unwrap(); + assert_eq!(parsed.last_naming_scheme, Some("{show name}".to_string())); + assert_eq!(parsed.last_movie_naming_scheme, None); + assert_eq!(parsed.theme, None); + } + + #[test] + fn last_movie_naming_scheme_round_trips_and_ignores_pure_whitespace() { + // Mirrors last_naming_scheme_round_trips_and_ignores_pure_whitespace below, + // for the separate movie scheme. + let mut settings = StoredSettings::default(); + settings.last_movie_naming_scheme = Some("{movie name} ({year})".to_string()); + assert_eq!( + settings.last_movie_naming_scheme.as_deref().filter(|t| !t.trim().is_empty()), + Some("{movie name} ({year})") + ); + + settings.last_movie_naming_scheme = Some(" ".to_string()); + assert_eq!( + settings.last_movie_naming_scheme.as_deref().filter(|t| !t.trim().is_empty()), + None + ); + } + + #[test] + fn theme_round_trips_and_ignores_pure_whitespace() { + let mut settings = StoredSettings::default(); + settings.theme = Some("ocean".to_string()); + assert_eq!(settings.theme.as_deref().filter(|t| !t.trim().is_empty()), Some("ocean")); + + settings.theme = Some(" ".to_string()); + assert_eq!(settings.theme.as_deref().filter(|t| !t.trim().is_empty()), 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 diff --git a/src/components/RenamePanel.tsx b/src/components/RenamePanel.tsx index d580b28..ceb12d2 100644 --- a/src/components/RenamePanel.tsx +++ b/src/components/RenamePanel.tsx @@ -330,6 +330,16 @@ const MATCHED_EXPRESSION = "{show name} - {season number}x{episode number}"; // What Match + Fetch Data together can fill in. const FETCHED_EXPRESSION = "{show name} - {season number}x{episode number} - {episode name}"; +// The separate naming scheme used for movie items (see `movieExpression` state below +// for why TV and movies need their own independent scheme rather than sharing one): +// unlike the TV scheme above, this one has no multi-stage "showcase the next field" +// onboarding chain, because there's no comparably rich vocabulary to stage - a movie +// item only ever has a title and a year (`{movie name}`/`{year}`), both of which are +// already available right after local Match, and Fetch Data only refines them rather +// than adding a genuinely new field the way it adds `{episode name}` for TV. So this +// is just always the useful pattern from the start, not a milestone in a chain. +const MOVIE_SAMPLE_EXPRESSION = "{movie name} ({year})"; + // Copy for each RenameAction, used in the confirmation modal's segmented selector, // subtitle, confirm-button label, and the post-confirm status message. const RENAME_ACTION_LABELS: Record< @@ -470,6 +480,16 @@ export default function RenamePanel() { // 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); + // The separate naming scheme for movie items - see MOVIE_SAMPLE_EXPRESSION's doc + // comment above for why movies get their own independent scheme instead of sharing + // `expression`. Persisted and restored the same way as the TV scheme, just to its + // own settings field (`last_movie_naming_scheme`) so switching between the two + // never overwrites the other. + const [movieExpression, setMovieExpression] = useState(MOVIE_SAMPLE_EXPRESSION); + const [movieSchemeLoaded, setMovieSchemeLoaded] = useState(false); + // Which of the two schemes the Naming Scheme editor is currently showing/editing. + // Purely a UI selection, not persisted - the editor always opens back on "tv". + const [editingKind, setEditingKind] = useState<"tv" | "movie">("tv"); const [editingFormat, setEditingFormat] = useState(false); const [items, setItems] = useState(SAMPLE_ITEMS); const [selected, setSelected] = useState(SAMPLE_ITEMS[0].id); @@ -616,14 +636,42 @@ export default function RenamePanel() { return () => window.clearTimeout(timer); }, [schemeLoaded, expression]); + // Restores/persists the movie naming scheme the same way as the TV one just above, + // to its own separate settings field. No restoredCustomSchemeRef equivalent is + // needed here - unlike the TV scheme, this one has no auto-upgrade checkpoints for + // a restored value to collide with (see MOVIE_SAMPLE_EXPRESSION's doc comment). + useEffect(() => { + invoke("get_last_movie_naming_scheme") + .then((saved) => { + if (saved && saved.trim()) setMovieExpression(saved); + }) + .catch(() => {}) + .finally(() => setMovieSchemeLoaded(true)); + }, []); + + useEffect(() => { + if (!movieSchemeLoaded) return; + const timer = window.setTimeout(() => { + invoke("save_last_movie_naming_scheme", { expression: movieExpression }).catch(() => {}); + }, 600); + return () => window.clearTimeout(timer); + }, [movieSchemeLoaded, movieExpression]); + useEffect(() => { let cancelled = false; Promise.all( items.map(async (item) => { if (!item.bindings) return [item.id, null] as const; + // Movie items render against their own separate scheme - see + // MOVIE_SAMPLE_EXPRESSION's doc comment for why: the TV scheme's fields + // (show name/season number/episode number/episode name) are never set on a + // movie item, so rendering a movie against the TV scheme leaves nothing but + // the scheme's own literal text (e.g. "{show name} - {season number}x + // {episode number} - {episode name}" renders as the bare "- x -"). + const activeExpr = item.mediaKind === "movie" ? movieExpression : expression; const rendered = await invoke("render_expression", { - expression, + expression: activeExpr, bindings: item.bindings, }); return [item.id, `${rendered}${item.ext ? `.${item.ext}` : ""}`] as const; @@ -646,7 +694,7 @@ export default function RenamePanel() { return () => { cancelled = true; }; - }, [expression, items]); + }, [expression, movieExpression, items]); const notImplemented = (feature: string) => { setStatus(`${feature} isn't implemented yet — see the porting roadmap in the README.`); @@ -1509,6 +1557,13 @@ export default function RenamePanel() { }); }; + // Whichever of the two schemes the editor is currently showing - presets, the field + // chips, and typing into the box all act on this one rather than always the TV + // scheme, so switching the "TV Shows"/"Movies" toggle changes what a 1-9 hotkey or a + // clicked field chip actually affects. + const activeExpression = editingKind === "movie" ? movieExpression : expression; + const setActiveExpression = editingKind === "movie" ? setMovieExpression : setExpression; + // Presets panel + 1-9 hotkeys. Applying a preset just swaps the naming-scheme // expression (same as typing a new one into the format editor) - it doesn't touch // `items` at all, so the live preview recomputes for whatever's already loaded. @@ -1525,7 +1580,7 @@ export default function RenamePanel() { const applyPreset = (slot: string) => { const value = presets?.[slot]; if (!value) return; - setExpression(value); + setActiveExpression(value); setStatus(`Applied preset ${slot}.`); window.clearTimeout((applyPreset as any)._t); (applyPreset as any)._t = window.setTimeout(() => setStatus(null), 2500); @@ -1534,8 +1589,8 @@ export default function RenamePanel() { const saveCurrentAsPreset = async (slot: string) => { setPresetsBusySlot(slot); try { - await invoke("save_preset", { slot, expression }); - setPresets((prev) => ({ ...(prev ?? {}), [slot]: expression })); + await invoke("save_preset", { slot, expression: activeExpression }); + setPresets((prev) => ({ ...(prev ?? {}), [slot]: activeExpression })); } catch (err) { setStatus(`Couldn't save preset ${slot}: ${err}`); } finally { @@ -1616,13 +1671,13 @@ export default function RenamePanel() { 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); + setActiveExpression((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); + setActiveExpression(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 @@ -1715,17 +1770,37 @@ export default function RenamePanel() { {editingFormat && (
- + Naming scheme for +
+ + +
+ setExpression(e.target.value)} + onChange={(e) => setActiveExpression(e.target.value)} /> {computeError &&

{computeError}

}

- This field is fully yours to edit — type any combination of literal text and{" "} + TV episodes and movies each get their own naming scheme, saved separately — switch the toggle above to + edit the other one. This field is fully yours to edit — type any combination of literal text and{" "} {"{field}"} placeholders and the preview updates live against {matchedCount} of{" "} {items.length} file{items.length === 1 ? "" : "s"} (the real Rust naming-expression engine, through Tauri). Every field below works as a plain {"{field name}"}, spaces and all — no coding @@ -1742,29 +1817,35 @@ export default function RenamePanel() {

-
- - TV, after Match (local, offline) - - - - -
-
- - TV, after Fetch Data (real TMDB metadata) - - - -
-
- - Movies, after Match or Fetch Data - - - - -
+ {editingKind === "tv" && ( +
+ + TV, after Match (local, offline) + + + + +
+ )} + {editingKind === "tv" && ( +
+ + TV, after Fetch Data (real TMDB metadata) + + + +
+ )} + {editingKind === "movie" && ( +
+ + Movies, after Match or Fetch Data + + + + +
+ )}
Release tags, after Match (TV or movies, local, offline)