diff --git a/frontend/e2e/localization.spec.js b/frontend/e2e/localization.spec.js new file mode 100644 index 0000000..42d28c8 --- /dev/null +++ b/frontend/e2e/localization.spec.js @@ -0,0 +1,48 @@ +import { test, expect } from '@playwright/test'; +import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js'; + +test.describe('Desktop localization', () => { + let consoleCollector; + + test.beforeEach(async ({ page }) => { + consoleCollector = setupConsoleCollector(page); + await resetMockState(page); + await page.goto('/'); + await waitForAppReady(page); + }); + + test.afterEach(async () => { + consoleCollector.assertNoErrors(); + }); + + test('switches shell language live and persists the choice', async ({ page }) => { + await page.locator('[data-settings-menu-button]').click(); + await expect(page.locator('[data-settings-language="system"]')).toHaveAttribute('aria-checked', 'true'); + await page.locator('[data-settings-language="ru"]').click(); + + await expect(page.locator('[data-settings-menu-button]')).toHaveAttribute('title', 'Настройки'); + await expect(page.locator('.vault-status')).toContainText('Хранилище: открыто'); + await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Тест платформы' })).toBeVisible(); + await expect(page.locator('[data-status-item-id="verstak.platform-test.status"]')).toContainText('Все тесты пройдены'); + + await page.locator('[data-settings-menu-button]').click(); + await expect(page.locator('[data-settings-action="plugin-manager"]')).toContainText('Менеджер плагинов'); + await expect(page.locator('[data-settings-language="ru"]')).toHaveAttribute('aria-checked', 'true'); + + await page.reload(); + await waitForAppReady(page); + await expect(page.locator('[data-settings-menu-button]')).toHaveAttribute('title', 'Настройки'); + await page.locator('[data-settings-menu-button]').click(); + await expect(page.locator('[data-settings-language="ru"]')).toHaveAttribute('aria-checked', 'true'); + }); + + test('switches back to English without reloading', async ({ page }) => { + await page.locator('[data-settings-menu-button]').click(); + await page.locator('[data-settings-language="ru"]').click(); + await page.locator('[data-settings-menu-button]').click(); + await page.locator('[data-settings-language="en"]').click(); + + await expect(page.locator('[data-settings-menu-button]')).toHaveAttribute('title', 'Settings'); + await expect(page.locator('.vault-status')).toContainText('Vault: open'); + }); +}); diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 1e7a007..7ededf2 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -9,13 +9,19 @@ import WorkspaceHost from './lib/shell/WorkspaceHost.svelte'; import * as App from '../wailsjs/go/api/App'; import { debug } from './lib/log/debug.js'; - import { onMount } from 'svelte'; - import { tick } from 'svelte'; + import { onDestroy, onMount, tick } from 'svelte'; + import { i18n } from './lib/i18n/index.js'; let currentView = 'workspace'; let vaultStatus = { status: 'unknown', path: '', vaultId: '' }; let needsVaultSelection = false; let loading = true; + let locale = i18n.getLocale(); + const unsubscribeLocale = i18n.subscribe((nextLocale) => { locale = nextLocale; }); + $: tr = ((activeLocale) => (key, params, fallback) => { + void activeLocale; + return i18n.t(key, params, fallback); + })(locale); let activeView = null; let activeViewPluginId = ''; @@ -375,11 +381,13 @@ await checkVault(); pushNavigation(); }); + + onDestroy(unsubscribeLocale); {#if loading}
-

Loading Verstak...

+

{tr('app.loading')}

{:else if needsVaultSelection} diff --git a/frontend/src/lib/i18n/catalogs/en.js b/frontend/src/lib/i18n/catalogs/en.js new file mode 100644 index 0000000..80c052b --- /dev/null +++ b/frontend/src/lib/i18n/catalogs/en.js @@ -0,0 +1,225 @@ +export default { + 'app.loading': 'Loading Verstak...', + 'settings.title': 'Settings', + 'settings.language': 'Language', + 'settings.language.system': 'System / Системный', + 'settings.language.en': 'English', + 'settings.language.ru': 'Русский', + 'settings.pluginManager': 'Plugin Manager', + 'statusBar.label': 'Status bar', + 'vault.label': 'Vault: {status}', + 'vault.status.open': 'open', + 'vault.status.closed': 'closed', + 'vault.status.unknown': 'unknown', + 'sidebar.tools': 'Tools', + 'sidebar.error.contributions': 'Failed to load plugin contributions', + 'sidebar.error.load': 'Failed to load sidebar', + 'sidebar.error.ui': 'Plugin UI error', + 'workspace.overview': 'Overview', + 'workspace.search': 'Workspace search', + 'workspace.tools': 'Workspace tools', + 'workspace.tool': 'Workspace tool', + 'workspace.emptyTools': 'No workspace tools available', + 'workspace.emptyToolsHint': 'Enable plugins with workspace tools or open Plugin Manager from settings.', + 'workspace.select': 'Select a workspace', + 'workspace.selectHint': 'Use the + button in Workspaces to add your first project.', + 'common.details': 'Details', + 'common.plugin': 'Plugin', + 'common.component': 'Component', + 'pluginView.error': 'Plugin view error', + 'pluginView.failed': 'Plugin UI failed', + 'pluginView.noVisual': 'This plugin does not provide a visual view yet.', + 'pluginView.viewId': 'View ID', + 'pluginView.bundleUnavailable': 'Frontend bundle unavailable', + 'pluginView.unavailable': 'View is unavailable.', + 'pluginView.select': 'Select a plugin view from the sidebar', + 'pluginView.selectHint': 'Plugin views will appear here', + 'common.retry': '⟳ Retry', + 'common.source': 'Source', + 'common.status': 'Status', + 'common.capability': 'Capability', + 'common.provider': 'Provider', + 'status.loaded': 'loaded', + 'status.degraded': 'degraded', + 'status.failed': 'failed', + 'status.disabled': 'disabled', + 'status.missing': 'missing', + 'pluginManager.reloading': '⟳ Reloading...', + 'pluginManager.reload': '⟳ Reload', + 'pluginManager.scanning': 'Scanning plugin directories...', + 'pluginManager.summary.plugins': '{count} plugin(s) discovered', + 'pluginManager.summary.capabilities': '{count} capabilities registered', + 'pluginManager.summary.permissions': '{count} permissions known', + 'pluginManager.scanSummary': 'Plugin scan summary', + 'pluginManager.health': 'Plugin Health', + 'pluginManager.permissionRisk': 'Permission Risk', + 'pluginManager.elevatedPermissions': '{count} plugins request elevated permissions', + 'pluginManager.none': 'No plugins found', + 'pluginManager.scannedDirs': 'Plugin directories scanned:', + 'pluginManager.userPlugins': 'user plugins', + 'pluginManager.bundledPlugins': 'bundled plugins (app directory)', + 'pluginManager.installHint': 'Place a plugin folder with plugin.json in one of these directories and click Reload.', + 'pluginManager.missingTitle': 'Missing Installed Plugins', + 'pluginManager.missingHint': 'These plugins are required by this vault but their packages are not installed locally.', + 'pluginManager.missingPackage': "This plugin is listed in the vault's desired plugins but the package is not installed.", + 'pluginManager.capabilityRegistry': 'Capability Registry ({count})', + 'pluginManager.settingsError': 'Settings Error', + 'pluginManager.pluginSettings': 'Plugin Settings', + 'pluginManager.settingsBundleUnavailable': 'Settings panel frontend bundle not available', + 'common.none': 'none', + 'common.unknown': 'unknown', + 'pluginCard.count.views': '{count} view(s)', + 'pluginCard.count.commands': '{count} command(s)', + 'pluginCard.count.searchProviders': '{count} search provider(s)', + 'pluginCard.count.sidebar': '{count} sidebar item(s)', + 'pluginCard.count.statusbar': '{count} status bar item(s)', + 'pluginCard.count.openProviders': '{count} open provider(s)', + 'pluginCard.count.workspace': '{count} workspace item(s)', + 'pluginCard.degraded': 'Plugin is usable, but some optional capabilities are unavailable.', + 'pluginCard.name': 'Name', + 'pluginCard.contributions': 'Contributions', + 'pluginCard.technicalDetails': 'Technical details', + 'pluginCard.apiVersion': 'API Version', + 'pluginCard.root': 'Root', + 'pluginCard.provides': 'Provides', + 'pluginCard.requires': 'Requires', + 'pluginCard.missingRequired': 'Missing required capabilities', + 'pluginCard.optionalRequires': 'Optional Requires', + 'pluginCard.optionalUnavailable': 'Optional capabilities not available — plugin running in degraded mode', + 'pluginCard.permissions': 'Permissions', + 'pluginCard.enabling': '⟳ Enabling...', + 'pluginCard.enable': '▶ Enable', + 'pluginCard.disabling': '⟳ Disabling...', + 'pluginCard.disable': '⏸ Disable', + 'pluginCard.openVault': 'Open a vault to manage plugin state', + 'pluginCard.missingUiPermission': 'Plugin has UI contributions but lacks ui.register permission', + 'common.loading': 'Loading...', + 'common.browse': 'Browse...', + 'vaultSelection.chooseNew': 'Choose or enter a folder for the new vault.', + 'vaultSelection.createError': 'Could not create vault: {error}', + 'vaultSelection.openError': 'Could not open vault: {error}', + 'vaultSelection.chooseExisting': 'Choose or enter an existing vault.', + 'vaultSelection.subtitle': 'Choose a vault to start working', + 'vaultSelection.createTitle': 'Create a new vault', + 'vaultSelection.createHint': 'Create a local vault folder for workspaces and projects.', + 'vaultSelection.pathPlaceholder': 'Choose or enter a path...', + 'vaultSelection.creating': 'Creating...', + 'vaultSelection.create': 'Create vault', + 'vaultSelection.openTitle': 'Open an existing vault', + 'vaultSelection.openHint': 'Use a vault that is already on this computer.', + 'vaultSelection.opening': 'Opening...', + 'vaultSelection.open': 'Open existing', + 'vaultSelection.recent': 'Recent vaults', + 'common.cancel': 'Cancel', + 'common.close': 'Close', + 'workspaceTree.title': 'Workspaces', + 'workspaceTree.new': 'New workspace', + 'workspaceTree.nameRequired': 'Name is required', + 'workspaceTree.chooseTemplate': 'Choose a workspace template', + 'workspaceTree.saveRename': 'Save rename', + 'workspaceTree.rename': 'Rename workspace', + 'workspaceTree.trash': 'Trash workspace', + 'workspaceTree.create': 'Create workspace', + 'workspaceTree.namePlaceholder': 'Workspace name', + 'workspaceTree.template': 'Template', + 'workspaceTree.creating': 'Creating...', + 'command.openOverview': 'Open Overview', + 'command.openFiles': 'Open Files', + 'command.openActivity': 'Open Activity', + 'command.openBrowserInbox': 'Open Browser Inbox', + 'command.createMarkdown': 'Create Markdown File', + 'command.createText': 'Create Text File', + 'command.syncNow': 'Sync Now', + 'command.openSyncSettings': 'Open Sync Settings', + 'command.openPluginManager': 'Open Plugin Manager', + 'command.handled': '{title} handled', + 'command.result': '{title} {status}', + 'command.statusHandled': 'handled', + 'command.palette': 'Command Palette', + 'command.run': 'Run command', + 'command.none': 'No commands', + 'search.indexing': 'Indexing...', + 'search.placeholder': 'Search', + 'search.global': 'Global search', + 'search.noResults': 'No results', + 'search.type.workspace': 'Workspace', + 'search.type.tool': 'Tool', + 'search.type.folder': 'Folder', + 'search.type.file': 'File', + 'search.type.journal': 'Journal', + 'search.type.browserInbox': 'Browser Inbox', + 'search.type.activity': 'Activity', + 'workbench.noProvider': 'No provider', + 'workbench.noViewer': 'No viewer/editor available', + 'workbench.noResource': 'No resource opened', + 'bundle.noFrontend': 'Plugin has no frontend bundle', + 'bundle.notFound': 'Plugin not found', + 'bundle.loadFailed': 'Failed to load bundle: {error}', + 'bundle.emptyContent': 'empty content', + 'bundle.executionError': 'Bundle execution error: {error}', + 'bundle.registrationMissing': 'Bundle loaded but no VerstakPluginRegister call detected', + 'bundle.componentMissing': 'Component "{component}" not found in bundle. Available: {available}', + 'bundle.mountError': 'Component mount error: {error}', + 'bundle.mountUnavailable': 'Mount container not available', + 'bundle.unexpectedError': 'Unexpected error: {error}', + 'bundle.unknownError': 'Unknown error', + 'bundle.frontendEntry': 'Frontend entry', + 'bundle.availableComponents': 'Available components', + 'bundle.loading': 'Loading plugin bundle...', + 'overview.filter.all': 'All', + 'overview.filter.notes': 'Notes', + 'overview.filter.files': 'Files', + 'overview.filter.captures': 'Captures', + 'overview.filter.journal': 'Journal', + 'overview.notes': 'Notes', + 'overview.files': 'Files', + 'overview.captures': 'Captures', + 'overview.activity': 'Activity', + 'overview.journal': 'Journal', + 'overview.attention': 'Needs attention', + 'overview.openNotes': 'Open Notes', + 'overview.openFiles': 'Open Files', + 'overview.reviewInbox': 'Review Inbox', + 'overview.viewActivity': 'View Activity', + 'overview.openJournal': 'Open Journal', + 'overview.openTodos': 'Open Todos', + 'overview.reviewCandidate': 'Review candidate', + 'overview.reviewPending': 'Review pending items', + 'overview.count.totalRecent.one': '{total} total · {recent} recent change', + 'overview.count.totalRecent.many': '{total} total · {recent} recent changes', + 'overview.count.recentChanges.one': '{count} recent change', + 'overview.count.recentChanges.many': '{count} recent changes', + 'overview.count.captures.one': '{count} capture to review', + 'overview.count.captures.many': '{count} captures to review', + 'overview.count.events.one': '{count} recorded event', + 'overview.count.events.many': '{count} recorded events', + 'overview.count.journal.one': '{count} journal entry', + 'overview.count.journal.many': '{count} journal entries', + 'overview.count.pending.one': '{count} pending item', + 'overview.count.pending.many': '{count} pending items', + 'overview.loadingContext': 'Loading workspace context...', + 'overview.lastActive': 'Last active {time}', + 'overview.noRecentActivity': 'No recent workspace activity', + 'overview.refresh': 'Refresh', + 'overview.summary': 'Workspace overview summary', + 'overview.continue': 'Continue working', + 'overview.continueHint': 'Pick up the next useful item in this workspace.', + 'overview.loadingSignals': 'Loading workspace signals...', + 'overview.noResume': 'No clear resume point yet', + 'overview.noResumeHint': 'Recent notes, files, captures, and journal entries will appear here.', + 'overview.recentChanges': 'Recent changes', + 'overview.recentChangesHint': 'Latest meaningful activity in this workspace.', + 'overview.recentFilter': 'Recent changes filter', + 'overview.loadingRecent': 'Loading recent changes...', + 'overview.noChanges': 'No meaningful changes for this filter yet.', + 'overview.attentionHint': 'Pending captures, urgent todos, and possible journal entries.', + 'overview.loadingPending': 'Loading pending items...', + 'overview.noPending': 'No pending captures, urgent todos, or possible journal entries.', + 'overview.keyResources': 'Key resources', + 'overview.time.none': 'No timestamp', + 'overview.time.now': 'Just now', + 'overview.time.minutes': '{count} min ago', + 'overview.time.hours': '{count}h ago', + 'overview.time.yesterday': 'Yesterday', + 'overview.time.days': '{count} days ago', +}; diff --git a/frontend/src/lib/i18n/catalogs/ru.js b/frontend/src/lib/i18n/catalogs/ru.js new file mode 100644 index 0000000..157243b --- /dev/null +++ b/frontend/src/lib/i18n/catalogs/ru.js @@ -0,0 +1,225 @@ +export default { + 'app.loading': 'Загрузка Верстака...', + 'settings.title': 'Настройки', + 'settings.language': 'Язык', + 'settings.language.system': 'System / Системный', + 'settings.language.en': 'English', + 'settings.language.ru': 'Русский', + 'settings.pluginManager': 'Менеджер плагинов', + 'statusBar.label': 'Строка состояния', + 'vault.label': 'Хранилище: {status}', + 'vault.status.open': 'открыто', + 'vault.status.closed': 'закрыто', + 'vault.status.unknown': 'неизвестно', + 'sidebar.tools': 'Инструменты', + 'sidebar.error.contributions': 'Не удалось загрузить элементы плагинов', + 'sidebar.error.load': 'Не удалось загрузить боковую панель', + 'sidebar.error.ui': 'Ошибка интерфейса плагина', + 'workspace.overview': 'Обзор', + 'workspace.search': 'Поиск в рабочем пространстве', + 'workspace.tools': 'Инструменты рабочего пространства', + 'workspace.tool': 'Инструмент рабочего пространства', + 'workspace.emptyTools': 'Нет доступных инструментов', + 'workspace.emptyToolsHint': 'Включите плагины с инструментами рабочего пространства или откройте Менеджер плагинов в настройках.', + 'workspace.select': 'Выберите рабочее пространство', + 'workspace.selectHint': 'Используйте кнопку + в разделе рабочих пространств, чтобы добавить первый проект.', + 'common.details': 'Подробнее', + 'common.plugin': 'Плагин', + 'common.component': 'Компонент', + 'pluginView.error': 'Ошибка представления плагина', + 'pluginView.failed': 'Ошибка интерфейса плагина', + 'pluginView.noVisual': 'Этот плагин пока не предоставляет визуальное представление.', + 'pluginView.viewId': 'ID представления', + 'pluginView.bundleUnavailable': 'Frontend bundle недоступен', + 'pluginView.unavailable': 'Представление недоступно.', + 'pluginView.select': 'Выберите представление плагина на боковой панели', + 'pluginView.selectHint': 'Представления плагинов появятся здесь', + 'common.retry': '⟳ Повторить', + 'common.source': 'Источник', + 'common.status': 'Состояние', + 'common.capability': 'Capability', + 'common.provider': 'Поставщик', + 'status.loaded': 'загружено', + 'status.degraded': 'ограничено', + 'status.failed': 'ошибка', + 'status.disabled': 'выключено', + 'status.missing': 'отсутствует', + 'pluginManager.reloading': '⟳ Обновление...', + 'pluginManager.reload': '⟳ Обновить', + 'pluginManager.scanning': 'Сканирование каталогов плагинов...', + 'pluginManager.summary.plugins': 'Обнаружено плагинов: {count}', + 'pluginManager.summary.capabilities': 'Зарегистрировано capabilities: {count}', + 'pluginManager.summary.permissions': 'Известно разрешений: {count}', + 'pluginManager.scanSummary': 'Сводка проверки плагинов', + 'pluginManager.health': 'Состояние плагинов', + 'pluginManager.permissionRisk': 'Риск разрешений', + 'pluginManager.elevatedPermissions': 'Плагинов с повышенными разрешениями: {count}', + 'pluginManager.none': 'Плагины не найдены', + 'pluginManager.scannedDirs': 'Проверенные каталоги плагинов:', + 'pluginManager.userPlugins': 'пользовательские плагины', + 'pluginManager.bundledPlugins': 'встроенные плагины (каталог приложения)', + 'pluginManager.installHint': 'Поместите папку плагина с plugin.json в один из этих каталогов и нажмите «Обновить».', + 'pluginManager.missingTitle': 'Отсутствующие установленные плагины', + 'pluginManager.missingHint': 'Эти плагины требуются хранилищу, но их пакеты не установлены на этом устройстве.', + 'pluginManager.missingPackage': 'Плагин указан среди желаемых плагинов хранилища, но его пакет не установлен.', + 'pluginManager.capabilityRegistry': 'Реестр capabilities ({count})', + 'pluginManager.settingsError': 'Ошибка настроек', + 'pluginManager.pluginSettings': 'Настройки плагина', + 'pluginManager.settingsBundleUnavailable': 'Frontend bundle панели настроек недоступен', + 'common.none': 'нет', + 'common.unknown': 'неизвестно', + 'pluginCard.count.views': 'представлений: {count}', + 'pluginCard.count.commands': 'команд: {count}', + 'pluginCard.count.searchProviders': 'поставщиков поиска: {count}', + 'pluginCard.count.sidebar': 'элементов боковой панели: {count}', + 'pluginCard.count.statusbar': 'элементов строки состояния: {count}', + 'pluginCard.count.openProviders': 'обработчиков открытия: {count}', + 'pluginCard.count.workspace': 'инструментов рабочего пространства: {count}', + 'pluginCard.degraded': 'Плагин работает, но некоторые необязательные capabilities недоступны.', + 'pluginCard.name': 'Название', + 'pluginCard.contributions': 'Элементы интерфейса', + 'pluginCard.technicalDetails': 'Технические сведения', + 'pluginCard.apiVersion': 'Версия API', + 'pluginCard.root': 'Корневой каталог', + 'pluginCard.provides': 'Предоставляет', + 'pluginCard.requires': 'Требует', + 'pluginCard.missingRequired': 'Отсутствуют обязательные capabilities', + 'pluginCard.optionalRequires': 'Необязательные зависимости', + 'pluginCard.optionalUnavailable': 'Необязательные capabilities недоступны — плагин работает в ограниченном режиме', + 'pluginCard.permissions': 'Разрешения', + 'pluginCard.enabling': '⟳ Включение...', + 'pluginCard.enable': '▶ Включить', + 'pluginCard.disabling': '⟳ Выключение...', + 'pluginCard.disable': '⏸ Выключить', + 'pluginCard.openVault': 'Откройте хранилище для управления состоянием плагина', + 'pluginCard.missingUiPermission': 'Плагин добавляет элементы интерфейса, но не имеет разрешения ui.register', + 'common.loading': 'Загрузка...', + 'common.browse': 'Обзор...', + 'vaultSelection.chooseNew': 'Выберите или введите папку для нового хранилища.', + 'vaultSelection.createError': 'Не удалось создать хранилище: {error}', + 'vaultSelection.openError': 'Не удалось открыть хранилище: {error}', + 'vaultSelection.chooseExisting': 'Выберите или введите существующее хранилище.', + 'vaultSelection.subtitle': 'Выберите хранилище, чтобы начать работу', + 'vaultSelection.createTitle': 'Создать новое хранилище', + 'vaultSelection.createHint': 'Создайте локальную папку хранилища для рабочих пространств и проектов.', + 'vaultSelection.pathPlaceholder': 'Выберите или введите путь...', + 'vaultSelection.creating': 'Создание...', + 'vaultSelection.create': 'Создать хранилище', + 'vaultSelection.openTitle': 'Открыть существующее хранилище', + 'vaultSelection.openHint': 'Используйте хранилище, которое уже находится на этом компьютере.', + 'vaultSelection.opening': 'Открытие...', + 'vaultSelection.open': 'Открыть', + 'vaultSelection.recent': 'Недавние хранилища', + 'common.cancel': 'Отмена', + 'common.close': 'Закрыть', + 'workspaceTree.title': 'Рабочие пространства', + 'workspaceTree.new': 'Новое рабочее пространство', + 'workspaceTree.nameRequired': 'Введите название', + 'workspaceTree.chooseTemplate': 'Выберите шаблон рабочего пространства', + 'workspaceTree.saveRename': 'Сохранить новое название', + 'workspaceTree.rename': 'Переименовать рабочее пространство', + 'workspaceTree.trash': 'Переместить рабочее пространство в корзину', + 'workspaceTree.create': 'Создать рабочее пространство', + 'workspaceTree.namePlaceholder': 'Название рабочего пространства', + 'workspaceTree.template': 'Шаблон', + 'workspaceTree.creating': 'Создание...', + 'command.openOverview': 'Открыть обзор', + 'command.openFiles': 'Открыть файлы', + 'command.openActivity': 'Открыть активность', + 'command.openBrowserInbox': 'Открыть входящие из браузера', + 'command.createMarkdown': 'Создать файл Markdown', + 'command.createText': 'Создать текстовый файл', + 'command.syncNow': 'Синхронизировать сейчас', + 'command.openSyncSettings': 'Открыть настройки синхронизации', + 'command.openPluginManager': 'Открыть менеджер плагинов', + 'command.handled': 'Выполнено: {title}', + 'command.result': '{title}: {status}', + 'command.statusHandled': 'выполнено', + 'command.palette': 'Палитра команд', + 'command.run': 'Выполнить команду', + 'command.none': 'Команды не найдены', + 'search.indexing': 'Индексирование...', + 'search.placeholder': 'Поиск', + 'search.global': 'Глобальный поиск', + 'search.noResults': 'Ничего не найдено', + 'search.type.workspace': 'Рабочее пространство', + 'search.type.tool': 'Инструмент', + 'search.type.folder': 'Папка', + 'search.type.file': 'Файл', + 'search.type.journal': 'Журнал', + 'search.type.browserInbox': 'Входящие из браузера', + 'search.type.activity': 'Активность', + 'workbench.noProvider': 'Нет обработчика', + 'workbench.noViewer': 'Нет доступного средства просмотра или редактора', + 'workbench.noResource': 'Ресурс не открыт', + 'bundle.noFrontend': 'У плагина нет frontend bundle', + 'bundle.notFound': 'Плагин не найден', + 'bundle.loadFailed': 'Не удалось загрузить bundle: {error}', + 'bundle.emptyContent': 'пустое содержимое', + 'bundle.executionError': 'Ошибка выполнения bundle: {error}', + 'bundle.registrationMissing': 'Bundle загружен, но вызов VerstakPluginRegister не обнаружен', + 'bundle.componentMissing': 'Компонент «{component}» не найден в bundle. Доступны: {available}', + 'bundle.mountError': 'Ошибка подключения компонента: {error}', + 'bundle.mountUnavailable': 'Контейнер для подключения недоступен', + 'bundle.unexpectedError': 'Непредвиденная ошибка: {error}', + 'bundle.unknownError': 'Неизвестная ошибка', + 'bundle.frontendEntry': 'Точка входа frontend', + 'bundle.availableComponents': 'Доступные компоненты', + 'bundle.loading': 'Загрузка bundle плагина...', + 'overview.filter.all': 'Все', + 'overview.filter.notes': 'Заметки', + 'overview.filter.files': 'Файлы', + 'overview.filter.captures': 'Сохранённое', + 'overview.filter.journal': 'Журнал', + 'overview.notes': 'Заметки', + 'overview.files': 'Файлы', + 'overview.captures': 'Сохранённое', + 'overview.activity': 'Активность', + 'overview.journal': 'Журнал', + 'overview.attention': 'Требует внимания', + 'overview.openNotes': 'Открыть заметки', + 'overview.openFiles': 'Открыть файлы', + 'overview.reviewInbox': 'Разобрать входящие', + 'overview.viewActivity': 'Открыть активность', + 'overview.openJournal': 'Открыть журнал', + 'overview.openTodos': 'Открыть задачи', + 'overview.reviewCandidate': 'Просмотреть запись', + 'overview.reviewPending': 'Разобрать ожидающее', + 'overview.count.totalRecent.one': 'Всего: {total} · недавних изменений: {recent}', + 'overview.count.totalRecent.many': 'Всего: {total} · недавних изменений: {recent}', + 'overview.count.recentChanges.one': 'Недавних изменений: {count}', + 'overview.count.recentChanges.many': 'Недавних изменений: {count}', + 'overview.count.captures.one': 'На разбор: {count}', + 'overview.count.captures.many': 'На разбор: {count}', + 'overview.count.events.one': 'Событий: {count}', + 'overview.count.events.many': 'Событий: {count}', + 'overview.count.journal.one': 'Записей журнала: {count}', + 'overview.count.journal.many': 'Записей журнала: {count}', + 'overview.count.pending.one': 'Ожидают: {count}', + 'overview.count.pending.many': 'Ожидают: {count}', + 'overview.loadingContext': 'Загрузка данных рабочего пространства...', + 'overview.lastActive': 'Последняя активность: {time}', + 'overview.noRecentActivity': 'В рабочем пространстве пока нет недавней активности', + 'overview.refresh': 'Обновить', + 'overview.summary': 'Сводка рабочего пространства', + 'overview.continue': 'Продолжить работу', + 'overview.continueHint': 'Вернитесь к следующему полезному делу в этом рабочем пространстве.', + 'overview.loadingSignals': 'Загрузка рабочих данных...', + 'overview.noResume': 'Пока неясно, с чего продолжить', + 'overview.noResumeHint': 'Здесь появятся недавние заметки, файлы, сохранённое и записи журнала.', + 'overview.recentChanges': 'Недавние изменения', + 'overview.recentChangesHint': 'Последняя важная активность в этом рабочем пространстве.', + 'overview.recentFilter': 'Фильтр недавних изменений', + 'overview.loadingRecent': 'Загрузка недавних изменений...', + 'overview.noChanges': 'Для этого фильтра пока нет важных изменений.', + 'overview.attentionHint': 'Неразобранное сохранённое, срочные задачи и возможные записи журнала.', + 'overview.loadingPending': 'Загрузка ожидающих элементов...', + 'overview.noPending': 'Нет неразобранного сохранённого, срочных задач или возможных записей журнала.', + 'overview.keyResources': 'Ключевые ресурсы', + 'overview.time.none': 'Время не указано', + 'overview.time.now': 'Только что', + 'overview.time.minutes': '{count} мин. назад', + 'overview.time.hours': '{count} ч. назад', + 'overview.time.yesterday': 'Вчера', + 'overview.time.days': '{count} дн. назад', +}; diff --git a/frontend/src/lib/i18n/index.js b/frontend/src/lib/i18n/index.js new file mode 100644 index 0000000..e64103c --- /dev/null +++ b/frontend/src/lib/i18n/index.js @@ -0,0 +1,200 @@ +import englishShellCatalog from './catalogs/en.js'; +import russianShellCatalog from './catalogs/ru.js'; + +const LANGUAGE_PREFERENCES = new Set(['system', 'en', 'ru']); +const CONTRIBUTION_TEXT_FIELDS = { + views: 'title', + commands: 'title', + settingsPanels: 'title', + sidebarItems: 'title', + fileActions: 'label', + noteActions: 'label', + contextMenuEntries: 'label', + searchProviders: 'label', + statusBarItems: 'label', + openProviders: 'title', + workspaceItems: 'title', +}; + +function normalizeSystemLanguages(languages) { + const values = Array.isArray(languages) ? languages : [languages]; + return values.map((value) => String(value || '').trim().toLowerCase()).filter(Boolean); +} + +export function resolveLocale(preference, systemLanguages = []) { + if (!LANGUAGE_PREFERENCES.has(preference)) { + throw new Error(`unsupported language preference: ${preference}`); + } + if (preference === 'en' || preference === 'ru') return preference; + return normalizeSystemLanguages(systemLanguages).some((locale) => locale === 'ru' || locale.startsWith('ru-')) + ? 'ru' + : 'en'; +} + +function interpolate(message, params) { + if (!params) return message; + return message.replace(/\{([A-Za-z0-9_.-]+)\}/g, (placeholder, name) => ( + Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : placeholder + )); +} + +function browserLanguages() { + if (typeof navigator === 'undefined') return ['en']; + if (Array.isArray(navigator.languages) && navigator.languages.length > 0) return navigator.languages; + return [navigator.language || 'en']; +} + +export function createI18n(options = {}) { + const shellCatalogs = options.shellCatalogs || { en: {}, ru: {} }; + let systemLanguages = options.systemLanguages || browserLanguages; + let catalogLoader = options.loadPluginCatalog || (async () => ({})); + let preference = 'system'; + let locale = 'en'; + const listeners = new Set(); + const pluginConfigs = new Map(); + const pluginCatalogs = new Map(); + + function configure(next = {}) { + if (next.systemLanguages) systemLanguages = next.systemLanguages; + if (next.loadPluginCatalog) catalogLoader = next.loadPluginCatalog; + } + + function notify() { + listeners.forEach((listener) => listener(locale)); + } + + async function initialize(initialPreference = 'system') { + preference = LANGUAGE_PREFERENCES.has(initialPreference) ? initialPreference : 'system'; + locale = resolveLocale(preference, systemLanguages()); + } + + async function loadCatalog(pluginId, catalogLocale) { + if (!catalogLocale) return; + let catalogs = pluginCatalogs.get(pluginId); + if (!catalogs) { + catalogs = new Map(); + pluginCatalogs.set(pluginId, catalogs); + } + if (catalogs.has(catalogLocale)) return; + const messages = await catalogLoader(pluginId, catalogLocale); + catalogs.set(catalogLocale, { ...(messages || {}) }); + } + + async function loadPlugin(pluginId, localization) { + if (!pluginId || !localization || !localization.defaultLocale || !localization.locales) return; + pluginConfigs.set(pluginId, localization); + const requested = localization.locales[locale] ? locale : ''; + const defaultLocale = localization.defaultLocale; + await Promise.all(Array.from(new Set([requested, defaultLocale].filter(Boolean))).map((item) => loadCatalog(pluginId, item))); + } + + async function setLanguagePreference(nextPreference) { + const nextLocale = resolveLocale(nextPreference, systemLanguages()); + preference = nextPreference; + locale = nextLocale; + await Promise.all(Array.from(pluginConfigs.entries()).map(([pluginId, config]) => ( + loadPlugin(pluginId, config).catch((error) => { + console.warn(`[i18n] failed to load ${pluginId} catalog for ${nextLocale}:`, error); + }) + ))); + notify(); + } + + function t(key, params, fallback) { + const message = shellCatalogs[locale]?.[key] + ?? shellCatalogs.en?.[key] + ?? fallback + ?? key; + return interpolate(message, params); + } + + function translatePlugin(pluginId, key, params, fallback) { + const config = pluginConfigs.get(pluginId); + const catalogs = pluginCatalogs.get(pluginId); + const message = catalogs?.get(locale)?.[key] + ?? catalogs?.get(config?.defaultLocale)?.[key] + ?? fallback + ?? key; + return interpolate(message, params); + } + + function localizeContributions(pluginId, contributions) { + if (!contributions) return contributions; + const localized = { ...contributions }; + Object.entries(CONTRIBUTION_TEXT_FIELDS).forEach(([point, field]) => { + if (!Array.isArray(contributions[point])) return; + localized[point] = contributions[point].map((item) => ({ + ...item, + [field]: translatePlugin( + pluginId, + `contributions.${point}.${item.id}.${field}`, + undefined, + item[field], + ), + })); + }); + return localized; + } + + function localizePlugin(plugin) { + if (!plugin) return plugin; + const hasStateWrapper = !!plugin.manifest; + const manifest = hasStateWrapper ? plugin.manifest : plugin; + const pluginId = manifest.id; + const localizedManifest = { + ...manifest, + name: translatePlugin(pluginId, 'manifest.name', undefined, manifest.name), + description: translatePlugin(pluginId, 'manifest.description', undefined, manifest.description), + contributes: localizeContributions(pluginId, manifest.contributes), + }; + return hasStateWrapper ? { ...plugin, manifest: localizedManifest } : localizedManifest; + } + + function localizeContributionSummary(summary) { + if (!summary) return summary; + const localized = { ...summary }; + Object.entries(CONTRIBUTION_TEXT_FIELDS).forEach(([point, field]) => { + if (!Array.isArray(summary[point])) return; + localized[point] = summary[point].map((item) => ({ + ...item, + [field]: translatePlugin( + item.pluginId, + `contributions.${point}.${item.id}.${field}`, + undefined, + item[field], + ), + })); + }); + return localized; + } + + function subscribe(listener) { + listeners.add(listener); + listener(locale); + return () => listeners.delete(listener); + } + + return { + configure, + initialize, + getLanguagePreference: () => preference, + getLocale: () => locale, + setLanguagePreference, + t, + subscribe, + loadPlugin, + translatePlugin, + localizeContributions, + localizePlugin, + localizeContributionSummary, + }; +} + +export const i18n = createI18n({ + shellCatalogs: { + en: englishShellCatalog, + ru: russianShellCatalog, + }, +}); + +export const t = (...args) => i18n.t(...args); diff --git a/frontend/src/lib/plugin-host/PluginBundleHost.svelte b/frontend/src/lib/plugin-host/PluginBundleHost.svelte index 5f33949..1d5b993 100644 --- a/frontend/src/lib/plugin-host/PluginBundleHost.svelte +++ b/frontend/src/lib/plugin-host/PluginBundleHost.svelte @@ -2,6 +2,7 @@ import { onMount, onDestroy } from 'svelte'; import * as App from '../../../wailsjs/go/api/App'; import Icon from '../ui/Icon.svelte'; + import { i18n } from '../i18n/index.js'; import { createPluginAPI } from './VerstakPluginAPI.js'; @@ -18,6 +19,13 @@ let currentComponent = null; let currentAPI = null; let currentPropsKey = ''; + let locale = i18n.getLocale(); + let unsubscribeLocale = null; + + $: tr = ((activeLocale) => (key, params, fallback) => { + void activeLocale; + return i18n.t(key, params, fallback); + })(locale); $: activePluginId = pluginId || viewPluginId; $: activeComponent = componentId; @@ -32,9 +40,14 @@ } onDestroy(() => { + unsubscribeLocale?.(); cleanup(); }); + onMount(() => { + unsubscribeLocale = i18n.subscribe((nextLocale) => locale = nextLocale); + }); + function cleanup() { if (currentAPI && typeof currentAPI.dispose === 'function') { try { @@ -93,11 +106,17 @@ if (!info || info.status === 'no-frontend' || info.status === 'not-found') { loadState = 'error'; errorText = info.status === 'no-frontend' - ? 'Plugin has no frontend bundle' - : 'Plugin not found'; + ? tr('bundle.noFrontend') + : tr('bundle.notFound'); return; } + try { + await i18n.loadPlugin(pId, info.localization); + } catch (catalogError) { + console.warn(`[PluginBundleHost] localization unavailable for ${pId}:`, catalogError); + } + // Check if bundle already loaded for this plugin const reg = window.__VERSTAK_PLUGIN_REGISTRY__ || {}; if (!reg[pId]) { @@ -106,7 +125,7 @@ const content = assetResult.value; if (assetResult.error || !content) { loadState = 'error'; - errorText = 'Failed to load bundle: ' + (assetResult.error || 'empty content'); + errorText = tr('bundle.loadFailed', { error: assetResult.error || tr('bundle.emptyContent') }); return; } @@ -117,7 +136,7 @@ fn(); } catch (e) { loadState = 'error'; - errorText = 'Bundle execution error: ' + e.message; + errorText = tr('bundle.executionError', { error: e.message }); console.error('[PluginBundleHost] bundle exec error:', e); return; } @@ -125,7 +144,7 @@ // Verify registration happened if (!window.__VERSTAK_PLUGIN_REGISTRY__[pId]) { loadState = 'error'; - errorText = 'Bundle loaded but no VerstakPluginRegister call detected'; + errorText = tr('bundle.registrationMissing'); return; } } @@ -135,8 +154,10 @@ const comp = components[compId]; if (!comp || !comp.mount) { loadState = 'error'; - errorText = 'Component "' + compId + '" not found in bundle. Available: ' - + (Object.keys(components).join(', ') || 'none'); + errorText = tr('bundle.componentMissing', { + component: compId, + available: Object.keys(components).join(', ') || tr('common.none'), + }); return; } @@ -156,16 +177,16 @@ errorText = ''; } catch (e) { loadState = 'error'; - errorText = 'Component mount error: ' + e.message; + errorText = tr('bundle.mountError', { error: e.message }); console.error('[PluginBundleHost] mount error:', e); } } else { loadState = 'error'; - errorText = 'Mount container not available'; + errorText = tr('bundle.mountUnavailable'); } } catch (e) { loadState = 'error'; - errorText = 'Unexpected error: ' + (e.message || e); + errorText = tr('bundle.unexpectedError', { error: e.message || e }); console.error('[PluginBundleHost] error:', e); } } @@ -180,22 +201,22 @@
{#if loadState === 'idle'}
-

Select a plugin view from the sidebar

+

{tr('pluginView.select')}

{:else if loadState === 'error'}
-

Plugin View Error

+

{tr('pluginView.error')}

-

Plugin: {currentPluginId || 'unknown'}

-

Component: {currentComponent || 'unknown'}

-

{errorText || 'Unknown error'}

+

{tr('common.plugin')}: {currentPluginId || tr('common.unknown')}

+

{tr('common.component')}: {currentComponent || tr('common.unknown')}

+

{errorText || tr('bundle.unknownError')}

{#if pluginInfo} -

Frontend entry: {pluginInfo.entry || 'none'}

+

{tr('bundle.frontendEntry')}: {pluginInfo.entry || tr('common.none')}

{/if} {#if getComponentList().length > 0} -

Available components: {getComponentList().join(', ')}

+

{tr('bundle.availableComponents')}: {getComponentList().join(', ')}

{/if}
@@ -204,7 +225,7 @@ {#if loadState === 'loading'}
-

Loading plugin bundle...

+

{tr('bundle.loading')}

{/if}
import Icon from '../ui/Icon.svelte'; + import { onDestroy } from 'svelte'; + import { i18n } from '../i18n/index.js'; export let p = {}; export let capabilities = []; export let permissions = []; @@ -8,6 +10,13 @@ export let settingsPanels = []; export let onEnable = () => {}; export let onDisable = () => {}; + let locale = i18n.getLocale(); + const unsubscribeLocale = i18n.subscribe((nextLocale) => { locale = nextLocale; }); + $: tr = ((activeLocale) => (key, params, fallback) => { + void activeLocale; + return i18n.t(key, params, fallback); + })(locale); + onDestroy(unsubscribeLocale); $: m = p.manifest || {}; $: pluginId = m.id || 'unknown'; @@ -39,14 +48,14 @@ $: contribSummary = (() => { const parts = []; - if (contribCounts.views > 0) parts.push(contribCounts.views + ' view' + (contribCounts.views !== 1 ? 's' : '')); - if (contribCounts.commands > 0) parts.push(contribCounts.commands + ' command' + (contribCounts.commands !== 1 ? 's' : '')); - if (contribCounts.searchProviders > 0) parts.push(contribCounts.searchProviders + ' searchProvider' + (contribCounts.searchProviders !== 1 ? 's' : '')); - if (contribCounts.sidebar > 0) parts.push(contribCounts.sidebar + ' sidebar' + (contribCounts.sidebar !== 1 ? 's' : '')); - if (contribCounts.statusbar > 0) parts.push(contribCounts.statusbar + ' statusbar' + (contribCounts.statusbar !== 1 ? 's' : '')); - if (contribCounts.openProviders > 0) parts.push(contribCounts.openProviders + ' openProvider' + (contribCounts.openProviders !== 1 ? 's' : '')); - if (contribCounts.workspaceItems > 0) parts.push(contribCounts.workspaceItems + ' workspace' + (contribCounts.workspaceItems !== 1 ? 's' : '')); - return parts.length > 0 ? parts.join(', ') : 'none'; + if (contribCounts.views > 0) parts.push(tr('pluginCard.count.views', { count: contribCounts.views })); + if (contribCounts.commands > 0) parts.push(tr('pluginCard.count.commands', { count: contribCounts.commands })); + if (contribCounts.searchProviders > 0) parts.push(tr('pluginCard.count.searchProviders', { count: contribCounts.searchProviders })); + if (contribCounts.sidebar > 0) parts.push(tr('pluginCard.count.sidebar', { count: contribCounts.sidebar })); + if (contribCounts.statusbar > 0) parts.push(tr('pluginCard.count.statusbar', { count: contribCounts.statusbar })); + if (contribCounts.openProviders > 0) parts.push(tr('pluginCard.count.openProviders', { count: contribCounts.openProviders })); + if (contribCounts.workspaceItems > 0) parts.push(tr('pluginCard.count.workspace', { count: contribCounts.workspaceItems })); + return parts.length > 0 ? parts.join(', ') : tr('common.none'); })(); $: dangerousPermissions = (m.permissions || []).filter(name => { @@ -81,11 +90,11 @@ {pluginId} v{m.version || '?'}
- {p.status} + {tr(`status.${p.status}`, undefined, p.status)}
{#if p.status === 'degraded'} -

Plugin is usable, but some optional capabilities are unavailable.

+

{tr('pluginCard.degraded')}

{/if} {#if m.description} @@ -94,34 +103,34 @@
- Name: + {tr('pluginCard.name')}: {m.name || '-'}
- Source: - {m.source || 'unknown'} + {tr('common.source')}: + {m.source || tr('common.unknown')}
- Contributions: + {tr('pluginCard.contributions')}: {contribSummary}
- Technical details + {tr('pluginCard.technicalDetails')}
- API Version: + {tr('pluginCard.apiVersion')}: {m.apiVersion || '-'}
- Root: + {tr('pluginCard.root')}: {p.rootPath || '-'}
- Provides + {tr('pluginCard.provides')}
{#each m.provides || [] as cap} {cap} @@ -131,7 +140,7 @@ {#if m.requires && m.requires.length > 0}
- Requires + {tr('pluginCard.requires')}
{#each m.requires as req} {@const found = capabilities.some(c => c.name === req)} @@ -142,14 +151,14 @@ {/each}
{#if missingRequired.length > 0} -

Missing required capabilities: {missingRequired.join(', ')}

+

{tr('pluginCard.missingRequired')}: {missingRequired.join(', ')}

{/if}
{/if} {#if m.optionalRequires && m.optionalRequires.length > 0}
- Optional Requires + {tr('pluginCard.optionalRequires')}
{#each m.optionalRequires as opt} {@const found = capabilities.some(c => c.name === opt)} @@ -160,14 +169,14 @@ {/each}
{#if missingOptional.length > 0} -

Optional capabilities not available — plugin running in degraded mode

+

{tr('pluginCard.optionalUnavailable')}

{/if}
{/if} {#if m.permissions && m.permissions.length > 0}
- Permissions + {tr('pluginCard.permissions')}
{#each m.permissions as perm} {@const isDangerous = dangerousPermissions.includes(perm)} @@ -190,28 +199,28 @@
{#if hasSettingsPanel} {/if} {#if vaultOpen && canToggle} {#if isDisabled} {:else} {/if} {/if} {#if !vaultOpen && canToggle} - Open a vault to manage plugin state + {tr('pluginCard.openVault')} {/if}
{#if !hasUIPermission && m.contributes && ((m.contributes.views || []).length > 0 || (m.contributes.sidebarItems || []).length > 0 || (m.contributes.settingsPanels || []).length > 0)} -

Plugin has UI contributions but lacks ui.register permission

+

{tr('pluginCard.missingUiPermission')}

{/if}
diff --git a/frontend/src/lib/plugin-manager/PluginManager.svelte b/frontend/src/lib/plugin-manager/PluginManager.svelte index 2651d85..b3ed81e 100644 --- a/frontend/src/lib/plugin-manager/PluginManager.svelte +++ b/frontend/src/lib/plugin-manager/PluginManager.svelte @@ -2,9 +2,10 @@ import Icon from '../ui/Icon.svelte'; import PluginCard from './PluginCard.svelte'; import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte'; - import { onMount, tick } from 'svelte'; + import { onDestroy, onMount, tick } from 'svelte'; import { GetPlugins, GetCapabilities, GetPermissions, GetContributions, ReloadPlugins, GetVaultStatus, GetVaultPluginState, EnablePlugin, DisablePlugin, ReadPluginSettings, WritePluginSettings, GetPluginFrontendInfo, WriteFrontendLog } from '../../../wailsjs/go/api/App'; import { debug } from '../log/debug.js'; + import { i18n } from '../i18n/index.js'; let plugins = []; let capabilities = []; @@ -20,6 +21,12 @@ let settingsError = null; let settingsPluginInfo = null; let lastOpenedKey = ''; + let locale = i18n.getLocale(); + let unsubscribeLocale = null; + $: tr = ((activeLocale) => (key, params, fallback) => { + void activeLocale; + return i18n.t(key, params, fallback); + })(locale); // Per-action loading state — shows feedback on specific buttons without hiding the whole list let actionFeedback = {}; // { [pluginId]: 'enabling' | 'disabling' | null } @@ -109,7 +116,10 @@ try { debug.log('[PluginManager] loadAll: calling GetPlugins...'); const p = await GetPlugins(); - plugins = p || []; + await Promise.all((p || []).map((plugin) => ( + i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {}) + ))); + plugins = (p || []).map((plugin) => i18n.localizePlugin(plugin)); debug.log('[PluginManager] loadAll: GetPlugins returned', plugins.length, 'plugins'); for (var i = 0; i < plugins.length; i++) { debug.log('[PluginManager] loadAll: plugin[' + i + ']:', plugins[i].manifest?.id, 'status:', plugins[i].status, 'enabled:', plugins[i].enabled); @@ -133,7 +143,7 @@ vaultStatus = v || { status: 'unknown', path: '', vaultId: '' }; capabilities = caps || []; permissions = perms || []; - contributions = contribs || {}; + contributions = i18n.localizeContributionSummary(contribs || {}); debug.log('[PluginManager] loadAll: vault=' + vaultStatus.status + ' caps=' + capabilities.length + ' perms=' + permissions.length); WriteFrontendLog('PluginManager', 'loadAll: vault=' + vaultStatus.status + ' caps=' + capabilities.length + ' perms=' + permissions.length); } catch (e) { @@ -156,7 +166,18 @@ WriteFrontendLog('PluginManager', 'loadAll: END, loading=false'); } - onMount(() => { loadAll(); }); + onMount(() => { + unsubscribeLocale = i18n.subscribe((nextLocale) => { + const changed = locale !== nextLocale; + locale = nextLocale; + if (changed) loadAll(); + }); + loadAll(); + }); + + onDestroy(() => { + if (unsubscribeLocale) unsubscribeLocale(); + }); async function reload() { debug.log('[PluginManager] reload: START'); @@ -272,48 +293,48 @@
-

Plugin Manager

+

{tr('settings.pluginManager')}

{#if vaultStatus.status !== 'unknown'} - Vault: {vaultStatus.status}{#if vaultStatus.path} ({vaultStatus.path}){/if} + {tr('vault.label', { status: tr(`vault.status.${vaultStatus.status}`, undefined, vaultStatus.status) })}{#if vaultStatus.path} ({vaultStatus.path}){/if} {/if}
{#if loading} -
Scanning plugin directories...
+
{tr('pluginManager.scanning')}
{:else if error}
{error}
- +
{:else}
- {totalPlugins} plugin(s) discovered - {totalCaps} capabilities registered - {totalPerms} permissions known + {tr('pluginManager.summary.plugins', { count: totalPlugins })} + {tr('pluginManager.summary.capabilities', { count: totalCaps })} + {tr('pluginManager.summary.permissions', { count: totalPerms })}
-
+
-
Plugin Health
+
{tr('pluginManager.health')}
- {statusSummary.loaded} loaded - {statusSummary.degraded} degraded - {statusSummary.failed} failed - {statusSummary.disabled} disabled + {statusSummary.loaded} {tr('status.loaded')} + {statusSummary.degraded} {tr('status.degraded')} + {statusSummary.failed} {tr('status.failed')} + {statusSummary.disabled} {tr('status.disabled')}
-
Permission Risk
+
{tr('pluginManager.permissionRisk')}
- {elevatedPermissionPluginCount} plugins request elevated permissions + {tr('pluginManager.elevatedPermissions', { count: elevatedPermissionPluginCount })}
@@ -325,13 +346,13 @@
-

No plugins found

-

Plugin directories scanned:

+

{tr('pluginManager.none')}

+

{tr('pluginManager.scannedDirs')}

    -
  • ~/.config/verstak/plugins/ — user plugins
  • -
  • ./plugins/ — bundled plugins (app directory)
  • +
  • ~/.config/verstak/plugins/ — {tr('pluginManager.userPlugins')}
  • +
  • ./plugins/ — {tr('pluginManager.bundledPlugins')}
-

Place a plugin folder with plugin.json in one of these directories and click Reload.

+

{tr('pluginManager.installHint')}

{:else}
@@ -343,8 +364,8 @@ {#if missingInstalled.length > 0}
-

Missing Installed Plugins

-

These plugins are required by this vault but their packages are not installed locally.

+

{tr('pluginManager.missingTitle')}

+

{tr('pluginManager.missingHint')}

{#each missingInstalled as mp}
@@ -354,12 +375,12 @@ {mp.id} {#if mp.version}v{mp.version}{/if}
- missing + {tr('status.missing')}

- This plugin is listed in the vault's desired plugins but the package is not installed. + {tr('pluginManager.missingPackage')} {#if mp.source && mp.source !== 'unknown'} - Source: {mp.source} + {tr('common.source')}: {mp.source} {/if}

@@ -370,10 +391,10 @@ {#if capabilities.length > 0}
- Capability Registry ({totalCaps}) + {tr('pluginManager.capabilityRegistry', { count: totalCaps })} - + {#each capabilities as cap} @@ -394,9 +415,9 @@ {#key `settings-${settingsPluginId}`} {#if settingsError}
CapabilityProviderSourceStatus
{tr('common.capability')}{tr('common.provider')}{tr('common.source')}{tr('common.status')}