From f9719f11b5733a02bf2a9583eb018f345a9652d6 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 26 Apr 2026 08:18:47 +0200 Subject: [PATCH] feat: add app localization foundation --- docs/ABSTRACTIONS.md | 14 +- docs/ARCHITECTURE.md | 10 +- docs/GETTING-STARTED.md | 5 +- docs/adr/0084-app-localization-foundation.md | 35 +++ src-tauri/src/settings.rs | 53 ++++ src/App.tsx | 31 +- src/components/CommandPalette.test.tsx | 9 + src/components/CommandPalette.tsx | 43 ++- src/components/SettingsPanel.test.tsx | 46 +++ src/components/SettingsPanel.tsx | 244 +++++++++++----- src/components/note-list/NoteListHeader.tsx | 10 +- src/components/note-list/NoteListLayout.tsx | 173 +++++------ src/components/note-list/NoteListViews.tsx | 22 +- .../note-list/noteListUtils.test.ts | 20 ++ src/components/note-list/noteListUtils.ts | 30 +- src/components/note-list/useNoteListModel.tsx | 8 +- src/hooks/commands/settingsCommands.test.ts | 54 ++++ src/hooks/commands/settingsCommands.ts | 91 +++++- src/hooks/useAppCommands.ts | 13 + src/hooks/useCommandRegistry.ts | 9 +- src/hooks/useSettings.test.ts | 28 +- src/hooks/useSettings.ts | 3 + src/lib/i18n.test.ts | 37 +++ src/lib/i18n.ts | 271 ++++++++++++++++++ src/mock-tauri/mock-handlers.coverage.test.ts | 2 + src/mock-tauri/mock-handlers.ts | 2 + src/types.ts | 2 + 27 files changed, 1069 insertions(+), 196 deletions(-) create mode 100644 docs/adr/0084-app-localization-foundation.md create mode 100644 src/lib/i18n.test.ts create mode 100644 src/lib/i18n.ts diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 2ec8af55..a0530655 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -553,6 +553,17 @@ The app uses internal light and dark themes owned by Tolaria (see [ADR-0081](adr 2. **Editor theme** (`src/theme.json`): BlockNote typography, flattened to CSS vars by `useEditorTheme` 3. **Runtime theme bridge**: Applies `data-theme` and `.dark` for shadcn/ui, while CodeMirror and editor-specific consumers derive any non-CSS-variable values from the same semantic contract +## Localization + +App UI strings are centralized in `src/lib/i18n.ts` (see [ADR-0084](adr/0084-app-localization-foundation.md)): + +- `AppLocale`: currently `'en' | 'zh-Hans'` +- `UiLanguagePreference`: `'system' | AppLocale`; persisted settings serialize `system` as `null` +- `resolveEffectiveLocale()`: maps an explicit preference or system/browser language list to the effective supported locale +- `translate()` / `createTranslator()`: resolve keys with English fallback and simple `{name}` interpolation + +`App.tsx` owns the effective locale and passes it to localized app chrome through props. Settings and command-palette language commands call back into `saveSettings`, so UI language changes update the current session without touching vault content or reopening the vault. + ## Inspector Abstraction The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels: @@ -663,11 +674,12 @@ interface Settings { anonymous_id: string | null release_channel: string | null // null = stable default, "alpha" = every-push prerelease feed theme_mode: 'light' | 'dark' | null + ui_language: 'en' | 'zh-Hans' | null default_ai_agent: 'claude_code' | 'codex' | null } ``` -Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation. +Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation. ## Telemetry diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9efe907e..1d077de8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -32,6 +32,7 @@ Examples: - ✅ Vault: `_pinned_properties` in a Type note (every device should show the same pinned properties) - ✅ Vault: `_icon: shapes` in a Type note (icon is part of the type's identity) - ✅ App settings: `zoom: 1.3` (machine-specific preference) +- ✅ App settings: `ui_language: "zh-Hans"` (installation-specific UI language) ### No hardcoded exceptions @@ -99,6 +100,7 @@ flowchart LR | Frontmatter parsing | gray_matter | 0.2 | | AI (agent panel) | CLI agent adapters (Claude Code + Codex) | - | | Search | Keyword (walkdir-based file scan) | - | +| Localization | App-owned dictionary (`src/lib/i18n.ts`) | English fallback + `zh-Hans` | | MCP | @modelcontextprotocol/sdk | 1.0 | | Tests | Vitest (unit), Playwright (E2E/smoke), cargo test (Rust) | - | | Package manager | pnpm | - | @@ -414,6 +416,12 @@ The app uses internal app-owned light and dark themes (see [ADR-0081](adr/0081-i 2. **Editor theme** (`src/theme.json`): BlockNote-specific typography. Flattened to CSS vars by `useEditorTheme`; editor colors resolve through the same semantic app variables. 3. **Theme runtime**: Applies `data-theme` and the shadcn-compatible `.dark` class before React consumers render, with a localStorage mirror to avoid startup flash when dark mode is selected. +## Localization + +Tolaria's app chrome uses an app-owned localization layer in `src/lib/i18n.ts` (see [ADR-0084](adr/0084-app-localization-foundation.md)). English is the canonical fallback, and Simplified Chinese (`zh-Hans`) is the first additional locale. The installation-local `ui_language` setting stores an explicit locale when the user chooses one; `null` means "follow the system language when Tolaria supports it, otherwise English." Missing translation keys fall back to English so partially translated locales do not render broken placeholders. + +`App.tsx` derives the effective locale from settings and browser/system language hints, then passes it down to localized surfaces. Settings exposes a keyboard-accessible shadcn `Select`, and the command palette includes actions to open language settings or switch directly to a supported language. + ## Vault Management ### Vault List @@ -763,7 +771,7 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks: | `useCommitFlow` | Commit dialog state, shared manual/automatic checkpoint runner | Git commit/push orchestration | | `useGitRemoteStatus` | `remoteStatus`, `refreshRemoteStatus()` | On-demand remote detection for commit UI | | `useUnifiedSearch` | Query, results, loading state | Keyword search | -| `useSettings` | App settings (telemetry, release channel, theme mode, auto-sync interval, AutoGit thresholds, default AI agent) | Persistent settings | +| `useSettings` | App settings (telemetry, release channel, theme mode, UI language, auto-sync interval, AutoGit thresholds, default AI agent) | Persistent settings | | `useVaultConfig` | Per-vault UI preferences | Vault-specific config | | `appCommandDispatcher` | Canonical shortcut/menu command IDs | Shared execution path for renderer and native menu commands | diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index fcfcd1a5..e0dc5642 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -157,6 +157,7 @@ tolaria/ │ ├── lib/ │ │ ├── aiAgents.ts # Shared agent registry + status helpers │ │ ├── appUpdater.ts # Frontend wrapper around channel-aware updater commands +│ │ ├── i18n.ts # App-owned localization dictionary and locale resolution │ │ ├── releaseChannel.ts # Alpha/stable normalization helpers │ │ └── utils.ts # Tailwind merge + cn() helper │ │ @@ -294,12 +295,12 @@ tolaria/ | File | Why it matters | |------|---------------| -| `src/hooks/useSettings.ts` | App settings (telemetry, release channel, theme mode, auto-sync interval, default AI agent). | +| `src/hooks/useSettings.ts` | App settings (telemetry, release channel, theme mode, UI language, auto-sync interval, default AI agent). | | `src/lib/releaseChannel.ts` | Normalizes persisted updater-channel values (`stable` default, optional `alpha`). | | `src/lib/appUpdater.ts` | Frontend wrapper for channel-aware updater commands. | | `src/hooks/useMainWindowSizeConstraints.ts` | Derives the main-window minimum width from the visible panes and asks Tauri to grow back to fit wider layouts. | | `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns, explicit organization workflow). | -| `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, sync interval, default AI agent, and the vault-level explicit organization toggle. | +| `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, sync interval, UI language, default AI agent, and the vault-level explicit organization toggle. | | `src/hooks/useUpdater.ts` | In-app updates using the selected alpha/stable feed. | ## Architecture Patterns diff --git a/docs/adr/0084-app-localization-foundation.md b/docs/adr/0084-app-localization-foundation.md new file mode 100644 index 00000000..e606f4c7 --- /dev/null +++ b/docs/adr/0084-app-localization-foundation.md @@ -0,0 +1,35 @@ +--- +type: ADR +id: "0084" +title: "App-owned localization foundation" +status: active +date: 2026-04-26 +--- + +## Context + +Tolaria was effectively English-only. Users requested a general i18n foundation and Chinese-language support. We need a path that lets the UI adopt additional locales without pushing UI-language preferences into vault files or making every partially translated string a runtime failure. + +## Decision + +Tolaria owns a dependency-free frontend localization layer in `src/lib/i18n.ts`. + +- English is the canonical fallback locale. +- Simplified Chinese (`zh-Hans`) is the first additional locale. +- `ui_language` is an installation-local app setting in `~/.config/com.tolaria.app/settings.json`; `null` means "follow system language when supported, otherwise English". +- Missing translation keys fall back to English. +- App-level chrome receives locale through props from `App.tsx`, following the existing props-down/callbacks-up architecture instead of introducing global React context. +- Language switching is exposed in Settings and through command-palette actions. + +## Alternatives considered + +- **Add an i18n dependency now**: useful long term, but unnecessary for the first locale and would add framework surface before we know Tolaria's locale workflow. +- **Store language in the vault**: rejected because UI language is an installation preference, not content structure. +- **Translate ad hoc strings inline**: rejected because it would make fallback behavior inconsistent and future locales expensive. + +## Consequences + +- New UI strings should be added to `src/lib/i18n.ts` first and rendered through `translate()` / `createTranslator()` where the surface already receives locale. +- Partially translated locales remain usable because English is the fallback for missing keys. +- Locale choice changes UI chrome immediately after settings save or command-palette language commands without reopening the vault. +- Larger feature surfaces can migrate to the shared localization module incrementally. diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 9431272b..a12a11bf 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -18,6 +18,7 @@ pub struct Settings { pub anonymous_id: Option, pub release_channel: Option, pub theme_mode: Option, + pub ui_language: Option, pub initial_h1_auto_rename_enabled: Option, pub default_ai_agent: Option, } @@ -61,6 +62,34 @@ pub fn normalize_theme_mode(value: Option<&str>) -> Option { } } +fn canonical_language_code(value: &str) -> Option { + let code = value.trim().replace('_', "-").to_ascii_lowercase(); + if code.is_empty() { + None + } else { + Some(code) + } +} + +fn is_english_language(code: &str) -> bool { + code == "en" || code.starts_with("en-") +} + +fn is_simplified_chinese_language(code: &str) -> bool { + matches!(code, "zh" | "zh-cn" | "zh-hans" | "zh-sg") +} + +pub fn normalize_ui_language(value: Option<&str>) -> Option { + let language = canonical_language_code(value?)?; + if is_english_language(&language) { + return Some("en".to_string()); + } + if is_simplified_chinese_language(&language) { + return Some("zh-Hans".to_string()); + } + None +} + fn normalize_settings(settings: Settings) -> Settings { Settings { auto_pull_interval_minutes: settings.auto_pull_interval_minutes, @@ -78,6 +107,7 @@ fn normalize_settings(settings: Settings) -> Settings { anonymous_id: normalize_optional_string(settings.anonymous_id), release_channel: normalize_release_channel(settings.release_channel.as_deref()), theme_mode: normalize_theme_mode(settings.theme_mode.as_deref()), + ui_language: normalize_ui_language(settings.ui_language.as_deref()), initial_h1_auto_rename_enabled: settings.initial_h1_auto_rename_enabled, default_ai_agent: normalize_default_ai_agent(settings.default_ai_agent.as_deref()), } @@ -219,6 +249,7 @@ mod tests { anonymous_id: Some("abc-123-uuid".to_string()), release_channel: Some("alpha".to_string()), theme_mode: Some("dark".to_string()), + ui_language: Some("zh-Hans".to_string()), initial_h1_auto_rename_enabled: Some(false), default_ai_agent: Some("codex".to_string()), }; @@ -245,6 +276,7 @@ mod tests { auto_advance_inbox_after_organize: Some(true), release_channel: Some("alpha".to_string()), theme_mode: Some("dark".to_string()), + ui_language: Some("zh-Hans".to_string()), initial_h1_auto_rename_enabled: Some(false), default_ai_agent: Some("codex".to_string()), ..Default::default() @@ -256,6 +288,7 @@ mod tests { assert_eq!(loaded.auto_advance_inbox_after_organize, Some(true)); assert_eq!(loaded.release_channel.as_deref(), Some("alpha")); assert_eq!(loaded.theme_mode.as_deref(), Some("dark")); + assert_eq!(loaded.ui_language.as_deref(), Some("zh-Hans")); assert_eq!(loaded.initial_h1_auto_rename_enabled, Some(false)); assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex")); } @@ -266,12 +299,14 @@ mod tests { anonymous_id: Some(" test-uuid ".to_string()), release_channel: Some(" alpha ".to_string()), theme_mode: Some(" dark ".to_string()), + ui_language: Some(" zh-cn ".to_string()), default_ai_agent: Some(" codex ".to_string()), ..Default::default() }); assert_eq!(loaded.anonymous_id.as_deref(), Some("test-uuid")); assert_eq!(loaded.release_channel.as_deref(), Some("alpha")); assert_eq!(loaded.theme_mode.as_deref(), Some("dark")); + assert_eq!(loaded.ui_language.as_deref(), Some("zh-Hans")); assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex")); } @@ -322,6 +357,24 @@ mod tests { assert!(loaded.theme_mode.is_none()); } + #[test] + fn test_invalid_ui_language_is_filtered() { + let loaded = save_and_reload(Settings { + ui_language: Some("fr-FR".to_string()), + ..Default::default() + }); + assert!(loaded.ui_language.is_none()); + } + + #[test] + fn test_ui_language_aliases_are_canonicalized() { + assert_eq!(normalize_ui_language(Some("en-US")).as_deref(), Some("en")); + assert_eq!( + normalize_ui_language(Some("zh_CN")).as_deref(), + Some("zh-Hans") + ); + } + #[test] fn test_get_settings_normalizes_legacy_beta_channel() { let dir = tempfile::TempDir::new().unwrap(); diff --git a/src/App.tsx b/src/App.tsx index 8043088e..c0cbca75 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -90,6 +90,13 @@ import { openNoteListPropertiesPicker } from './components/note-list/noteListPro import type { NoteListMultiSelectionCommands } from './components/note-list/multiSelectionCommands' import { focusNoteIconPropertyEditor } from './components/noteIconPropertyEvents' import { trackEvent } from './lib/telemetry' +import { + SYSTEM_UI_LANGUAGE, + getBrowserLanguagePreferences, + resolveEffectiveLocale, + serializeUiLanguagePreference, + type UiLanguagePreference, +} from './lib/i18n' import { normalizeReleaseChannel } from './lib/releaseChannel' import { buildVaultAiGuidanceRefreshKey, @@ -375,12 +382,27 @@ function App() { }) }, [updateConfig, vaultConfig.inbox?.noteListProperties]) const { settings, loaded: settingsLoaded, saveSettings } = useSettings() + const systemLocale = useMemo( + () => resolveEffectiveLocale(SYSTEM_UI_LANGUAGE, getBrowserLanguagePreferences()), + [], + ) + const appLocale = useMemo( + () => resolveEffectiveLocale(settings.ui_language, [systemLocale]), + [settings.ui_language, systemLocale], + ) + const selectedUiLanguage = settings.ui_language ?? SYSTEM_UI_LANGUAGE + useEffect(() => { + document.documentElement.lang = appLocale + }, [appLocale]) useThemeMode(settings.theme_mode, settingsLoaded) const documentThemeMode = useDocumentThemeMode() const handleToggleThemeMode = useCallback(() => { const theme_mode = documentThemeMode === 'dark' ? 'light' : 'dark' void saveSettings({ ...settings, theme_mode }) }, [documentThemeMode, saveSettings, settings]) + const handleSetUiLanguage = useCallback((uiLanguage: UiLanguagePreference) => { + void saveSettings({ ...settings, ui_language: serializeUiLanguagePreference(uiLanguage) }) + }, [saveSettings, settings]) const aiAgentPreferences = useAiAgentPreferences({ settings, saveSettings, @@ -1320,6 +1342,10 @@ function App() { onRestoreGettingStarted: cloneGettingStartedVault, isGettingStartedHidden: vaultSwitcher.isGettingStartedHidden, vaultCount: vaultSwitcher.allVaults.length, + locale: appLocale, + systemLocale, + selectedUiLanguage, + onSetUiLanguage: handleSetUiLanguage, mcpStatus, onInstallMcp: openMcpSetupDialog, onOpenAiAgents: dialogs.openSettings, @@ -1459,7 +1485,7 @@ function App() { {effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? ( handleSetViewMode('all')} /> ) : ( - + )} @@ -1537,6 +1563,7 @@ function App() { entries={vault.entries} aiAgentReady={aiAgentPreferences.defaultAiAgentReady} aiAgentLabel={aiAgentPreferences.defaultAiAgentLabel} + locale={appLocale} onClose={dialogs.closeCommandPalette} /> @@ -1570,7 +1597,7 @@ function App() { onCommit={conflictResolver.commitResolution} onClose={conflictFlow.handleCloseConflictResolver} /> - + diff --git a/src/components/CommandPalette.test.tsx b/src/components/CommandPalette.test.tsx index a8a641c6..8966d36a 100644 --- a/src/components/CommandPalette.test.tsx +++ b/src/components/CommandPalette.test.tsx @@ -165,6 +165,15 @@ describe('CommandPalette', () => { expect(screen.getByText('No matching commands')).toBeInTheDocument() }) + it('localizes command palette chrome', () => { + render() + const input = screen.getByPlaceholderText('输入命令...') + fireEvent.change(input, { target: { value: 'zzzzzzz' } }) + + expect(screen.getByText('没有匹配的命令')).toBeInTheDocument() + expect(screen.getByText('↑↓ 导航')).toBeInTheDocument() + }) + it('calls onClose when pressing Escape', () => { render() fireEvent.keyDown(window, { key: 'Escape' }) diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index 088fd962..a5f10ae3 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -7,6 +7,7 @@ import type { NoteReference } from '../utils/ai-context' import type { CommandAction, CommandGroup } from '../hooks/useCommandRegistry' import { groupSortKey } from '../hooks/useCommandRegistry' import { rememberFeedbackDialogOpener } from '../lib/feedbackDialogOpener' +import { createTranslator, type AppLocale } from '../lib/i18n' import { CommandPaletteAiMode } from './CommandPaletteAiMode' import { Input } from './ui/input' @@ -17,6 +18,7 @@ interface CommandPaletteProps { claudeCodeReady?: boolean aiAgentReady?: boolean aiAgentLabel?: string + locale?: AppLocale onClose: () => void } @@ -119,17 +121,19 @@ function CommandPaletteInput({ inputRef, query, onChange, + placeholder, }: { inputRef: React.RefObject query: string onChange: (value: string) => void + placeholder: string }) { return ( + emptyText: string onHover: (index: number) => void onSelect: (command: CommandAction) => void }) { @@ -159,7 +165,7 @@ function CommandPaletteResults({ return (
- No matching commands + {emptyText}
) @@ -207,15 +213,23 @@ function CommandPaletteResults({ function CommandPaletteFooter({ aiMode, aiAgentLabel = 'Claude Code', + footerText, }: { aiMode: boolean aiAgentLabel?: string + footerText: { + aiMode: string + navigate: string + select: string + send: string + close: string + } }) { return (
- {aiMode ? `${aiAgentLabel} mode` : '↑↓ navigate'} - {aiMode ? '↵ send' : '↵ select'} - esc close + {aiMode ? footerText.aiMode.replace('{agent}', aiAgentLabel) : footerText.navigate} + {aiMode ? footerText.send : footerText.select} + {footerText.close}
) } @@ -231,6 +245,7 @@ function OpenCommandPalette({ claudeCodeReady = true, aiAgentReady, aiAgentLabel = 'Claude Code', + locale = 'en', onClose, }: Omit) { const [query, setQuery] = useState('') @@ -242,6 +257,14 @@ function OpenCommandPalette({ const aiMode = aiValue.startsWith(' ') const resolvedAiAgentReady = aiAgentReady ?? claudeCodeReady const { groups, flatList } = usePaletteResults(commands, query) + const t = createTranslator(locale) + const footerText = { + aiMode: t('command.aiMode', { agent: '{agent}' }), + navigate: t('command.footerNavigate'), + select: t('command.footerSelect'), + send: t('command.footerSend'), + close: t('command.footerClose'), + } useLayoutEffect(() => { const target = aiMode ? aiInputRef.current : inputRef.current @@ -364,15 +387,21 @@ function OpenCommandPalette({ /> ) : ( <> - + - + )} diff --git a/src/components/SettingsPanel.test.tsx b/src/components/SettingsPanel.test.tsx index 9b1494c8..633834eb 100644 --- a/src/components/SettingsPanel.test.tsx +++ b/src/components/SettingsPanel.test.tsx @@ -16,6 +16,7 @@ const emptySettings: Settings = { anonymous_id: null, release_channel: null, theme_mode: null, + ui_language: null, } function installPointerCapturePolyfill() { @@ -97,6 +98,51 @@ describe('SettingsPanel', () => { expect(screen.getByRole('radio', { name: 'Dark' })).toHaveAttribute('aria-checked', 'false') }) + it('defaults the language selector to system language', () => { + render( + + ) + + expect(screen.getByTestId('settings-ui-language')).toHaveAttribute('data-value', 'system') + expect(screen.getByText('系统(简体中文)')).toBeInTheDocument() + }) + + it('keeps the language selector keyboard accessible', () => { + render( + + ) + + const trigger = screen.getByTestId('settings-ui-language') + trigger.focus() + fireEvent.keyDown(trigger, { key: 'ArrowDown', code: 'ArrowDown' }) + + expect(screen.getByRole('option', { name: 'Simplified Chinese' })).toBeInTheDocument() + }) + + it('saves the selected UI language and updates visible settings text', () => { + render( + + ) + + fireEvent.pointerDown(screen.getByTestId('settings-ui-language'), { button: 0, pointerType: 'mouse' }) + fireEvent.click(screen.getByRole('option', { name: 'Simplified Chinese' })) + + expect(screen.getByText('设置')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('settings-save')) + + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + ui_language: 'zh-Hans', + })) + }) + it('uses the stored color mode mirror when settings have no saved mode', () => { window.localStorage.setItem(THEME_MODE_STORAGE_KEY, 'dark') diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 82f9ebb3..3b6f8548 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -17,6 +17,15 @@ import { } from 'react' import { Moon, Sun, X } from '@phosphor-icons/react' import type { Settings } from '../types' +import { + SYSTEM_UI_LANGUAGE, + createTranslator, + localeDisplayName, + resolveEffectiveLocale, + serializeUiLanguagePreference, + type AppLocale, + type UiLanguagePreference, +} from '../lib/i18n' import { DEFAULT_THEME_MODE, readStoredThemeMode, @@ -40,6 +49,8 @@ interface SettingsPanelProps { open: boolean settings: Settings aiAgentsStatus?: AiAgentsStatus + locale?: AppLocale + systemLocale?: AppLocale onSave: (settings: Settings) => void isGitVault?: boolean explicitOrganizationEnabled?: boolean @@ -56,6 +67,7 @@ interface SettingsDraft { defaultAiAgent: AiAgentId releaseChannel: ReleaseChannel themeMode: ThemeMode + uiLanguage: UiLanguagePreference initialH1AutoRename: boolean crashReporting: boolean analytics: boolean @@ -63,6 +75,7 @@ interface SettingsDraft { } interface SettingsBodyProps { + t: Translate pullInterval: number setPullInterval: (value: number) => void isGitVault: boolean @@ -81,6 +94,10 @@ interface SettingsBodyProps { setReleaseChannel: (value: ReleaseChannel) => void themeMode: ThemeMode setThemeMode: (value: ThemeMode) => void + uiLanguage: UiLanguagePreference + setUiLanguage: (value: UiLanguagePreference) => void + locale: AppLocale + systemLocale: AppLocale initialH1AutoRename: boolean setInitialH1AutoRename: (value: boolean) => void explicitOrganization: boolean @@ -94,6 +111,7 @@ interface SettingsBodyProps { const PULL_INTERVAL_OPTIONS = [1, 2, 5, 10, 15, 30] as const const DEFAULT_AUTOGIT_IDLE_THRESHOLD_SECONDS = 90 const DEFAULT_AUTOGIT_INACTIVE_THRESHOLD_SECONDS = 30 +type Translate = ReturnType function isSaveShortcut(event: ReactKeyboardEvent): boolean { return event.key === 'Enter' && (event.metaKey || event.ctrlKey) @@ -118,6 +136,7 @@ function createSettingsDraft( defaultAiAgent: resolveDefaultAiAgent(settings.default_ai_agent), releaseChannel: normalizeReleaseChannel(settings.release_channel), themeMode: resolveSettingsDraftThemeMode(settings.theme_mode), + uiLanguage: settings.ui_language ?? SYSTEM_UI_LANGUAGE, initialH1AutoRename: settings.initial_h1_auto_rename_enabled ?? true, crashReporting: settings.crash_reporting_enabled ?? false, analytics: settings.analytics_enabled ?? false, @@ -157,6 +176,7 @@ function buildSettingsFromDraft(settings: Settings, draft: SettingsDraft): Setti anonymous_id: resolveAnonymousId(settings, draft), release_channel: serializeReleaseChannel(draft.releaseChannel), theme_mode: draft.themeMode, + ui_language: serializeUiLanguagePreference(draft.uiLanguage), initial_h1_auto_rename_enabled: draft.initialH1AutoRename, default_ai_agent: draft.defaultAiAgent, } @@ -180,6 +200,8 @@ export function SettingsPanel({ open, settings, aiAgentsStatus = createMissingAiAgentsStatus(), + locale = 'en', + systemLocale = locale, onSave, isGitVault = true, explicitOrganizationEnabled = true, @@ -192,6 +214,8 @@ export function SettingsPanel({ & { aiAgentsStatus: AiAgentsStatus + locale: AppLocale + systemLocale: AppLocale isGitVault: boolean explicitOrganizationEnabled: boolean } @@ -210,6 +236,7 @@ type SettingsPanelInnerProps = Omit createSettingsDraft(settings, explicitOrganizationEnabled)) const panelRef = useRef(null) + const draftLocale = resolveEffectiveLocale(draft.uiLanguage, [systemLocale]) + const t = createTranslator(draftLocale) useEffect(() => { const timer = setTimeout(() => { @@ -277,8 +306,11 @@ function SettingsPanelInner({ className="rounded-lg border border-border bg-background shadow-[0_18px_55px_var(--shadow-dialog)]" style={{ width: 520, maxHeight: '80vh', display: 'flex', flexDirection: 'column' }} > - + updateDraft('pullInterval', value)} isGitVault={isGitVault} @@ -297,6 +329,8 @@ function SettingsPanelInner({ setReleaseChannel={(value) => updateDraft('releaseChannel', value)} themeMode={draft.themeMode} setThemeMode={(value) => updateDraft('themeMode', value)} + uiLanguage={draft.uiLanguage} + setUiLanguage={(value) => updateDraft('uiLanguage', value)} initialH1AutoRename={draft.initialH1AutoRename} setInitialH1AutoRename={(value) => updateDraft('initialH1AutoRename', value)} explicitOrganization={draft.explicitOrganization} @@ -306,25 +340,25 @@ function SettingsPanelInner({ analytics={draft.analytics} setAnalytics={(value) => updateDraft('analytics', value)} /> - + ) } -function SettingsHeader({ onClose }: { onClose: () => void }) { +function SettingsHeader({ onClose, t }: { onClose: () => void; t: Translate }) { return (
- Settings + {t('settings.title')} @@ -333,6 +367,9 @@ function SettingsHeader({ onClose }: { onClose: () => void }) { } function SettingsBody({ + t, + locale, + systemLocale, pullInterval, setPullInterval, isGitVault, @@ -351,6 +388,8 @@ function SettingsBody({ setReleaseChannel, themeMode, setThemeMode, + uiLanguage, + setUiLanguage, initialH1AutoRename, setInitialH1AutoRename, explicitOrganization, @@ -364,6 +403,7 @@ function SettingsBody({
+ + + + @@ -399,6 +452,7 @@ function SettingsBody({ ) { +}: Pick) { return ( <> setPullInterval(Number(value))} options={PULL_INTERVAL_OPTIONS.map((value) => ({ @@ -452,12 +509,12 @@ function SyncAndUpdatesSection({ /> setReleaseChannel(value as ReleaseChannel)} options={[ - { value: 'stable', label: 'Stable' }, - { value: 'alpha', label: 'Alpha' }, + { value: 'stable', label: t('settings.releaseStable') }, + { value: 'alpha', label: t('settings.releaseAlpha') }, ]} testId="settings-release-channel" /> @@ -466,17 +523,18 @@ function SyncAndUpdatesSection({ } function AppearanceSettingsSection({ + t, themeMode, setThemeMode, -}: Pick) { +}: Pick) { return ( <> - + ) } @@ -484,21 +542,23 @@ function AppearanceSettingsSection({ function ThemeModeControl({ value, onChange, + t, }: { value: ThemeMode onChange: (value: ThemeMode) => void + t: Translate }) { return (
- + - +
@@ -540,13 +600,14 @@ function ThemeModeButton({ ) } -function autoGitSectionDescription(isGitVault: boolean): string { +function autoGitSectionDescription(isGitVault: boolean, t: Translate): string { return isGitVault - ? 'Automatically create conservative Git checkpoints after editing pauses or when the app is no longer active.' - : 'AutoGit is unavailable until the current vault is Git-enabled. Initialize Git for this vault first.' + ? t('settings.autogit.description.enabled') + : t('settings.autogit.description.disabled') } function AutoGitSettingsSection({ + t, isGitVault, autoGitEnabled, setAutoGitEnabled, @@ -556,6 +617,7 @@ function AutoGitSettingsSection({ setAutoGitInactiveThresholdSeconds, }: Pick< SettingsBodyProps, + | 't' | 'isGitVault' | 'autoGitEnabled' | 'setAutoGitEnabled' @@ -567,13 +629,13 @@ function AutoGitSettingsSection({ return ( <> ) { +function buildLanguageOptions(t: Translate, locale: AppLocale, systemLocale: AppLocale) { + return [ + { + value: SYSTEM_UI_LANGUAGE, + label: t('settings.language.system', { + language: localeDisplayName(systemLocale, locale), + }), + }, + { value: 'en', label: t('settings.language.en') }, + { value: 'zh-Hans', label: t('settings.language.zhHans') }, + ] +} + +function LanguageSettingsSection({ + t, + locale, + systemLocale, + uiLanguage, + setUiLanguage, +}: Pick) { return ( <> + + setUiLanguage(value as UiLanguagePreference)} + options={buildLanguageOptions(t, locale, systemLocale)} + testId="settings-ui-language" + /> + +
+ {t('settings.language.summary')} +
+ + ) +} + +function TitleSettingsSection({ + t, + initialH1AutoRename, + setInitialH1AutoRename, +}: Pick) { + return ( + <> + { +function buildDefaultAiAgentOptions(aiAgentsStatus: AiAgentsStatus, t: Translate): Array<{ value: string; label: string }> { return AI_AGENT_DEFINITIONS.map((definition) => { const status = aiAgentsStatus[definition.id] const suffix = status.status === 'installed' - ? ` (installed${status.version ? ` ${status.version}` : ''})` - : ' (missing)' + ? ` (${t('settings.aiAgents.installed')}${status.version ? ` ${status.version}` : ''})` + : ` (${t('settings.aiAgents.missing')})` return { value: definition.id, label: `${definition.label}${suffix}`, @@ -635,55 +740,57 @@ function buildDefaultAiAgentOptions(aiAgentsStatus: AiAgentsStatus): Array<{ val } function AiAgentSettingsSection({ + t, aiAgentsStatus, defaultAiAgent, setDefaultAiAgent, -}: Pick) { +}: Pick) { return ( <> setDefaultAiAgent(value as AiAgentId)} - options={buildDefaultAiAgentOptions(aiAgentsStatus)} + options={buildDefaultAiAgentOptions(aiAgentsStatus, t)} testId="settings-default-ai-agent" />
- {renderDefaultAiAgentSummary(defaultAiAgent, aiAgentsStatus)} + {renderDefaultAiAgentSummary(defaultAiAgent, aiAgentsStatus, t)}
) } function PrivacySettingsSection({ + t, crashReporting, setCrashReporting, analytics, setAnalytics, -}: Pick) { +}: Pick) { return ( <> } -function renderDefaultAiAgentSummary(defaultAiAgent: AiAgentId, aiAgentsStatus: AiAgentsStatus): string { +function renderDefaultAiAgentSummary(defaultAiAgent: AiAgentId, aiAgentsStatus: AiAgentsStatus, t: Translate): string { const definition = getAiAgentDefinition(defaultAiAgent) const status = aiAgentsStatus[defaultAiAgent] if (status.status === 'installed') { - return `${definition.label}${status.version ? ` ${status.version}` : ''} is ready to use.` + return t('settings.aiAgents.ready', { + agent: definition.label, + version: status.version ? ` ${status.version}` : '', + }) } - return `${definition.label} is not installed yet. You can still select it now and install it later.` + return t('settings.aiAgents.notInstalled', { agent: definition.label }) } function LabeledSelect({ @@ -818,11 +928,13 @@ function LabeledNumberInput({ } function OrganizationWorkflowSection({ + t, checked, onChange, autoAdvanceInboxAfterOrganize, onChangeAutoAdvanceInboxAfterOrganize, }: { + t: Translate checked: boolean onChange: (value: boolean) => void autoAdvanceInboxAfterOrganize: boolean @@ -831,21 +943,21 @@ function OrganizationWorkflowSection({ return ( <> void; onSave: () => void }) { +function SettingsFooter({ onClose, onSave, t }: { onClose: () => void; onSave: () => void; t: Translate }) { return (
- {'\u2318'}, to open settings + {t('settings.footerShortcut')}
diff --git a/src/components/note-list/NoteListHeader.tsx b/src/components/note-list/NoteListHeader.tsx index f8245911..b12f6d7f 100644 --- a/src/components/note-list/NoteListHeader.tsx +++ b/src/components/note-list/NoteListHeader.tsx @@ -2,6 +2,7 @@ import { MagnifyingGlass, Plus } from '@phosphor-icons/react' import { Loader2 } from 'lucide-react' import type { VaultEntry } from '../../types' import type { SortOption, SortDirection } from '../../utils/noteListHelpers' +import { translate, type AppLocale } from '../../lib/i18n' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { useDragRegion } from '../../hooks/useDragRegion' @@ -10,7 +11,7 @@ import { ListPropertiesPopover, type ListPropertiesPopoverProps } from './ListPr const NOTE_LIST_ACTION_BUTTON_CLASSNAME = '!h-auto !w-auto !min-w-0 !rounded-none !p-0 !text-muted-foreground hover:!bg-transparent hover:!text-foreground focus-visible:!bg-transparent data-[state=open]:!bg-transparent data-[state=open]:!text-foreground [&_svg]:!size-4' -export function NoteListHeader({ title, typeDocument, isEntityView, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, isSearching, searchInputRef, propertyPicker, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange, onSearchKeyDown }: { +export function NoteListHeader({ title, typeDocument, isEntityView, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, isSearching, searchInputRef, propertyPicker, locale = 'en', onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange, onSearchKeyDown }: { title: string typeDocument: VaultEntry | null isEntityView: boolean @@ -23,6 +24,7 @@ export function NoteListHeader({ title, typeDocument, isEntityView, listSort, li isSearching: boolean searchInputRef: React.RefObject propertyPicker?: ListPropertiesPopoverProps | null + locale?: AppLocale onSortChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void onCreateNote: () => void onOpenType: (entry: VaultEntry) => void @@ -44,11 +46,11 @@ export function NoteListHeader({ title, typeDocument, isEntityView, listSort, li
{!isEntityView && } - {propertyPicker && } -
@@ -58,7 +60,7 @@ export function NoteListHeader({ title, typeDocument, isEntityView, listSort, li
onSearchChange(e.target.value)} onKeyDown={onSearchKeyDown} diff --git a/src/components/note-list/NoteListLayout.tsx b/src/components/note-list/NoteListLayout.tsx index 30f1bdf7..ee7e46f7 100644 --- a/src/components/note-list/NoteListLayout.tsx +++ b/src/components/note-list/NoteListLayout.tsx @@ -46,6 +46,7 @@ function NoteListContent({ modifiedFilesError, searched, noteListVirtuosoRef, + locale, }: Pick< NoteListLayoutProps, | 'entitySelection' @@ -62,6 +63,7 @@ function NoteListContent({ | 'modifiedFilesError' | 'searched' | 'noteListVirtuosoRef' + | 'locale' >) { return (
@@ -75,6 +77,7 @@ function NoteListContent({ onToggleGroup={toggleGroup} onSortChange={handleSortChange} renderItem={renderItem} + locale={locale} /> ) : ( )}
@@ -112,6 +116,7 @@ function NoteListBody({ isInboxView, modifiedFilesError, searched, + locale, showFilterPills, noteListFilter, filterCounts, @@ -137,6 +142,7 @@ function NoteListBody({ | 'isInboxView' | 'modifiedFilesError' | 'searched' + | 'locale' | 'showFilterPills' | 'noteListFilter' | 'filterCounts' @@ -169,6 +175,7 @@ function NoteListBody({ modifiedFilesError={modifiedFilesError} searched={searched} noteListVirtuosoRef={noteListVirtuosoRef} + locale={locale} /> {showFilterPills && ( ) { + return ( + + ) +} + +function NoteListFooter({ multiSelect, + isArchivedView, handleBulkOrganize, handleBulkArchive, handleBulkDeletePermanently, handleBulkUnarchive, contextMenuNode, dialogNode, +}: Pick< + NoteListLayoutProps, + | 'multiSelect' + | 'isArchivedView' + | 'handleBulkOrganize' + | 'handleBulkArchive' + | 'handleBulkDeletePermanently' + | 'handleBulkUnarchive' + | 'contextMenuNode' + | 'dialogNode' +>) { + return ( + <> + + {contextMenuNode}{dialogNode} + + ) +} + +export function NoteListLayout({ + noteListPanelRef, + handleNoteListPanelBlurCapture, + handleNoteListPanelFocusCapture, + ...contentProps }: NoteListLayoutProps) { return (
- - - - {contextMenuNode} - {dialogNode} + + +
) } diff --git a/src/components/note-list/NoteListViews.tsx b/src/components/note-list/NoteListViews.tsx index 2f8dfd58..e000519e 100644 --- a/src/components/note-list/NoteListViews.tsx +++ b/src/components/note-list/NoteListViews.tsx @@ -1,6 +1,7 @@ import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso' import type { VaultEntry } from '../../types' import type { SortOption, SortDirection, SortConfig, RelationshipGroup } from '../../utils/noteListHelpers' +import { translate, type AppLocale } from '../../lib/i18n' import { PinnedCard } from './PinnedCard' import { RelationshipGroupSection } from './RelationshipGroupSection' import { EmptyMessage } from './TrashWarningBanner' @@ -11,31 +12,34 @@ function resolveEmptyText({ isArchivedView, isInboxView, query, + locale, }: { isChangesView: boolean changesError: string | null | undefined isArchivedView: boolean isInboxView: boolean query: string + locale: AppLocale }): string { - if (isChangesView && changesError) return `Failed to load changes: ${changesError}` - if (isChangesView) return 'No pending changes' - if (isArchivedView) return 'No archived notes' - if (isInboxView) return query ? 'No matching notes' : 'All notes are organized' - return query ? 'No matching notes' : 'No notes found' + if (isChangesView && changesError) return translate(locale, 'noteList.empty.changesError', { error: changesError }) + if (isChangesView) return translate(locale, 'noteList.empty.noChanges') + if (isArchivedView) return translate(locale, 'noteList.empty.noArchived') + if (isInboxView) return query ? translate(locale, 'noteList.empty.noMatching') : translate(locale, 'noteList.empty.allOrganized') + return query ? translate(locale, 'noteList.empty.noMatching') : translate(locale, 'noteList.empty.noNotes') } -export function EntityView({ entity, groups, query, collapsedGroups, sortPrefs, onToggleGroup, onSortChange, renderItem }: { +export function EntityView({ entity, groups, query, collapsedGroups, sortPrefs, onToggleGroup, onSortChange, renderItem, locale = 'en' }: { entity: VaultEntry; groups: RelationshipGroup[]; query: string collapsedGroups: Set; sortPrefs: Record onToggleGroup: (label: string) => void; onSortChange: (label: string, opt: SortOption, dir: SortDirection) => void renderItem: (entry: VaultEntry, options?: { forceSelected?: boolean }) => React.ReactNode + locale?: AppLocale }) { return (
{groups.length === 0 - ? + ? : groups.map((group) => ( onToggleGroup(group.label)} handleSortChange={onSortChange} renderItem={renderItem} /> )) @@ -44,11 +48,12 @@ export function EntityView({ entity, groups, query, collapsedGroups, sortPrefs, ) } -export function ListView({ isArchivedView, isChangesView, isInboxView, changesError, searched, query, renderItem, virtuosoRef }: { +export function ListView({ isArchivedView, isChangesView, isInboxView, changesError, searched, query, renderItem, virtuosoRef, locale = 'en' }: { isArchivedView?: boolean; isChangesView?: boolean; isInboxView?: boolean; changesError?: string | null searched: VaultEntry[]; query: string renderItem: (entry: VaultEntry) => React.ReactNode virtuosoRef?: React.RefObject + locale?: AppLocale }) { const emptyText = resolveEmptyText({ isChangesView: !!isChangesView, @@ -56,6 +61,7 @@ export function ListView({ isArchivedView, isChangesView, isInboxView, changesEr isArchivedView: !!isArchivedView, isInboxView: !!isInboxView, query, + locale, }) if (searched.length === 0) { diff --git a/src/components/note-list/noteListUtils.test.ts b/src/components/note-list/noteListUtils.test.ts index e0ef54e0..e7bc2539 100644 --- a/src/components/note-list/noteListUtils.test.ts +++ b/src/components/note-list/noteListUtils.test.ts @@ -37,6 +37,26 @@ describe('resolveHeaderTitle', () => { const selection: SidebarSelection = { kind: 'filter', filter: 'pulse' } expect(resolveHeaderTitle(selection, null)).toBe('History') }) + + it('localizes built-in note list titles', () => { + const selection: SidebarSelection = { kind: 'filter', filter: 'archived' } + expect(resolveHeaderTitle(selection, null, [], 'zh-Hans')).toBe('归档') + }) + + it('keeps user-authored view names unchanged', () => { + const selection: SidebarSelection = { kind: 'view', filename: 'custom.yml' } + + expect(resolveHeaderTitle(selection, null, [{ + filename: 'custom.yml', + definition: { + name: '客户', + icon: null, + color: null, + sort: null, + filters: { all: [] }, + }, + }], 'en')).toBe('客户') + }) }) describe('routeNoteClick', () => { diff --git a/src/components/note-list/noteListUtils.ts b/src/components/note-list/noteListUtils.ts index ed640260..288dbc79 100644 --- a/src/components/note-list/noteListUtils.ts +++ b/src/components/note-list/noteListUtils.ts @@ -1,5 +1,6 @@ -import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, ViewFile } from '../../types' +import type { VaultEntry, SidebarSelection, SidebarFilter, ModifiedFile, NoteStatus, ViewFile } from '../../types' import type { RelationshipGroup } from '../../utils/noteListHelpers' +import { translate, type AppLocale } from '../../lib/i18n' import { filenameStemToTitle } from '../../utils/noteTitle' export interface DeletedNoteEntry extends VaultEntry { @@ -10,27 +11,34 @@ export interface DeletedNoteEntry extends VaultEntry { __changeBinary: boolean } -const FILTER_TITLES: Partial> = { - archived: 'Archive', - changes: 'Changes', - inbox: 'Inbox', - pulse: 'History', +const FILTER_TITLE_KEYS = { + archived: 'noteList.title.archive', + changes: 'noteList.title.changes', + inbox: 'noteList.title.inbox', + pulse: 'noteList.title.history', +} as const + +type LocalizedFilter = keyof typeof FILTER_TITLE_KEYS + +function isLocalizedFilter(filter: SidebarFilter): filter is LocalizedFilter { + return filter in FILTER_TITLE_KEYS } -function resolveSelectionFilterTitle(selection: SidebarSelection): string | null { +function resolveSelectionFilterTitle(selection: SidebarSelection, locale: AppLocale): string | null { if (selection.kind !== 'filter') return null - return FILTER_TITLES[selection.filter as keyof typeof FILTER_TITLES] ?? null + if (!isLocalizedFilter(selection.filter)) return null + return translate(locale, FILTER_TITLE_KEYS[selection.filter]) } -export function resolveHeaderTitle(selection: SidebarSelection, typeDocument: VaultEntry | null, views?: ViewFile[]): string { +export function resolveHeaderTitle(selection: SidebarSelection, typeDocument: VaultEntry | null, views?: ViewFile[], locale: AppLocale = 'en'): string { if (selection.kind === 'view') { const view = views?.find((v) => v.filename === selection.filename) - return view?.definition.name ?? 'View' + return view?.definition.name ?? translate(locale, 'noteList.title.view') } if (selection.kind === 'entity') return selection.entry.title if (typeDocument) return typeDocument.title - return resolveSelectionFilterTitle(selection) ?? 'Notes' + return resolveSelectionFilterTitle(selection, locale) ?? translate(locale, 'noteList.title.notes') } export function filterByQuery(items: T[], query: string): T[] { diff --git a/src/components/note-list/useNoteListModel.tsx b/src/components/note-list/useNoteListModel.tsx index eded6a9d..f364d957 100644 --- a/src/components/note-list/useNoteListModel.tsx +++ b/src/components/note-list/useNoteListModel.tsx @@ -8,6 +8,7 @@ import type { ViewDefinition, ViewFile, } from '../../types' +import type { AppLocale } from '../../lib/i18n' import type { NoteListFilter } from '../../utils/noteListHelpers' import { countByFilter, countAllByFilter, countAllNotesByFilter } from '../../utils/noteListHelpers' import { NoteItem } from '../NoteItem' @@ -428,6 +429,7 @@ export interface NoteListProps { onUpdateViewDefinition?: (filename: string, patch: Partial) => void views?: ViewFile[] visibleNotesRef?: React.MutableRefObject + locale?: AppLocale } function buildNoteListLayoutModel(params: { @@ -439,6 +441,7 @@ function buildNoteListLayoutModel(params: { filterCounts: ReturnType onNoteListFilterChange: (filter: NoteListFilter) => void onOpenType: (entry: VaultEntry) => void + locale: AppLocale content: ReturnType & { handleSearchKeyDown: (event: React.KeyboardEvent) => void } @@ -448,7 +451,8 @@ function buildNoteListLayoutModel(params: { } }) { return { - title: resolveHeaderTitle(params.selection, params.content.typeDocument, params.views), + title: resolveHeaderTitle(params.selection, params.content.typeDocument, params.views, params.locale), + locale: params.locale, typeDocument: params.content.typeDocument, isEntityView: params.content.isEntityView, listSort: params.content.listSort, @@ -531,6 +535,7 @@ export function useNoteListModel({ onUpdateViewDefinition, views, visibleNotesRef, + locale = 'en', }: NoteListProps) { const selectedNotePath = selectedNote?.path ?? null const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus) @@ -626,6 +631,7 @@ export function useNoteListModel({ noteListFilter, filterCounts, onNoteListFilterChange, + locale, content: { ...content, handleSearchKeyDown, diff --git a/src/hooks/commands/settingsCommands.test.ts b/src/hooks/commands/settingsCommands.test.ts index 0167cf26..7c4d91e0 100644 --- a/src/hooks/commands/settingsCommands.test.ts +++ b/src/hooks/commands/settingsCommands.test.ts @@ -31,6 +31,60 @@ describe('buildSettingsCommands', () => { }) }) + it('adds a discoverable language settings command', () => { + const onOpenSettings = vi.fn() + + const commands = buildSettingsCommands({ onOpenSettings }) + const command = commands.find((item) => item.id === 'open-language-settings') + + expect(command).toMatchObject({ + label: 'Open Language Settings', + enabled: true, + group: 'Settings', + }) + + command?.execute() + expect(onOpenSettings).toHaveBeenCalledTimes(1) + }) + + it('adds language switch commands when a setter is available', () => { + const onOpenSettings = vi.fn() + const onSetUiLanguage = vi.fn() + + const commands = buildSettingsCommands({ + onOpenSettings, + selectedUiLanguage: 'en', + onSetUiLanguage, + }) + + const chinese = commands.find((item) => item.id === 'switch-language-zh-hans') + expect(chinese).toMatchObject({ + label: 'Switch Language to Simplified Chinese', + enabled: true, + }) + + chinese?.execute() + expect(onSetUiLanguage).toHaveBeenCalledWith('zh-Hans') + }) + + it('localizes language commands', () => { + const commands = buildSettingsCommands({ + onOpenSettings: vi.fn(), + locale: 'zh-Hans', + systemLocale: 'zh-Hans', + selectedUiLanguage: 'system', + onSetUiLanguage: vi.fn(), + }) + + expect(commands.find((item) => item.id === 'open-language-settings')).toMatchObject({ + label: '打开语言设置', + }) + expect(commands.find((item) => item.id === 'use-system-language')).toMatchObject({ + label: '使用系统语言 (简体中文)', + enabled: false, + }) + }) + it('adds a create-empty-vault command when the handler is available', () => { const onOpenSettings = vi.fn() const onCreateEmptyVault = vi.fn() diff --git a/src/hooks/commands/settingsCommands.ts b/src/hooks/commands/settingsCommands.ts index f617f6d0..9b94b28c 100644 --- a/src/hooks/commands/settingsCommands.ts +++ b/src/hooks/commands/settingsCommands.ts @@ -1,6 +1,13 @@ import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../appCommandCatalog' import type { CommandAction } from './types' import { rememberFeedbackDialogOpener } from '../../lib/feedbackDialogOpener' +import { + SYSTEM_UI_LANGUAGE, + createTranslator, + localeDisplayName, + type AppLocale, + type UiLanguagePreference, +} from '../../lib/i18n' interface SettingsCommandsConfig { mcpStatus?: string @@ -16,18 +23,36 @@ interface SettingsCommandsConfig { onInstallMcp?: () => void onReloadVault?: () => void onRepairVault?: () => void + locale?: AppLocale + systemLocale?: AppLocale + selectedUiLanguage?: UiLanguagePreference + onSetUiLanguage?: (language: UiLanguagePreference) => void +} + +function commandKeywords(raw: string): string[] { + return raw.split(/\s+/).filter(Boolean) } function buildPrimarySettingsCommands({ + locale = 'en', onOpenSettings, onOpenFeedback, onCheckForUpdates, -}: Pick): CommandAction[] { +}: Pick): CommandAction[] { + const t = createTranslator(locale) return [ - { id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.appSettings), keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings }, + { + id: 'open-settings', + label: t('command.openSettings'), + group: 'Settings', + shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.appSettings), + keywords: commandKeywords(t('command.openSettings.keywords')), + enabled: true, + execute: onOpenSettings, + }, { id: 'open-h1-auto-rename-setting', - label: 'Open H1 Auto-Rename Setting', + label: t('command.openH1Setting'), group: 'Settings', keywords: ['h1', 'title', 'filename', 'rename', 'auto', 'untitled', 'sync', 'preference'], enabled: true, @@ -35,7 +60,7 @@ function buildPrimarySettingsCommands({ }, { id: 'open-contribute', - label: 'Contribute', + label: t('command.contribute'), group: 'Settings', keywords: ['contribute', 'feedback', 'feature', 'canny', 'discussion', 'github', 'bug', 'report'], enabled: !!onOpenFeedback, @@ -44,7 +69,53 @@ function buildPrimarySettingsCommands({ onOpenFeedback?.() }, }, - { id: 'check-updates', label: 'Check for Updates', group: 'Settings', keywords: ['update', 'version', 'upgrade', 'release'], enabled: true, execute: () => onCheckForUpdates?.() }, + { id: 'check-updates', label: t('command.checkUpdates'), group: 'Settings', keywords: ['update', 'version', 'upgrade', 'release'], enabled: true, execute: () => onCheckForUpdates?.() }, + ] +} + +function buildLanguageCommands({ + locale = 'en', + systemLocale = locale, + selectedUiLanguage = SYSTEM_UI_LANGUAGE, + onOpenSettings, + onSetUiLanguage, +}: Pick): CommandAction[] { + const t = createTranslator(locale) + const canSwitchLanguage = !!onSetUiLanguage + + return [ + { + id: 'open-language-settings', + label: t('command.openLanguageSettings'), + group: 'Settings', + keywords: commandKeywords(t('command.openLanguageSettings.keywords')), + enabled: true, + execute: onOpenSettings, + }, + { + id: 'use-system-language', + label: `${t('command.useSystemLanguage')} (${localeDisplayName(systemLocale, locale)})`, + group: 'Settings', + keywords: ['language', 'locale', 'system', 'auto'], + enabled: canSwitchLanguage && selectedUiLanguage !== SYSTEM_UI_LANGUAGE, + execute: () => onSetUiLanguage?.(SYSTEM_UI_LANGUAGE), + }, + { + id: 'switch-language-en', + label: t('command.switchToEnglish'), + group: 'Settings', + keywords: ['language', 'locale', 'english', 'en'], + enabled: canSwitchLanguage && selectedUiLanguage !== 'en', + execute: () => onSetUiLanguage?.('en'), + }, + { + id: 'switch-language-zh-hans', + label: t('command.switchToChinese'), + group: 'Settings', + keywords: ['language', 'locale', 'chinese', 'simplified', 'zh', '中文'], + enabled: canSwitchLanguage && selectedUiLanguage !== 'zh-Hans', + execute: () => onSetUiLanguage?.('zh-Hans'), + }, ] } @@ -89,10 +160,18 @@ export function buildSettingsCommands(config: SettingsCommandsConfig): CommandAc mcpStatus, vaultCount, isGettingStartedHidden, onOpenSettings, onOpenFeedback, onOpenVault, onCreateEmptyVault, onRemoveActiveVault, onRestoreGettingStarted, onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault, + locale = 'en', systemLocale = locale, selectedUiLanguage = SYSTEM_UI_LANGUAGE, onSetUiLanguage, } = config return [ - ...buildPrimarySettingsCommands({ onOpenSettings, onOpenFeedback, onCheckForUpdates }), + ...buildPrimarySettingsCommands({ locale, onOpenSettings, onOpenFeedback, onCheckForUpdates }), + ...buildLanguageCommands({ + locale, + systemLocale, + selectedUiLanguage, + onOpenSettings, + onSetUiLanguage, + }), ...buildVaultSettingsCommands({ vaultCount, isGettingStartedHidden, diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index 7f8802b3..14bdb7f1 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -1,5 +1,6 @@ import { useCallback, useRef } from 'react' import type { AiAgentId, AiAgentsStatus } from '../lib/aiAgents' +import type { AppLocale, UiLanguagePreference } from '../lib/i18n' import type { VaultAiGuidanceStatus } from '../lib/vaultAiGuidance' import { useAppKeyboard } from './useAppKeyboard' import { useCommandRegistry } from './useCommandRegistry' @@ -66,6 +67,10 @@ interface AppCommandsConfig { onRestoreGettingStarted?: () => void isGettingStartedHidden?: boolean vaultCount?: number + locale?: AppLocale + systemLocale?: AppLocale + selectedUiLanguage?: UiLanguagePreference + onSetUiLanguage?: (language: UiLanguagePreference) => void mcpStatus?: string onInstallMcp?: () => void aiAgentsStatus?: AiAgentsStatus @@ -149,6 +154,10 @@ type CommandRegistryVaultActions = Pick< | 'canAddRemote' | 'onCheckForUpdates' | 'onCreateType' + | 'locale' + | 'systemLocale' + | 'selectedUiLanguage' + | 'onSetUiLanguage' | 'onRemoveActiveVault' | 'onRestoreGettingStarted' | 'isGettingStartedHidden' @@ -398,6 +407,10 @@ function createCommandRegistryVaultConfig( canAddRemote: config.canAddRemote ?? true, onCheckForUpdates: config.onCheckForUpdates, onCreateType: config.onCreateType, + locale: config.locale, + systemLocale: config.systemLocale, + selectedUiLanguage: config.selectedUiLanguage, + onSetUiLanguage: config.onSetUiLanguage, onRemoveActiveVault: config.onRemoveActiveVault, onRestoreGettingStarted: config.onRestoreGettingStarted, isGettingStartedHidden: config.isGettingStartedHidden, diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts index 9a046b4d..0df3af42 100644 --- a/src/hooks/useCommandRegistry.ts +++ b/src/hooks/useCommandRegistry.ts @@ -1,5 +1,6 @@ import { useMemo } from 'react' import type { AiAgentId, AiAgentsStatus } from '../lib/aiAgents' +import type { AppLocale, UiLanguagePreference } from '../lib/i18n' import type { VaultAiGuidanceStatus } from '../lib/vaultAiGuidance' import type { NoteLayout, SidebarSelection, VaultEntry } from '../types' import type { NoteListFilter } from '../utils/noteListHelpers' @@ -40,6 +41,10 @@ interface CommandRegistryConfig { onRepairVault?: () => void onSetNoteIcon?: () => void onRemoveNoteIcon?: () => void + locale?: AppLocale + systemLocale?: AppLocale + selectedUiLanguage?: UiLanguagePreference + onSetUiLanguage?: (language: UiLanguagePreference) => void onChangeNoteType?: () => void onMoveNoteToFolder?: () => void canMoveNoteToFolder?: boolean @@ -114,6 +119,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com mcpStatus, onInstallMcp, aiAgentsStatus, vaultAiGuidanceStatus, onOpenAiAgents, onRestoreVaultAiGuidance, onSetDefaultAiAgent, selectedAiAgent, onCycleDefaultAiAgent, selectedAiAgentLabel, onReloadVault, onRepairVault, + locale, systemLocale, selectedUiLanguage, onSetUiLanguage, onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onChangeNoteType, onMoveNoteToFolder, canMoveNoteToFolder, onOpenInNewWindow, onToggleFavorite, onToggleOrganized, onCustomizeNoteListColumns, canCustomizeNoteListColumns, @@ -178,6 +184,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com mcpStatus, vaultCount, isGettingStartedHidden, onOpenSettings, onOpenFeedback, onOpenVault, onCreateEmptyVault, onRemoveActiveVault, onRestoreGettingStarted, onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault, + locale, systemLocale, selectedUiLanguage, onSetUiLanguage, }), ...buildAiAgentCommands({ aiAgentsStatus, @@ -205,7 +212,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount, mcpStatus, onInstallMcp, aiAgentsStatus, vaultAiGuidanceStatus, onOpenAiAgents, onRestoreVaultAiGuidance, onSetDefaultAiAgent, selectedAiAgent, onCycleDefaultAiAgent, selectedAiAgentLabel, - onReloadVault, onRepairVault, + onReloadVault, onRepairVault, locale, systemLocale, selectedUiLanguage, onSetUiLanguage, onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onChangeNoteType, onMoveNoteToFolder, canMoveNoteToFolder, isSectionGroup, noteListFilter, onSetNoteListFilter, selection, diff --git a/src/hooks/useSettings.test.ts b/src/hooks/useSettings.test.ts index 0eab602a..3bd65ebb 100644 --- a/src/hooks/useSettings.test.ts +++ b/src/hooks/useSettings.test.ts @@ -15,6 +15,7 @@ const defaultSettings: Settings = { anonymous_id: null, release_channel: null, theme_mode: null, + ui_language: null, default_ai_agent: null, } @@ -30,6 +31,7 @@ const savedSettings: Settings = { anonymous_id: null, release_channel: null, theme_mode: null, + ui_language: null, default_ai_agent: null, } @@ -53,6 +55,16 @@ vi.mock('../mock-tauri', () => ({ mockInvoke: (cmd: string, args?: Record) => mockInvokeFn(cmd, args), })) +async function renderLoadedSettings(): Promise { + const { result } = renderHook(() => useSettings()) + + await waitFor(() => { + expect(result.current.loaded).toBe(true) + }) + + return result.current.settings +} + describe('useSettings', () => { beforeEach(() => { vi.clearAllMocks() @@ -86,13 +98,18 @@ describe('useSettings', () => { release_channel: 'beta', } - const { result } = renderHook(() => useSettings()) + const settings = await renderLoadedSettings() + expect(settings.release_channel).toBeNull() + }) - await waitFor(() => { - expect(result.current.loaded).toBe(true) - }) + it('normalizes unsupported language preferences on load', async () => { + mockSettingsStore = { + ...savedSettings, + ui_language: 'fr-FR' as Settings['ui_language'], + } - expect(result.current.settings.release_channel).toBeNull() + const settings = await renderLoadedSettings() + expect(settings.ui_language).toBeNull() }) it('saves settings via backend', async () => { @@ -114,6 +131,7 @@ describe('useSettings', () => { anonymous_id: null, release_channel: null, theme_mode: null, + ui_language: 'zh-Hans', default_ai_agent: null, } diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 8b524f01..443ec5a2 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from '../mock-tauri' import { normalizeStoredAiAgent } from '../lib/aiAgents' +import { serializeUiLanguagePreference } from '../lib/i18n' import { normalizeReleaseChannel, serializeReleaseChannel } from '../lib/releaseChannel' import { normalizeThemeMode } from '../lib/themeMode' import type { Settings } from '../types' @@ -22,6 +23,7 @@ const EMPTY_SETTINGS: Settings = { anonymous_id: null, release_channel: null, theme_mode: null, + ui_language: null, default_ai_agent: null, } @@ -32,6 +34,7 @@ function normalizeSettings(settings: Settings): Settings { normalizeReleaseChannel(settings.release_channel), ), theme_mode: normalizeThemeMode(settings.theme_mode), + ui_language: serializeUiLanguagePreference(settings.ui_language), default_ai_agent: normalizeStoredAiAgent(settings.default_ai_agent), } } diff --git a/src/lib/i18n.test.ts b/src/lib/i18n.test.ts new file mode 100644 index 00000000..dd53e880 --- /dev/null +++ b/src/lib/i18n.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { + localeDisplayName, + normalizeUiLanguagePreference, + resolveEffectiveLocale, + serializeUiLanguagePreference, + translate, +} from './i18n' + +describe('i18n', () => { + it('uses supported system languages before falling back to English', () => { + expect(resolveEffectiveLocale(null, ['zh-CN'])).toBe('zh-Hans') + expect(resolveEffectiveLocale('system', ['fr-FR'])).toBe('en') + }) + + it('normalizes stored language preferences', () => { + expect(normalizeUiLanguagePreference(' zh-cn ')).toBe('zh-Hans') + expect(normalizeUiLanguagePreference('auto')).toBe('system') + expect(normalizeUiLanguagePreference('fr-FR')).toBeNull() + }) + + it('serializes system preference as the settings default', () => { + expect(serializeUiLanguagePreference('system')).toBeNull() + expect(serializeUiLanguagePreference('zh-Hans')).toBe('zh-Hans') + }) + + it('falls back to English when a locale is partially translated', () => { + expect(translate('zh-Hans', 'settings.aiAgents.description')).toBe( + translate('en', 'settings.aiAgents.description'), + ) + }) + + it('formats locale display names in the active language', () => { + expect(localeDisplayName('zh-Hans', 'zh-Hans')).toBe('简体中文') + expect(localeDisplayName('en', 'zh-Hans')).toBe('英文') + }) +}) diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts new file mode 100644 index 00000000..1a553d61 --- /dev/null +++ b/src/lib/i18n.ts @@ -0,0 +1,271 @@ +export const DEFAULT_APP_LOCALE = 'en' +export const SYSTEM_UI_LANGUAGE = 'system' + +export const APP_LOCALES = ['en', 'zh-Hans'] as const +export type AppLocale = typeof APP_LOCALES[number] +export type UiLanguagePreference = typeof SYSTEM_UI_LANGUAGE | AppLocale + +const SIMPLIFIED_CHINESE_LANGUAGE_CODES = new Set(['zh', 'zh-cn', 'zh-hans', 'zh-sg']) + +const EN_TRANSLATIONS = { + 'command.noMatches': 'No matching commands', + 'command.palettePlaceholder': 'Type a command...', + 'command.footerNavigate': '↑↓ navigate', + 'command.footerSelect': '↵ select', + 'command.footerClose': 'esc close', + 'command.footerSend': '↵ send', + 'command.aiMode': '{agent} mode', + 'command.openSettings': 'Open Settings', + 'command.openSettings.keywords': 'preferences config', + 'command.openLanguageSettings': 'Open Language Settings', + 'command.openLanguageSettings.keywords': 'language locale i18n internationalization localization chinese english 中文', + 'command.useSystemLanguage': 'Use System Language', + 'command.switchToEnglish': 'Switch Language to English', + 'command.switchToChinese': 'Switch Language to Simplified Chinese', + 'command.openH1Setting': 'Open H1 Auto-Rename Setting', + 'command.contribute': 'Contribute', + 'command.checkUpdates': 'Check for Updates', + + 'settings.title': 'Settings', + 'settings.close': 'Close settings', + 'settings.sync.title': 'Sync & Updates', + 'settings.sync.description': 'Configure background pulling and which update feed Tolaria follows. Stable only receives manually promoted releases, while Alpha follows every push to main.', + 'settings.pullInterval': 'Pull interval (minutes)', + 'settings.releaseChannel': 'Release channel', + 'settings.releaseStable': 'Stable', + 'settings.releaseAlpha': 'Alpha', + 'settings.appearance.title': 'Appearance', + 'settings.appearance.description': 'Choose the app color mode used for Tolaria chrome, editor surfaces, menus, and dialogs.', + 'settings.theme.label': 'Theme', + 'settings.theme.light': 'Light', + 'settings.theme.dark': 'Dark', + 'settings.language.title': 'Language', + 'settings.language.description': 'Choose the display language for Tolaria chrome. System follows macOS when that language is supported, with English as the fallback.', + 'settings.language.label': 'Display language', + 'settings.language.system': 'System ({language})', + 'settings.language.en': 'English', + 'settings.language.zhHans': 'Simplified Chinese', + 'settings.language.summary': 'Missing translations fall back to English so partially translated locales stay usable.', + 'settings.autogit.title': 'AutoGit', + 'settings.autogit.description.enabled': 'Automatically create conservative Git checkpoints after editing pauses or when the app is no longer active.', + 'settings.autogit.description.disabled': 'AutoGit is unavailable until the current vault is Git-enabled. Initialize Git for this vault first.', + 'settings.autogit.enable': 'Enable AutoGit', + 'settings.autogit.enableDescription': 'When enabled, Tolaria will commit and push saved local changes automatically after an idle pause or after the app becomes inactive.', + 'settings.autogit.idleThreshold': 'Idle threshold (seconds)', + 'settings.autogit.inactiveThreshold': 'Inactive-app grace period (seconds)', + 'settings.titles.title': 'Titles & Filenames', + 'settings.titles.description': 'Choose whether Tolaria automatically syncs untitled note filenames from the first H1 title.', + 'settings.titles.autoRename': 'Auto-rename untitled notes from first H1', + 'settings.titles.autoRenameDescription': 'When enabled, Tolaria renames untitled-note files as soon as the first H1 becomes a real title. Turn this off to keep the filename unchanged until you rename it manually from the breadcrumb bar.', + 'settings.aiAgents.title': 'AI Agents', + 'settings.aiAgents.description': 'Choose which CLI AI agent Tolaria uses in the AI panel and command palette.', + 'settings.aiAgents.default': 'Default AI agent', + 'settings.aiAgents.installed': 'installed', + 'settings.aiAgents.missing': 'missing', + 'settings.aiAgents.ready': '{agent}{version} is ready to use.', + 'settings.aiAgents.notInstalled': '{agent} is not installed yet. You can still select it now and install it later.', + 'settings.workflow.title': 'Workflow', + 'settings.workflow.description': 'Choose whether Tolaria shows the Inbox workflow, plus how it moves through items while you triage them.', + 'settings.workflow.explicit': 'Organize notes explicitly', + 'settings.workflow.explicitDescription': 'When enabled, an Inbox section shows unorganized notes, and a toggle lets you mark notes as organized.', + 'settings.workflow.autoAdvance': 'Auto-advance to next Inbox item', + 'settings.workflow.autoAdvanceDescription': 'When enabled, marking an Inbox note as organized immediately opens the next visible Inbox note.', + 'settings.privacy.title': 'Privacy & Telemetry', + 'settings.privacy.description': 'Anonymous data helps us fix bugs and improve Tolaria. No vault content, note titles, or file paths are ever sent.', + 'settings.privacy.crashReporting': 'Crash reporting', + 'settings.privacy.crashReportingDescription': 'Send anonymous error reports', + 'settings.privacy.analytics': 'Usage analytics', + 'settings.privacy.analyticsDescription': 'Share anonymous usage patterns', + 'settings.footerShortcut': '⌘, to open settings', + 'settings.cancel': 'Cancel', + 'settings.save': 'Save', + + 'locale.en': 'English', + 'locale.zhHans': 'Simplified Chinese', + + 'noteList.title.archive': 'Archive', + 'noteList.title.changes': 'Changes', + 'noteList.title.inbox': 'Inbox', + 'noteList.title.history': 'History', + 'noteList.title.view': 'View', + 'noteList.title.notes': 'Notes', + 'noteList.searchPlaceholder': 'Search notes...', + 'noteList.searchAction': 'Search notes', + 'noteList.createNote': 'Create new note', + 'noteList.empty.changesError': 'Failed to load changes: {error}', + 'noteList.empty.noChanges': 'No pending changes', + 'noteList.empty.noArchived': 'No archived notes', + 'noteList.empty.noMatching': 'No matching notes', + 'noteList.empty.allOrganized': 'All notes are organized', + 'noteList.empty.noNotes': 'No notes found', + 'noteList.empty.noMatchingItems': 'No matching items', + 'noteList.empty.noRelatedItems': 'No related items', +} as const + +export type TranslationKey = keyof typeof EN_TRANSLATIONS +type TranslationValues = Record + +const ZH_HANS_TRANSLATIONS: Partial> = { + 'command.noMatches': '没有匹配的命令', + 'command.palettePlaceholder': '输入命令...', + 'command.footerNavigate': '↑↓ 导航', + 'command.footerSelect': '↵ 选择', + 'command.footerClose': 'esc 关闭', + 'command.footerSend': '↵ 发送', + 'command.aiMode': '{agent} 模式', + 'command.openSettings': '打开设置', + 'command.openSettings.keywords': '设置 偏好 配置', + 'command.openLanguageSettings': '打开语言设置', + 'command.openLanguageSettings.keywords': '语言 区域 i18n 国际化 本地化 中文 english', + 'command.useSystemLanguage': '使用系统语言', + 'command.switchToEnglish': '切换到英文', + 'command.switchToChinese': '切换到简体中文', + 'command.openH1Setting': '打开 H1 自动重命名设置', + 'command.contribute': '参与贡献', + 'command.checkUpdates': '检查更新', + + 'settings.title': '设置', + 'settings.close': '关闭设置', + 'settings.sync.title': '同步与更新', + 'settings.sync.description': '配置后台拉取以及 Tolaria 使用的更新通道。Stable 只接收手动推广的版本,Alpha 跟随 main 的每次推送。', + 'settings.pullInterval': '拉取间隔(分钟)', + 'settings.releaseChannel': '发布通道', + 'settings.releaseStable': 'Stable', + 'settings.releaseAlpha': 'Alpha', + 'settings.appearance.title': '外观', + 'settings.appearance.description': '选择 Tolaria 界面、编辑器、菜单和对话框使用的颜色模式。', + 'settings.theme.label': '主题', + 'settings.theme.light': '浅色', + 'settings.theme.dark': '深色', + 'settings.language.title': '语言', + 'settings.language.description': '选择 Tolaria 界面的显示语言。系统选项会在支持时跟随 macOS,否则回退到英文。', + 'settings.language.label': '显示语言', + 'settings.language.system': '系统({language})', + 'settings.language.en': '英文', + 'settings.language.zhHans': '简体中文', + 'settings.language.summary': '缺失的翻译会回退到英文,因此部分翻译的语言也能正常使用。', + 'settings.autogit.title': 'AutoGit', + 'settings.autogit.description.enabled': '在编辑暂停或应用不再活跃后,自动创建保守的 Git 检查点。', + 'settings.autogit.description.disabled': '当前仓库启用 Git 后才能使用 AutoGit。请先为此仓库初始化 Git。', + 'settings.autogit.enable': '启用 AutoGit', + 'settings.autogit.enableDescription': '启用后,Tolaria 会在空闲暂停或应用变为非活跃后自动提交并推送保存的本地更改。', + 'settings.autogit.idleThreshold': '空闲阈值(秒)', + 'settings.autogit.inactiveThreshold': '应用非活跃宽限期(秒)', + 'settings.titles.title': '标题与文件名', + 'settings.titles.description': '选择 Tolaria 是否根据第一个 H1 标题自动同步未命名笔记的文件名。', + 'settings.titles.autoRename': '根据第一个 H1 自动重命名未命名笔记', + 'settings.titles.autoRenameDescription': '启用后,只要第一个 H1 成为真实标题,Tolaria 就会重命名 untitled-note 文件。关闭后,文件名会保持不变,直到你从面包屑栏手动重命名。', + 'settings.aiAgents.title': 'AI 代理', + 'settings.aiAgents.default': '默认 AI 代理', + 'settings.aiAgents.installed': '已安装', + 'settings.aiAgents.missing': '缺失', + 'settings.aiAgents.ready': '{agent}{version} 已可使用。', + 'settings.aiAgents.notInstalled': '{agent} 尚未安装。你仍可先选择它,稍后再安装。', + 'settings.workflow.title': '工作流', + 'settings.workflow.description': '选择 Tolaria 是否显示 Inbox 工作流,以及整理时如何移动到下一项。', + 'settings.workflow.explicit': '显式整理笔记', + 'settings.workflow.explicitDescription': '启用后,Inbox 会显示尚未整理的笔记,并提供一个开关用于标记为已整理。', + 'settings.workflow.autoAdvance': '自动前进到下一条 Inbox', + 'settings.workflow.autoAdvanceDescription': '启用后,将 Inbox 笔记标记为已整理会立即打开下一条可见的 Inbox 笔记。', + 'settings.privacy.title': '隐私与遥测', + 'settings.privacy.description': '匿名数据可帮助我们修复错误并改进 Tolaria。不会发送仓库内容、笔记标题或文件路径。', + 'settings.privacy.crashReporting': '崩溃报告', + 'settings.privacy.crashReportingDescription': '发送匿名错误报告', + 'settings.privacy.analytics': '使用分析', + 'settings.privacy.analyticsDescription': '分享匿名使用模式', + 'settings.footerShortcut': '⌘, 打开设置', + 'settings.cancel': '取消', + 'settings.save': '保存', + + 'locale.en': '英文', + 'locale.zhHans': '简体中文', + + 'noteList.title.archive': '归档', + 'noteList.title.changes': '更改', + 'noteList.title.inbox': 'Inbox', + 'noteList.title.history': '历史', + 'noteList.title.view': '视图', + 'noteList.title.notes': '笔记', + 'noteList.searchPlaceholder': '搜索笔记...', + 'noteList.searchAction': '搜索笔记', + 'noteList.createNote': '新建笔记', + 'noteList.empty.changesError': '加载更改失败:{error}', + 'noteList.empty.noChanges': '没有待处理更改', + 'noteList.empty.noArchived': '没有归档笔记', + 'noteList.empty.noMatching': '没有匹配的笔记', + 'noteList.empty.allOrganized': '所有笔记都已整理', + 'noteList.empty.noNotes': '没有笔记', + 'noteList.empty.noMatchingItems': '没有匹配项', + 'noteList.empty.noRelatedItems': '没有相关项', +} + +const TRANSLATIONS: Record>> = { + en: EN_TRANSLATIONS, + 'zh-Hans': ZH_HANS_TRANSLATIONS, +} + +export function interpolate(template: string, values: TranslationValues = {}): string { + return template.replace(/\{(\w+)\}/g, (match, key) => { + const value = values[key] + return value === undefined ? match : String(value) + }) +} + +export function translate(locale: AppLocale, key: TranslationKey, values?: TranslationValues): string { + const template = TRANSLATIONS[locale]?.[key] ?? EN_TRANSLATIONS[key] + return interpolate(template, values) +} + +export function createTranslator(locale: AppLocale) { + return (key: TranslationKey, values?: TranslationValues) => translate(locale, key, values) +} + +function normalizeLocaleCode(value: string): AppLocale | null { + const normalized = value.trim().replace('_', '-').toLowerCase() + if (normalized === 'en' || normalized.startsWith('en-')) return 'en' + if (SIMPLIFIED_CHINESE_LANGUAGE_CODES.has(normalized)) return 'zh-Hans' + return null +} + +export function normalizeUiLanguagePreference(value: unknown): UiLanguagePreference | null { + if (typeof value !== 'string') return null + const trimmed = value.trim() + if (!trimmed) return null + const lower = trimmed.toLowerCase() + if (lower === SYSTEM_UI_LANGUAGE || lower === 'auto') return SYSTEM_UI_LANGUAGE + return normalizeLocaleCode(trimmed) +} + +export function serializeUiLanguagePreference(value: unknown): AppLocale | null { + const normalized = normalizeUiLanguagePreference(value) + if (!normalized || normalized === SYSTEM_UI_LANGUAGE) return null + return normalized +} + +export function getBrowserLanguagePreferences(): string[] { + if (typeof navigator === 'undefined') return [] + const languages = Array.isArray(navigator.languages) ? navigator.languages : [] + if (languages.length > 0) return [...languages] + return navigator.language ? [navigator.language] : [] +} + +export function resolveEffectiveLocale( + preference: unknown, + languagePreferences: readonly string[] = getBrowserLanguagePreferences(), +): AppLocale { + const normalizedPreference = normalizeUiLanguagePreference(preference) + if (normalizedPreference && normalizedPreference !== SYSTEM_UI_LANGUAGE) { + return normalizedPreference + } + + for (const language of languagePreferences) { + const locale = normalizeLocaleCode(language) + if (locale) return locale + } + + return DEFAULT_APP_LOCALE +} + +export function localeDisplayName(locale: AppLocale, displayLocale: AppLocale = locale): string { + return translate(displayLocale, locale === 'zh-Hans' ? 'locale.zhHans' : 'locale.en') +} diff --git a/src/mock-tauri/mock-handlers.coverage.test.ts b/src/mock-tauri/mock-handlers.coverage.test.ts index f0de76a0..0347f332 100644 --- a/src/mock-tauri/mock-handlers.coverage.test.ts +++ b/src/mock-tauri/mock-handlers.coverage.test.ts @@ -152,6 +152,7 @@ describe('mockHandlers coverage', () => { analytics_enabled: true, anonymous_id: 'anon-1', release_channel: 'alpha', + ui_language: 'zh-Hans', default_ai_agent: 'codex', }, }) @@ -168,6 +169,7 @@ describe('mockHandlers coverage', () => { anonymous_id: 'anon-1', release_channel: 'alpha', theme_mode: null, + ui_language: 'zh-Hans', default_ai_agent: 'codex', }) diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index 2816f868..a7fda3d2 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -112,6 +112,7 @@ let mockSettings: Settings = { anonymous_id: null, release_channel: null, theme_mode: null, + ui_language: null, default_ai_agent: 'claude_code', } @@ -418,6 +419,7 @@ export const mockHandlers: Record any> = { anonymous_id: s.anonymous_id, release_channel: s.release_channel, theme_mode: s.theme_mode ?? null, + ui_language: s.ui_language ?? null, default_ai_agent: s.default_ai_agent ?? null, } return null diff --git a/src/types.ts b/src/types.ts index 99c1b1c9..5080240c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,6 @@ import type { AiAgentId } from './lib/aiAgents' import type { ThemeMode } from './lib/themeMode' +import type { AppLocale } from './lib/i18n' export interface VaultEntry { path: string @@ -91,6 +92,7 @@ export interface Settings { anonymous_id: string | null release_channel: string | null theme_mode?: ThemeMode | null + ui_language?: AppLocale | null initial_h1_auto_rename_enabled?: boolean | null default_ai_agent?: AiAgentId | null }