Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion openless-all/app/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, VocabPresetStore, WindowsImeStatus,
};

type CoordinatorState<'a> = State<'a, Arc<Coordinator>>;
Expand Down Expand Up @@ -356,6 +356,16 @@ pub fn set_vocab_enabled(
.map_err(|e| e.to_string())
}

#[tauri::command]
pub fn list_vocab_presets() -> Result<VocabPresetStore, String> {
crate::persistence::list_vocab_presets().map_err(|e| e.to_string())
}

#[tauri::command]
pub fn save_vocab_presets(store: VocabPresetStore) -> Result<(), String> {
crate::persistence::save_vocab_presets(&store).map_err(|e| e.to_string())
}

// ─────────────────────────── dictation lifecycle ───────────────────────────

#[tauri::command]
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
51 changes: 50 additions & 1 deletion openless-all/app/src-tauri/src/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@ use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::types::{DictationSession, DictionaryEntry, UserPreferences};
use crate::types::{DictationSession, DictionaryEntry, UserPreferences, VocabPresetStore};

const HISTORY_CAP: usize = 200;
const HISTORY_FILE: &str = "history.json";
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 没有时回落读这个文件,
Expand Down Expand Up @@ -593,6 +594,20 @@ fn count_occurrences(haystack: &str, needle: &str) -> u64 {
count
}

pub fn list_vocab_presets() -> Result<VocabPresetStore> {
let dir = data_dir()?;
ensure_dir(&dir)?;
read_or_default::<VocabPresetStore>(&dir.join(VOCAB_PRESETS_FILE))
}

pub fn save_vocab_presets(store: &VocabPresetStore) -> Result<()> {
let dir = data_dir()?;
ensure_dir(&dir)?;
let path = dir.join(VOCAB_PRESETS_FILE);
let json = serde_json::to_vec_pretty(store).context("encode vocab presets failed")?;
atomic_write(&path, &json)
}

// ───────────────────────── CredentialsVault ─────────────────────────

#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
Expand Down Expand Up @@ -719,3 +734,37 @@ impl CredentialsVault {
}
}
}

