feat(settings): persist tags and fragment types via Tauri settings

This commit is contained in:
Jacob Schmidt 2026-02-26 21:10:18 -06:00
parent 235e46240d
commit 7c3161c61b
3 changed files with 160 additions and 9 deletions

View File

@ -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<Value>,
}
#[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<String>,
#[serde(default = "default_settings_tags")]
tags: Vec<String>,
#[serde(default = "default_fragment_types")]
fragment_types: Vec<String>,
}
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<String> {
DEFAULT_SETTINGS_TAGS
.iter()
.map(|v| (*v).to_string())
.collect()
}
fn default_fragment_types() -> Vec<String> {
DEFAULT_FRAGMENT_TYPES
.iter()
.map(|v| (*v).to_string())
.collect()
}
fn normalize_items(values: Vec<String>, fallback: &[&str]) -> Vec<String> {
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<Value, String> {
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<String>,
fragment_types: Vec<String>,
) -> Result<Value, String> {
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()?;

View File

@ -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<string[]>(["Personal", "Work", "Ideas", "Journal"]);
export const settingsFragmentTypes = writable<string[]>(["Quote", "Snippet", "Reference"]);
const defaultTags = ["Personal", "Work", "Ideas", "Journal"];
const defaultFragmentTypes = ["Quote", "Snippet", "Reference"];
export const settingsTags = writable<string[]>([...defaultTags]);
export const settingsFragmentTypes = writable<string[]>([...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<string>();
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<void> {
if (hydrating || hydrationComplete) return;
hydrating = true;
try {
const payload = await invoke<UiSettingsPayload>("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<void> {
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;
}

View File

@ -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;