From 9f8e9650e07d77734780c9cc8940f4e90318b0f8 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Mon, 4 May 2026 09:29:30 +0800 Subject: [PATCH 1/7] Add vocabulary presets with JSON-based repo and user storage Issue #224 requires scenario presets with multi-select apply and editable custom presets, while preparing for import/export. This revision moves built-in presets into a repository JSON file and stores user presets in the app data directory as JSON via Tauri commands, so preset persistence uses one JSON shape both in repo and user space. Constraint: Keep vocabulary entry backend and UI interaction model unchanged Rejected: Keep presets in localStorage/TS constants | does not satisfy unified JSON storage requirement Confidence: high Scope-risk: narrow Reversibility: clean Directive: Preserve vocab-presets JSON schema compatibility for future import/export Tested: cargo check (src-tauri) passed Not-tested: npm build currently fails on pre-existing Settings.tsx typing issues unrelated to this change --- openless-all/app/src-tauri/src/commands.rs | 12 +- openless-all/app/src-tauri/src/lib.rs | 2 + openless-all/app/src-tauri/src/persistence.rs | 17 ++- openless-all/app/src-tauri/src/types.rs | 8 ++ openless-all/app/src/i18n/en.ts | 11 ++ openless-all/app/src/i18n/zh-CN.ts | 11 ++ openless-all/app/src/lib/ipc.ts | 9 ++ openless-all/app/src/lib/types.ts | 6 + openless-all/app/src/lib/vocab-presets.json | 17 +++ openless-all/app/src/lib/vocabPresets.ts | 17 +++ openless-all/app/src/pages/Vocab.tsx | 108 +++++++++++++++++- 11 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 openless-all/app/src/lib/vocab-presets.json create mode 100644 openless-all/app/src/lib/vocabPresets.ts diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 508000d3..f54e6102 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -13,7 +13,7 @@ use crate::persistence::{CredentialAccount, CredentialsSnapshot, CredentialsVaul use crate::polish::{LLMError, OpenAICompatibleConfig, OpenAICompatibleLLMProvider}; use crate::types::{ CredentialsStatus, DictationSession, DictionaryEntry, HotkeyCapability, HotkeyStatus, - PolishMode, QaHotkeyBinding, UserPreferences, WindowsImeStatus, + PolishMode, QaHotkeyBinding, UserPreferences, VocabPreset, WindowsImeStatus, }; type CoordinatorState<'a> = State<'a, Arc>; @@ -356,6 +356,16 @@ pub fn set_vocab_enabled( .map_err(|e| e.to_string()) } +#[tauri::command] +pub fn list_vocab_presets() -> Result, String> { + crate::persistence::list_vocab_presets().map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn save_vocab_presets(presets: Vec) -> Result<(), String> { + crate::persistence::save_vocab_presets(&presets).map_err(|e| e.to_string()) +} + // ─────────────────────────── dictation lifecycle ─────────────────────────── #[tauri::command] diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 8bf20894..ecd872a1 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -202,6 +202,8 @@ pub fn run() { commands::add_vocab, commands::remove_vocab, commands::set_vocab_enabled, + commands::list_vocab_presets, + commands::save_vocab_presets, commands::start_dictation, commands::stop_dictation, commands::cancel_dictation, diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index 3d772ace..632288a8 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -22,7 +22,7 @@ use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::types::{DictationSession, DictionaryEntry, UserPreferences}; +use crate::types::{DictationSession, DictionaryEntry, UserPreferences, VocabPreset}; const HISTORY_CAP: usize = 200; const HISTORY_FILE: &str = "history.json"; @@ -30,6 +30,7 @@ const PREFERENCES_FILE: &str = "preferences.json"; /// 与 Swift `Sources/OpenLessPersistence/DictionaryStore.swift` 同名, /// 让旧版词汇表在升级后无缝继承。**不要**改成 `vocab.json`,会丢用户数据。 const VOCAB_FILE: &str = "dictionary.json"; +const VOCAB_PRESETS_FILE: &str = "vocab-presets.json"; /// Swift 老 `CredentialsVault` 的 JSON 备用路径。 /// 升级到 Tauri 版后,先尝试 Keychain;Keychain 没有时回落读这个文件, @@ -593,6 +594,20 @@ fn count_occurrences(haystack: &str, needle: &str) -> u64 { count } +pub fn list_vocab_presets() -> Result> { + let dir = data_dir()?; + ensure_dir(&dir)?; + read_or_default::>(&dir.join(VOCAB_PRESETS_FILE)) +} + +pub fn save_vocab_presets(presets: &[VocabPreset]) -> Result<()> { + let dir = data_dir()?; + ensure_dir(&dir)?; + let path = dir.join(VOCAB_PRESETS_FILE); + let json = serde_json::to_vec_pretty(presets).context("encode vocab presets failed")?; + atomic_write(&path, &json) +} + // ───────────────────────── CredentialsVault ───────────────────────── #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 50b2f078..6fea10f8 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -68,6 +68,14 @@ pub struct DictionaryEntry { pub created_at: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VocabPreset { + pub id: String, + pub name: String, + pub phrases: Vec, +} + fn default_true() -> bool { true } diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index b4a3804a..dda72c28 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -166,6 +166,17 @@ export const en: typeof zhCN = { tipDisabled: 'Click to disable this entry', tipEnabled: 'Click to enable this entry', removeAria: 'Remove', + presets: { + title: 'Scenario presets', + tip: 'Multi-select to apply in batch; supports edit/create and keeps local preset structure ready for future import/export.', + create: 'New preset', + apply: 'Apply selected', + save: 'Save preset', + edit: 'Edit {{name}}', + newPreset: 'New preset', + namePlaceholder: 'Preset name', + wordsPlaceholder: 'Terms (comma or newline separated)', + }, }, style: { kicker: 'STYLE', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 99380f66..c5bd4220 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -164,6 +164,17 @@ export const zhCN = { tipDisabled: '点击禁用此词条', tipEnabled: '点击启用此词条', removeAria: '删除', + presets: { + title: '场景预设', + tip: '可多选后批量启用;支持编辑和新建,已为后续导入导出预留本地结构。', + create: '新建预设', + apply: '启用所选', + save: '保存预设', + edit: '编辑 {{name}}', + newPreset: '新预设', + namePlaceholder: '预设名称', + wordsPlaceholder: '词条(用逗号或换行分隔)', + }, }, style: { kicker: 'STYLE', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index e49824f8..54462c7a 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -13,6 +13,7 @@ import type { QaHotkeyBinding, UserPreferences, WindowsImeStatus, + VocabPreset, } from './types'; import { OL_DATA } from './mockData'; @@ -200,6 +201,14 @@ export function setVocabEnabled(id: string, enabled: boolean): Promise { return invokeOrMock('set_vocab_enabled', { id, enabled }, () => undefined); } +export function listVocabPresets(): Promise { + return invokeOrMock('list_vocab_presets', undefined, () => []); +} + +export function saveVocabPresets(presets: VocabPreset[]): Promise { + return invokeOrMock('save_vocab_presets', { presets }, () => undefined); +} + // ── Dictation lifecycle ──────────────────────────────────────────────── export function startDictation(): Promise { return invokeOrMock('start_dictation', undefined, () => undefined); diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 53b62037..edf2d691 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -29,6 +29,12 @@ export interface DictionaryEntry { createdAt: string; } +export interface VocabPreset { + id: string; + name: string; + phrases: string[]; +} + export type HotkeyTrigger = | 'rightOption' | 'leftOption' diff --git a/openless-all/app/src/lib/vocab-presets.json b/openless-all/app/src/lib/vocab-presets.json new file mode 100644 index 00000000..8351b215 --- /dev/null +++ b/openless-all/app/src/lib/vocab-presets.json @@ -0,0 +1,17 @@ +[ + { + "id": "programmer", + "name": "程序员", + "phrases": ["PR", "CI", "tag", "release", "issue", "Rust", "TypeScript"] + }, + { + "id": "chef", + "name": "厨师", + "phrases": ["出品", "备料", "火候", "刀工", "摆盘", "sous vide"] + }, + { + "id": "civil-servant", + "name": "公务员", + "phrases": ["公文", "批示", "督办", "政务", "会签", "材料"] + } +] diff --git a/openless-all/app/src/lib/vocabPresets.ts b/openless-all/app/src/lib/vocabPresets.ts new file mode 100644 index 00000000..a0aa3331 --- /dev/null +++ b/openless-all/app/src/lib/vocabPresets.ts @@ -0,0 +1,17 @@ +import defaultPresetsJson from './vocab-presets.json'; +import { listVocabPresets, saveVocabPresets } from './ipc'; +import type { VocabPreset } from './types'; + +export const DEFAULT_VOCAB_PRESETS: VocabPreset[] = defaultPresetsJson as VocabPreset[]; + +export async function loadVocabPresets(): Promise { + const userPresets = await listVocabPresets(); + if (!Array.isArray(userPresets) || userPresets.length === 0) { + return DEFAULT_VOCAB_PRESETS; + } + return userPresets; +} + +export async function persistVocabPresets(presets: VocabPreset[]) { + await saveVocabPresets(presets); +} diff --git a/openless-all/app/src/pages/Vocab.tsx b/openless-all/app/src/pages/Vocab.tsx index 0e53f65f..e407e2fb 100644 --- a/openless-all/app/src/pages/Vocab.tsx +++ b/openless-all/app/src/pages/Vocab.tsx @@ -4,7 +4,8 @@ import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { addVocab, isTauri, listVocab, removeVocab, setVocabEnabled } from '../lib/ipc'; -import type { DictionaryEntry } from '../lib/types'; +import type { DictionaryEntry, VocabPreset } from '../lib/types'; +import { DEFAULT_VOCAB_PRESETS, loadVocabPresets, persistVocabPresets } from '../lib/vocabPresets'; import { Btn, Card, PageHeader } from './_atoms'; export function Vocab() { @@ -14,6 +15,11 @@ export function Vocab() { const inputRef = useRef(null); const [error, setError] = useState(null); + const [presets, setPresets] = useState(DEFAULT_VOCAB_PRESETS); + const [selectedPresetIds, setSelectedPresetIds] = useState([]); + const [editingPresetId, setEditingPresetId] = useState(null); + const [presetNameDraft, setPresetNameDraft] = useState(''); + const [presetPhrasesDraft, setPresetPhrasesDraft] = useState(''); const refresh = async () => { try { @@ -30,6 +36,9 @@ export function Vocab() { useEffect(() => { refresh(); + void loadVocabPresets() + .then(setPresets) + .catch(err => setError(err instanceof Error ? err.message : String(err))); // 订阅后端 vocab:updated:每段口述结束、record_hits 触发后由 coordinator 推送。 // Vocab 页面打开期间能即时看到命中数累加,无需切到其他 tab 再切回。 if (!isTauri) return; @@ -82,6 +91,61 @@ export function Vocab() { } }; + const togglePreset = (id: string) => { + setSelectedPresetIds(prev => (prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])); + }; + + const startEditPreset = (preset: VocabPreset) => { + setEditingPresetId(preset.id); + setPresetNameDraft(preset.name); + setPresetPhrasesDraft(preset.phrases.join(', ')); + }; + + const savePreset = () => { + if (!editingPresetId) return; + const name = presetNameDraft.trim(); + if (!name) return; + const phrases = Array.from( + new Set( + presetPhrasesDraft + .split(/[,\n]/) + .map(s => s.trim()) + .filter(Boolean), + ), + ); + const next = presets.map(p => (p.id === editingPresetId ? { ...p, name, phrases } : p)); + setPresets(next); + void persistVocabPresets(next).catch(err => setError(err instanceof Error ? err.message : String(err))); + setEditingPresetId(null); + }; + + const createPreset = () => { + const next: VocabPreset = { + id: `user-${Date.now()}`, + name: t('vocab.presets.newPreset'), + phrases: [], + }; + const updated = [...presets, next]; + setPresets(updated); + void persistVocabPresets(updated).catch(err => setError(err instanceof Error ? err.message : String(err))); + startEditPreset(next); + }; + + const applySelectedPresets = async () => { + const selected = presets.filter(p => selectedPresetIds.includes(p.id)); + if (selected.length === 0) return; + const phraseSet = new Set(entries.map(e => e.phrase.trim().toLowerCase())); + for (const p of selected) { + for (const phrase of p.phrases) { + if (!phraseSet.has(phrase.trim().toLowerCase())) { + await addVocab(phrase); + phraseSet.add(phrase.trim().toLowerCase()); + } + } + } + await refresh(); + }; + return ( <> +
+
+ {t('vocab.presets.title')} + {presets.map(p => ( + + ))} + {t('vocab.presets.create')} + {t('vocab.presets.apply')} +
+
{t('vocab.presets.tip')}
+ {editingPresetId && ( +
+ setPresetNameDraft(e.target.value)} placeholder={t('vocab.presets.namePlaceholder')} /> +