#[cfg(test)]
mod tests {
use super::{list_vocab_presets, save_vocab_presets};
use crate::types::{VocabPreset, VocabPresetStore};
use std::fs;
use std::path::PathBuf;

#[test]
fn vocab_presets_roundtrip_json_file() {
let tmp: PathBuf = std::env::temp_dir().join(format!("openless-test-{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&tmp).expect("create temp dir");
// Linux path helper uses XDG_DATA_HOME first.
unsafe {
std::env::set_var("XDG_DATA_HOME", &tmp);
}
let store = VocabPresetStore {
custom: vec![VocabPreset {
id: "test".into(),
name: "测试".into(),
phrases: vec!["PR".into(), "CI".into()],
}],
overrides: vec![],
disabled_builtin_preset_ids: vec!["chef".into()],
};
save_vocab_presets(&store).expect("save presets");
let loaded = list_vocab_presets().expect("list presets");
assert_eq!(loaded.custom.len(), 1);
assert_eq!(loaded.custom[0].id, "test");
assert_eq!(loaded.custom[0].phrases, vec!["PR".to_string(), "CI".to_string()]);
assert_eq!(loaded.disabled_builtin_preset_ids, vec!["chef".to_string()]);
let _ = fs::remove_dir_all(&tmp);
}
}
16 changes: 16 additions & 0 deletions openless-all/app/src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,22 @@ 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<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct VocabPresetStore {
pub custom: Vec<VocabPreset>,
pub overrides: Vec<VocabPreset>,
pub disabled_builtin_preset_ids: Vec<String>,
}

fn default_true() -> bool {
true
}
Expand Down
11 changes: 11 additions & 0 deletions openless-all/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
11 changes: 11 additions & 0 deletions openless-all/app/src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,17 @@ export const zhCN = {
tipDisabled: '点击禁用此词条',
tipEnabled: '点击启用此词条',
removeAria: '删除',
presets: {
title: '场景预设',
tip: '可多选后批量启用;支持编辑和新建,已为后续导入导出预留本地结构。',
create: '新建预设',
apply: '启用所选',
save: '保存预设',
edit: '编辑 {{name}}',
newPreset: '新预设',
namePlaceholder: '预设名称',
wordsPlaceholder: '词条(用逗号或换行分隔)',
},
},
style: {
kicker: 'STYLE',
Expand Down
14 changes: 14 additions & 0 deletions openless-all/app/src/lib/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import type {
QaHotkeyBinding,
UserPreferences,
WindowsImeStatus,
VocabPreset,
VocabPresetStore,
} from './types';
import { OL_DATA } from './mockData';

Expand Down Expand Up @@ -200,6 +202,18 @@ export function setVocabEnabled(id: string, enabled: boolean): Promise<void> {
return invokeOrMock('set_vocab_enabled', { id, enabled }, () => undefined);
}

export function listVocabPresets(): Promise<VocabPresetStore> {
return invokeOrMock('list_vocab_presets', undefined, () => ({
custom: [],
overrides: [],
disabledBuiltinPresetIds: [],
}));
}

export function saveVocabPresets(store: VocabPresetStore): Promise<void> {
return invokeOrMock('save_vocab_presets', { store }, () => undefined);
}

// ── Dictation lifecycle ────────────────────────────────────────────────
export function startDictation(): Promise<void> {
return invokeOrMock('start_dictation', undefined, () => undefined);
Expand Down
12 changes: 12 additions & 0 deletions openless-all/app/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@ export interface DictionaryEntry {
createdAt: string;
}

export interface VocabPreset {
id: string;
name: string;
phrases: string[];
}

export interface VocabPresetStore {
custom: VocabPreset[];
overrides: VocabPreset[];
disabledBuiltinPresetIds: string[];
}

export type HotkeyTrigger =
| 'rightOption'
| 'leftOption'
Expand Down
17 changes: 17 additions & 0 deletions openless-all/app/src/lib/vocab-presets.json
Original file line number Diff line number Diff line change
@@ -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": ["公文", "批示", "督办", "政务", "会签", "材料"]
}
]
46 changes: 46 additions & 0 deletions openless-all/app/src/lib/vocabPresets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import defaultPresetsJson from './vocab-presets.json';
import { listVocabPresets, saveVocabPresets } from './ipc';
import type { VocabPreset, VocabPresetStore } from './types';

export const DEFAULT_VOCAB_PRESETS: VocabPreset[] = defaultPresetsJson as VocabPreset[];

export async function loadVocabPresets(): Promise<VocabPreset[]> {
const store = await listVocabPresets();
const builtin = new Map(DEFAULT_VOCAB_PRESETS.map(p => [p.id, p] as const));
for (const id of store.disabledBuiltinPresetIds || []) {
builtin.delete(id);
}
for (const preset of store.overrides || []) {
if (!preset || !preset.id) continue;
if (builtin.has(preset.id)) builtin.set(preset.id, preset);
}
const custom = (store.custom || []).filter(p => p && p.id);
return [...builtin.values(), ...custom];
}

export async function persistVocabPresets(presets: VocabPreset[]) {
const builtinMap = new Map(DEFAULT_VOCAB_PRESETS.map(p => [p.id, p] as const));
const store: VocabPresetStore = {
custom: [],
overrides: [],
disabledBuiltinPresetIds: [],
};
const seenBuiltin = new Set<string>();
for (const preset of presets) {
const base = builtinMap.get(preset.id);
if (!base) {
store.custom.push(preset);
continue;
}
seenBuiltin.add(preset.id);
if (JSON.stringify(base) !== JSON.stringify(preset)) {
store.overrides.push(preset);
}
}
for (const id of builtinMap.keys()) {
if (!seenBuiltin.has(id)) {
store.disabledBuiltinPresetIds.push(id);
}
}
await saveVocabPresets(store);
}
32 changes: 26 additions & 6 deletions openless-all/app/src/pages/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { disable as disableAutostart, enable as enableAutostart, isEnabled as isAutostartEnabled } from '@tauri-apps/plugin-autostart';
import { Icon } from '../components/Icon';
import { isDialogStatus, UpdateDialog, useAutoUpdate } from '../components/AutoUpdate';
import { APP_VERSION_LABEL } from '../lib/appVersion';
Expand All @@ -15,6 +14,7 @@ import {
checkMicrophonePermission,
getHotkeyStatus,
getWindowsImeStatus,
isTauri,
openExternal,
openSystemSettings,
listProviderModels,
Expand Down Expand Up @@ -52,6 +52,21 @@ export type SettingsSectionId = 'recording' | 'providers' | 'shortcuts' | 'permi

const SECTION_ORDER: SettingsSectionId[] = ['recording', 'providers', 'shortcuts', 'permissions', 'language', 'about'];

async function autostartIsEnabled(): Promise<boolean> {
const { invoke } = await import('@tauri-apps/api/core');
return invoke<boolean>('plugin:autostart|is_enabled');
}

async function autostartEnable(): Promise<void> {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('plugin:autostart|enable');
}

async function autostartDisable(): Promise<void> {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('plugin:autostart|disable');
}

export function Settings({ embedded = false, initialSection = 'recording' }: SettingsProps) {
const { t } = useTranslation();
const [section, setSection] = useState<SettingsSectionId>(initialSection);
Expand Down Expand Up @@ -269,15 +284,19 @@ function AutostartRow() {
const [error, setError] = useState<string | null>(null);

useEffect(() => {
if (!isTauri) {
setLoaded(true);
return;
}
let cancelled = false;
isAutostartEnabled()
.then(v => {
autostartIsEnabled()
.then((v: boolean) => {
if (!cancelled) {
setEnabled(v);
setLoaded(true);
}
})
.catch(err => {
.catch((err: unknown) => {
console.error('[autostart] isEnabled failed', err);
if (!cancelled) setLoaded(true);
});
Expand All @@ -290,8 +309,9 @@ function AutostartRow() {
setEnabled(next);
setError(null);
try {
if (next) await enableAutostart();
else await disableAutostart();
if (!isTauri) return;
if (next) await autostartEnable();
else await autostartDisable();
} catch (err) {
console.error('[autostart] toggle failed', err);
setEnabled(!next);
Expand Down
Loading
Loading