diff --git a/internal/api/app.go b/internal/api/app.go index e92669e..705ee3b 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -1621,6 +1621,7 @@ func (a *App) GetAppSettings() map[string]interface{} { "currentVaultPath": cfg.CurrentVaultPath, "recentVaults": cfg.RecentVaults, "theme": cfg.Theme, + "language": cfg.Language, "devMode": cfg.DevMode, "userPluginsDir": cfg.UserPluginsDir, "lastOpenedAt": cfg.LastOpenedAt, @@ -1634,18 +1635,33 @@ func (a *App) UpdateAppSettings(patch map[string]interface{}) string { } cfg := &appsettings.Config{} + hasConfigPatch := false if v, ok := patch["theme"].(string); ok && v != "" { cfg.Theme = v + hasConfigPatch = true } if v, ok := patch["devMode"].(bool); ok { cfg.DevMode = v + hasConfigPatch = true } if v, ok := patch["userPluginsDir"].(string); ok && v != "" { cfg.UserPluginsDir = v + hasConfigPatch = true } - if err := a.appSettings.Update(cfg); err != nil { - return err.Error() + if hasConfigPatch { + if err := a.appSettings.Update(cfg); err != nil { + return err.Error() + } + } + if value, exists := patch["language"]; exists { + language, ok := value.(string) + if !ok { + return "language must be a string" + } + if err := a.appSettings.UpdateLanguage(language); err != nil { + return err.Error() + } } return "" } diff --git a/internal/api/app_test.go b/internal/api/app_test.go index 99f247b..5e8ac00 100644 --- a/internal/api/app_test.go +++ b/internal/api/app_test.go @@ -1294,6 +1294,34 @@ func assertSyncState(t *testing.T, service *syncsvc.Service, wantDeviceID, wantS } } +func TestAppSettingsLanguageAPI(t *testing.T) { + settings := appsettings.NewManager(filepath.Join(t.TempDir(), "config.json")) + if err := settings.Load(); err != nil { + t.Fatal(err) + } + if err := settings.Update(&appsettings.Config{Theme: "light", DevMode: true}); err != nil { + t.Fatal(err) + } + app := &App{appSettings: settings} + + if got := app.GetAppSettings()["language"]; got != "system" { + t.Fatalf("initial language = %#v, want system", got) + } + if errStr := app.UpdateAppSettings(map[string]interface{}{"language": "ru"}); errStr != "" { + t.Fatalf("UpdateAppSettings language: %s", errStr) + } + got := app.GetAppSettings() + if got["language"] != "ru" || got["theme"] != "light" || got["devMode"] != true { + t.Fatalf("settings after language update = %#v", got) + } + if errStr := app.UpdateAppSettings(map[string]interface{}{"language": "de"}); errStr == "" { + t.Fatal("UpdateAppSettings accepted unsupported language") + } + if got := app.GetAppSettings()["language"]; got != "ru" { + t.Fatalf("language after rejected update = %#v, want ru", got) + } +} + func TestSetCurrentVaultInitializesWorkspaceWhenMissingAtStartup(t *testing.T) { tmpDir := t.TempDir() vaultParent := filepath.Join(tmpDir, "vault-parent") diff --git a/internal/core/appsettings/manager.go b/internal/core/appsettings/manager.go index 3c1a685..22b5fbd 100644 --- a/internal/core/appsettings/manager.go +++ b/internal/core/appsettings/manager.go @@ -21,6 +21,7 @@ type Config struct { CurrentVaultPath string `json:"currentVaultPath"` RecentVaults []string `json:"recentVaults"` Theme string `json:"theme"` + Language string `json:"language"` DevMode bool `json:"devMode"` UserPluginsDir string `json:"userPluginsDir"` Workbench WorkbenchPreferences `json:"workbench,omitempty"` @@ -30,6 +31,21 @@ type Config struct { LastOpenedAt string `json:"lastOpenedAt"` } +const ( + LanguageSystem = "system" + LanguageEnglish = "en" + LanguageRussian = "ru" +) + +func validLanguage(language string) bool { + switch language { + case LanguageSystem, LanguageEnglish, LanguageRussian: + return true + default: + return false + } +} + type WorkbenchPreferences struct { DefaultTextEditorProvider string `json:"defaultTextEditorProvider,omitempty"` DefaultMarkdownEditorProvider string `json:"defaultMarkdownEditorProvider,omitempty"` @@ -121,6 +137,9 @@ func (m *Manager) Load() error { if cfg.Theme == "" { cfg.Theme = "dark" } + if !validLanguage(cfg.Language) { + cfg.Language = LanguageSystem + } if cfg.RecentVaults == nil { cfg.RecentVaults = []string{} } @@ -179,6 +198,9 @@ func (m *Manager) Update(patch *Config) error { if patch.Theme != "" { m.config.Theme = patch.Theme } + if patch.Language != "" && validLanguage(patch.Language) { + m.config.Language = patch.Language + } if patch.UserPluginsDir != "" { m.config.UserPluginsDir = patch.UserPluginsDir } @@ -200,6 +222,23 @@ func (m *Manager) Update(patch *Config) error { return m.saveLocked() } +// UpdateLanguage validates and persists the installation-local UI language. +func (m *Manager) UpdateLanguage(language string) error { + language = strings.TrimSpace(language) + if !validLanguage(language) { + return fmt.Errorf("unsupported language %q", language) + } + + m.mu.Lock() + defer m.mu.Unlock() + if m.config == nil { + m.config = defaultConfig() + } + m.config.Language = language + m.config.LastOpenedAt = time.Now().UTC().Format(time.RFC3339) + return m.saveLocked() +} + // UpdateSync replaces sync settings without changing unrelated app settings. func (m *Manager) UpdateSync(syncSettings SyncSettings) error { m.mu.Lock() @@ -287,6 +326,7 @@ func defaultConfig() *Config { CurrentVaultPath: "", RecentVaults: []string{}, Theme: "dark", + Language: LanguageSystem, DevMode: false, UserPluginsDir: filepath.Join(os.Getenv("HOME"), ".config", "verstak", "plugins"), Workbench: WorkbenchPreferences{}, @@ -303,6 +343,7 @@ func copyConfig(c *Config) *Config { CurrentVaultPath: c.CurrentVaultPath, RecentVaults: recent, Theme: c.Theme, + Language: c.Language, DevMode: c.DevMode, UserPluginsDir: c.UserPluginsDir, Workbench: c.Workbench, diff --git a/internal/core/appsettings/manager_test.go b/internal/core/appsettings/manager_test.go index 3ed8726..3c980e9 100644 --- a/internal/core/appsettings/manager_test.go +++ b/internal/core/appsettings/manager_test.go @@ -25,6 +25,53 @@ func TestLoad_DefaultCreation(t *testing.T) { if cfg.CurrentVaultPath != "" { t.Errorf("CurrentVaultPath: got %q, want empty", cfg.CurrentVaultPath) } + if cfg.Language != "system" { + t.Errorf("Language: got %q, want %q", cfg.Language, "system") + } +} + +func TestLoad_MissingLanguageDefaultsToSystem(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(`{"schemaVersion":1,"theme":"dark","devMode":true}`), 0o600); err != nil { + t.Fatal(err) + } + + m := NewManager(path) + if err := m.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + if got := m.Get().Language; got != "system" { + t.Fatalf("Language = %q, want system", got) + } +} + +func TestUpdateLanguagePersistsAndPreservesUnrelatedSettings(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + m := NewManager(path) + if err := m.Load(); err != nil { + t.Fatal(err) + } + if err := m.Update(&Config{Theme: "light", DevMode: true}); err != nil { + t.Fatal(err) + } + if err := m.UpdateLanguage("ru"); err != nil { + t.Fatalf("UpdateLanguage: %v", err) + } + + reloaded := NewManager(path) + if err := reloaded.Load(); err != nil { + t.Fatal(err) + } + cfg := reloaded.Get() + if cfg.Language != "ru" || cfg.Theme != "light" || !cfg.DevMode { + t.Fatalf("settings after language update = %+v", cfg) + } + if err := reloaded.UpdateLanguage("de"); err == nil { + t.Fatal("UpdateLanguage(de) succeeded, want validation error") + } + if got := reloaded.Get().Language; got != "ru" { + t.Fatalf("Language after rejected update = %q, want ru", got) + } } func TestLoad_CorruptConfig(t *testing.T) {