From 7c3161c61b52e7f7609f363ea47b40a8242f6d60 Mon Sep 17 00:00:00 2001 From: Jacob Schmidt Date: Thu, 26 Feb 2026 21:10:18 -0600 Subject: [PATCH] feat(settings): persist tags and fragment types via Tauri settings --- Journal.App/src-tauri/src/lib.rs | 95 ++++++++++++++++++++++++-- Journal.App/src/lib/stores/settings.ts | 71 ++++++++++++++++++- Journal.App/src/routes/+layout.svelte | 3 + 3 files changed, 160 insertions(+), 9 deletions(-) diff --git a/Journal.App/src-tauri/src/lib.rs b/Journal.App/src-tauri/src/lib.rs index adb5069..14f72a7 100644 --- a/Journal.App/src-tauri/src/lib.rs +++ b/Journal.App/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::collections::HashSet; use std::env; use std::fs; use std::path::{Path, PathBuf}; @@ -25,10 +26,62 @@ struct CommandEnvelope { payload: Option, } -#[derive(Deserialize, Serialize, Default)] +const DEFAULT_SETTINGS_TAGS: &[&str] = &["Personal", "Work", "Ideas", "Journal"]; +const DEFAULT_FRAGMENT_TYPES: &[&str] = &["Quote", "Snippet", "Reference"]; + +#[derive(Deserialize, Serialize)] struct AppSettings { #[serde(default, skip_serializing_if = "Option::is_none")] sidecar_root: Option, + #[serde(default = "default_settings_tags")] + tags: Vec, + #[serde(default = "default_fragment_types")] + fragment_types: Vec, +} + +impl Default for AppSettings { + fn default() -> Self { + Self { + sidecar_root: None, + tags: default_settings_tags(), + fragment_types: default_fragment_types(), + } + } +} + +fn default_settings_tags() -> Vec { + DEFAULT_SETTINGS_TAGS + .iter() + .map(|v| (*v).to_string()) + .collect() +} + +fn default_fragment_types() -> Vec { + DEFAULT_FRAGMENT_TYPES + .iter() + .map(|v| (*v).to_string()) + .collect() +} + +fn normalize_items(values: Vec, fallback: &[&str]) -> Vec { + let mut seen = HashSet::new(); + let mut normalized = Vec::new(); + for item in values { + let trimmed = item.trim(); + if trimmed.is_empty() { + continue; + } + let key = trimmed.to_lowercase(); + if seen.insert(key) { + normalized.push(trimmed.to_string()); + } + } + + if normalized.is_empty() { + return fallback.iter().map(|v| (*v).to_string()).collect(); + } + + normalized } struct ManagedSidecar { @@ -295,12 +348,9 @@ async fn set_sidecar_root( let is_custom = new_override.is_some(); *state.root_override.lock().await = new_override.clone(); - save_settings( - &state.config_path, - &AppSettings { - sidecar_root: new_override.map(|p| p.to_string_lossy().into_owned()), - }, - )?; + let mut settings = load_settings(&state.config_path); + settings.sidecar_root = new_override.map(|p| p.to_string_lossy().into_owned()); + save_settings(&state.config_path, &settings)?; Ok(serde_json::json!({ "root": root.to_string_lossy(), @@ -308,6 +358,35 @@ async fn set_sidecar_root( })) } +#[tauri::command] +async fn get_ui_settings(state: tauri::State<'_, SidecarState>) -> Result { + let settings = load_settings(&state.config_path); + let tags = normalize_items(settings.tags, DEFAULT_SETTINGS_TAGS); + let fragment_types = normalize_items(settings.fragment_types, DEFAULT_FRAGMENT_TYPES); + + Ok(serde_json::json!({ + "tags": tags, + "fragmentTypes": fragment_types + })) +} + +#[tauri::command] +async fn set_ui_settings( + state: tauri::State<'_, SidecarState>, + tags: Vec, + fragment_types: Vec, +) -> Result { + let mut settings = load_settings(&state.config_path); + settings.tags = normalize_items(tags, DEFAULT_SETTINGS_TAGS); + settings.fragment_types = normalize_items(fragment_types, DEFAULT_FRAGMENT_TYPES); + save_settings(&state.config_path, &settings)?; + + Ok(serde_json::json!({ + "tags": settings.tags, + "fragmentTypes": settings.fragment_types + })) +} + #[tauri::command] async fn shutdown( state: tauri::State<'_, SidecarState>, @@ -343,6 +422,8 @@ pub fn run() { shutdown, get_sidecar_root, set_sidecar_root, + get_ui_settings, + set_ui_settings, ]) .setup(|app| { let config_dir = app.path().app_config_dir()?; diff --git a/Journal.App/src/lib/stores/settings.ts b/Journal.App/src/lib/stores/settings.ts index f4a2578..80e4e80 100644 --- a/Journal.App/src/lib/stores/settings.ts +++ b/Journal.App/src/lib/stores/settings.ts @@ -1,8 +1,20 @@ import { writable } from "svelte/store"; import { get } from "svelte/store"; +import { invoke } from "@tauri-apps/api/core"; -export const settingsTags = writable(["Personal", "Work", "Ideas", "Journal"]); -export const settingsFragmentTypes = writable(["Quote", "Snippet", "Reference"]); +const defaultTags = ["Personal", "Work", "Ideas", "Journal"]; +const defaultFragmentTypes = ["Quote", "Snippet", "Reference"]; + +export const settingsTags = writable([...defaultTags]); +export const settingsFragmentTypes = writable([...defaultFragmentTypes]); + +let hydrationComplete = false; +let hydrating = false; + +type UiSettingsPayload = { + tags?: string[]; + fragmentTypes?: string[]; +}; function normalize(value: string): string { return value.trim().toLowerCase(); @@ -13,12 +25,62 @@ function hasDuplicate(values: string[], candidate: string, excludeIndex?: number return values.some((value, index) => index !== excludeIndex && normalize(value) === normalized); } +function normalizeValues(values: string[], fallback: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + const trimmed = value.trim(); + if (!trimmed) continue; + const key = normalize(trimmed); + if (seen.has(key)) continue; + seen.add(key); + result.push(trimmed); + } + return result.length ? result : [...fallback]; +} + +export async function hydrateUiSettings(): Promise { + if (hydrating || hydrationComplete) return; + hydrating = true; + try { + const payload = await invoke("get_ui_settings"); + settingsTags.set(normalizeValues(payload.tags ?? [], defaultTags)); + settingsFragmentTypes.set(normalizeValues(payload.fragmentTypes ?? [], defaultFragmentTypes)); + } catch (error) { + console.error("[settings] hydrate failed", error); + } finally { + hydrating = false; + hydrationComplete = true; + } +} + +export async function persistUiSettings(): Promise { + const tags = normalizeValues(get(settingsTags), defaultTags); + const fragmentTypes = normalizeValues(get(settingsFragmentTypes), defaultFragmentTypes); + settingsTags.set(tags); + settingsFragmentTypes.set(fragmentTypes); + + await invoke("set_ui_settings", { + tags, + fragmentTypes, + fragment_types: fragmentTypes + }); +} + +function queuePersist(): void { + if (!hydrationComplete || hydrating) return; + void persistUiSettings().catch((error) => { + console.error("[settings] persist failed", error); + }); +} + export function addSettingsTag(value: string): boolean { const next = value.trim(); if (!next) return false; const tags = get(settingsTags); if (hasDuplicate(tags, next)) return false; settingsTags.set([...tags, next]); + queuePersist(); return true; } @@ -29,6 +91,7 @@ export function updateSettingsTag(index: number, value: string): boolean { if (index < 0 || index >= tags.length) return false; if (hasDuplicate(tags, next, index)) return false; settingsTags.set(tags.map((tag, idx) => (idx === index ? next : tag))); + queuePersist(); return true; } @@ -36,6 +99,7 @@ export function removeSettingsTag(index: number): boolean { const tags = get(settingsTags); if (index < 0 || index >= tags.length) return false; settingsTags.set(tags.filter((_, idx) => idx !== index)); + queuePersist(); return true; } @@ -45,6 +109,7 @@ export function addFragmentType(value: string): boolean { const types = get(settingsFragmentTypes); if (hasDuplicate(types, next)) return false; settingsFragmentTypes.set([...types, next]); + queuePersist(); return true; } @@ -55,6 +120,7 @@ export function updateFragmentType(index: number, value: string): boolean { if (index < 0 || index >= types.length) return false; if (hasDuplicate(types, next, index)) return false; settingsFragmentTypes.set(types.map((type, idx) => (idx === index ? next : type))); + queuePersist(); return true; } @@ -62,5 +128,6 @@ export function removeFragmentType(index: number): boolean { const types = get(settingsFragmentTypes); if (index < 0 || index >= types.length) return false; settingsFragmentTypes.set(types.filter((_, idx) => idx !== index)); + queuePersist(); return true; } diff --git a/Journal.App/src/routes/+layout.svelte b/Journal.App/src/routes/+layout.svelte index 569251c..cd0bc0a 100644 --- a/Journal.App/src/routes/+layout.svelte +++ b/Journal.App/src/routes/+layout.svelte @@ -4,10 +4,13 @@ import { onMount } from "svelte"; import { getSessionPassword, clearVaultSession, flushBeforeClose } from "$lib/stores/session"; import { persistAndClearVault } from "$lib/backend/auth"; + import { hydrateUiSettings } from "$lib/stores/settings"; let closeInProgress = false; onMount(() => { + void hydrateUiSettings(); + const appWindow = getCurrentWindow(); const unlistenPromise = appWindow.onCloseRequested(async (event) => { if (closeInProgress) return;