feat: add Russian desktop localization
This commit is contained in:
parent
8b1151e03a
commit
7f0cc308c2
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -9,13 +9,19 @@
|
||||||
import WorkspaceHost from './lib/shell/WorkspaceHost.svelte';
|
import WorkspaceHost from './lib/shell/WorkspaceHost.svelte';
|
||||||
import * as App from '../wailsjs/go/api/App';
|
import * as App from '../wailsjs/go/api/App';
|
||||||
import { debug } from './lib/log/debug.js';
|
import { debug } from './lib/log/debug.js';
|
||||||
import { onMount } from 'svelte';
|
import { onDestroy, onMount, tick } from 'svelte';
|
||||||
import { tick } from 'svelte';
|
import { i18n } from './lib/i18n/index.js';
|
||||||
|
|
||||||
let currentView = 'workspace';
|
let currentView = 'workspace';
|
||||||
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
|
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
|
||||||
let needsVaultSelection = false;
|
let needsVaultSelection = false;
|
||||||
let loading = true;
|
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 activeView = null;
|
||||||
let activeViewPluginId = '';
|
let activeViewPluginId = '';
|
||||||
|
|
@ -375,11 +381,13 @@
|
||||||
await checkVault();
|
await checkVault();
|
||||||
pushNavigation();
|
pushNavigation();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onDestroy(unsubscribeLocale);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="app-loading">
|
<div class="app-loading">
|
||||||
<p>Loading Verstak...</p>
|
<p>{tr('app.loading')}</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if needsVaultSelection}
|
{:else if needsVaultSelection}
|
||||||
<VaultSelection />
|
<VaultSelection />
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
};
|
||||||
|
|
@ -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} дн. назад',
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import * as App from '../../../wailsjs/go/api/App';
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
import Icon from '../ui/Icon.svelte';
|
import Icon from '../ui/Icon.svelte';
|
||||||
|
import { i18n } from '../i18n/index.js';
|
||||||
|
|
||||||
import { createPluginAPI } from './VerstakPluginAPI.js';
|
import { createPluginAPI } from './VerstakPluginAPI.js';
|
||||||
|
|
||||||
|
|
@ -18,6 +19,13 @@
|
||||||
let currentComponent = null;
|
let currentComponent = null;
|
||||||
let currentAPI = null;
|
let currentAPI = null;
|
||||||
let currentPropsKey = '';
|
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;
|
$: activePluginId = pluginId || viewPluginId;
|
||||||
$: activeComponent = componentId;
|
$: activeComponent = componentId;
|
||||||
|
|
@ -32,9 +40,14 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
unsubscribeLocale?.();
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
unsubscribeLocale = i18n.subscribe((nextLocale) => locale = nextLocale);
|
||||||
|
});
|
||||||
|
|
||||||
function cleanup() {
|
function cleanup() {
|
||||||
if (currentAPI && typeof currentAPI.dispose === 'function') {
|
if (currentAPI && typeof currentAPI.dispose === 'function') {
|
||||||
try {
|
try {
|
||||||
|
|
@ -93,11 +106,17 @@
|
||||||
if (!info || info.status === 'no-frontend' || info.status === 'not-found') {
|
if (!info || info.status === 'no-frontend' || info.status === 'not-found') {
|
||||||
loadState = 'error';
|
loadState = 'error';
|
||||||
errorText = info.status === 'no-frontend'
|
errorText = info.status === 'no-frontend'
|
||||||
? 'Plugin has no frontend bundle'
|
? tr('bundle.noFrontend')
|
||||||
: 'Plugin not found';
|
: tr('bundle.notFound');
|
||||||
return;
|
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
|
// Check if bundle already loaded for this plugin
|
||||||
const reg = window.__VERSTAK_PLUGIN_REGISTRY__ || {};
|
const reg = window.__VERSTAK_PLUGIN_REGISTRY__ || {};
|
||||||
if (!reg[pId]) {
|
if (!reg[pId]) {
|
||||||
|
|
@ -106,7 +125,7 @@
|
||||||
const content = assetResult.value;
|
const content = assetResult.value;
|
||||||
if (assetResult.error || !content) {
|
if (assetResult.error || !content) {
|
||||||
loadState = 'error';
|
loadState = 'error';
|
||||||
errorText = 'Failed to load bundle: ' + (assetResult.error || 'empty content');
|
errorText = tr('bundle.loadFailed', { error: assetResult.error || tr('bundle.emptyContent') });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -117,7 +136,7 @@
|
||||||
fn();
|
fn();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loadState = 'error';
|
loadState = 'error';
|
||||||
errorText = 'Bundle execution error: ' + e.message;
|
errorText = tr('bundle.executionError', { error: e.message });
|
||||||
console.error('[PluginBundleHost] bundle exec error:', e);
|
console.error('[PluginBundleHost] bundle exec error:', e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -125,7 +144,7 @@
|
||||||
// Verify registration happened
|
// Verify registration happened
|
||||||
if (!window.__VERSTAK_PLUGIN_REGISTRY__[pId]) {
|
if (!window.__VERSTAK_PLUGIN_REGISTRY__[pId]) {
|
||||||
loadState = 'error';
|
loadState = 'error';
|
||||||
errorText = 'Bundle loaded but no VerstakPluginRegister call detected';
|
errorText = tr('bundle.registrationMissing');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -135,8 +154,10 @@
|
||||||
const comp = components[compId];
|
const comp = components[compId];
|
||||||
if (!comp || !comp.mount) {
|
if (!comp || !comp.mount) {
|
||||||
loadState = 'error';
|
loadState = 'error';
|
||||||
errorText = 'Component "' + compId + '" not found in bundle. Available: '
|
errorText = tr('bundle.componentMissing', {
|
||||||
+ (Object.keys(components).join(', ') || 'none');
|
component: compId,
|
||||||
|
available: Object.keys(components).join(', ') || tr('common.none'),
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -156,16 +177,16 @@
|
||||||
errorText = '';
|
errorText = '';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loadState = 'error';
|
loadState = 'error';
|
||||||
errorText = 'Component mount error: ' + e.message;
|
errorText = tr('bundle.mountError', { error: e.message });
|
||||||
console.error('[PluginBundleHost] mount error:', e);
|
console.error('[PluginBundleHost] mount error:', e);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
loadState = 'error';
|
loadState = 'error';
|
||||||
errorText = 'Mount container not available';
|
errorText = tr('bundle.mountUnavailable');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loadState = 'error';
|
loadState = 'error';
|
||||||
errorText = 'Unexpected error: ' + (e.message || e);
|
errorText = tr('bundle.unexpectedError', { error: e.message || e });
|
||||||
console.error('[PluginBundleHost] error:', e);
|
console.error('[PluginBundleHost] error:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -180,22 +201,22 @@
|
||||||
<div class="plugin-bundle-host">
|
<div class="plugin-bundle-host">
|
||||||
{#if loadState === 'idle'}
|
{#if loadState === 'idle'}
|
||||||
<div class="host-state idle">
|
<div class="host-state idle">
|
||||||
<p>Select a plugin view from the sidebar</p>
|
<p>{tr('pluginView.select')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if loadState === 'error'}
|
{:else if loadState === 'error'}
|
||||||
<div class="host-state error">
|
<div class="host-state error">
|
||||||
<Icon name="warning" size={24} class="error-icon" />
|
<Icon name="warning" size={24} class="error-icon" />
|
||||||
<p class="error-title">Plugin View Error</p>
|
<p class="error-title">{tr('pluginView.error')}</p>
|
||||||
<div class="error-details">
|
<div class="error-details">
|
||||||
<p><strong>Plugin:</strong> {currentPluginId || 'unknown'}</p>
|
<p><strong>{tr('common.plugin')}:</strong> {currentPluginId || tr('common.unknown')}</p>
|
||||||
<p><strong>Component:</strong> {currentComponent || 'unknown'}</p>
|
<p><strong>{tr('common.component')}:</strong> {currentComponent || tr('common.unknown')}</p>
|
||||||
<p class="error-message">{errorText || 'Unknown error'}</p>
|
<p class="error-message">{errorText || tr('bundle.unknownError')}</p>
|
||||||
{#if pluginInfo}
|
{#if pluginInfo}
|
||||||
<p class="error-meta">Frontend entry: {pluginInfo.entry || 'none'}</p>
|
<p class="error-meta">{tr('bundle.frontendEntry')}: {pluginInfo.entry || tr('common.none')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if getComponentList().length > 0}
|
{#if getComponentList().length > 0}
|
||||||
<p class="error-meta">Available components: {getComponentList().join(', ')}</p>
|
<p class="error-meta">{tr('bundle.availableComponents')}: {getComponentList().join(', ')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -204,7 +225,7 @@
|
||||||
{#if loadState === 'loading'}
|
{#if loadState === 'loading'}
|
||||||
<div class="host-state loading">
|
<div class="host-state loading">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
<p>Loading plugin bundle...</p>
|
<p>{tr('bundle.loading')}</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import * as App from '../../../wailsjs/go/api/App';
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
|
import { i18n } from '../i18n/index.js';
|
||||||
|
|
||||||
window.__VERSTAK_PLUGIN_REGISTRY__ = window.__VERSTAK_PLUGIN_REGISTRY__ || {};
|
window.__VERSTAK_PLUGIN_REGISTRY__ = window.__VERSTAK_PLUGIN_REGISTRY__ || {};
|
||||||
window.__VERSTAK_EVENT_HANDLERS__ = window.__VERSTAK_EVENT_HANDLERS__ || {};
|
window.__VERSTAK_EVENT_HANDLERS__ = window.__VERSTAK_EVENT_HANDLERS__ || {};
|
||||||
|
|
@ -144,6 +145,24 @@ export function createPluginAPI(pluginId) {
|
||||||
return {
|
return {
|
||||||
pluginId: pluginId,
|
pluginId: pluginId,
|
||||||
|
|
||||||
|
i18n: {
|
||||||
|
getLocale: function() {
|
||||||
|
assertActive('i18n.getLocale');
|
||||||
|
return i18n.getLocale();
|
||||||
|
},
|
||||||
|
t: function(key, params, fallback) {
|
||||||
|
assertActive('i18n.t(' + key + ')');
|
||||||
|
return i18n.translatePlugin(pluginId, key, params, fallback);
|
||||||
|
},
|
||||||
|
onDidChangeLocale: function(listener) {
|
||||||
|
assertActive('i18n.onDidChangeLocale');
|
||||||
|
if (typeof listener !== 'function') {
|
||||||
|
throw new Error('i18n.onDidChangeLocale requires a listener function');
|
||||||
|
}
|
||||||
|
return trackCleanup(i18n.subscribe(listener));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
ui: {
|
ui: {
|
||||||
openSettings: function(panelId) {
|
openSettings: function(panelId) {
|
||||||
assertActive('ui.openSettings');
|
assertActive('ui.openSettings');
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<script>
|
<script>
|
||||||
import Icon from '../ui/Icon.svelte';
|
import Icon from '../ui/Icon.svelte';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import { i18n } from '../i18n/index.js';
|
||||||
export let p = {};
|
export let p = {};
|
||||||
export let capabilities = [];
|
export let capabilities = [];
|
||||||
export let permissions = [];
|
export let permissions = [];
|
||||||
|
|
@ -8,6 +10,13 @@
|
||||||
export let settingsPanels = [];
|
export let settingsPanels = [];
|
||||||
export let onEnable = () => {};
|
export let onEnable = () => {};
|
||||||
export let onDisable = () => {};
|
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 || {};
|
$: m = p.manifest || {};
|
||||||
$: pluginId = m.id || 'unknown';
|
$: pluginId = m.id || 'unknown';
|
||||||
|
|
@ -39,14 +48,14 @@
|
||||||
|
|
||||||
$: contribSummary = (() => {
|
$: contribSummary = (() => {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (contribCounts.views > 0) parts.push(contribCounts.views + ' view' + (contribCounts.views !== 1 ? 's' : ''));
|
if (contribCounts.views > 0) parts.push(tr('pluginCard.count.views', { count: contribCounts.views }));
|
||||||
if (contribCounts.commands > 0) parts.push(contribCounts.commands + ' command' + (contribCounts.commands !== 1 ? 's' : ''));
|
if (contribCounts.commands > 0) parts.push(tr('pluginCard.count.commands', { count: contribCounts.commands }));
|
||||||
if (contribCounts.searchProviders > 0) parts.push(contribCounts.searchProviders + ' searchProvider' + (contribCounts.searchProviders !== 1 ? 's' : ''));
|
if (contribCounts.searchProviders > 0) parts.push(tr('pluginCard.count.searchProviders', { count: contribCounts.searchProviders }));
|
||||||
if (contribCounts.sidebar > 0) parts.push(contribCounts.sidebar + ' sidebar' + (contribCounts.sidebar !== 1 ? 's' : ''));
|
if (contribCounts.sidebar > 0) parts.push(tr('pluginCard.count.sidebar', { count: contribCounts.sidebar }));
|
||||||
if (contribCounts.statusbar > 0) parts.push(contribCounts.statusbar + ' statusbar' + (contribCounts.statusbar !== 1 ? 's' : ''));
|
if (contribCounts.statusbar > 0) parts.push(tr('pluginCard.count.statusbar', { count: contribCounts.statusbar }));
|
||||||
if (contribCounts.openProviders > 0) parts.push(contribCounts.openProviders + ' openProvider' + (contribCounts.openProviders !== 1 ? 's' : ''));
|
if (contribCounts.openProviders > 0) parts.push(tr('pluginCard.count.openProviders', { count: contribCounts.openProviders }));
|
||||||
if (contribCounts.workspaceItems > 0) parts.push(contribCounts.workspaceItems + ' workspace' + (contribCounts.workspaceItems !== 1 ? 's' : ''));
|
if (contribCounts.workspaceItems > 0) parts.push(tr('pluginCard.count.workspace', { count: contribCounts.workspaceItems }));
|
||||||
return parts.length > 0 ? parts.join(', ') : 'none';
|
return parts.length > 0 ? parts.join(', ') : tr('common.none');
|
||||||
})();
|
})();
|
||||||
|
|
||||||
$: dangerousPermissions = (m.permissions || []).filter(name => {
|
$: dangerousPermissions = (m.permissions || []).filter(name => {
|
||||||
|
|
@ -81,11 +90,11 @@
|
||||||
<strong>{pluginId}</strong>
|
<strong>{pluginId}</strong>
|
||||||
<span class="version">v{m.version || '?'}</span>
|
<span class="version">v{m.version || '?'}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="status-badge" style="color: {statusColor}">{p.status}</span>
|
<span class="status-badge" style="color: {statusColor}">{tr(`status.${p.status}`, undefined, p.status)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if p.status === 'degraded'}
|
{#if p.status === 'degraded'}
|
||||||
<p class="degraded-text">Plugin is usable, but some optional capabilities are unavailable.</p>
|
<p class="degraded-text">{tr('pluginCard.degraded')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if m.description}
|
{#if m.description}
|
||||||
|
|
@ -94,34 +103,34 @@
|
||||||
|
|
||||||
<div class="card-meta">
|
<div class="card-meta">
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
<span class="label">Name:</span>
|
<span class="label">{tr('pluginCard.name')}:</span>
|
||||||
<span>{m.name || '-'}</span>
|
<span>{m.name || '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
<span class="label">Source:</span>
|
<span class="label">{tr('common.source')}:</span>
|
||||||
<span>{m.source || 'unknown'}</span>
|
<span>{m.source || tr('common.unknown')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
<span class="label">Contributions:</span>
|
<span class="label">{tr('pluginCard.contributions')}:</span>
|
||||||
<span>{contribSummary}</span>
|
<span>{contribSummary}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details class="plugin-details">
|
<details class="plugin-details">
|
||||||
<summary>Technical details</summary>
|
<summary>{tr('pluginCard.technicalDetails')}</summary>
|
||||||
<div class="card-meta technical-meta">
|
<div class="card-meta technical-meta">
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
<span class="label">API Version:</span>
|
<span class="label">{tr('pluginCard.apiVersion')}:</span>
|
||||||
<span>{m.apiVersion || '-'}</span>
|
<span>{m.apiVersion || '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
<span class="label">Root:</span>
|
<span class="label">{tr('pluginCard.root')}:</span>
|
||||||
<span class="path">{p.rootPath || '-'}</span>
|
<span class="path">{p.rootPath || '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<span class="section-title">Provides</span>
|
<span class="section-title">{tr('pluginCard.provides')}</span>
|
||||||
<div class="tags">
|
<div class="tags">
|
||||||
{#each m.provides || [] as cap}
|
{#each m.provides || [] as cap}
|
||||||
<span class="tag provides">{cap}</span>
|
<span class="tag provides">{cap}</span>
|
||||||
|
|
@ -131,7 +140,7 @@
|
||||||
|
|
||||||
{#if m.requires && m.requires.length > 0}
|
{#if m.requires && m.requires.length > 0}
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<span class="section-title">Requires</span>
|
<span class="section-title">{tr('pluginCard.requires')}</span>
|
||||||
<div class="tags">
|
<div class="tags">
|
||||||
{#each m.requires as req}
|
{#each m.requires as req}
|
||||||
{@const found = capabilities.some(c => c.name === req)}
|
{@const found = capabilities.some(c => c.name === req)}
|
||||||
|
|
@ -142,14 +151,14 @@
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{#if missingRequired.length > 0}
|
{#if missingRequired.length > 0}
|
||||||
<p class="warning"><Icon name="warning" size={12} /> Missing required capabilities: {missingRequired.join(', ')}</p>
|
<p class="warning"><Icon name="warning" size={12} /> {tr('pluginCard.missingRequired')}: {missingRequired.join(', ')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if m.optionalRequires && m.optionalRequires.length > 0}
|
{#if m.optionalRequires && m.optionalRequires.length > 0}
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<span class="section-title">Optional Requires</span>
|
<span class="section-title">{tr('pluginCard.optionalRequires')}</span>
|
||||||
<div class="tags">
|
<div class="tags">
|
||||||
{#each m.optionalRequires as opt}
|
{#each m.optionalRequires as opt}
|
||||||
{@const found = capabilities.some(c => c.name === opt)}
|
{@const found = capabilities.some(c => c.name === opt)}
|
||||||
|
|
@ -160,14 +169,14 @@
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{#if missingOptional.length > 0}
|
{#if missingOptional.length > 0}
|
||||||
<p class="info"><Icon name="warning" size={12} /> Optional capabilities not available — plugin running in degraded mode</p>
|
<p class="info"><Icon name="warning" size={12} /> {tr('pluginCard.optionalUnavailable')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if m.permissions && m.permissions.length > 0}
|
{#if m.permissions && m.permissions.length > 0}
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<span class="section-title">Permissions</span>
|
<span class="section-title">{tr('pluginCard.permissions')}</span>
|
||||||
<div class="tags">
|
<div class="tags">
|
||||||
{#each m.permissions as perm}
|
{#each m.permissions as perm}
|
||||||
{@const isDangerous = dangerousPermissions.includes(perm)}
|
{@const isDangerous = dangerousPermissions.includes(perm)}
|
||||||
|
|
@ -190,28 +199,28 @@
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
{#if hasSettingsPanel}
|
{#if hasSettingsPanel}
|
||||||
<button class="btn-settings" on:click={() => window.dispatchEvent(new CustomEvent('verstak:open-settings', { detail: { pluginId: m.id, panelId: settingsPanels[0]?.id } }))} type="button" disabled={isDisabled || p.status === 'failed'}>
|
<button class="btn-settings" on:click={() => window.dispatchEvent(new CustomEvent('verstak:open-settings', { detail: { pluginId: m.id, panelId: settingsPanels[0]?.id } }))} type="button" disabled={isDisabled || p.status === 'failed'}>
|
||||||
<Icon name="gear" size={14} /> Settings
|
<Icon name="gear" size={14} /> {tr('settings.title')}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if vaultOpen && canToggle}
|
{#if vaultOpen && canToggle}
|
||||||
{#if isDisabled}
|
{#if isDisabled}
|
||||||
<button class="btn-enable" on:click={() => onEnable(m.id)} type="button" disabled={isBusy}>
|
<button class="btn-enable" on:click={() => onEnable(m.id)} type="button" disabled={isBusy}>
|
||||||
{#if busyAction === 'enabling'}⟳ Enabling...{:else}▶ Enable{/if}
|
{#if busyAction === 'enabling'}{tr('pluginCard.enabling')}{:else}{tr('pluginCard.enable')}{/if}
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="btn-disable" on:click={() => onDisable(m.id)} type="button" disabled={isBusy}>
|
<button class="btn-disable" on:click={() => onDisable(m.id)} type="button" disabled={isBusy}>
|
||||||
{#if busyAction === 'disabling'}⟳ Disabling...{:else}⏸ Disable{/if}
|
{#if busyAction === 'disabling'}{tr('pluginCard.disabling')}{:else}{tr('pluginCard.disable')}{/if}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{#if !vaultOpen && canToggle}
|
{#if !vaultOpen && canToggle}
|
||||||
<span class="vault-hint">Open a vault to manage plugin state</span>
|
<span class="vault-hint">{tr('pluginCard.openVault')}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Permission warnings -->
|
<!-- Permission warnings -->
|
||||||
{#if !hasUIPermission && m.contributes && ((m.contributes.views || []).length > 0 || (m.contributes.sidebarItems || []).length > 0 || (m.contributes.settingsPanels || []).length > 0)}
|
{#if !hasUIPermission && m.contributes && ((m.contributes.views || []).length > 0 || (m.contributes.sidebarItems || []).length > 0 || (m.contributes.settingsPanels || []).length > 0)}
|
||||||
<p class="warning"><Icon name="warning" size={12} /> Plugin has UI contributions but lacks ui.register permission</p>
|
<p class="warning"><Icon name="warning" size={12} /> {tr('pluginCard.missingUiPermission')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@
|
||||||
import Icon from '../ui/Icon.svelte';
|
import Icon from '../ui/Icon.svelte';
|
||||||
import PluginCard from './PluginCard.svelte';
|
import PluginCard from './PluginCard.svelte';
|
||||||
import PluginBundleHost from '../plugin-host/PluginBundleHost.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 { 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 { debug } from '../log/debug.js';
|
||||||
|
import { i18n } from '../i18n/index.js';
|
||||||
|
|
||||||
let plugins = [];
|
let plugins = [];
|
||||||
let capabilities = [];
|
let capabilities = [];
|
||||||
|
|
@ -20,6 +21,12 @@
|
||||||
let settingsError = null;
|
let settingsError = null;
|
||||||
let settingsPluginInfo = null;
|
let settingsPluginInfo = null;
|
||||||
let lastOpenedKey = '';
|
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
|
// Per-action loading state — shows feedback on specific buttons without hiding the whole list
|
||||||
let actionFeedback = {}; // { [pluginId]: 'enabling' | 'disabling' | null }
|
let actionFeedback = {}; // { [pluginId]: 'enabling' | 'disabling' | null }
|
||||||
|
|
@ -109,7 +116,10 @@
|
||||||
try {
|
try {
|
||||||
debug.log('[PluginManager] loadAll: calling GetPlugins...');
|
debug.log('[PluginManager] loadAll: calling GetPlugins...');
|
||||||
const p = await 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');
|
debug.log('[PluginManager] loadAll: GetPlugins returned', plugins.length, 'plugins');
|
||||||
for (var i = 0; i < plugins.length; i++) {
|
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);
|
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: '' };
|
vaultStatus = v || { status: 'unknown', path: '', vaultId: '' };
|
||||||
capabilities = caps || [];
|
capabilities = caps || [];
|
||||||
permissions = perms || [];
|
permissions = perms || [];
|
||||||
contributions = contribs || {};
|
contributions = i18n.localizeContributionSummary(contribs || {});
|
||||||
debug.log('[PluginManager] loadAll: vault=' + vaultStatus.status + ' caps=' + capabilities.length + ' perms=' + permissions.length);
|
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);
|
WriteFrontendLog('PluginManager', 'loadAll: vault=' + vaultStatus.status + ' caps=' + capabilities.length + ' perms=' + permissions.length);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -156,7 +166,18 @@
|
||||||
WriteFrontendLog('PluginManager', 'loadAll: END, loading=false');
|
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() {
|
async function reload() {
|
||||||
debug.log('[PluginManager] reload: START');
|
debug.log('[PluginManager] reload: START');
|
||||||
|
|
@ -272,48 +293,48 @@
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<h2>Plugin Manager</h2>
|
<h2>{tr('settings.pluginManager')}</h2>
|
||||||
{#if vaultStatus.status !== 'unknown'}
|
{#if vaultStatus.status !== 'unknown'}
|
||||||
<span class="vault-badge" class:vault-open={vaultStatus.status === 'open'} class:vault-not-created={vaultStatus.status === 'not-created'} class:vault-closed={vaultStatus.status === 'closed'} class:vault-error={vaultStatus.status === 'error'}>
|
<span class="vault-badge" class:vault-open={vaultStatus.status === 'open'} class:vault-not-created={vaultStatus.status === 'not-created'} class:vault-closed={vaultStatus.status === 'closed'} class:vault-error={vaultStatus.status === 'error'}>
|
||||||
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}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<button class="reload-btn" on:click={reload} type="button" disabled={loading || reloading}>
|
<button class="reload-btn" on:click={reload} type="button" disabled={loading || reloading}>
|
||||||
{reloading ? '⟳ Reloading...' : '⟳ Reload'}
|
{reloading ? tr('pluginManager.reloading') : tr('pluginManager.reload')}
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="loading">Scanning plugin directories...</div>
|
<div class="loading">{tr('pluginManager.scanning')}</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="error">
|
<div class="error">
|
||||||
<Icon name="warning" size={24} class="error-icon" />
|
<Icon name="warning" size={24} class="error-icon" />
|
||||||
<div class="error-message">{error}</div>
|
<div class="error-message">{error}</div>
|
||||||
<button class="retry-btn" on:click={loadAll} type="button">⟳ Retry</button>
|
<button class="retry-btn" on:click={loadAll} type="button">{tr('common.retry')}</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="summary">
|
<div class="summary">
|
||||||
<span class="badge">{totalPlugins} plugin(s) discovered</span>
|
<span class="badge">{tr('pluginManager.summary.plugins', { count: totalPlugins })}</span>
|
||||||
<span class="badge">{totalCaps} capabilities registered</span>
|
<span class="badge">{tr('pluginManager.summary.capabilities', { count: totalCaps })}</span>
|
||||||
<span class="badge">{totalPerms} permissions known</span>
|
<span class="badge">{tr('pluginManager.summary.permissions', { count: totalPerms })}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="scan-summary" aria-label="Plugin scan summary">
|
<div class="scan-summary" aria-label={tr('pluginManager.scanSummary')}>
|
||||||
<section class="scan-card" data-plugin-manager-summary="health">
|
<section class="scan-card" data-plugin-manager-summary="health">
|
||||||
<div class="scan-card-title">Plugin Health</div>
|
<div class="scan-card-title">{tr('pluginManager.health')}</div>
|
||||||
<div class="scan-metrics">
|
<div class="scan-metrics">
|
||||||
<span data-plugin-status-summary="loaded"><strong>{statusSummary.loaded}</strong> loaded</span>
|
<span data-plugin-status-summary="loaded"><strong>{statusSummary.loaded}</strong> {tr('status.loaded')}</span>
|
||||||
<span data-plugin-status-summary="degraded"><strong>{statusSummary.degraded}</strong> degraded</span>
|
<span data-plugin-status-summary="degraded"><strong>{statusSummary.degraded}</strong> {tr('status.degraded')}</span>
|
||||||
<span data-plugin-status-summary="failed"><strong>{statusSummary.failed}</strong> failed</span>
|
<span data-plugin-status-summary="failed"><strong>{statusSummary.failed}</strong> {tr('status.failed')}</span>
|
||||||
<span data-plugin-status-summary="disabled"><strong>{statusSummary.disabled}</strong> disabled</span>
|
<span data-plugin-status-summary="disabled"><strong>{statusSummary.disabled}</strong> {tr('status.disabled')}</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="scan-card" data-plugin-manager-summary="risk">
|
<section class="scan-card" data-plugin-manager-summary="risk">
|
||||||
<div class="scan-card-title">Permission Risk</div>
|
<div class="scan-card-title">{tr('pluginManager.permissionRisk')}</div>
|
||||||
<div class="scan-metrics">
|
<div class="scan-metrics">
|
||||||
<span data-plugin-risk-summary="elevated-permissions"><strong>{elevatedPermissionPluginCount}</strong> plugins request elevated permissions</span>
|
<span data-plugin-risk-summary="elevated-permissions">{tr('pluginManager.elevatedPermissions', { count: elevatedPermissionPluginCount })}</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -325,13 +346,13 @@
|
||||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p>No plugins found</p>
|
<p>{tr('pluginManager.none')}</p>
|
||||||
<p class="hint">Plugin directories scanned:</p>
|
<p class="hint">{tr('pluginManager.scannedDirs')}</p>
|
||||||
<ul class="hint-list">
|
<ul class="hint-list">
|
||||||
<li><code>~/.config/verstak/plugins/</code> — user plugins</li>
|
<li><code>~/.config/verstak/plugins/</code> — {tr('pluginManager.userPlugins')}</li>
|
||||||
<li><code>./plugins/</code> — bundled plugins (app directory)</li>
|
<li><code>./plugins/</code> — {tr('pluginManager.bundledPlugins')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="hint">Place a plugin folder with <code>plugin.json</code> in one of these directories and click Reload.</p>
|
<p class="hint">{tr('pluginManager.installHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="plugin-list">
|
<div class="plugin-list">
|
||||||
|
|
@ -343,8 +364,8 @@
|
||||||
|
|
||||||
{#if missingInstalled.length > 0}
|
{#if missingInstalled.length > 0}
|
||||||
<div class="missing-section">
|
<div class="missing-section">
|
||||||
<h3>Missing Installed Plugins</h3>
|
<h3>{tr('pluginManager.missingTitle')}</h3>
|
||||||
<p class="missing-hint">These plugins are required by this vault but their packages are not installed locally.</p>
|
<p class="missing-hint">{tr('pluginManager.missingHint')}</p>
|
||||||
<div class="plugin-list">
|
<div class="plugin-list">
|
||||||
{#each missingInstalled as mp}
|
{#each missingInstalled as mp}
|
||||||
<div class="plugin-card missing-card">
|
<div class="plugin-card missing-card">
|
||||||
|
|
@ -354,12 +375,12 @@
|
||||||
<strong>{mp.id}</strong>
|
<strong>{mp.id}</strong>
|
||||||
{#if mp.version}<span class="version">v{mp.version}</span>{/if}
|
{#if mp.version}<span class="version">v{mp.version}</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
<span class="status-badge" style="color: #e94560">missing</span>
|
<span class="status-badge" style="color: #e94560">{tr('status.missing')}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="missing-text">
|
<p class="missing-text">
|
||||||
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'}
|
{#if mp.source && mp.source !== 'unknown'}
|
||||||
<span class="source-hint">Source: {mp.source}</span>
|
<span class="source-hint">{tr('common.source')}: {mp.source}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -370,10 +391,10 @@
|
||||||
|
|
||||||
{#if capabilities.length > 0}
|
{#if capabilities.length > 0}
|
||||||
<details class="registry-section">
|
<details class="registry-section">
|
||||||
<summary>Capability Registry ({totalCaps})</summary>
|
<summary>{tr('pluginManager.capabilityRegistry', { count: totalCaps })}</summary>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Capability</th><th>Provider</th><th>Source</th><th>Status</th></tr>
|
<tr><th>{tr('common.capability')}</th><th>{tr('common.provider')}</th><th>{tr('common.source')}</th><th>{tr('common.status')}</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each capabilities as cap}
|
{#each capabilities as cap}
|
||||||
|
|
@ -394,9 +415,9 @@
|
||||||
{#key `settings-${settingsPluginId}`}
|
{#key `settings-${settingsPluginId}`}
|
||||||
{#if settingsError}
|
{#if settingsError}
|
||||||
<div class="modal-overlay" on:click|self={closeSettings} on:keydown|self={(e) => e.key === 'Escape' && closeSettings()} role="presentation">
|
<div class="modal-overlay" on:click|self={closeSettings} on:keydown|self={(e) => e.key === 'Escape' && closeSettings()} role="presentation">
|
||||||
<div class="modal" role="dialog" aria-modal="true" aria-label="Settings Error">
|
<div class="modal" role="dialog" aria-modal="true" aria-label={tr('pluginManager.settingsError')}>
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3>Settings Error</h3>
|
<h3>{tr('pluginManager.settingsError')}</h3>
|
||||||
<button class="modal-close" on:click={closeSettings} type="button">✕</button>
|
<button class="modal-close" on:click={closeSettings} type="button">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
|
@ -406,21 +427,21 @@
|
||||||
</div>
|
</div>
|
||||||
{:else if settingsPanel}
|
{:else if settingsPanel}
|
||||||
<div class="modal-overlay" on:click|self={closeSettings} on:keydown|self={(e) => e.key === 'Escape' && closeSettings()} role="presentation">
|
<div class="modal-overlay" on:click|self={closeSettings} on:keydown|self={(e) => e.key === 'Escape' && closeSettings()} role="presentation">
|
||||||
<div class="modal" role="dialog" aria-modal="true" aria-label="Plugin Settings">
|
<div class="modal" role="dialog" aria-modal="true" aria-label={tr('pluginManager.pluginSettings')}>
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3>{settingsPanel.title}</h3>
|
<h3>{settingsPanel.title}</h3>
|
||||||
<button class="modal-close" on:click={closeSettings} type="button">✕</button>
|
<button class="modal-close" on:click={closeSettings} type="button">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p class="settings-hint">Plugin: <code>{settingsPluginId}</code></p>
|
<p class="settings-hint">{tr('common.plugin')}: <code>{settingsPluginId}</code></p>
|
||||||
{#if settingsPluginInfo && settingsPluginInfo.entry}
|
{#if settingsPluginInfo && settingsPluginInfo.entry}
|
||||||
<PluginBundleHost
|
<PluginBundleHost
|
||||||
pluginId={settingsPluginId}
|
pluginId={settingsPluginId}
|
||||||
componentId={settingsPanel.component || settingsPanel.id}
|
componentId={settingsPanel.component || settingsPanel.id}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="settings-hint">Component: <code>{settingsPanel.component}</code></p>
|
<p class="settings-hint">{tr('common.component')}: <code>{settingsPanel.component}</code></p>
|
||||||
<p class="placeholder">Settings panel frontend bundle not available</p>
|
<p class="placeholder">{tr('pluginManager.settingsBundleUnavailable')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
<script>
|
<script>
|
||||||
import { onDestroy, tick } from 'svelte';
|
import { onDestroy, onMount, tick } from 'svelte';
|
||||||
import * as App from '../../../wailsjs/go/api/App';
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
import { executePluginCommand } from '../plugin-host/VerstakPluginAPI.js';
|
import { executePluginCommand } from '../plugin-host/VerstakPluginAPI.js';
|
||||||
|
import { i18n } from '../i18n/index.js';
|
||||||
|
|
||||||
let open = false;
|
let open = false;
|
||||||
let query = '';
|
let query = '';
|
||||||
|
|
@ -10,18 +11,25 @@
|
||||||
let inputEl = null;
|
let inputEl = null;
|
||||||
let statusMessage = '';
|
let statusMessage = '';
|
||||||
let statusType = '';
|
let statusType = '';
|
||||||
|
let locale = i18n.getLocale();
|
||||||
|
let unsubscribeLocale = null;
|
||||||
|
|
||||||
|
$: tr = ((activeLocale) => (key, params, fallback) => {
|
||||||
|
void activeLocale;
|
||||||
|
return i18n.t(key, params, fallback);
|
||||||
|
})(locale);
|
||||||
|
|
||||||
const inactiveStatuses = new Set(['disabled', 'failed', 'incompatible', 'missing-required-capability']);
|
const inactiveStatuses = new Set(['disabled', 'failed', 'incompatible', 'missing-required-capability']);
|
||||||
const shellCommands = [
|
$: shellCommands = [
|
||||||
{ id: 'verstak.shell.open-overview', title: 'Open Overview', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 10, shellAction: 'overview' },
|
{ id: 'verstak.shell.open-overview', title: tr('command.openOverview'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 10, shellAction: 'overview' },
|
||||||
{ id: 'verstak.shell.open-files', title: 'Open Files', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 20, shellAction: 'files' },
|
{ id: 'verstak.shell.open-files', title: tr('command.openFiles'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 20, shellAction: 'files' },
|
||||||
{ id: 'verstak.shell.open-activity', title: 'Open Activity', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 30, shellAction: 'activity' },
|
{ id: 'verstak.shell.open-activity', title: tr('command.openActivity'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 30, shellAction: 'activity' },
|
||||||
{ id: 'verstak.shell.open-browser-inbox', title: 'Open Browser Inbox', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 40, shellAction: 'browser-inbox' },
|
{ id: 'verstak.shell.open-browser-inbox', title: tr('command.openBrowserInbox'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 40, shellAction: 'browser-inbox' },
|
||||||
{ id: 'verstak.shell.create-markdown', title: 'Create Markdown File', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 50, shellAction: 'create-markdown' },
|
{ id: 'verstak.shell.create-markdown', title: tr('command.createMarkdown'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 50, shellAction: 'create-markdown' },
|
||||||
{ id: 'verstak.shell.create-text', title: 'Create Text File', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 60, shellAction: 'create-text' },
|
{ id: 'verstak.shell.create-text', title: tr('command.createText'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 60, shellAction: 'create-text' },
|
||||||
{ id: 'verstak.shell.sync-now', title: 'Sync Now', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 70, shellAction: 'sync-now' },
|
{ id: 'verstak.shell.sync-now', title: tr('command.syncNow'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 70, shellAction: 'sync-now' },
|
||||||
{ id: 'verstak.shell.open-sync-settings', title: 'Open Sync Settings', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 80, shellAction: 'sync-settings' },
|
{ id: 'verstak.shell.open-sync-settings', title: tr('command.openSyncSettings'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 80, shellAction: 'sync-settings' },
|
||||||
{ id: 'verstak.shell.open-plugin-manager', title: 'Open Plugin Manager', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 90, shellAction: 'plugin-manager' },
|
{ id: 'verstak.shell.open-plugin-manager', title: tr('command.openPluginManager'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 90, shellAction: 'plugin-manager' },
|
||||||
];
|
];
|
||||||
|
|
||||||
$: normalizedQuery = query.trim().toLowerCase();
|
$: normalizedQuery = query.trim().toLowerCase();
|
||||||
|
|
@ -44,14 +52,21 @@
|
||||||
App.GetContributions().catch(() => ({})),
|
App.GetContributions().catch(() => ({})),
|
||||||
]);
|
]);
|
||||||
const pluginById = new Map((plugins || []).map((plugin) => [plugin.manifest?.id, plugin]));
|
const pluginById = new Map((plugins || []).map((plugin) => [plugin.manifest?.id, plugin]));
|
||||||
const pluginCommands = (contributions.commands || [])
|
await Promise.all((plugins || []).map((plugin) => i18n.loadPlugin(
|
||||||
|
plugin.manifest?.id,
|
||||||
|
plugin.manifest?.localization,
|
||||||
|
).catch(() => {})));
|
||||||
|
const localizedPlugins = (plugins || []).map((plugin) => i18n.localizePlugin(plugin));
|
||||||
|
const localizedPluginById = new Map(localizedPlugins.map((plugin) => [plugin.manifest?.id, plugin]));
|
||||||
|
const localizedContributions = i18n.localizeContributionSummary(contributions || {});
|
||||||
|
const pluginCommands = (localizedContributions.commands || [])
|
||||||
.filter((command) => {
|
.filter((command) => {
|
||||||
const plugin = pluginById.get(command.pluginId);
|
const plugin = pluginById.get(command.pluginId);
|
||||||
if (!plugin) return false;
|
if (!plugin) return false;
|
||||||
return !inactiveStatuses.has(plugin.status);
|
return !inactiveStatuses.has(plugin.status);
|
||||||
})
|
})
|
||||||
.map((command) => {
|
.map((command) => {
|
||||||
const plugin = pluginById.get(command.pluginId);
|
const plugin = localizedPluginById.get(command.pluginId);
|
||||||
return {
|
return {
|
||||||
...command,
|
...command,
|
||||||
pluginName: plugin?.manifest?.name || command.pluginId,
|
pluginName: plugin?.manifest?.name || command.pluginId,
|
||||||
|
|
@ -170,14 +185,14 @@
|
||||||
if (command.shellAction) {
|
if (command.shellAction) {
|
||||||
await runShellCommand(command);
|
await runShellCommand(command);
|
||||||
closePalette();
|
closePalette();
|
||||||
setStatus('success', `${command.title || command.id} handled`);
|
setStatus('success', tr('command.handled', { title: command.title || command.id }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await executePluginCommand(command.pluginId, command.id, {
|
const result = await executePluginCommand(command.pluginId, command.id, {
|
||||||
source: 'command-palette',
|
source: 'command-palette',
|
||||||
});
|
});
|
||||||
closePalette();
|
closePalette();
|
||||||
setStatus('success', `${command.title || command.id} ${result.status || 'handled'}`);
|
setStatus('success', tr('command.result', { title: command.title || command.id, status: result.status || tr('command.statusHandled') }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus('error', `${command.title || command.id}: ${err?.message || String(err)}`);
|
setStatus('error', `${command.title || command.id}: ${err?.message || String(err)}`);
|
||||||
}
|
}
|
||||||
|
|
@ -221,7 +236,16 @@
|
||||||
window.addEventListener('keydown', onWindowKeydown);
|
window.addEventListener('keydown', onWindowKeydown);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
unsubscribeLocale = i18n.subscribe((nextLocale) => {
|
||||||
|
const changed = locale !== nextLocale;
|
||||||
|
locale = nextLocale;
|
||||||
|
if (changed && open) loadCommands();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
unsubscribeLocale?.();
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.removeEventListener('keydown', onWindowKeydown);
|
window.removeEventListener('keydown', onWindowKeydown);
|
||||||
window.clearTimeout(setStatus.timer);
|
window.clearTimeout(setStatus.timer);
|
||||||
|
|
@ -240,19 +264,19 @@
|
||||||
|
|
||||||
{#if open}
|
{#if open}
|
||||||
<div class="command-palette-overlay" role="presentation" on:mousedown={onOverlayMouseDown}>
|
<div class="command-palette-overlay" role="presentation" on:mousedown={onOverlayMouseDown}>
|
||||||
<section class="command-palette" role="dialog" aria-modal="true" aria-label="Command Palette">
|
<section class="command-palette" role="dialog" aria-modal="true" aria-label={tr('command.palette')}>
|
||||||
<input
|
<input
|
||||||
bind:this={inputEl}
|
bind:this={inputEl}
|
||||||
bind:value={query}
|
bind:value={query}
|
||||||
class="command-palette-input"
|
class="command-palette-input"
|
||||||
data-command-palette-input
|
data-command-palette-input
|
||||||
placeholder="Run command"
|
placeholder={tr('command.run')}
|
||||||
aria-label="Run command"
|
aria-label={tr('command.run')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="command-palette-list" role="listbox">
|
<div class="command-palette-list" role="listbox">
|
||||||
{#if filteredCommands.length === 0}
|
{#if filteredCommands.length === 0}
|
||||||
<div class="command-palette-empty">No commands</div>
|
<div class="command-palette-empty">{tr('command.none')}</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each filteredCommands as command, index}
|
{#each filteredCommands as command, index}
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import * as App from '../../../wailsjs/go/api/App';
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
import Icon from '../ui/Icon.svelte';
|
import Icon from '../ui/Icon.svelte';
|
||||||
|
import { i18n } from '../i18n/index.js';
|
||||||
|
|
||||||
const TEXT_EXTENSIONS = new Set(['txt', 'md', 'markdown', 'log', 'json', 'csv', 'yaml', 'yml', 'toml']);
|
const TEXT_EXTENSIONS = new Set(['txt', 'md', 'markdown', 'log', 'json', 'csv', 'yaml', 'yml', 'toml']);
|
||||||
const FILE_INDEX_LIMIT = 220;
|
const FILE_INDEX_LIMIT = 220;
|
||||||
|
|
@ -16,12 +17,26 @@
|
||||||
let loading = true;
|
let loading = true;
|
||||||
let searchTimer = null;
|
let searchTimer = null;
|
||||||
let buildSeq = 0;
|
let buildSeq = 0;
|
||||||
|
let locale = i18n.getLocale();
|
||||||
|
|
||||||
|
$: tr = ((activeLocale) => (key, params, fallback) => {
|
||||||
|
void activeLocale;
|
||||||
|
return i18n.t(key, params, fallback);
|
||||||
|
})(locale);
|
||||||
|
|
||||||
$: scheduleSearch(query);
|
$: scheduleSearch(query);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
const unsubscribeLocale = i18n.subscribe((nextLocale) => {
|
||||||
|
const changed = locale !== nextLocale;
|
||||||
|
locale = nextLocale;
|
||||||
|
if (changed) buildIndex();
|
||||||
|
});
|
||||||
buildIndex();
|
buildIndex();
|
||||||
return () => clearTimeout(searchTimer);
|
return () => {
|
||||||
|
unsubscribeLocale();
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
function normalize(value) {
|
function normalize(value) {
|
||||||
|
|
@ -160,8 +175,9 @@
|
||||||
nodes.forEach(node => {
|
nodes.forEach(node => {
|
||||||
next.push({
|
next.push({
|
||||||
type: 'Workspace',
|
type: 'Workspace',
|
||||||
|
typeLabel: tr('search.type.workspace'),
|
||||||
title: workspaceTitle(node),
|
title: workspaceTitle(node),
|
||||||
subtitle: 'Workspace',
|
subtitle: tr('search.type.workspace'),
|
||||||
keywords: `${node.id || ''} ${node.rootPath || ''}`,
|
keywords: `${node.id || ''} ${node.rootPath || ''}`,
|
||||||
rank: 10,
|
rank: 10,
|
||||||
action: 'workspace',
|
action: 'workspace',
|
||||||
|
|
@ -170,7 +186,15 @@
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const contributions = await resultOrEmpty(App.GetContributions(), {});
|
const [rawPlugins, rawContributions] = await Promise.all([
|
||||||
|
resultOrEmpty(App.GetPlugins(), []),
|
||||||
|
resultOrEmpty(App.GetContributions(), {}),
|
||||||
|
]);
|
||||||
|
await Promise.all((rawPlugins || []).map((plugin) => i18n.loadPlugin(
|
||||||
|
plugin.manifest?.id,
|
||||||
|
plugin.manifest?.localization,
|
||||||
|
).catch(() => {})));
|
||||||
|
const contributions = i18n.localizeContributionSummary(rawContributions || {});
|
||||||
const viewByPluginId = new Map();
|
const viewByPluginId = new Map();
|
||||||
(contributions.views || []).forEach(view => {
|
(contributions.views || []).forEach(view => {
|
||||||
if (view.pluginId && !viewByPluginId.has(view.pluginId)) viewByPluginId.set(view.pluginId, view);
|
if (view.pluginId && !viewByPluginId.has(view.pluginId)) viewByPluginId.set(view.pluginId, view);
|
||||||
|
|
@ -178,6 +202,7 @@
|
||||||
(contributions.sidebarItems || []).forEach(item => {
|
(contributions.sidebarItems || []).forEach(item => {
|
||||||
next.push({
|
next.push({
|
||||||
type: 'Tool',
|
type: 'Tool',
|
||||||
|
typeLabel: tr('search.type.tool'),
|
||||||
title: item.title || item.id,
|
title: item.title || item.id,
|
||||||
subtitle: item.pluginId || '',
|
subtitle: item.pluginId || '',
|
||||||
keywords: `${item.id || ''} ${item.view || ''}`,
|
keywords: `${item.id || ''} ${item.view || ''}`,
|
||||||
|
|
@ -194,6 +219,7 @@
|
||||||
const snippet = await readFileSnippet(path);
|
const snippet = await readFileSnippet(path);
|
||||||
next.push({
|
next.push({
|
||||||
type: entry.type === 'folder' ? 'Folder' : 'File',
|
type: entry.type === 'folder' ? 'Folder' : 'File',
|
||||||
|
typeLabel: tr(entry.type === 'folder' ? 'search.type.folder' : 'search.type.file'),
|
||||||
title: path.split('/').pop() || path,
|
title: path.split('/').pop() || path,
|
||||||
subtitle: path,
|
subtitle: path,
|
||||||
keywords: snippet,
|
keywords: snippet,
|
||||||
|
|
@ -205,9 +231,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const pluginItems = await Promise.all([
|
const pluginItems = await Promise.all([
|
||||||
indexPluginSettings('verstak.journal', 'Journal', 50, viewByPluginId.get('verstak.journal'), nodes),
|
indexPluginSettings('verstak.journal', tr('search.type.journal'), 50, viewByPluginId.get('verstak.journal'), nodes),
|
||||||
indexPluginSettings('verstak.browser-inbox', 'Browser Inbox', 55, viewByPluginId.get('verstak.browser-inbox'), nodes),
|
indexPluginSettings('verstak.browser-inbox', tr('search.type.browserInbox'), 55, viewByPluginId.get('verstak.browser-inbox'), nodes),
|
||||||
indexPluginSettings('verstak.activity', 'Activity', 60, viewByPluginId.get('verstak.activity'), nodes),
|
indexPluginSettings('verstak.activity', tr('search.type.activity'), 60, viewByPluginId.get('verstak.activity'), nodes),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (seq !== buildSeq) return;
|
if (seq !== buildSeq) return;
|
||||||
|
|
@ -293,8 +319,8 @@
|
||||||
on:focus={handleFocus}
|
on:focus={handleFocus}
|
||||||
on:blur={() => setTimeout(() => focused = false, 120)}
|
on:blur={() => setTimeout(() => focused = false, 120)}
|
||||||
type="search"
|
type="search"
|
||||||
placeholder={loading ? 'Indexing...' : 'Search'}
|
placeholder={loading ? tr('search.indexing') : tr('search.placeholder')}
|
||||||
aria-label="Global search"
|
aria-label={tr('search.global')}
|
||||||
data-global-search-input
|
data-global-search-input
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -310,11 +336,11 @@
|
||||||
on:mousedown|preventDefault={() => openResult(item)}
|
on:mousedown|preventDefault={() => openResult(item)}
|
||||||
>
|
>
|
||||||
<span class="global-search-result-title">{item.title}</span>
|
<span class="global-search-result-title">{item.title}</span>
|
||||||
<span class="global-search-result-meta">{item.type} · {item.subtitle}</span>
|
<span class="global-search-result-meta">{item.typeLabel || item.type} · {item.subtitle}</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="global-search-empty vt-empty-title">No results</div>
|
<div class="global-search-empty vt-empty-title">{tr('search.noResults')}</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
import GlobalSearch from './GlobalSearch.svelte';
|
import GlobalSearch from './GlobalSearch.svelte';
|
||||||
import Icon from '../ui/Icon.svelte';
|
import Icon from '../ui/Icon.svelte';
|
||||||
import { debug } from '../log/debug.js';
|
import { debug } from '../log/debug.js';
|
||||||
|
import { i18n } from '../i18n/index.js';
|
||||||
|
|
||||||
export let showGlobalSearch = true;
|
export let showGlobalSearch = true;
|
||||||
|
|
||||||
|
|
@ -16,6 +17,13 @@
|
||||||
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
|
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
|
||||||
let sidebarItems = [];
|
let sidebarItems = [];
|
||||||
let errorMessage = '';
|
let errorMessage = '';
|
||||||
|
let locale = i18n.getLocale();
|
||||||
|
let unsubscribeLocale = null;
|
||||||
|
|
||||||
|
$: tr = ((activeLocale) => (key, params, fallback) => {
|
||||||
|
void activeLocale;
|
||||||
|
return i18n.t(key, params, fallback);
|
||||||
|
})(locale);
|
||||||
|
|
||||||
$: vaultOpen = vaultStatus.status === 'open';
|
$: vaultOpen = vaultStatus.status === 'open';
|
||||||
|
|
||||||
|
|
@ -31,14 +39,18 @@
|
||||||
App.GetVaultStatus().catch(() => ({ status: 'unknown', path: '', vaultId: '' })),
|
App.GetVaultStatus().catch(() => ({ status: 'unknown', path: '', vaultId: '' })),
|
||||||
App.GetContributions().catch(() => { contribErr = true; return {}; }),
|
App.GetContributions().catch(() => { contribErr = true; return {}; }),
|
||||||
]);
|
]);
|
||||||
plugins = p || [];
|
await Promise.all((p || []).map((plugin) => (
|
||||||
|
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
|
||||||
|
)));
|
||||||
|
plugins = (p || []).map((plugin) => i18n.localizePlugin(plugin));
|
||||||
|
const localizedContributions = i18n.localizeContributionSummary(contribs || {});
|
||||||
vaultStatus = v;
|
vaultStatus = v;
|
||||||
debug.log('[Sidebar] onMount: plugins=' + plugins.length + ' vault=' + vaultStatus.status);
|
debug.log('[Sidebar] onMount: plugins=' + plugins.length + ' vault=' + vaultStatus.status);
|
||||||
flog('onMount: plugins=' + plugins.length + ' vault=' + vaultStatus.status);
|
flog('onMount: plugins=' + plugins.length + ' vault=' + vaultStatus.status);
|
||||||
if (contribErr) {
|
if (contribErr) {
|
||||||
errorMessage = 'Failed to load plugin contributions';
|
errorMessage = tr('sidebar.error.contributions');
|
||||||
}
|
}
|
||||||
sidebarItems = (contribs.sidebarItems || []).filter(item => {
|
sidebarItems = (localizedContributions.sidebarItems || []).filter(item => {
|
||||||
const plugin = plugins.find(p => p.manifest?.id === item.pluginId);
|
const plugin = plugins.find(p => p.manifest?.id === item.pluginId);
|
||||||
if (!plugin) return false;
|
if (!plugin) return false;
|
||||||
return plugin.status !== 'disabled' && plugin.status !== 'failed' && plugin.status !== 'incompatible' && plugin.status !== 'missing-required-capability';
|
return plugin.status !== 'disabled' && plugin.status !== 'failed' && plugin.status !== 'incompatible' && plugin.status !== 'missing-required-capability';
|
||||||
|
|
@ -50,18 +62,24 @@
|
||||||
debug.log('[Sidebar] onMount: ERROR:', String(e));
|
debug.log('[Sidebar] onMount: ERROR:', String(e));
|
||||||
flog('onMount: ERROR: ' + String(e));
|
flog('onMount: ERROR: ' + String(e));
|
||||||
console.error('[Sidebar] load error:', e);
|
console.error('[Sidebar] load error:', e);
|
||||||
errorMessage = 'Failed to load sidebar';
|
errorMessage = tr('sidebar.error.load');
|
||||||
}
|
}
|
||||||
debug.log('[Sidebar] onMount: END');
|
debug.log('[Sidebar] onMount: END');
|
||||||
flog('onMount: END');
|
flog('onMount: END');
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
unsubscribeLocale = i18n.subscribe((nextLocale) => {
|
||||||
|
const changed = locale !== nextLocale;
|
||||||
|
locale = nextLocale;
|
||||||
|
if (changed) loadSidebar();
|
||||||
|
});
|
||||||
loadSidebar();
|
loadSidebar();
|
||||||
window.addEventListener('verstak:plugins-changed', loadSidebar);
|
window.addEventListener('verstak:plugins-changed', loadSidebar);
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
if (unsubscribeLocale) unsubscribeLocale();
|
||||||
window.removeEventListener('verstak:plugins-changed', loadSidebar);
|
window.removeEventListener('verstak:plugins-changed', loadSidebar);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -85,7 +103,7 @@
|
||||||
|
|
||||||
{#if sidebarItems.length > 0}
|
{#if sidebarItems.length > 0}
|
||||||
<div class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
<span class="section-label">Tools</span>
|
<span class="section-label">{tr('sidebar.tools')}</span>
|
||||||
{#each sidebarItems as item}
|
{#each sidebarItems as item}
|
||||||
<button
|
<button
|
||||||
class="nav-item plugin-item vt-list-row"
|
class="nav-item plugin-item vt-list-row"
|
||||||
|
|
@ -107,7 +125,7 @@
|
||||||
{#if errorMessage}
|
{#if errorMessage}
|
||||||
<span class="sidebar-error">
|
<span class="sidebar-error">
|
||||||
<Icon name="warning" size={10} class="sidebar-error-icon" />
|
<Icon name="warning" size={10} class="sidebar-error-icon" />
|
||||||
Plugin UI error
|
{tr('sidebar.error.ui')}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,26 +2,41 @@
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import * as App from '../../../wailsjs/go/api/App';
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
import Icon from '../ui/Icon.svelte';
|
import Icon from '../ui/Icon.svelte';
|
||||||
|
import { i18n } from '../i18n/index.js';
|
||||||
|
|
||||||
let items = [];
|
let items = [];
|
||||||
let settingsPanels = [];
|
let settingsPanels = [];
|
||||||
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
|
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
|
||||||
let settingsOpen = false;
|
let settingsOpen = false;
|
||||||
|
let locale = i18n.getLocale();
|
||||||
|
let selectedLanguage = i18n.getLanguagePreference();
|
||||||
|
let unsubscribeLocale = null;
|
||||||
|
|
||||||
|
$: tr = ((activeLocale) => (key, params, fallback) => {
|
||||||
|
void activeLocale;
|
||||||
|
return i18n.t(key, params, fallback);
|
||||||
|
})(locale);
|
||||||
|
|
||||||
$: leftItems = items.filter((item) => item.position === 'left');
|
$: leftItems = items.filter((item) => item.position === 'left');
|
||||||
$: centerItems = items.filter((item) => item.position === 'center');
|
$: centerItems = items.filter((item) => item.position === 'center');
|
||||||
$: rightItems = items.filter((item) => item.position === 'right');
|
$: rightItems = items.filter((item) => item.position === 'right');
|
||||||
$: vaultOpen = vaultStatus.status === 'open';
|
$: vaultOpen = vaultStatus.status === 'open';
|
||||||
$: vaultLabel = vaultStatus.status && vaultStatus.status !== 'unknown' ? 'Vault: ' + vaultStatus.status : 'Vault: unknown';
|
$: vaultStatusLabel = tr(`vault.status.${vaultStatus.status || 'unknown'}`, undefined, vaultStatus.status || 'unknown');
|
||||||
|
$: vaultLabel = tr('vault.label', { status: vaultStatusLabel });
|
||||||
|
|
||||||
const inactiveStatuses = new Set(['disabled', 'failed', 'incompatible', 'missing-required-capability']);
|
const inactiveStatuses = new Set(['disabled', 'failed', 'incompatible', 'missing-required-capability']);
|
||||||
|
|
||||||
async function loadStatusBar() {
|
async function loadStatusBar() {
|
||||||
const [plugins, contributions, vault] = await Promise.all([
|
const [rawPlugins, rawContributions, vault] = await Promise.all([
|
||||||
App.GetPlugins().catch(() => []),
|
App.GetPlugins().catch(() => []),
|
||||||
App.GetContributions().catch(() => ({})),
|
App.GetContributions().catch(() => ({})),
|
||||||
App.GetVaultStatus().catch(() => ({ status: 'unknown', path: '', vaultId: '' })),
|
App.GetVaultStatus().catch(() => ({ status: 'unknown', path: '', vaultId: '' })),
|
||||||
]);
|
]);
|
||||||
|
await Promise.all((rawPlugins || []).map((plugin) => (
|
||||||
|
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
|
||||||
|
)));
|
||||||
|
const plugins = (rawPlugins || []).map((plugin) => i18n.localizePlugin(plugin));
|
||||||
|
const contributions = i18n.localizeContributionSummary(rawContributions || {});
|
||||||
const pluginById = new Map((plugins || []).map((plugin) => [plugin.manifest?.id, plugin]));
|
const pluginById = new Map((plugins || []).map((plugin) => [plugin.manifest?.id, plugin]));
|
||||||
vaultStatus = vault || { status: 'unknown', path: '', vaultId: '' };
|
vaultStatus = vault || { status: 'unknown', path: '', vaultId: '' };
|
||||||
items = (contributions.statusBarItems || [])
|
items = (contributions.statusBarItems || [])
|
||||||
|
|
@ -56,6 +71,16 @@
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function selectLanguage(language) {
|
||||||
|
const err = await App.UpdateAppSettings({ language });
|
||||||
|
if (err) {
|
||||||
|
throw new Error(err);
|
||||||
|
}
|
||||||
|
await i18n.setLanguagePreference(language);
|
||||||
|
selectedLanguage = language;
|
||||||
|
settingsOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
function toggleSettings(event) {
|
function toggleSettings(event) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
settingsOpen = !settingsOpen;
|
settingsOpen = !settingsOpen;
|
||||||
|
|
@ -66,6 +91,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
unsubscribeLocale = i18n.subscribe((nextLocale) => {
|
||||||
|
const changed = locale !== nextLocale;
|
||||||
|
locale = nextLocale;
|
||||||
|
selectedLanguage = i18n.getLanguagePreference();
|
||||||
|
if (changed) loadStatusBar();
|
||||||
|
});
|
||||||
loadStatusBar();
|
loadStatusBar();
|
||||||
window.addEventListener('verstak:plugins-changed', loadStatusBar);
|
window.addEventListener('verstak:plugins-changed', loadStatusBar);
|
||||||
window.addEventListener('verstak:vault-opened', loadStatusBar);
|
window.addEventListener('verstak:vault-opened', loadStatusBar);
|
||||||
|
|
@ -73,13 +104,14 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
if (unsubscribeLocale) unsubscribeLocale();
|
||||||
window.removeEventListener('verstak:plugins-changed', loadStatusBar);
|
window.removeEventListener('verstak:plugins-changed', loadStatusBar);
|
||||||
window.removeEventListener('verstak:vault-opened', loadStatusBar);
|
window.removeEventListener('verstak:vault-opened', loadStatusBar);
|
||||||
window.removeEventListener('click', closeSettings);
|
window.removeEventListener('click', closeSettings);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<footer class="status-bar" aria-label="Status bar">
|
<footer class="status-bar" aria-label={tr('statusBar.label')}>
|
||||||
<div class="status-bar-group status-left">
|
<div class="status-bar-group status-left">
|
||||||
<span
|
<span
|
||||||
class="vault-status"
|
class="vault-status"
|
||||||
|
|
@ -132,7 +164,7 @@
|
||||||
class="settings-button"
|
class="settings-button"
|
||||||
class:active={settingsOpen}
|
class:active={settingsOpen}
|
||||||
type="button"
|
type="button"
|
||||||
title="Settings"
|
title={tr('settings.title')}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={settingsOpen}
|
aria-expanded={settingsOpen}
|
||||||
data-settings-menu-button
|
data-settings-menu-button
|
||||||
|
|
@ -143,6 +175,22 @@
|
||||||
</button>
|
</button>
|
||||||
{#if settingsOpen}
|
{#if settingsOpen}
|
||||||
<div class="settings-menu" role="menu">
|
<div class="settings-menu" role="menu">
|
||||||
|
<div class="settings-menu-heading">{tr('settings.language')}</div>
|
||||||
|
{#each ['system', 'en', 'ru'] as language}
|
||||||
|
<button
|
||||||
|
class="settings-menu-item language-item"
|
||||||
|
class:active-language={selectedLanguage === language}
|
||||||
|
type="button"
|
||||||
|
role="menuitemradio"
|
||||||
|
aria-checked={selectedLanguage === language}
|
||||||
|
data-settings-language={language}
|
||||||
|
on:click={() => selectLanguage(language)}
|
||||||
|
>
|
||||||
|
<span class="language-check" aria-hidden="true">{selectedLanguage === language ? '✓' : ''}</span>
|
||||||
|
<span>{tr(`settings.language.${language}`)}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<div class="settings-menu-separator"></div>
|
||||||
<button
|
<button
|
||||||
class="settings-menu-item"
|
class="settings-menu-item"
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -151,7 +199,7 @@
|
||||||
on:click={openPluginManager}
|
on:click={openPluginManager}
|
||||||
>
|
>
|
||||||
<Icon name="puzzle" size={14} class="settings-menu-icon" />
|
<Icon name="puzzle" size={14} class="settings-menu-icon" />
|
||||||
<span>Plugin Manager</span>
|
<span>{tr('settings.pluginManager')}</span>
|
||||||
</button>
|
</button>
|
||||||
{#if settingsPanels.length > 0}
|
{#if settingsPanels.length > 0}
|
||||||
<div class="settings-menu-separator"></div>
|
<div class="settings-menu-separator"></div>
|
||||||
|
|
@ -320,6 +368,25 @@
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-menu-heading {
|
||||||
|
padding: 0.25rem 0.45rem 0.2rem;
|
||||||
|
color: #7f8aa3;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-check {
|
||||||
|
width: 0.9rem;
|
||||||
|
color: #4ecca3;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-language {
|
||||||
|
background: rgba(78, 204, 163, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
.settings-menu-separator {
|
.settings-menu-separator {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
margin: 0.25rem 0.2rem;
|
margin: 0.25rem 0.2rem;
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,12 @@
|
||||||
<script>
|
<script>
|
||||||
import { createEventDispatcher, onMount } from 'svelte';
|
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
|
||||||
import * as App from '../../../wailsjs/go/api/App';
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
|
import { i18n } from '../i18n/index.js';
|
||||||
|
|
||||||
export let workspaceRootPath = '';
|
export let workspaceRootPath = '';
|
||||||
export let availableTools = [];
|
export let availableTools = [];
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
const FILTERS = [
|
|
||||||
{ key: 'all', label: 'All' },
|
|
||||||
{ key: 'notes', label: 'Notes' },
|
|
||||||
{ key: 'files', label: 'Files' },
|
|
||||||
{ key: 'captures', label: 'Captures' },
|
|
||||||
{ key: 'journal', label: 'Journal' },
|
|
||||||
];
|
|
||||||
const LOW_VALUE_RECENT_TYPES = new Set([
|
const LOW_VALUE_RECENT_TYPES = new Set([
|
||||||
'workspace.selected',
|
'workspace.selected',
|
||||||
'case.selected',
|
'case.selected',
|
||||||
|
|
@ -37,6 +31,20 @@
|
||||||
let totalNotes = 0;
|
let totalNotes = 0;
|
||||||
let loadedWorkspaceRoot = '';
|
let loadedWorkspaceRoot = '';
|
||||||
let toolProbe = 0;
|
let toolProbe = 0;
|
||||||
|
let locale = i18n.getLocale();
|
||||||
|
let unsubscribeLocale = null;
|
||||||
|
|
||||||
|
$: tr = ((activeLocale) => (key, params, fallback) => {
|
||||||
|
void activeLocale;
|
||||||
|
return i18n.t(key, params, fallback);
|
||||||
|
})(locale);
|
||||||
|
$: FILTERS = ['all', 'notes', 'files', 'captures', 'journal'].map((key) => ({
|
||||||
|
key,
|
||||||
|
label: tr(`overview.filter.${key}`),
|
||||||
|
}));
|
||||||
|
function countText(key, count, params = {}) {
|
||||||
|
return tr(`${key}.${count === 1 ? 'one' : 'many'}`, { count, ...params });
|
||||||
|
}
|
||||||
|
|
||||||
$: hasNotes = hasTool('notes', availableTools);
|
$: hasNotes = hasTool('notes', availableTools);
|
||||||
$: hasTodos = hasTool('todo', availableTools);
|
$: hasTodos = hasTool('todo', availableTools);
|
||||||
|
|
@ -55,18 +63,21 @@
|
||||||
$: attentionActionKind = needsAttention[0]?.actionKind || 'browser-inbox';
|
$: attentionActionKind = needsAttention[0]?.actionKind || 'browser-inbox';
|
||||||
$: lastActive = lastActiveDate([...recentChanges, ...continueItems], captures, journalEntries, todos);
|
$: lastActive = lastActiveDate([...recentChanges, ...continueItems], captures, journalEntries, todos);
|
||||||
$: summaryItems = [
|
$: summaryItems = [
|
||||||
{ key: 'notes', label: 'Notes', count: totalNotes, detail: totalAndRecentLabel(totalNotes, noteRecentChanges), actionKind: 'notes', actionLabel: 'Open Notes' },
|
{ key: 'notes', label: tr('overview.notes'), count: totalNotes, detail: countText('overview.count.totalRecent', noteRecentChanges, { total: totalNotes, recent: noteRecentChanges }), actionKind: 'notes', actionLabel: tr('overview.openNotes') },
|
||||||
{ key: 'files', label: 'Files', count: fileRecentChanges, detail: countLabel(fileRecentChanges, 'recent change'), actionKind: 'files', actionLabel: 'Open Files' },
|
{ key: 'files', label: tr('overview.files'), count: fileRecentChanges, detail: countText('overview.count.recentChanges', fileRecentChanges), actionKind: 'files', actionLabel: tr('overview.openFiles') },
|
||||||
{ key: 'captures', label: 'Captures', count: unprocessedCaptures.length, detail: captureReviewLabel(unprocessedCaptures.length), actionKind: 'browser-inbox', actionLabel: 'Review Inbox' },
|
{ key: 'captures', label: tr('overview.captures'), count: unprocessedCaptures.length, detail: countText('overview.count.captures', unprocessedCaptures.length), actionKind: 'browser-inbox', actionLabel: tr('overview.reviewInbox') },
|
||||||
{ key: 'activity', label: 'Activity', count: activityEvents.length, detail: countLabel(activityEvents.length, 'recorded event'), actionKind: 'activity', actionLabel: 'View Activity' },
|
{ key: 'activity', label: tr('overview.activity'), count: activityEvents.length, detail: countText('overview.count.events', activityEvents.length), actionKind: 'activity', actionLabel: tr('overview.viewActivity') },
|
||||||
{ key: 'journal', label: 'Journal', count: journalEntries.length, detail: journalEntryLabel(journalEntries.length), actionKind: 'journal', actionLabel: 'Open Journal' },
|
{ key: 'journal', label: tr('overview.journal'), count: journalEntries.length, detail: countText('overview.count.journal', journalEntries.length), actionKind: 'journal', actionLabel: tr('overview.openJournal') },
|
||||||
{ key: 'attention', label: 'Needs attention', count: needsAttention.length, detail: countLabel(needsAttention.length, 'pending item'), actionKind: attentionActionKind, actionLabel: 'Review pending items' },
|
{ key: 'attention', label: tr('overview.attention'), count: needsAttention.length, detail: countText('overview.count.pending', needsAttention.length), actionKind: attentionActionKind, actionLabel: tr('overview.reviewPending') },
|
||||||
];
|
];
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
unsubscribeLocale = i18n.subscribe((nextLocale) => locale = nextLocale);
|
||||||
toolProbe += 1;
|
toolProbe += 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onDestroy(() => unsubscribeLocale?.());
|
||||||
|
|
||||||
$: if (workspaceRootPath && workspaceRootPath !== loadedWorkspaceRoot) {
|
$: if (workspaceRootPath && workspaceRootPath !== loadedWorkspaceRoot) {
|
||||||
loadOverview();
|
loadOverview();
|
||||||
}
|
}
|
||||||
|
|
@ -245,17 +256,17 @@
|
||||||
|
|
||||||
function relativeTime(value) {
|
function relativeTime(value) {
|
||||||
const ms = timeMs({ occurredAt: value });
|
const ms = timeMs({ occurredAt: value });
|
||||||
if (!ms) return 'No timestamp';
|
if (!ms) return tr('overview.time.none');
|
||||||
const diff = Date.now() - ms;
|
const diff = Date.now() - ms;
|
||||||
if (diff < 0) return absoluteTime(value);
|
if (diff < 0) return absoluteTime(value);
|
||||||
const minute = 60 * 1000;
|
const minute = 60 * 1000;
|
||||||
const hour = 60 * minute;
|
const hour = 60 * minute;
|
||||||
const day = 24 * hour;
|
const day = 24 * hour;
|
||||||
if (diff < minute) return 'Just now';
|
if (diff < minute) return tr('overview.time.now');
|
||||||
if (diff < hour) return `${Math.floor(diff / minute)} min ago`;
|
if (diff < hour) return tr('overview.time.minutes', { count: Math.floor(diff / minute) });
|
||||||
if (diff < day) return `${Math.floor(diff / hour)}h ago`;
|
if (diff < day) return tr('overview.time.hours', { count: Math.floor(diff / hour) });
|
||||||
if (diff < 2 * day) return 'Yesterday';
|
if (diff < 2 * day) return tr('overview.time.yesterday');
|
||||||
if (diff < 7 * day) return `${Math.floor(diff / day)} days ago`;
|
if (diff < 7 * day) return tr('overview.time.days', { count: Math.floor(diff / day) });
|
||||||
const date = new Date(ms);
|
const date = new Date(ms);
|
||||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: date.getFullYear() === new Date().getFullYear() ? undefined : 'numeric' });
|
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: date.getFullYear() === new Date().getFullYear() ? undefined : 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
@ -291,11 +302,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function actionForCategory(category) {
|
function actionForCategory(category) {
|
||||||
if (category === 'notes') return { kind: 'notes', label: 'Open Notes' };
|
if (category === 'notes') return { kind: 'notes', label: tr('overview.openNotes') };
|
||||||
if (category === 'files') return { kind: 'files', label: 'Open Files' };
|
if (category === 'files') return { kind: 'files', label: tr('overview.openFiles') };
|
||||||
if (category === 'captures') return { kind: 'browser-inbox', label: 'Review Inbox' };
|
if (category === 'captures') return { kind: 'browser-inbox', label: tr('overview.reviewInbox') };
|
||||||
if (category === 'journal') return { kind: 'journal', label: 'Open Journal' };
|
if (category === 'journal') return { kind: 'journal', label: tr('overview.openJournal') };
|
||||||
return { kind: 'activity', label: 'View Activity' };
|
return { kind: 'activity', label: tr('overview.viewActivity') };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRecentChanges(events, captureRows, journalRows) {
|
function buildRecentChanges(events, captureRows, journalRows) {
|
||||||
|
|
@ -323,7 +334,7 @@
|
||||||
time: timeValue(item),
|
time: timeValue(item),
|
||||||
absolute: absoluteTime(timeValue(item)),
|
absolute: absoluteTime(timeValue(item)),
|
||||||
actionKind: 'browser-inbox',
|
actionKind: 'browser-inbox',
|
||||||
actionLabel: 'Review Inbox',
|
actionLabel: tr('overview.reviewInbox'),
|
||||||
}));
|
}));
|
||||||
const journalItems = journalRows.map(item => ({
|
const journalItems = journalRows.map(item => ({
|
||||||
id: item.entryId || `journal:${timeValue(item)}`,
|
id: item.entryId || `journal:${timeValue(item)}`,
|
||||||
|
|
@ -333,7 +344,7 @@
|
||||||
time: timeValue(item),
|
time: timeValue(item),
|
||||||
absolute: absoluteTime(timeValue(item)),
|
absolute: absoluteTime(timeValue(item)),
|
||||||
actionKind: 'journal',
|
actionKind: 'journal',
|
||||||
actionLabel: 'Open Journal',
|
actionLabel: tr('overview.openJournal'),
|
||||||
}));
|
}));
|
||||||
return sortByTime([...activityItems, ...captureItems, ...journalItems]).slice(0, 12);
|
return sortByTime([...activityItems, ...captureItems, ...journalItems]).slice(0, 12);
|
||||||
}
|
}
|
||||||
|
|
@ -374,7 +385,7 @@
|
||||||
time: todoTimeValue(item),
|
time: todoTimeValue(item),
|
||||||
absolute: absoluteTime(todoTimeValue(item)),
|
absolute: absoluteTime(todoTimeValue(item)),
|
||||||
actionKind: 'todo',
|
actionKind: 'todo',
|
||||||
actionLabel: 'Open Todos',
|
actionLabel: tr('overview.openTodos'),
|
||||||
}));
|
}));
|
||||||
const captureCandidates = sortByTime(captureRows).map(item => ({
|
const captureCandidates = sortByTime(captureRows).map(item => ({
|
||||||
id: item.captureId || `capture:${timeValue(item)}`,
|
id: item.captureId || `capture:${timeValue(item)}`,
|
||||||
|
|
@ -384,7 +395,7 @@
|
||||||
time: timeValue(item),
|
time: timeValue(item),
|
||||||
absolute: absoluteTime(timeValue(item)),
|
absolute: absoluteTime(timeValue(item)),
|
||||||
actionKind: 'browser-inbox',
|
actionKind: 'browser-inbox',
|
||||||
actionLabel: 'Review Inbox',
|
actionLabel: tr('overview.reviewInbox'),
|
||||||
}));
|
}));
|
||||||
const noteCandidates = sortByTime(events)
|
const noteCandidates = sortByTime(events)
|
||||||
.filter(item => isResumeEvent(item) && activityCategory(item) === 'notes')
|
.filter(item => isResumeEvent(item) && activityCategory(item) === 'notes')
|
||||||
|
|
@ -400,7 +411,7 @@
|
||||||
time: timeValue(item),
|
time: timeValue(item),
|
||||||
absolute: absoluteTime(timeValue(item)),
|
absolute: absoluteTime(timeValue(item)),
|
||||||
actionKind: 'journal',
|
actionKind: 'journal',
|
||||||
actionLabel: 'Open Journal',
|
actionLabel: tr('overview.openJournal'),
|
||||||
}));
|
}));
|
||||||
return [...todoCandidates, ...captureCandidates, ...noteCandidates, ...fileCandidates, ...journalCandidates].slice(0, 4);
|
return [...todoCandidates, ...captureCandidates, ...noteCandidates, ...fileCandidates, ...journalCandidates].slice(0, 4);
|
||||||
}
|
}
|
||||||
|
|
@ -411,14 +422,14 @@
|
||||||
title: captureTitle(item),
|
title: captureTitle(item),
|
||||||
meta: `${item.kind || 'capture'} · ${itemTimeLabel(item)}`,
|
meta: `${item.kind || 'capture'} · ${itemTimeLabel(item)}`,
|
||||||
actionKind: 'browser-inbox',
|
actionKind: 'browser-inbox',
|
||||||
actionLabel: 'Review Inbox',
|
actionLabel: tr('overview.reviewInbox'),
|
||||||
}));
|
}));
|
||||||
const candidateItems = sortByTime(candidates).slice(0, 4).map(item => ({
|
const candidateItems = sortByTime(candidates).slice(0, 4).map(item => ({
|
||||||
id: item.candidateId || `work-session:${timeValue(item)}`,
|
id: item.candidateId || `work-session:${timeValue(item)}`,
|
||||||
title: 'Possible journal entry',
|
title: 'Possible journal entry',
|
||||||
meta: `Workspace: ${item.workspaceRootPath || workspaceRootPath || 'Unknown'} · ${item.estimatedMinutes || 0} min · ${item.activityCount || (item.activityIds || []).length || 0} activities`,
|
meta: `Workspace: ${item.workspaceRootPath || workspaceRootPath || 'Unknown'} · ${item.estimatedMinutes || 0} min · ${item.activityCount || (item.activityIds || []).length || 0} activities`,
|
||||||
actionKind: 'journal',
|
actionKind: 'journal',
|
||||||
actionLabel: 'Review candidate',
|
actionLabel: tr('overview.reviewCandidate'),
|
||||||
toolRequest: { type: 'work-session-candidate', candidate: item },
|
toolRequest: { type: 'work-session-candidate', candidate: item },
|
||||||
}));
|
}));
|
||||||
const todoItems = [...todoRows].sort((a, b) => todoDateMs(todoTimeValue(a)) - todoDateMs(todoTimeValue(b))).slice(0, 4).map(item => ({
|
const todoItems = [...todoRows].sort((a, b) => todoDateMs(todoTimeValue(a)) - todoDateMs(todoTimeValue(b))).slice(0, 4).map(item => ({
|
||||||
|
|
@ -426,7 +437,7 @@
|
||||||
title: todoTitle(item),
|
title: todoTitle(item),
|
||||||
meta: `${todoAttentionState(item)}${item.dueAt ? ` · Due ${item.dueAt}` : ''}`,
|
meta: `${todoAttentionState(item)}${item.dueAt ? ` · Due ${item.dueAt}` : ''}`,
|
||||||
actionKind: 'todo',
|
actionKind: 'todo',
|
||||||
actionLabel: 'Open Todos',
|
actionLabel: tr('overview.openTodos'),
|
||||||
}));
|
}));
|
||||||
return [...todoItems, ...captureItems, ...candidateItems].slice(0, 6);
|
return [...todoItems, ...captureItems, ...candidateItems].slice(0, 6);
|
||||||
}
|
}
|
||||||
|
|
@ -481,7 +492,7 @@
|
||||||
title: overview.name || fileName(overview.relativePath) || 'Overview.md',
|
title: overview.name || fileName(overview.relativePath) || 'Overview.md',
|
||||||
meta: overview.relativePath || 'Workspace overview note',
|
meta: overview.relativePath || 'Workspace overview note',
|
||||||
actionKind: hasNotes ? 'notes' : 'files',
|
actionKind: hasNotes ? 'notes' : 'files',
|
||||||
actionLabel: hasNotes ? 'Open Notes' : 'Open Files',
|
actionLabel: hasNotes ? tr('overview.openNotes') : tr('overview.openFiles'),
|
||||||
}] : [],
|
}] : [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -523,24 +534,24 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="today-root overview-root" aria-label="Overview" data-overview-root>
|
<div class="today-root overview-root" aria-label={tr('workspace.overview')} data-overview-root>
|
||||||
<div class="today-header overview-header">
|
<div class="today-header overview-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Overview</h2>
|
<h2>{tr('workspace.overview')}</h2>
|
||||||
<p title={lastActive ? absoluteTime(lastActive) : ''}>
|
<p title={lastActive ? absoluteTime(lastActive) : ''}>
|
||||||
{#if loading}
|
{#if loading}
|
||||||
Loading workspace context...
|
{tr('overview.loadingContext')}
|
||||||
{:else if lastActive}
|
{:else if lastActive}
|
||||||
Last active {relativeTime(lastActive)}
|
{tr('overview.lastActive', { time: relativeTime(lastActive) })}
|
||||||
{:else}
|
{:else}
|
||||||
No recent workspace activity
|
{tr('overview.noRecentActivity')}
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" data-overview-action="refresh" on:click={loadOverview}>Refresh</button>
|
<button type="button" data-overview-action="refresh" on:click={loadOverview}>{tr('overview.refresh')}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="today-summary overview-summary" aria-label="Workspace overview summary">
|
<div class="today-summary overview-summary" aria-label={tr('overview.summary')}>
|
||||||
{#each summaryItems as item}
|
{#each summaryItems as item}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -553,7 +564,7 @@
|
||||||
>
|
>
|
||||||
<strong>{loading ? '...' : item.count}</strong>
|
<strong>{loading ? '...' : item.count}</strong>
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
<small>{loading ? 'Loading...' : item.detail}</small>
|
<small>{loading ? tr('common.loading') : item.detail}</small>
|
||||||
<em>{item.actionLabel}</em>
|
<em>{item.actionLabel}</em>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
@ -563,11 +574,11 @@
|
||||||
<main class="overview-main">
|
<main class="overview-main">
|
||||||
<section class="today-resume overview-continue" data-overview-section="continue">
|
<section class="today-resume overview-continue" data-overview-section="continue">
|
||||||
<div class="today-resume-copy overview-continue-copy">
|
<div class="today-resume-copy overview-continue-copy">
|
||||||
<span>Continue working</span>
|
<span>{tr('overview.continue')}</span>
|
||||||
<h3>Pick up the next useful item in this workspace.</h3>
|
<h3>{tr('overview.continueHint')}</h3>
|
||||||
</div>
|
</div>
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p class="today-empty compact">Loading workspace signals...</p>
|
<p class="today-empty compact">{tr('overview.loadingSignals')}</p>
|
||||||
{:else if continueItems.length}
|
{:else if continueItems.length}
|
||||||
<div class="overview-continue-list">
|
<div class="overview-continue-list">
|
||||||
{#each continueItems as item}
|
{#each continueItems as item}
|
||||||
|
|
@ -588,8 +599,8 @@
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="overview-continue-empty">
|
<div class="overview-continue-empty">
|
||||||
<strong>No clear resume point yet</strong>
|
<strong>{tr('overview.noResume')}</strong>
|
||||||
<p>Recent notes, files, captures, and journal entries will appear here.</p>
|
<p>{tr('overview.noResumeHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -597,10 +608,10 @@
|
||||||
<section class="today-panel overview-panel overview-recent" data-overview-section="recent">
|
<section class="today-panel overview-panel overview-recent" data-overview-section="recent">
|
||||||
<div class="today-panel-head overview-panel-head">
|
<div class="today-panel-head overview-panel-head">
|
||||||
<div>
|
<div>
|
||||||
<h3>Recent changes</h3>
|
<h3>{tr('overview.recentChanges')}</h3>
|
||||||
<p>Latest meaningful activity in this workspace.</p>
|
<p>{tr('overview.recentChangesHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="overview-filters" aria-label="Recent changes filter">
|
<div class="overview-filters" aria-label={tr('overview.recentFilter')}>
|
||||||
{#each FILTERS as filter}
|
{#each FILTERS as filter}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -615,7 +626,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p class="today-empty">Loading recent changes...</p>
|
<p class="today-empty">{tr('overview.loadingRecent')}</p>
|
||||||
{:else if filteredRecentChanges.length}
|
{:else if filteredRecentChanges.length}
|
||||||
<div class="today-list overview-list">
|
<div class="today-list overview-list">
|
||||||
{#each filteredRecentChanges as item}
|
{#each filteredRecentChanges as item}
|
||||||
|
|
@ -635,7 +646,7 @@
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="today-empty">No meaningful changes for this filter yet.</p>
|
<p class="today-empty">{tr('overview.noChanges')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -645,12 +656,12 @@
|
||||||
<section class="today-panel overview-panel" data-overview-section="attention">
|
<section class="today-panel overview-panel" data-overview-section="attention">
|
||||||
<div class="today-panel-head overview-panel-head">
|
<div class="today-panel-head overview-panel-head">
|
||||||
<div>
|
<div>
|
||||||
<h3>Needs attention</h3>
|
<h3>{tr('overview.attention')}</h3>
|
||||||
<p>Pending captures, urgent todos, and possible journal entries.</p>
|
<p>{tr('overview.attentionHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p class="today-empty">Loading pending items...</p>
|
<p class="today-empty">{tr('overview.loadingPending')}</p>
|
||||||
{:else if needsAttention.length}
|
{:else if needsAttention.length}
|
||||||
<div class="today-list overview-list compact">
|
<div class="today-list overview-list compact">
|
||||||
{#each needsAttention as item}
|
{#each needsAttention as item}
|
||||||
|
|
@ -662,7 +673,7 @@
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="today-empty compact">No pending captures, urgent todos, or possible journal entries.</p>
|
<p class="today-empty compact">{tr('overview.noPending')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -670,7 +681,7 @@
|
||||||
{#if keyResources.length}
|
{#if keyResources.length}
|
||||||
<section class="today-panel overview-panel secondary" data-overview-section="key-resources">
|
<section class="today-panel overview-panel secondary" data-overview-section="key-resources">
|
||||||
<div class="today-panel-head overview-panel-head">
|
<div class="today-panel-head overview-panel-head">
|
||||||
<h3>Key resources</h3>
|
<h3>{tr('overview.keyResources')}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="today-list overview-list compact">
|
<div class="today-list overview-list compact">
|
||||||
{#each keyResources as item}
|
{#each keyResources as item}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import * as App from '../../../wailsjs/go/api/App';
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
import Icon from '../ui/Icon.svelte';
|
import Icon from '../ui/Icon.svelte';
|
||||||
|
import { i18n } from '../i18n/index.js';
|
||||||
|
|
||||||
let appSettings = {};
|
let appSettings = {};
|
||||||
let recentVaults = [];
|
let recentVaults = [];
|
||||||
|
|
@ -11,6 +12,13 @@
|
||||||
let opening = false;
|
let opening = false;
|
||||||
let newVaultPath = '';
|
let newVaultPath = '';
|
||||||
let openVaultPath = '';
|
let openVaultPath = '';
|
||||||
|
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);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -39,20 +47,20 @@
|
||||||
async function createVault() {
|
async function createVault() {
|
||||||
error = '';
|
error = '';
|
||||||
if (!newVaultPath.trim()) {
|
if (!newVaultPath.trim()) {
|
||||||
error = 'Choose or enter a folder for the new vault.';
|
error = tr('vaultSelection.chooseNew');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
creating = true;
|
creating = true;
|
||||||
try {
|
try {
|
||||||
const createErr = await App.CreateVault(newVaultPath.trim());
|
const createErr = await App.CreateVault(newVaultPath.trim());
|
||||||
if (createErr) {
|
if (createErr) {
|
||||||
error = 'Could not create vault: ' + createErr;
|
error = tr('vaultSelection.createError', { error: createErr });
|
||||||
creating = false;
|
creating = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const openErr = await App.OpenVault(newVaultPath.trim());
|
const openErr = await App.OpenVault(newVaultPath.trim());
|
||||||
if (openErr) {
|
if (openErr) {
|
||||||
error = 'Could not open vault: ' + openErr;
|
error = tr('vaultSelection.openError', { error: openErr });
|
||||||
creating = false;
|
creating = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -70,14 +78,14 @@
|
||||||
async function openExistingVault() {
|
async function openExistingVault() {
|
||||||
error = '';
|
error = '';
|
||||||
if (!openVaultPath.trim()) {
|
if (!openVaultPath.trim()) {
|
||||||
error = 'Choose or enter an existing vault.';
|
error = tr('vaultSelection.chooseExisting');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
opening = true;
|
opening = true;
|
||||||
try {
|
try {
|
||||||
const openErr = await App.OpenVault(openVaultPath.trim());
|
const openErr = await App.OpenVault(openVaultPath.trim());
|
||||||
if (openErr) {
|
if (openErr) {
|
||||||
error = 'Could not open vault: ' + openErr;
|
error = tr('vaultSelection.openError', { error: openErr });
|
||||||
opening = false;
|
opening = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -98,7 +106,7 @@
|
||||||
try {
|
try {
|
||||||
const openErr = await App.OpenVault(path);
|
const openErr = await App.OpenVault(path);
|
||||||
if (openErr) {
|
if (openErr) {
|
||||||
error = 'Could not open vault: ' + openErr;
|
error = tr('vaultSelection.openError', { error: openErr });
|
||||||
opening = false;
|
opening = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -117,7 +125,7 @@
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="vault-selection">
|
<div class="vault-selection">
|
||||||
<div class="vault-selection-inner">
|
<div class="vault-selection-inner">
|
||||||
<p class="loading-text">Loading...</p>
|
<p class="loading-text">{tr('common.loading')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
@ -130,7 +138,7 @@
|
||||||
<line x1="9" y1="14" x2="15" y2="14"/>
|
<line x1="9" y1="14" x2="15" y2="14"/>
|
||||||
</svg>
|
</svg>
|
||||||
<h1>Verstak</h1>
|
<h1>Verstak</h1>
|
||||||
<p class="subtitle">Choose a vault to start working</p>
|
<p class="subtitle">{tr('vaultSelection.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
|
|
@ -142,43 +150,43 @@
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<div class="action-card">
|
<div class="action-card">
|
||||||
<h3>Create a new vault</h3>
|
<h3>{tr('vaultSelection.createTitle')}</h3>
|
||||||
<p class="hint">Create a local vault folder for workspaces and projects.</p>
|
<p class="hint">{tr('vaultSelection.createHint')}</p>
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={newVaultPath}
|
bind:value={newVaultPath}
|
||||||
placeholder="Choose or enter a path..."
|
placeholder={tr('vaultSelection.pathPlaceholder')}
|
||||||
disabled={creating}
|
disabled={creating}
|
||||||
/>
|
/>
|
||||||
<button class="btn-secondary" on:click={browseNewVault} type="button" disabled={creating}>
|
<button class="btn-secondary" on:click={browseNewVault} type="button" disabled={creating}>
|
||||||
Browse...
|
{tr('common.browse')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<button class="btn-primary" on:click={createVault} type="button" disabled={creating}>
|
<button class="btn-primary" on:click={createVault} type="button" disabled={creating}>
|
||||||
{creating ? 'Creating...' : 'Create vault'}
|
{creating ? tr('vaultSelection.creating') : tr('vaultSelection.create')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="action-card">
|
<div class="action-card">
|
||||||
<h3>Open an existing vault</h3>
|
<h3>{tr('vaultSelection.openTitle')}</h3>
|
||||||
<p class="hint">Use a vault that is already on this computer.</p>
|
<p class="hint">{tr('vaultSelection.openHint')}</p>
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={openVaultPath}
|
bind:value={openVaultPath}
|
||||||
placeholder="Choose or enter a path..."
|
placeholder={tr('vaultSelection.pathPlaceholder')}
|
||||||
disabled={opening}
|
disabled={opening}
|
||||||
/>
|
/>
|
||||||
<button class="btn-secondary" on:click={browseOpenVault} type="button" disabled={opening}>
|
<button class="btn-secondary" on:click={browseOpenVault} type="button" disabled={opening}>
|
||||||
Browse...
|
{tr('common.browse')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<button class="btn-secondary open-existing-btn" on:click={openExistingVault} type="button" disabled={opening}>
|
<button class="btn-secondary open-existing-btn" on:click={openExistingVault} type="button" disabled={opening}>
|
||||||
{opening ? 'Opening...' : 'Open existing'}
|
{opening ? tr('vaultSelection.opening') : tr('vaultSelection.open')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -186,7 +194,7 @@
|
||||||
|
|
||||||
{#if recentVaults.length > 0}
|
{#if recentVaults.length > 0}
|
||||||
<div class="recent-section">
|
<div class="recent-section">
|
||||||
<h3>Recent vaults</h3>
|
<h3>{tr('vaultSelection.recent')}</h3>
|
||||||
<ul class="recent-list">
|
<ul class="recent-list">
|
||||||
{#each recentVaults as path}
|
{#each recentVaults as path}
|
||||||
<li>
|
<li>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
<script>
|
<script>
|
||||||
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
|
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import * as App from '../../../wailsjs/go/api/App';
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
import Icon from '../ui/Icon.svelte';
|
import Icon from '../ui/Icon.svelte';
|
||||||
|
import { i18n } from '../i18n/index.js';
|
||||||
|
|
||||||
export let activeView = null;
|
export let activeView = null;
|
||||||
export let activeViewPluginId = null;
|
export let activeViewPluginId = null;
|
||||||
|
|
@ -10,18 +11,40 @@
|
||||||
let views = [];
|
let views = [];
|
||||||
let plugins = [];
|
let plugins = [];
|
||||||
let renderError = null;
|
let renderError = null;
|
||||||
|
let locale = i18n.getLocale();
|
||||||
|
let unsubscribeLocale = null;
|
||||||
|
$: tr = ((activeLocale) => (key, params, fallback) => {
|
||||||
|
void activeLocale;
|
||||||
|
return i18n.t(key, params, fallback);
|
||||||
|
})(locale);
|
||||||
|
|
||||||
onMount(async () => {
|
async function loadViews() {
|
||||||
try {
|
try {
|
||||||
const [contribs, pluginList] = await Promise.all([
|
const [contribs, pluginList] = await Promise.all([
|
||||||
App.GetContributions().catch(() => ({ views: [] })),
|
App.GetContributions().catch(() => ({ views: [] })),
|
||||||
App.GetPlugins().catch(() => []),
|
App.GetPlugins().catch(() => []),
|
||||||
]);
|
]);
|
||||||
views = contribs.views || [];
|
await Promise.all((pluginList || []).map((plugin) => (
|
||||||
plugins = pluginList;
|
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
|
||||||
|
)));
|
||||||
|
views = i18n.localizeContributionSummary(contribs || {}).views || [];
|
||||||
|
plugins = (pluginList || []).map((plugin) => i18n.localizePlugin(plugin));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[ViewContainer] load error:', e);
|
console.error('[ViewContainer] load error:', e);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
unsubscribeLocale = i18n.subscribe((nextLocale) => {
|
||||||
|
const changed = locale !== nextLocale;
|
||||||
|
locale = nextLocale;
|
||||||
|
if (changed) loadViews();
|
||||||
|
});
|
||||||
|
loadViews();
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (unsubscribeLocale) unsubscribeLocale();
|
||||||
});
|
});
|
||||||
|
|
||||||
$: currentView = views.find(v => v.id === activeView && v.pluginId === activeViewPluginId);
|
$: currentView = views.find(v => v.id === activeView && v.pluginId === activeViewPluginId);
|
||||||
|
|
@ -39,7 +62,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function onHostError(e) {
|
function onHostError(e) {
|
||||||
renderError = e.detail?.message || 'Plugin view error';
|
renderError = e.detail?.message || tr('pluginView.error');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -49,9 +72,9 @@
|
||||||
<div class="error-boundary">
|
<div class="error-boundary">
|
||||||
<div class="error-fallback vt-inline-alert error">
|
<div class="error-fallback vt-inline-alert error">
|
||||||
<Icon name="warning" size={24} class="error-icon" />
|
<Icon name="warning" size={24} class="error-icon" />
|
||||||
<p class="error-title">Plugin UI failed</p>
|
<p class="error-title">{tr('pluginView.failed')}</p>
|
||||||
<details class="error-details">
|
<details class="error-details">
|
||||||
<summary>Details</summary>
|
<summary>{tr('common.details')}</summary>
|
||||||
<p class="error-text">{renderError}</p>
|
<p class="error-text">{renderError}</p>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -72,14 +95,14 @@
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="placeholder">
|
<div class="placeholder">
|
||||||
<p class="placeholder-label">This plugin does not provide a visual view yet.</p>
|
<p class="placeholder-label">{tr('pluginView.noVisual')}</p>
|
||||||
<details class="placeholder-details">
|
<details class="placeholder-details">
|
||||||
<summary>Details</summary>
|
<summary>{tr('common.details')}</summary>
|
||||||
<p class="placeholder-info"><span class="placeholder-key">Plugin:</span> <strong>{currentView.pluginId}</strong></p>
|
<p class="placeholder-info"><span class="placeholder-key">{tr('common.plugin')}:</span> <strong>{currentView.pluginId}</strong></p>
|
||||||
<p class="placeholder-info"><span class="placeholder-key">View ID:</span> <code>{currentView.id}</code></p>
|
<p class="placeholder-info"><span class="placeholder-key">{tr('pluginView.viewId')}:</span> <code>{currentView.id}</code></p>
|
||||||
<p class="placeholder-info"><span class="placeholder-key">Component:</span> <code>{currentView.component}</code></p>
|
<p class="placeholder-info"><span class="placeholder-key">{tr('common.component')}:</span> <code>{currentView.component}</code></p>
|
||||||
</details>
|
</details>
|
||||||
<p class="placeholder-badge vt-badge">Frontend bundle unavailable</p>
|
<p class="placeholder-badge vt-badge">{tr('pluginView.bundleUnavailable')}</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -87,12 +110,12 @@
|
||||||
</div>
|
</div>
|
||||||
{:else if activeView}
|
{:else if activeView}
|
||||||
<div class="view-container empty">
|
<div class="view-container empty">
|
||||||
<p>View is unavailable.</p>
|
<p>{tr('pluginView.unavailable')}</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="view-container empty">
|
<div class="view-container empty">
|
||||||
<p>Select a plugin view from the sidebar</p>
|
<p>{tr('pluginView.select')}</p>
|
||||||
<p class="sub">Plugin views will appear here</p>
|
<p class="sub">{tr('pluginView.selectHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/key}
|
{/key}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,17 @@
|
||||||
<script>
|
<script>
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
|
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
|
||||||
import Icon from '../ui/Icon.svelte';
|
import Icon from '../ui/Icon.svelte';
|
||||||
|
import { i18n } from '../i18n/index.js';
|
||||||
|
|
||||||
export let openedResource = null;
|
export let openedResource = null;
|
||||||
|
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);
|
||||||
|
|
||||||
$: providerPluginId = openedResource?.providerPluginId || '';
|
$: providerPluginId = openedResource?.providerPluginId || '';
|
||||||
$: providerComponent = openedResource?.providerComponent || '';
|
$: providerComponent = openedResource?.providerComponent || '';
|
||||||
|
|
@ -24,26 +33,28 @@
|
||||||
function closeWorkbench() {
|
function closeWorkbench() {
|
||||||
window.dispatchEvent(new CustomEvent('verstak:close-workbench', { cancelable: true }));
|
window.dispatchEvent(new CustomEvent('verstak:close-workbench', { cancelable: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onDestroy(unsubscribeLocale);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="workbench-host vt-page">
|
<div class="workbench-host vt-page">
|
||||||
{#if openedResource?.status === 'no-provider'}
|
{#if openedResource?.status === 'no-provider'}
|
||||||
<div class="workbench-header vt-page-header">
|
<div class="workbench-header vt-page-header">
|
||||||
<span class="workbench-title vt-page-title">{resourcePath}</span>
|
<span class="workbench-title vt-page-title">{resourcePath}</span>
|
||||||
<span class="workbench-provider vt-badge">No provider</span>
|
<span class="workbench-provider vt-badge">{tr('workbench.noProvider')}</span>
|
||||||
<button class="close-btn btn-ghost btn-icon" type="button" title="Close" aria-label="Close" on:click={closeWorkbench}>
|
<button class="close-btn btn-ghost btn-icon" type="button" title={tr('common.close')} aria-label={tr('common.close')} on:click={closeWorkbench}>
|
||||||
<Icon name="x" size={18} />
|
<Icon name="x" size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="workbench-empty no-provider vt-empty-state" data-workbench-status="no-provider">
|
<div class="workbench-empty no-provider vt-empty-state" data-workbench-status="no-provider">
|
||||||
<p class="vt-empty-title">No viewer/editor available</p>
|
<p class="vt-empty-title">{tr('workbench.noViewer')}</p>
|
||||||
<p class="workbench-meta">{requestMode} · {requestContext}</p>
|
<p class="workbench-meta">{requestMode} · {requestContext}</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if openedResource}
|
{:else if openedResource}
|
||||||
<div class="workbench-header vt-page-header">
|
<div class="workbench-header vt-page-header">
|
||||||
<span class="workbench-title vt-page-title">{resourcePath}</span>
|
<span class="workbench-title vt-page-title">{resourcePath}</span>
|
||||||
<span class="workbench-provider vt-badge accent">{providerId}</span>
|
<span class="workbench-provider vt-badge accent">{providerId}</span>
|
||||||
<button class="close-btn btn-ghost btn-icon" type="button" title="Close" aria-label="Close" on:click={closeWorkbench}>
|
<button class="close-btn btn-ghost btn-icon" type="button" title={tr('common.close')} aria-label={tr('common.close')} on:click={closeWorkbench}>
|
||||||
<Icon name="x" size={18} />
|
<Icon name="x" size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -58,7 +69,7 @@
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="workbench-empty vt-empty-state">
|
<div class="workbench-empty vt-empty-state">
|
||||||
<p class="vt-empty-title">No resource opened</p>
|
<p class="vt-empty-title">{tr('workbench.noResource')}</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import TodaySurface from './TodaySurface.svelte';
|
import TodaySurface from './TodaySurface.svelte';
|
||||||
import * as App from '../../../wailsjs/go/api/App';
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import { i18n } from '../i18n/index.js';
|
||||||
|
|
||||||
export let selectedWorkspaceName = '';
|
export let selectedWorkspaceName = '';
|
||||||
export let nodes = [];
|
export let nodes = [];
|
||||||
|
|
@ -20,8 +21,14 @@
|
||||||
let requestedToolRequest = null;
|
let requestedToolRequest = null;
|
||||||
let activeToolRequest = null;
|
let activeToolRequest = null;
|
||||||
let requestedWorkspaceRoot = '';
|
let requestedWorkspaceRoot = '';
|
||||||
|
let locale = i18n.getLocale();
|
||||||
|
let unsubscribeLocale = null;
|
||||||
|
$: tr = ((activeLocale) => (key, params, fallback) => {
|
||||||
|
void activeLocale;
|
||||||
|
return i18n.t(key, params, fallback);
|
||||||
|
})(locale);
|
||||||
// TODO: Rename TodaySurface.svelte to OverviewSurface.svelte in a refactor-only follow-up.
|
// TODO: Rename TodaySurface.svelte to OverviewSurface.svelte in a refactor-only follow-up.
|
||||||
const overviewTool = { id: '__overview', title: 'Overview', pluginId: 'verstak.shell', component: 'TodaySurface', shell: true };
|
$: overviewTool = { id: '__overview', title: tr('workspace.overview'), pluginId: 'verstak.shell', component: 'TodaySurface', shell: true };
|
||||||
|
|
||||||
const toolOrder = new Map([
|
const toolOrder = new Map([
|
||||||
['notes', 10],
|
['notes', 10],
|
||||||
|
|
@ -66,10 +73,16 @@
|
||||||
$: if (selectedWorkspaceName) loadTools();
|
$: if (selectedWorkspaceName) loadTools();
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
unsubscribeLocale = i18n.subscribe((nextLocale) => {
|
||||||
|
const changed = locale !== nextLocale;
|
||||||
|
locale = nextLocale;
|
||||||
|
if (changed && selectedWorkspaceName) loadTools();
|
||||||
|
});
|
||||||
window.addEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool);
|
window.addEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool);
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
if (unsubscribeLocale) unsubscribeLocale();
|
||||||
window.removeEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool);
|
window.removeEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -163,8 +176,11 @@
|
||||||
App.GetContributions().catch(() => ({})),
|
App.GetContributions().catch(() => ({})),
|
||||||
App.GetPlugins().catch(() => []),
|
App.GetPlugins().catch(() => []),
|
||||||
]);
|
]);
|
||||||
contributions = c || {};
|
await Promise.all((p || []).map((plugin) => (
|
||||||
plugins = p || [];
|
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
|
||||||
|
)));
|
||||||
|
contributions = i18n.localizeContributionSummary(c || {});
|
||||||
|
plugins = (p || []).map((plugin) => i18n.localizePlugin(plugin));
|
||||||
|
|
||||||
const enabledIds = new Set(
|
const enabledIds = new Set(
|
||||||
plugins.filter(pl => pl.enabled && (pl.status === 'loaded' || pl.status === 'degraded')).map(pl => pl.manifest?.id)
|
plugins.filter(pl => pl.enabled && (pl.status === 'loaded' || pl.status === 'degraded')).map(pl => pl.manifest?.id)
|
||||||
|
|
@ -186,13 +202,13 @@
|
||||||
<span class="workspace-title vt-page-title">{workspaceTitle}</span>
|
<span class="workspace-title vt-page-title">{workspaceTitle}</span>
|
||||||
<span class="workspace-type vt-badge accent">{workspaceType}</span>
|
<span class="workspace-type vt-badge accent">{workspaceType}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="workspace-search" aria-label="Workspace search">
|
<div class="workspace-search" aria-label={tr('workspace.search')}>
|
||||||
<GlobalSearch />
|
<GlobalSearch />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if displayedTools.length > 0}
|
{#if displayedTools.length > 0}
|
||||||
<div class="workspace-tabs vt-tabbar" role="tablist" aria-label="Workspace tools">
|
<div class="workspace-tabs vt-tabbar" role="tablist" aria-label={tr('workspace.tools')}>
|
||||||
{#each displayedTools as tool (tool.id + tool.pluginId)}
|
{#each displayedTools as tool (tool.id + tool.pluginId)}
|
||||||
<button
|
<button
|
||||||
class="vt-tab"
|
class="vt-tab"
|
||||||
|
|
@ -207,7 +223,7 @@
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div class="workspace-tool-content" role="tabpanel" aria-label={activeTool?.title || activeTool?.id || 'Workspace tool'}>
|
<div class="workspace-tool-content" role="tabpanel" aria-label={activeTool?.title || activeTool?.id || tr('workspace.tool')}>
|
||||||
{#if activeTool}
|
{#if activeTool}
|
||||||
{#if activeTool.shell}
|
{#if activeTool.shell}
|
||||||
<TodaySurface
|
<TodaySurface
|
||||||
|
|
@ -226,14 +242,14 @@
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="workspace-empty vt-empty-state">
|
<div class="workspace-empty vt-empty-state">
|
||||||
<p class="vt-empty-title">No workspace tools available</p>
|
<p class="vt-empty-title">{tr('workspace.emptyTools')}</p>
|
||||||
<p class="workspace-hint">Enable plugins with workspace tools or open Plugin Manager from settings.</p>
|
<p class="workspace-hint">{tr('workspace.emptyToolsHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="workspace-empty vt-empty-state">
|
<div class="workspace-empty vt-empty-state">
|
||||||
<p class="vt-empty-title">Select a workspace</p>
|
<p class="vt-empty-title">{tr('workspace.select')}</p>
|
||||||
<p class="workspace-hint">Use the + button in Workspaces to add your first project.</p>
|
<p class="workspace-hint">{tr('workspace.selectHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import * as App from '../../../wailsjs/go/api/App';
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
import Icon from '../ui/Icon.svelte';
|
import Icon from '../ui/Icon.svelte';
|
||||||
|
import { i18n } from '../i18n/index.js';
|
||||||
|
|
||||||
let loading = true;
|
let loading = true;
|
||||||
let localError = '';
|
let localError = '';
|
||||||
|
|
@ -24,14 +25,26 @@
|
||||||
let renamingId = '';
|
let renamingId = '';
|
||||||
let renameValue = '';
|
let renameValue = '';
|
||||||
let busyId = '';
|
let busyId = '';
|
||||||
|
let locale = i18n.getLocale();
|
||||||
|
let unsubscribeLocale = null;
|
||||||
|
$: tr = ((activeLocale) => (key, params, fallback) => {
|
||||||
|
void activeLocale;
|
||||||
|
return i18n.t(key, params, fallback);
|
||||||
|
})(locale);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
unsubscribeLocale = i18n.subscribe((nextLocale) => {
|
||||||
|
const changed = locale !== nextLocale;
|
||||||
|
locale = nextLocale;
|
||||||
|
if (changed) loadWorkspaceTemplates();
|
||||||
|
});
|
||||||
loadWorkspaces();
|
loadWorkspaces();
|
||||||
loadWorkspaceTemplates();
|
loadWorkspaceTemplates();
|
||||||
window.addEventListener('verstak:workspace-active-changed', onActiveWorkspaceChanged);
|
window.addEventListener('verstak:workspace-active-changed', onActiveWorkspaceChanged);
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
if (unsubscribeLocale) unsubscribeLocale();
|
||||||
window.removeEventListener('verstak:workspace-active-changed', onActiveWorkspaceChanged);
|
window.removeEventListener('verstak:workspace-active-changed', onActiveWorkspaceChanged);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -67,7 +80,10 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
workspaceTemplates = Array.isArray(list) ? list : [];
|
workspaceTemplates = Array.isArray(list) ? list : [];
|
||||||
templatePluginNames = (Array.isArray(plugins) ? plugins : []).reduce((names, plugin) => {
|
await Promise.all((Array.isArray(plugins) ? plugins : []).map((plugin) => (
|
||||||
|
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
|
||||||
|
)));
|
||||||
|
templatePluginNames = (Array.isArray(plugins) ? plugins : []).map((plugin) => i18n.localizePlugin(plugin)).reduce((names, plugin) => {
|
||||||
const id = plugin?.manifest?.id;
|
const id = plugin?.manifest?.id;
|
||||||
const name = plugin?.manifest?.name;
|
const name = plugin?.manifest?.name;
|
||||||
if (id && name) names[id] = name;
|
if (id && name) names[id] = name;
|
||||||
|
|
@ -154,11 +170,11 @@
|
||||||
async function doCreate() {
|
async function doCreate() {
|
||||||
const name = newWorkspaceName.trim();
|
const name = newWorkspaceName.trim();
|
||||||
if (!name) {
|
if (!name) {
|
||||||
createError = 'Name is required';
|
createError = tr('workspaceTree.nameRequired');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!selectedTemplate) {
|
if (!selectedTemplate) {
|
||||||
createError = 'Choose a workspace template';
|
createError = tr('workspaceTree.chooseTemplate');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
creating = true;
|
creating = true;
|
||||||
|
|
@ -246,12 +262,12 @@
|
||||||
|
|
||||||
<div class="wt">
|
<div class="wt">
|
||||||
<div class="wt-header">
|
<div class="wt-header">
|
||||||
<span class="wt-title">Workspaces</span>
|
<span class="wt-title">{tr('workspaceTree.title')}</span>
|
||||||
<button class="wt-btn" on:click={openCreateDialog} title="New workspace" type="button">+</button>
|
<button class="wt-btn" on:click={openCreateDialog} title={tr('workspaceTree.new')} type="button">+</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="wt-loading">Loading...</div>
|
<div class="wt-loading">{tr('common.loading')}</div>
|
||||||
{:else if localError}
|
{:else if localError}
|
||||||
<div class="wt-error">{localError}</div>
|
<div class="wt-error">{localError}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -272,14 +288,14 @@
|
||||||
if (e.key === 'Escape') cancelRename();
|
if (e.key === 'Escape') cancelRename();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button class="wt-btn wt-btn-small wt-always" on:click={() => commitRename(workspace)} title="Save rename" type="button" disabled={busyId === id}>OK</button>
|
<button class="wt-btn wt-btn-small wt-always" on:click={() => commitRename(workspace)} title={tr('workspaceTree.saveRename')} type="button" disabled={busyId === id}>OK</button>
|
||||||
<button class="wt-btn wt-btn-small wt-always" on:click={cancelRename} title="Cancel rename" type="button" disabled={busyId === id}>Cancel</button>
|
<button class="wt-btn wt-btn-small wt-always" on:click={cancelRename} title={tr('common.cancel')} type="button" disabled={busyId === id}>{tr('common.cancel')}</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="wt-label" on:click={() => selectWorkspace(workspace)} type="button">{id}</button>
|
<button class="wt-label" on:click={() => selectWorkspace(workspace)} type="button">{id}</button>
|
||||||
<button class="wt-icon-btn" on:click={() => startRename(workspace)} title="Rename workspace" type="button" disabled={busyId === id}>
|
<button class="wt-icon-btn" on:click={() => startRename(workspace)} title={tr('workspaceTree.rename')} type="button" disabled={busyId === id}>
|
||||||
<Icon name="edit" size={12} />
|
<Icon name="edit" size={12} />
|
||||||
</button>
|
</button>
|
||||||
<button class="wt-icon-btn danger" on:click={() => trashWorkspace(workspace)} title="Trash workspace" type="button" disabled={busyId === id}>
|
<button class="wt-icon-btn danger" on:click={() => trashWorkspace(workspace)} title={tr('workspaceTree.trash')} type="button" disabled={busyId === id}>
|
||||||
<Icon name="trash" size={12} />
|
<Icon name="trash" size={12} />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -289,20 +305,20 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showCreate}
|
{#if showCreate}
|
||||||
<div class="workspace-create-overlay" data-workspace-create-modal role="dialog" aria-modal="true" aria-label="Create workspace">
|
<div class="workspace-create-overlay" data-workspace-create-modal role="dialog" aria-modal="true" aria-label={tr('workspaceTree.create')}>
|
||||||
<div class="workspace-create-modal">
|
<div class="workspace-create-modal">
|
||||||
<div class="workspace-create-header">
|
<div class="workspace-create-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>New workspace</h2>
|
<h2>{tr('workspaceTree.new')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<button class="wt-btn" on:click={closeCreateDialog} type="button" disabled={creating}>Close</button>
|
<button class="wt-btn" on:click={closeCreateDialog} type="button" disabled={creating}>{tr('common.close')}</button>
|
||||||
</div>
|
</div>
|
||||||
<label class="workspace-create-field">
|
<label class="workspace-create-field">
|
||||||
<span>Name</span>
|
<span>{tr('pluginCard.name')}</span>
|
||||||
<input data-workspace-name type="text" bind:value={newWorkspaceName} placeholder="Workspace name" disabled={creating} on:keydown={(event) => event.key === 'Enter' && doCreate()} />
|
<input data-workspace-name type="text" bind:value={newWorkspaceName} placeholder={tr('workspaceTree.namePlaceholder')} disabled={creating} on:keydown={(event) => event.key === 'Enter' && doCreate()} />
|
||||||
</label>
|
</label>
|
||||||
<label class="workspace-create-field">
|
<label class="workspace-create-field">
|
||||||
<span>Template</span>
|
<span>{tr('workspaceTree.template')}</span>
|
||||||
<select data-workspace-template bind:value={selectedTemplateId} disabled={creating || templatesLoading || !workspaceTemplates.length}>
|
<select data-workspace-template bind:value={selectedTemplateId} disabled={creating || templatesLoading || !workspaceTemplates.length}>
|
||||||
{#each workspaceTemplates as template (template.id)}
|
{#each workspaceTemplates as template (template.id)}
|
||||||
<option value={template.id}>{template.name}</option>
|
<option value={template.id}>{template.name}</option>
|
||||||
|
|
@ -323,8 +339,8 @@
|
||||||
<p class="workspace-create-error" data-workspace-create-error role="alert">{createError}</p>
|
<p class="workspace-create-error" data-workspace-create-error role="alert">{createError}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="workspace-create-actions">
|
<div class="workspace-create-actions">
|
||||||
<button class="wt-btn-primary" on:click={doCreate} type="button" disabled={creating || templatesLoading || !selectedTemplate}>{creating ? 'Creating...' : 'Create workspace'}</button>
|
<button class="wt-btn-primary" on:click={doCreate} type="button" disabled={creating || templatesLoading || !selectedTemplate}>{creating ? tr('workspaceTree.creating') : tr('workspaceTree.create')}</button>
|
||||||
<button class="wt-btn" on:click={closeCreateDialog} type="button" disabled={creating}>Cancel</button>
|
<button class="wt-btn" on:click={closeCreateDialog} type="button" disabled={creating}>{tr('common.cancel')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -283,6 +283,64 @@
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var russianPluginNames = {
|
||||||
|
'verstak.platform-test': 'Тест платформы',
|
||||||
|
'verstak.default-editor': 'Стандартный редактор',
|
||||||
|
'verstak.files': 'Файлы',
|
||||||
|
'verstak.notes': 'Заметки',
|
||||||
|
'verstak.sync': 'Синхронизация',
|
||||||
|
'verstak.activity': 'Активность',
|
||||||
|
'verstak.journal': 'Журнал',
|
||||||
|
'verstak.browser-inbox': 'Входящие из браузера',
|
||||||
|
'verstak.search': 'Поиск',
|
||||||
|
'verstak.trash': 'Корзина',
|
||||||
|
'verstak.todo': 'Задачи',
|
||||||
|
'verstak.secrets': 'Секреты'
|
||||||
|
};
|
||||||
|
var russianContributionLabels = {
|
||||||
|
'verstak.platform-test.diagnostics': 'Диагностика платформы',
|
||||||
|
'verstak.platform-test.run-tests': 'Запустить тесты платформы',
|
||||||
|
'verstak.platform-test.show-version': 'Показать сведения о версии',
|
||||||
|
'verstak.platform-test.sidebar': 'Тест платформы',
|
||||||
|
'verstak.platform-test.status': '[OK] Все тесты пройдены',
|
||||||
|
'verstak.platform-test.settings': 'Настройки теста платформы',
|
||||||
|
'verstak.platform-test.markdown-diagnostic': 'Диагностика Markdown платформы',
|
||||||
|
'verstak.search.searchVaultText': 'Искать текст в хранилище',
|
||||||
|
'verstak.search.vault-text': 'Поиск по тексту хранилища'
|
||||||
|
};
|
||||||
|
Object.keys(pluginStates).forEach(function (pluginId) {
|
||||||
|
var manifest = pluginStates[pluginId].manifest;
|
||||||
|
manifest.localization = {
|
||||||
|
defaultLocale: 'en',
|
||||||
|
locales: { en: 'locales/en.json', ru: 'locales/ru.json' }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockPluginCatalog(pluginId, locale) {
|
||||||
|
var state = pluginStates[pluginId];
|
||||||
|
if (!state || !state.manifest) return {};
|
||||||
|
var manifest = state.manifest;
|
||||||
|
var translatedName = locale === 'ru' ? (russianPluginNames[pluginId] || manifest.name) : manifest.name;
|
||||||
|
var catalog = {
|
||||||
|
'manifest.name': translatedName,
|
||||||
|
'manifest.description': manifest.description || ''
|
||||||
|
};
|
||||||
|
var contributionFields = {
|
||||||
|
views: 'title', commands: 'title', settingsPanels: 'title', sidebarItems: 'title',
|
||||||
|
fileActions: 'label', noteActions: 'label', contextMenuEntries: 'label',
|
||||||
|
searchProviders: 'label', statusBarItems: 'label', openProviders: 'title', workspaceItems: 'title'
|
||||||
|
};
|
||||||
|
Object.keys(contributionFields).forEach(function (point) {
|
||||||
|
var field = contributionFields[point];
|
||||||
|
((manifest.contributes || {})[point] || []).forEach(function (item) {
|
||||||
|
catalog['contributions.' + point + '.' + item.id + '.' + field] = locale === 'ru'
|
||||||
|
? (russianContributionLabels[item.id] || translatedName)
|
||||||
|
: item[field];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return catalog;
|
||||||
|
}
|
||||||
|
|
||||||
var vaultStatus = { status: 'open', path: '/tmp/verstak-test/vault', vaultId: 'test-vault-001' };
|
var vaultStatus = { status: 'open', path: '/tmp/verstak-test/vault', vaultId: 'test-vault-001' };
|
||||||
var vaultPluginState = { enabledPlugins: ['verstak.platform-test', 'verstak.default-editor', 'verstak.files', 'verstak.notes', 'verstak.sync', 'verstak.activity', 'verstak.journal', 'verstak.browser-inbox', 'verstak.search'], disabledPlugins: [], desiredPlugins: [{ id: 'verstak.platform-test', version: '0.1.0', source: 'official' }, { id: 'verstak.default-editor', version: '0.1.0', source: 'official' }, { id: 'verstak.files', version: '0.1.0', source: 'official' }, { id: 'verstak.notes', version: '0.1.0', source: 'official' }, { id: 'verstak.sync', version: '0.1.0', source: 'official' }, { id: 'verstak.activity', version: '0.1.0', source: 'official' }, { id: 'verstak.journal', version: '0.1.0', source: 'official' }, { id: 'verstak.browser-inbox', version: '0.1.0', source: 'official' }, { id: 'verstak.search', version: '0.1.0', source: 'official' }] };
|
var vaultPluginState = { enabledPlugins: ['verstak.platform-test', 'verstak.default-editor', 'verstak.files', 'verstak.notes', 'verstak.sync', 'verstak.activity', 'verstak.journal', 'verstak.browser-inbox', 'verstak.search'], disabledPlugins: [], desiredPlugins: [{ id: 'verstak.platform-test', version: '0.1.0', source: 'official' }, { id: 'verstak.default-editor', version: '0.1.0', source: 'official' }, { id: 'verstak.files', version: '0.1.0', source: 'official' }, { id: 'verstak.notes', version: '0.1.0', source: 'official' }, { id: 'verstak.sync', version: '0.1.0', source: 'official' }, { id: 'verstak.activity', version: '0.1.0', source: 'official' }, { id: 'verstak.journal', version: '0.1.0', source: 'official' }, { id: 'verstak.browser-inbox', version: '0.1.0', source: 'official' }, { id: 'verstak.search', version: '0.1.0', source: 'official' }] };
|
||||||
vaultPluginState.enabledPlugins.push('verstak.trash');
|
vaultPluginState.enabledPlugins.push('verstak.trash');
|
||||||
|
|
@ -291,7 +349,11 @@
|
||||||
vaultPluginState.desiredPlugins.push({ id: 'verstak.todo', version: '0.1.0', source: 'official' });
|
vaultPluginState.desiredPlugins.push({ id: 'verstak.todo', version: '0.1.0', source: 'official' });
|
||||||
vaultPluginState.enabledPlugins.push('verstak.secrets');
|
vaultPluginState.enabledPlugins.push('verstak.secrets');
|
||||||
vaultPluginState.desiredPlugins.push({ id: 'verstak.secrets', version: '0.1.0', source: 'official' });
|
vaultPluginState.desiredPlugins.push({ id: 'verstak.secrets', version: '0.1.0', source: 'official' });
|
||||||
var appSettings = { currentVaultPath: '/tmp/verstak-test/vault', recentVaults: [] };
|
var appSettings = {
|
||||||
|
currentVaultPath: '/tmp/verstak-test/vault',
|
||||||
|
recentVaults: [],
|
||||||
|
language: localStorage.getItem('verstak-test-language') || 'system'
|
||||||
|
};
|
||||||
var workbenchPreferences = {};
|
var workbenchPreferences = {};
|
||||||
var openedResources = [];
|
var openedResources = [];
|
||||||
var pluginSettings = {
|
var pluginSettings = {
|
||||||
|
|
@ -3263,6 +3325,9 @@
|
||||||
}
|
}
|
||||||
return Promise.resolve({});
|
return Promise.resolve({});
|
||||||
},
|
},
|
||||||
|
GetPluginLocalization: function (pluginId, locale) {
|
||||||
|
return Promise.resolve([mockPluginCatalog(pluginId, locale), '']);
|
||||||
|
},
|
||||||
ReadPluginSettings: function (pluginId) {
|
ReadPluginSettings: function (pluginId) {
|
||||||
return Promise.resolve([Object.assign({}, pluginSettings[pluginId] || {}), '']);
|
return Promise.resolve([Object.assign({}, pluginSettings[pluginId] || {}), '']);
|
||||||
},
|
},
|
||||||
|
|
@ -3736,7 +3801,11 @@
|
||||||
OpenVault: function () { return Promise.resolve(null); },
|
OpenVault: function () { return Promise.resolve(null); },
|
||||||
CloseVault: function () { return Promise.resolve(null); },
|
CloseVault: function () { return Promise.resolve(null); },
|
||||||
SetCurrentVault: function () { return Promise.resolve(''); },
|
SetCurrentVault: function () { return Promise.resolve(''); },
|
||||||
UpdateAppSettings: function () { return Promise.resolve(''); },
|
UpdateAppSettings: function (patch) {
|
||||||
|
appSettings = Object.assign({}, appSettings, patch || {});
|
||||||
|
if (patch && patch.language) localStorage.setItem('verstak-test-language', patch.language);
|
||||||
|
return Promise.resolve('');
|
||||||
|
},
|
||||||
RecordDesiredPlugin: function () { return Promise.resolve(''); },
|
RecordDesiredPlugin: function () { return Promise.resolve(''); },
|
||||||
WriteFrontendLog: function () { return Promise.resolve(); },
|
WriteFrontendLog: function () { return Promise.resolve(); },
|
||||||
|
|
||||||
|
|
@ -4066,7 +4135,8 @@
|
||||||
vaultPluginState.desiredPlugins.push({ id: 'verstak.todo', version: '0.1.0', source: 'official' });
|
vaultPluginState.desiredPlugins.push({ id: 'verstak.todo', version: '0.1.0', source: 'official' });
|
||||||
vaultPluginState.enabledPlugins.push('verstak.secrets');
|
vaultPluginState.enabledPlugins.push('verstak.secrets');
|
||||||
vaultPluginState.desiredPlugins.push({ id: 'verstak.secrets', version: '0.1.0', source: 'official' });
|
vaultPluginState.desiredPlugins.push({ id: 'verstak.secrets', version: '0.1.0', source: 'official' });
|
||||||
appSettings = { currentVaultPath: '/tmp/verstak-test/vault', recentVaults: [] };
|
localStorage.removeItem('verstak-test-language');
|
||||||
|
appSettings = { currentVaultPath: '/tmp/verstak-test/vault', recentVaults: [], language: 'system' };
|
||||||
workbenchPreferences = {};
|
workbenchPreferences = {};
|
||||||
openedResources = [];
|
openedResources = [];
|
||||||
pluginSettings = { 'verstak.platform-test': { savedText: 'initial value' } };
|
pluginSettings = { 'verstak.platform-test': { savedText: 'initial value' } };
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,33 @@
|
||||||
import App from './App.svelte';
|
import App from './App.svelte';
|
||||||
|
import * as Backend from '../wailsjs/go/api/App';
|
||||||
|
import { i18n } from './lib/i18n/index.js';
|
||||||
|
|
||||||
const app = new App({
|
function unpack(result) {
|
||||||
target: document.getElementById('app'),
|
if (Array.isArray(result) && result.length === 2 && (typeof result[1] === 'string' || result[1] == null)) {
|
||||||
|
if (result[1]) throw new Error(result[1]);
|
||||||
|
return result[0];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
i18n.configure({
|
||||||
|
loadPluginCatalog: async (pluginId, locale) => unpack(await Backend.GetPluginLocalization(pluginId, locale)),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
try {
|
||||||
|
const settings = await Backend.GetAppSettings();
|
||||||
|
await i18n.initialize(settings?.language || 'system');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[i18n] initialization failed:', error);
|
||||||
|
await i18n.initialize('system');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new App({
|
||||||
|
target: document.getElementById('app'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = start();
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
createI18n,
|
||||||
|
resolveLocale,
|
||||||
|
} from '../src/lib/i18n/index.js';
|
||||||
|
import englishShellCatalog from '../src/lib/i18n/catalogs/en.js';
|
||||||
|
import russianShellCatalog from '../src/lib/i18n/catalogs/ru.js';
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
Object.keys(russianShellCatalog).sort(),
|
||||||
|
Object.keys(englishShellCatalog).sort(),
|
||||||
|
'English and Russian shell catalogs must have identical keys',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(resolveLocale('system', ['ru-RU']), 'ru');
|
||||||
|
assert.equal(resolveLocale('system', ['uk-UA', 'en-US']), 'en');
|
||||||
|
assert.equal(resolveLocale('ru', ['en-US']), 'ru');
|
||||||
|
assert.equal(resolveLocale('en', ['ru-RU']), 'en');
|
||||||
|
assert.throws(() => resolveLocale('de', ['de-DE']), /unsupported language/);
|
||||||
|
|
||||||
|
const catalogs = {
|
||||||
|
'localized.plugin': {
|
||||||
|
en: { 'manifest.name': 'Localized Plugin', greeting: 'Hello, {name}!' },
|
||||||
|
ru: {
|
||||||
|
'manifest.name': 'Локализованный плагин',
|
||||||
|
'contributions.views.localized.view.title': 'Локализованный экран',
|
||||||
|
greeting: 'Привет, {name}!',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const loads = [];
|
||||||
|
const service = createI18n({
|
||||||
|
shellCatalogs: {
|
||||||
|
en: { loading: 'Loading {name}...', fallbackOnly: 'English fallback' },
|
||||||
|
ru: { loading: 'Загрузка {name}...' },
|
||||||
|
},
|
||||||
|
systemLanguages: () => ['ru-RU'],
|
||||||
|
loadPluginCatalog: async (pluginId, locale) => {
|
||||||
|
loads.push(`${pluginId}:${locale}`);
|
||||||
|
return catalogs[pluginId]?.[locale] || {};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.initialize('system');
|
||||||
|
assert.equal(service.getLanguagePreference(), 'system');
|
||||||
|
assert.equal(service.getLocale(), 'ru');
|
||||||
|
assert.equal(service.t('loading', { name: 'Верстак' }), 'Загрузка Верстак...');
|
||||||
|
assert.equal(service.t('fallbackOnly'), 'English fallback');
|
||||||
|
assert.equal(service.t('missing', undefined, 'Explicit fallback'), 'Explicit fallback');
|
||||||
|
assert.equal(service.t('unknown'), 'unknown');
|
||||||
|
|
||||||
|
await service.loadPlugin('localized.plugin', {
|
||||||
|
defaultLocale: 'en',
|
||||||
|
locales: { en: 'locales/en.json', ru: 'locales/ru.json' },
|
||||||
|
});
|
||||||
|
assert.deepEqual(loads.sort(), ['localized.plugin:en', 'localized.plugin:ru']);
|
||||||
|
assert.equal(service.translatePlugin('localized.plugin', 'greeting', { name: 'Мир' }), 'Привет, Мир!');
|
||||||
|
|
||||||
|
const plugin = {
|
||||||
|
manifest: {
|
||||||
|
id: 'localized.plugin',
|
||||||
|
name: 'Localized Plugin',
|
||||||
|
description: 'Literal description',
|
||||||
|
contributes: {
|
||||||
|
views: [{ id: 'localized.view', title: 'Literal View', component: 'View' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const localized = service.localizePlugin(plugin);
|
||||||
|
assert.notEqual(localized, plugin);
|
||||||
|
assert.equal(localized.manifest.name, 'Локализованный плагин');
|
||||||
|
assert.equal(localized.manifest.description, 'Literal description');
|
||||||
|
assert.equal(localized.manifest.contributes.views[0].title, 'Локализованный экран');
|
||||||
|
assert.equal(plugin.manifest.name, 'Localized Plugin');
|
||||||
|
const summary = service.localizeContributionSummary({
|
||||||
|
views: [{ pluginId: 'localized.plugin', id: 'localized.view', title: 'Literal View', component: 'View' }],
|
||||||
|
});
|
||||||
|
assert.equal(summary.views[0].title, 'Локализованный экран');
|
||||||
|
|
||||||
|
let notifications = 0;
|
||||||
|
const unsubscribe = service.subscribe(() => { notifications += 1; });
|
||||||
|
assert.equal(notifications, 1);
|
||||||
|
await service.setLanguagePreference('en');
|
||||||
|
assert.equal(service.getLocale(), 'en');
|
||||||
|
assert.equal(service.translatePlugin('localized.plugin', 'greeting', { name: 'World' }), 'Hello, World!');
|
||||||
|
assert.equal(notifications, 2);
|
||||||
|
unsubscribe();
|
||||||
|
await service.setLanguagePreference('ru');
|
||||||
|
assert.equal(notifications, 2);
|
||||||
|
|
||||||
|
const resilientService = createI18n({
|
||||||
|
shellCatalogs: { en: { title: 'Title' }, ru: { title: 'Заголовок' } },
|
||||||
|
systemLanguages: () => ['en-US'],
|
||||||
|
loadPluginCatalog: async () => { throw new Error('broken catalog'); },
|
||||||
|
});
|
||||||
|
await resilientService.initialize('en');
|
||||||
|
await assert.rejects(
|
||||||
|
resilientService.loadPlugin('broken.plugin', {
|
||||||
|
defaultLocale: 'en',
|
||||||
|
locales: { en: 'locales/en.json', ru: 'locales/ru.json' },
|
||||||
|
}),
|
||||||
|
/broken catalog/,
|
||||||
|
);
|
||||||
|
let resilientNotification = '';
|
||||||
|
resilientService.subscribe((nextLocale) => { resilientNotification = nextLocale; });
|
||||||
|
await resilientService.setLanguagePreference('ru');
|
||||||
|
assert.equal(resilientService.getLanguagePreference(), 'ru');
|
||||||
|
assert.equal(resilientService.getLocale(), 'ru');
|
||||||
|
assert.equal(resilientNotification, 'ru');
|
||||||
|
assert.equal(resilientService.t('title'), 'Заголовок');
|
||||||
|
|
||||||
|
console.log('i18n service tests passed');
|
||||||
|
|
@ -43,10 +43,27 @@ globalThis.window = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
globalThis.__mockApp = window.go.api.App;
|
globalThis.__mockApp = window.go.api.App;
|
||||||
|
const localeListeners = new Set();
|
||||||
|
globalThis.__mockI18n = {
|
||||||
|
getLocale: () => 'ru',
|
||||||
|
translatePlugin: (_pluginId, key, params, fallback) => {
|
||||||
|
const messages = { greeting: 'Привет, {name}!' };
|
||||||
|
const message = messages[key] || fallback || key;
|
||||||
|
return message.replace(/\{([^}]+)\}/g, (placeholder, name) => (
|
||||||
|
Object.prototype.hasOwnProperty.call(params || {}, name) ? String(params[name]) : placeholder
|
||||||
|
));
|
||||||
|
},
|
||||||
|
subscribe: (listener) => {
|
||||||
|
localeListeners.add(listener);
|
||||||
|
listener('ru');
|
||||||
|
return () => localeListeners.delete(listener);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const sourcePath = path.resolve('frontend/src/lib/plugin-host/VerstakPluginAPI.js');
|
const sourcePath = path.resolve('frontend/src/lib/plugin-host/VerstakPluginAPI.js');
|
||||||
const source = fs.readFileSync(sourcePath, 'utf8')
|
const source = fs.readFileSync(sourcePath, 'utf8')
|
||||||
.replace("import * as App from '../../../wailsjs/go/api/App';", 'const App = globalThis.__mockApp;');
|
.replace("import * as App from '../../../wailsjs/go/api/App';", 'const App = globalThis.__mockApp;')
|
||||||
|
.replace("import { i18n } from '../i18n/index.js';", 'const i18n = globalThis.__mockI18n;');
|
||||||
const tempPath = path.resolve('/tmp/verstak-plugin-api-contributions-test.mjs');
|
const tempPath = path.resolve('/tmp/verstak-plugin-api-contributions-test.mjs');
|
||||||
fs.writeFileSync(tempPath, source);
|
fs.writeFileSync(tempPath, source);
|
||||||
|
|
||||||
|
|
@ -59,6 +76,17 @@ if (!api.contributions || typeof api.contributions.list !== 'function') {
|
||||||
if (!api.commands || typeof api.commands.executeFor !== 'function') {
|
if (!api.commands || typeof api.commands.executeFor !== 'function') {
|
||||||
throw new Error('api.commands.executeFor is missing');
|
throw new Error('api.commands.executeFor is missing');
|
||||||
}
|
}
|
||||||
|
if (!api.i18n || typeof api.i18n.getLocale !== 'function' || typeof api.i18n.t !== 'function' || typeof api.i18n.onDidChangeLocale !== 'function') {
|
||||||
|
throw new Error('api.i18n contract is missing');
|
||||||
|
}
|
||||||
|
if (api.i18n.getLocale() !== 'ru' || api.i18n.t('greeting', { name: 'Мир' }) !== 'Привет, Мир!') {
|
||||||
|
throw new Error('api.i18n locale or translation is incorrect');
|
||||||
|
}
|
||||||
|
let localeNotifications = 0;
|
||||||
|
api.i18n.onDidChangeLocale(() => { localeNotifications += 1; });
|
||||||
|
if (localeNotifications !== 1 || localeListeners.size !== 1) {
|
||||||
|
throw new Error('api.i18n locale subscription was not registered');
|
||||||
|
}
|
||||||
|
|
||||||
const fileActions = await api.contributions.list('fileActions');
|
const fileActions = await api.contributions.list('fileActions');
|
||||||
if (fileActions.length !== 1 || fileActions[0].id !== 'provider.file.action') {
|
if (fileActions.length !== 1 || fileActions[0].id !== 'provider.file.action') {
|
||||||
|
|
@ -88,4 +116,9 @@ if (stored.version !== 1 || stored.workspaceRootPath !== 'Project') {
|
||||||
throw new Error(`unexpected storage data: ${JSON.stringify(stored)}`);
|
throw new Error(`unexpected storage data: ${JSON.stringify(stored)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
api.dispose();
|
||||||
|
if (localeListeners.size !== 0) {
|
||||||
|
throw new Error('api.i18n locale subscription was not disposed');
|
||||||
|
}
|
||||||
|
|
||||||
console.log('plugin api contributions smoke passed');
|
console.log('plugin api contributions smoke passed');
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,8 @@ export function GetPluginCapability(arg1:string,arg2:string):Promise<Record<stri
|
||||||
|
|
||||||
export function GetPluginFrontendInfo(arg1:string):Promise<Record<string, any>>;
|
export function GetPluginFrontendInfo(arg1:string):Promise<Record<string, any>>;
|
||||||
|
|
||||||
|
export function GetPluginLocalization(arg1:string,arg2:string):Promise<Record<string, string>|string>;
|
||||||
|
|
||||||
export function GetPlugins():Promise<Array<plugin.Plugin>>;
|
export function GetPlugins():Promise<Array<plugin.Plugin>>;
|
||||||
|
|
||||||
export function GetVaultFileMetadata(arg1:string,arg2:string):Promise<files.FileMetadata|string>;
|
export function GetVaultFileMetadata(arg1:string,arg2:string):Promise<files.FileMetadata|string>;
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,10 @@ export function GetPluginFrontendInfo(arg1) {
|
||||||
return window['go']['api']['App']['GetPluginFrontendInfo'](arg1);
|
return window['go']['api']['App']['GetPluginFrontendInfo'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetPluginLocalization(arg1, arg2) {
|
||||||
|
return window['go']['api']['App']['GetPluginLocalization'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function GetPlugins() {
|
export function GetPlugins() {
|
||||||
return window['go']['api']['App']['GetPlugins']();
|
return window['go']['api']['App']['GetPlugins']();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -920,6 +920,20 @@ export namespace plugin {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class LocalizationConfig {
|
||||||
|
defaultLocale: string;
|
||||||
|
locales: Record<string, string>;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new LocalizationConfig(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.defaultLocale = source["defaultLocale"];
|
||||||
|
this.locales = source["locales"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class SyncConfig {
|
export class SyncConfig {
|
||||||
namespaces?: string[];
|
namespaces?: string[];
|
||||||
participate?: boolean;
|
participate?: boolean;
|
||||||
|
|
@ -955,6 +969,7 @@ export namespace plugin {
|
||||||
description?: string;
|
description?: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
|
localization?: LocalizationConfig;
|
||||||
provides: string[];
|
provides: string[];
|
||||||
requires?: string[];
|
requires?: string[];
|
||||||
optionalRequires?: string[];
|
optionalRequires?: string[];
|
||||||
|
|
@ -979,6 +994,7 @@ export namespace plugin {
|
||||||
this.description = source["description"];
|
this.description = source["description"];
|
||||||
this.source = source["source"];
|
this.source = source["source"];
|
||||||
this.icon = source["icon"];
|
this.icon = source["icon"];
|
||||||
|
this.localization = this.convertValues(source["localization"], LocalizationConfig);
|
||||||
this.provides = source["provides"];
|
this.provides = source["provides"];
|
||||||
this.requires = source["requires"];
|
this.requires = source["requires"];
|
||||||
this.optionalRequires = source["optionalRequires"];
|
this.optionalRequires = source["optionalRequires"];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue