Author SHA1 Message Date
mirivlad 030f66d806 build: publish portable desktop releases 2026-07-14 01:58:42 +08:00
mirivlad da76b89856 build: use system WebView2 for Windows archive 2026-07-13 22:53:03 +08:00
mirivlad 20952e8477 build: add portable desktop packages 2026-07-13 22:29:58 +08:00
mirivlad f4e911c14a build: add Windows desktop test bundle 2026-07-13 21:46:48 +08:00
mirivlad 47a8d565e8 build: publish desktop releases to GitHub 2026-07-13 05:01:34 +08:00
mirivlad 581e6b27d8 docs: document public alpha setup 2026-07-13 03:57:58 +08:00
mirivlad ca5290015a build: guard desktop release prerequisites 2026-07-12 21:38:24 +08:00
mirivlad ad0c00bade docs: prepare desktop alpha release 2026-07-12 21:21:45 +08:00
mirivlad 4db5a7a132 feat: show plugin diagnostics only in debug mode 2026-07-12 21:18:09 +08:00
mirivlad c567b229cc feat: scope overview to durable activity data 2026-07-12 21:11:53 +08:00
mirivlad 5a249dcf24 feat: persist handled activity session watermarks 2026-07-12 20:48:57 +08:00
mirivlad 6b556c59d9 feat: scope activity and browser links by durable workspace 2026-07-12 20:39:03 +08:00
mirivlad fca9fff4b4 feat: retain archived browser inbox captures 2026-07-12 17:43:12 +08:00
mirivlad bcf49ed32c feat: persist passive browser activity batches 2026-07-12 17:29:07 +08:00
mirivlad 39052ffe39 feat: add canonical hostname normalization 2026-07-12 17:16:53 +08:00
mirivlad e1a6b2af64 feat: publish workspace trash lifecycle 2026-07-12 17:11:00 +08:00
mirivlad 5280d245ee feat: add durable workspace identities 2026-07-12 17:06:34 +08:00
mirivlad 477b372110 fix: persist browser captures atomically 2026-07-11 23:30:38 +08:00
mirivlad c129c1a2ac fix: preserve plugin contribution label contract 2026-07-11 12:56:17 +08:00
mirivlad 7f0cc308c2 feat: add Russian desktop localization 2026-07-11 12:50:00 +08:00
mirivlad 8b1151e03a feat: load declared plugin locale catalogs 2026-07-11 02:02:47 +08:00
mirivlad 15f1f413d0 feat: persist desktop language preference 2026-07-11 02:00:10 +08:00
63 changed files with 5358 additions and 401 deletions
+2 -1
View File
@@ -1,8 +1,9 @@
# .gitignore # .gitignore
frontend/node_modules/ frontend/node_modules/
frontend/dist/ frontend/dist/
build/bin/verstak-desktop build/
smoke-platform smoke-platform
plugins/ plugins/
vendor/ vendor/
frontend/e2e-results/ frontend/e2e-results/
release/
+17
View File
@@ -0,0 +1,17 @@
Verstak Desktop
Copyright (C) 2026 Verstak contributors
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU Affero General Public License as published by the Free
Software Foundation, either version 3 of the License, or (at your option) any
later version.
This program is distributed in the hope that it will be useful, but WITHOUT
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
details.
The complete license text is available at:
https://www.gnu.org/licenses/agpl-3.0.html
SPDX-License-Identifier: AGPL-3.0-or-later
+207 -2
View File
@@ -1,3 +1,208 @@
# verstak-desktop # Verstak
Verstak Core Platform + UI Shell — plugin runtime, capability registry, vault API, event bus, sync client Verstak is a local-first workspace for files, notes, browser captures,
activity and work-journal entries. This repository is the desktop application:
the Go/Wails host and UI shell that loads plugins from a `plugins/` directory
next to its executable.
> **Alpha software.** Use a disposable vault while evaluating it. APIs, storage
> formats and packaging can change before the first stable release.
## Components
The public alpha is split into small repositories. Keep their `main` branches
in the same release line when building from source.
| Component | Repository | What it is for |
| --- | --- | --- |
| Desktop | [mirivlad/verstak](https://github.com/mirivlad/verstak) | This application: vault UI, local host and plugin runtime. |
| Official plugins | [mirivlad/verstak-official-plugins](https://github.com/mirivlad/verstak-official-plugins) | Files, Notes, Browser Inbox, Activity, Journal, Sync, Todo and other first-party plugins. |
| Browser extension | [mirivlad/verstak-browser-extension](https://github.com/mirivlad/verstak-browser-extension) | Manual browser captures and opt-in domain-time activity. |
| Sync server | [mirivlad/verstak-sync-server](https://github.com/mirivlad/verstak-sync-server) | Optional self-hosted synchronization between devices. |
| Plugin SDK | [mirivlad/verstak-sdk](https://github.com/mirivlad/verstak-sdk) | TypeScript API, JSON schemas and contract tests for plugin authors. |
| Architecture documentation | [mirivlad/verstak-docs](https://github.com/mirivlad/verstak-docs) | Product, platform and plugin-system design documents. |
No server account or browser extension is needed for a local desktop vault.
## Build the desktop and official plugins
To build from source, install Go 1.24+, Node.js 20+ with npm, Python 3, the [Wails v2 build
prerequisites](https://wails.io/docs/gettingstarted/installation/), and your
distribution's WebKitGTK development package.
Clone the repositories as siblings. The directory names below are intentional:
the desktop helper finds the official-plugin checkout at
`../verstak-official-plugins`.
```text
verstak-workspace/
├── verstak/
├── verstak-sdk/
├── verstak-official-plugins/
└── verstak-browser-extension/ # optional for browser integration
```
```bash
git clone https://github.com/mirivlad/verstak.git verstak
git clone https://github.com/mirivlad/verstak-sdk.git verstak-sdk
git clone https://github.com/mirivlad/verstak-official-plugins.git verstak-official-plugins
git clone https://github.com/mirivlad/verstak-browser-extension.git verstak-browser-extension
cd verstak-sdk && ./scripts/build.sh
cd ../verstak-official-plugins && ./scripts/build.sh
cd ../verstak
./scripts/install-dev-plugins.sh
./scripts/build.sh
```
`install-dev-plugins.sh` copies the packages from
`../verstak-official-plugins/dist/` into this repository's `plugins/`
directory. `build.sh` then copies them to `build/bin/plugins/`, beside the
desktop executable:
```bash
./build/bin/verstak-desktop
```
For a manually assembled installation, place each unpacked plugin directory
directly in `plugins/` beside `verstak-desktop`; for example,
`plugins/browser-inbox/plugin.json`. Do not put the release archive itself in
that directory. The desktop release archive already includes its matching
`plugins/` directory.
Start with `--debug` to show internal plugin-provider identifiers and write
diagnostic logs:
```bash
./build/bin/verstak-desktop --debug
```
## Portable test artifacts
These commands make local alpha artifacts in `release/`. They do not create a
GitHub Release.
```bash
# Debian 13 / Ubuntu 24.04 or later. APT installs WebKitGTK dependencies.
./scripts/package-deb.sh v0.1.0-alpha.1
# Portable Linux x86_64 AppImage with bundled WebKitGTK runtime and plugins.
./scripts/package-appimage.sh v0.1.0-alpha.1
```
Install the Debian package with `sudo apt install ./release/verstak_*.deb`,
then launch `verstak`. Run the AppImage directly after making it executable:
```bash
chmod +x release/verstak-linux-x86_64-v0.1.0-alpha.1.AppImage
./release/verstak-linux-x86_64-v0.1.0-alpha.1.AppImage
```
On distributions without FUSE support, use
`APPIMAGE_EXTRACT_AND_RUN=1 ./release/verstak-linux-x86_64-v0.1.0-alpha.1.AppImage`.
Build the Windows portable ZIP on Linux with MinGW:
```bash
sudo apt install gcc-mingw-w64-x86-64 zip
./scripts/package-windows-portable.sh v0.1.0-alpha.1
```
The ZIP uses the x64 Evergreen Microsoft WebView2 Runtime installed in Windows
and writes `release/verstak-windows-amd64-<version>.zip`. Extract it to a local
disk (not a network share) and launch `Verstak.cmd`. Windows 11 and most
Windows 10 installations already include this runtime. If Verstak does not
start, install the official [Microsoft WebView2 Runtime x64 standalone
installer](https://go.microsoft.com/fwlink/p/?LinkId=2124701), then launch it
again.
Each format is listed in `release/SHA256SUMS` after packaging.
## First local vault
1. Launch the desktop application and choose or create a writable vault folder.
Verstak stores its local metadata in that vault; do not point it at a
read-only directory.
2. Create a Дело (workspace) in the Files plugin before assigning captures or
activity to it.
3. Open Notes, Files, Activity and Journal as needed. Activity candidates are
only suggestions: a Journal entry and a new Дело are always created by the
user.
## Browser extension
The extension is optional. Build it locally with `npm ci && npm test && npm run
build` in `verstak-browser-extension/`, then load `dist/chromium` as an
unpacked Chromium extension or `dist/firefox` temporarily in Firefox. A signed
Firefox XPI is available from the [extension's GitHub
Releases](https://github.com/mirivlad/verstak-browser-extension/releases).
To connect it to the desktop application:
1. Ensure the `browser-inbox` plugin is installed and open its settings in
Verstak.
2. Copy the displayed Receiver URL and Pairing Token.
3. Paste both values into the extension's settings and save.
4. Use a manual Send Page, selection, link or file action to create a Browser
Inbox capture.
Passive domain activity is disabled by default. When the user explicitly turns
it on, the extension sends only bounded time totals by normalized domain. It
does not send URLs, page titles, page contents, keystrokes, navigation history
or inactive-tab time. The extension settings provide a domain exclusion list.
## Optional sync server
The sync server is self-hosted and is not required for local use. Build and
start a development instance from a sibling checkout:
```bash
git clone https://github.com/mirivlad/verstak-sync-server.git verstak-sync-server
cd verstak-sync-server
./scripts/build.sh
./build/bin/verstak-sync-server --port 47732 --data ./server-data \
--admin-user admin --admin-pass 'choose-a-strong-password'
```
For a second device or a production host, follow the deployment, HTTPS and
backup guidance in the [sync server
README](https://github.com/mirivlad/verstak-sync-server#readme). In the desktop
application, open the Sync plugin, enter the server URL and user credentials,
test the connection, then select **Connect**. Each vault is paired separately.
## Separate plugin archives
The desktop artifacts already include matching official plugins. To distribute
plugins separately, build OS-specific archives in the sibling repository:
```bash
cd ../verstak-official-plugins
sudo apt install gcc-mingw-w64-x86-64 zip
./scripts/package-portable.sh v0.1.0-alpha.1
```
It writes a Linux `tar.gz` and a Windows ZIP. Both archives expand directly
into the `plugins/` directory beside the corresponding desktop executable.
Separate archives are required because `platform-test` includes a native
sidecar for its target OS.
## Publish a GitHub Release
```bash
./scripts/publish-github-release.sh v0.1.0-alpha.1
```
The command requires an authenticated [`gh`](https://cli.github.com/) CLI, a
clean local `main` equal to `origin/main`, and the sibling official-plugins
checkout. It builds the Debian package, Linux AppImage and Windows portable
ZIP, then creates or updates the GitHub Release with those three artifacts and
`SHA256SUMS`. Alpha, beta and release-candidate tags are marked as GitHub
prereleases; stable tags are marked latest. The command creates an annotated
tag when needed and pushes it through `origin`, which also mirrors tags in this
checkout. Publish the compatible official-plugins release before the desktop
release that embeds those plugins.
## License
Copyright © 2026 Verstak contributors. Licensed under
[GNU AGPLv3 or later](LICENSE).
+48
View File
@@ -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');
});
});
+13 -3
View File
@@ -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 = '';
@@ -48,6 +54,7 @@
const name = workspaceName(workspace); const name = workspaceName(workspace);
return { return {
id: name, id: name,
workspaceId: workspace?.id || workspace?.workspaceId || '',
type: workspace?.type || 'space', type: workspace?.type || 'space',
title: workspace?.title || name, title: workspace?.title || name,
name, name,
@@ -212,6 +219,7 @@
const settings = await App.GetAppSettings(); const settings = await App.GetAppSettings();
debug.log('[App] checkVault: GetAppSettings returned', settings); debug.log('[App] checkVault: GetAppSettings returned', settings);
flog('checkVault: GetAppSettings returned'); flog('checkVault: GetAppSettings returned');
if (settings?.debug) debug.enable();
debug.log('[App] checkVault: calling GetVaultStatus...'); debug.log('[App] checkVault: calling GetVaultStatus...');
vaultStatus = await App.GetVaultStatus() || { status: 'unknown', path: '', vaultId: '' }; vaultStatus = await App.GetVaultStatus() || { status: 'unknown', path: '', vaultId: '' };
@@ -375,11 +383,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 />
+225
View File
@@ -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} openProviders',
'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',
};
+225
View File
@@ -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} дн. назад',
};
+200
View File
@@ -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');
@@ -217,12 +236,10 @@ export function createPluginAPI(pluginId) {
if (!key) { if (!key) {
throw new Error('settings.write requires a key'); throw new Error('settings.write requires a key');
} }
const settings = await this.read();
settings[key] = value;
await callBackendErrorString(pluginId, 'settings.write(' + key + ')', function() { await callBackendErrorString(pluginId, 'settings.write(' + key + ')', function() {
return App.WritePluginSettings(pluginId, settings); return App.WritePluginSetting(pluginId, key, value);
}); });
return settings; return this.read();
}, },
writeAll: function(settings) { writeAll: function(settings) {
assertActive('settings.writeAll'); assertActive('settings.writeAll');
@@ -245,6 +262,26 @@ export function createPluginAPI(pluginId) {
return data || {}; return data || {};
}); });
}, },
readNDJSON: function(name) {
assertActive('storage.data.readNDJSON(' + name + ')');
if (!name) {
throw new Error('storage.data.readNDJSON requires a name');
}
return callBackend(pluginId, 'storage.data.readNDJSON(' + name + ')', function() {
return App.ReadPluginDataNDJSON(pluginId, name);
}).then(function(records) {
return Array.isArray(records) ? records : [];
});
},
writeNDJSON: function(name, records) {
assertActive('storage.data.writeNDJSON(' + name + ')');
if (!name) {
throw new Error('storage.data.writeNDJSON requires a name');
}
return callBackendErrorString(pluginId, 'storage.data.writeNDJSON(' + name + ')', function() {
return App.WritePluginDataNDJSON(pluginId, name, Array.isArray(records) ? records : []);
});
},
write: function(name, data) { write: function(name, data) {
assertActive('storage.data.write(' + name + ')'); assertActive('storage.data.write(' + name + ')');
if (!name) { if (!name) {
@@ -392,6 +429,12 @@ export function createPluginAPI(pluginId) {
return App.OpenVaultPathExternal(pluginId, relativePath); return App.OpenVaultPathExternal(pluginId, relativePath);
}); });
}, },
openURL: function(url) {
assertActive('files.openURL');
return callBackendErrorString(pluginId, 'files.openURL', function() {
return App.OpenExternalURL(pluginId, String(url == null ? '' : url));
});
},
showInFolder: function(relativePath) { showInFolder: function(relativePath) {
assertActive('files.showInFolder(' + relativePath + ')'); assertActive('files.showInFolder(' + relativePath + ')');
return callBackendErrorString(pluginId, 'files.showInFolder(' + relativePath + ')', function() { return callBackendErrorString(pluginId, 'files.showInFolder(' + relativePath + ')', function() {
@@ -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>
+43 -19
View File
@@ -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
+36 -10
View File
@@ -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}
+24 -6
View File
@@ -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>
+72 -5
View File
@@ -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;
+79 -63
View File
@@ -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();
} }
@@ -125,6 +136,7 @@
].flatMap(key => normalizeRows(settings?.[key])).filter(item => { ].flatMap(key => normalizeRows(settings?.[key])).filter(item => {
const tagged = String(item.workspaceRootPath || item.workspaceName || item.workspaceNodeId || '').trim(); const tagged = String(item.workspaceRootPath || item.workspaceName || item.workspaceNodeId || '').trim();
if (tagged !== workspace) return false; if (tagged !== workspace) return false;
if (String(item.globalState || 'inbox') === 'archived') return false;
const captureId = String(item.captureId || ''); const captureId = String(item.captureId || '');
if (!captureId || seen.has(captureId)) return !captureId; if (!captureId || seen.has(captureId)) return !captureId;
seen.add(captureId); seen.add(captureId);
@@ -245,17 +257,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 +303,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 +335,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 +345,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 +386,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 +396,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 +412,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 +423,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 +438,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 +493,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'),
}] : [], }] : [],
}; };
} }
@@ -490,9 +502,14 @@
const workspaceAtStart = String(workspaceRootPath || '').trim(); const workspaceAtStart = String(workspaceRootPath || '').trim();
loadedWorkspaceRoot = workspaceAtStart; loadedWorkspaceRoot = workspaceAtStart;
loading = true; loading = true;
const [browserSettings, activitySettings, journalSettings, todoSettings, resources] = await Promise.all([ const [browserSettings, activitySettings, activityRecords, journalSettings, todoSettings, resources] = await Promise.all([
readPluginSettings('verstak.browser-inbox'), readPluginSettings('verstak.browser-inbox'),
readPluginSettings('verstak.activity'), readPluginSettings('verstak.activity'),
App.ReadPluginDataNDJSON
? App.ReadPluginDataNDJSON('verstak.activity', 'activity-events')
.then(value => decodeTuple(value, []))
.catch(() => [])
: Promise.resolve([]),
readPluginSettings('verstak.journal'), readPluginSettings('verstak.journal'),
readPluginSettings('verstak.todo'), readPluginSettings('verstak.todo'),
loadWorkspaceResources(), loadWorkspaceResources(),
@@ -500,11 +517,10 @@
if (workspaceAtStart !== String(workspaceRootPath || '').trim()) return; if (workspaceAtStart !== String(workspaceRootPath || '').trim()) return;
captures = browserCaptureRowsForWorkspace(browserSettings); captures = browserCaptureRowsForWorkspace(browserSettings);
activityEvents = rowsFor(activitySettings, [ activityEvents = normalizeRows(activityRecords).filter(item => {
workspaceKey('events:workspace:'), const tagged = String(item.workspaceRootPath || item.workspaceName || item.workspaceNodeId || '').trim();
'events:global', return !tagged || tagged === workspaceAtStart;
'events', });
]);
journalEntries = rowsFor(journalSettings, [ journalEntries = rowsFor(journalSettings, [
workspaceKey('worklog:workspace:'), workspaceKey('worklog:workspace:'),
'worklog', 'worklog',
@@ -523,24 +539,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 +569,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 +579,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 +604,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 +613,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 +631,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 +651,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 +661,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 +678,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 +686,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}
+28 -20
View File
@@ -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>
+39 -16
View File
@@ -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}
+20 -6
View File
@@ -1,8 +1,18 @@
<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';
import { debug } from '../log/debug.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 +34,30 @@
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> {#if debug.isEnabled() && providerId}
<button class="close-btn btn-ghost btn-icon" type="button" title="Close" aria-label="Close" on:click={closeWorkbench}> <span class="workbench-provider vt-badge accent" data-debug-provider>{providerId}</span>
{/if}
<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 +72,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>
+28 -11
View File
@@ -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],
@@ -36,6 +43,7 @@
$: selectedWorkspace = nodes.find(n => n.id === selectedWorkspaceName || n.name === selectedWorkspaceName || n.rootPath === selectedWorkspaceName) || null; $: selectedWorkspace = nodes.find(n => n.id === selectedWorkspaceName || n.name === selectedWorkspaceName || n.rootPath === selectedWorkspaceName) || null;
$: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || ''; $: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || '';
$: workspaceId = selectedWorkspace?.workspaceId || '';
$: workspaceTitle = selectedWorkspace?.title || selectedWorkspace?.name || selectedWorkspace?.id || selectedWorkspaceName; $: workspaceTitle = selectedWorkspace?.title || selectedWorkspace?.name || selectedWorkspace?.id || selectedWorkspaceName;
$: workspaceType = selectedWorkspace?.type || 'workspace'; $: workspaceType = selectedWorkspace?.type || 'workspace';
$: if (workspaceRootPath !== metadataWorkspaceRoot) { $: if (workspaceRootPath !== metadataWorkspaceRoot) {
@@ -66,10 +74,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 +177,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 +203,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 +224,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
@@ -219,21 +236,21 @@
<PluginBundleHost <PluginBundleHost
pluginId={activeTool.pluginId} pluginId={activeTool.pluginId}
componentId={activeTool.component} componentId={activeTool.component}
componentProps={{ workspaceName: selectedWorkspaceName, workspaceNodeId: selectedWorkspaceName, workspaceNode: selectedWorkspace, workspaceRootPath, toolRequest: activeToolRequest }} componentProps={{ workspaceName: selectedWorkspaceName, workspaceNodeId: selectedWorkspaceName, workspaceNode: selectedWorkspace, workspaceRootPath, workspaceId, toolRequest: activeToolRequest }}
/> />
{/if} {/if}
{/if} {/if}
</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>
+34 -18
View File
@@ -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>
+97 -3
View File
@@ -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] || {}), '']);
}, },
@@ -3276,11 +3341,28 @@
var data = (pluginData[pluginId] && pluginData[pluginId][name]) || {}; var data = (pluginData[pluginId] && pluginData[pluginId][name]) || {};
return Promise.resolve([Object.assign({}, data), '']); return Promise.resolve([Object.assign({}, data), '']);
}, },
ReadPluginDataNDJSON: function (pluginId, name) {
var data = (pluginData[pluginId] && pluginData[pluginId][name]) || [];
if (!Array.isArray(data) && pluginId === 'verstak.activity' && name === 'activity-events') data = [];
if (!data.length && pluginId === 'verstak.activity' && name === 'activity-events') {
data = Object.keys(pluginSettings[pluginId] || {}).filter(function (key) {
return key === 'events' || key === 'events:global' || key.indexOf('events:workspace:') === 0;
}).flatMap(function (key) {
return Array.isArray(pluginSettings[pluginId][key]) ? pluginSettings[pluginId][key] : [];
});
}
return Promise.resolve([Array.isArray(data) ? data.slice() : [], '']);
},
WritePluginDataJSON: function (pluginId, name, data) { WritePluginDataJSON: function (pluginId, name, data) {
pluginData[pluginId] = pluginData[pluginId] || {}; pluginData[pluginId] = pluginData[pluginId] || {};
pluginData[pluginId][name] = Object.assign({}, data || {}); pluginData[pluginId][name] = Object.assign({}, data || {});
return Promise.resolve(''); return Promise.resolve('');
}, },
WritePluginDataNDJSON: function (pluginId, name, records) {
pluginData[pluginId] = pluginData[pluginId] || {};
pluginData[pluginId][name] = Array.isArray(records) ? records.slice() : [];
return Promise.resolve('');
},
OpenWorkbenchResource: function (pluginId, request) { OpenWorkbenchResource: function (pluginId, request) {
return openWorkbenchResource(pluginId, request || {}, ''); return openWorkbenchResource(pluginId, request || {}, '');
}, },
@@ -3613,6 +3695,13 @@
window.__wailsMockExternalOpens = externalOpens.slice(); window.__wailsMockExternalOpens = externalOpens.slice();
return Promise.resolve(''); return Promise.resolve('');
}, },
OpenExternalURL: function (pluginId, rawURL) {
var err = requirePluginPermission(pluginId, 'files.openExternal');
if (err) return Promise.resolve(err);
externalOpens.push({ action: 'url', path: String(rawURL || '') });
window.__wailsMockExternalOpens = externalOpens.slice();
return Promise.resolve('');
},
ShowVaultPathInFolder: function (pluginId, relativePath) { ShowVaultPathInFolder: function (pluginId, relativePath) {
var err = requirePluginPermission(pluginId, 'files.openExternal'); var err = requirePluginPermission(pluginId, 'files.openExternal');
if (err) return Promise.resolve(err); if (err) return Promise.resolve(err);
@@ -3736,7 +3825,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 +4159,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' } };
+28 -2
View File
@@ -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;
+112
View File
@@ -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');
+16
View File
@@ -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>;
@@ -72,6 +74,8 @@ export function ListVaultTrash(arg1:string):Promise<Array<files.TrashEntry>|stri
export function ListWorkspaces():Promise<Array<workspace.Workspace>|string>; export function ListWorkspaces():Promise<Array<workspace.Workspace>|string>;
export function ListWorkspaceIdentities():Promise<Array<workspace.WorkspaceIdentity>|string>;
export function ListWorkspaceTemplates():Promise<Array<workspace.WorkspaceTemplate>|string>; export function ListWorkspaceTemplates():Promise<Array<workspace.WorkspaceTemplate>|string>;
export function MoveVaultPath(arg1:string,arg2:string,arg3:string,arg4:files.MoveOptions):Promise<string>; export function MoveVaultPath(arg1:string,arg2:string,arg3:string,arg4:files.MoveOptions):Promise<string>;
@@ -82,6 +86,8 @@ export function OpenVault(arg1:string):Promise<void>;
export function OpenVaultPathExternal(arg1:string,arg2:string):Promise<string>; export function OpenVaultPathExternal(arg1:string,arg2:string):Promise<string>;
export function OpenExternalURL(arg1:string,arg2:string):Promise<string>;
export function OpenWorkbenchResource(arg1:string,arg2:Record<string, any>):Promise<workbench.OpenResourceResult|string>; export function OpenWorkbenchResource(arg1:string,arg2:Record<string, any>):Promise<workbench.OpenResourceResult|string>;
export function PluginBrowserReceiverPairing(arg1:string):Promise<Record<string, string>|string>; export function PluginBrowserReceiverPairing(arg1:string):Promise<Record<string, string>|string>;
@@ -120,6 +126,8 @@ export function PublishPluginEvent(arg1:string,arg2:string,arg3:Record<string, a
export function ReadPluginDataJSON(arg1:string,arg2:string):Promise<Record<string, any>>; export function ReadPluginDataJSON(arg1:string,arg2:string):Promise<Record<string, any>>;
export function ReadPluginDataNDJSON(arg1:string,arg2:string):Promise<Array<Record<string, any>>>;
export function ReadPluginSetting(arg1:string,arg2:string):Promise<any>; export function ReadPluginSetting(arg1:string,arg2:string):Promise<any>;
export function ReadPluginSettings(arg1:string):Promise<Record<string, any>|string>; export function ReadPluginSettings(arg1:string):Promise<Record<string, any>|string>;
@@ -134,12 +142,18 @@ export function ReloadPlugins():Promise<number|string>;
export function RenameWorkspace(arg1:string,arg2:string):Promise<string>; export function RenameWorkspace(arg1:string,arg2:string):Promise<string>;
export function RepairWorkspaceIdentity(arg1:string,arg2:string):Promise<string>;
export function RenameWorkspaceNode(arg1:string,arg2:string):Promise<string>; export function RenameWorkspaceNode(arg1:string,arg2:string):Promise<string>;
export function RestoreVaultTrash(arg1:string,arg2:string,arg3:files.RestoreOptions):Promise<string|string>; export function RestoreVaultTrash(arg1:string,arg2:string,arg3:files.RestoreOptions):Promise<string|string>;
export function RestoreWorkspaceTrash(arg1:string,arg2:string):Promise<workspace.Workspace|string>;
export function SelectDirectory():Promise<string>; export function SelectDirectory():Promise<string>;
export function PurgeWorkspaceTrash(arg1:string):Promise<string>;
export function SelectVaultForOpen():Promise<string>; export function SelectVaultForOpen():Promise<string>;
export function SetCurrentVault(arg1:string):Promise<string>; export function SetCurrentVault(arg1:string):Promise<string>;
@@ -166,6 +180,8 @@ export function WriteFrontendLog(arg1:string,arg2:string):Promise<void>;
export function WritePluginDataJSON(arg1:string,arg2:string,arg3:Record<string, any>):Promise<string>; export function WritePluginDataJSON(arg1:string,arg2:string,arg3:Record<string, any>):Promise<string>;
export function WritePluginDataNDJSON(arg1:string,arg2:string,arg3:Array<Record<string, any>>):Promise<string>;
export function WritePluginSetting(arg1:string,arg2:string,arg3:any):Promise<string>; export function WritePluginSetting(arg1:string,arg2:string,arg3:any):Promise<string>;
export function WritePluginSettings(arg1:string,arg2:Record<string, any>):Promise<string>; export function WritePluginSettings(arg1:string,arg2:Record<string, any>):Promise<string>;
+32
View File
@@ -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']();
} }
@@ -130,6 +134,10 @@ export function ListWorkspaces() {
return window['go']['api']['App']['ListWorkspaces'](); return window['go']['api']['App']['ListWorkspaces']();
} }
export function ListWorkspaceIdentities() {
return window['go']['api']['App']['ListWorkspaceIdentities']();
}
export function ListWorkspaceTemplates() { export function ListWorkspaceTemplates() {
return window['go']['api']['App']['ListWorkspaceTemplates'](); return window['go']['api']['App']['ListWorkspaceTemplates']();
} }
@@ -150,6 +158,10 @@ export function OpenVaultPathExternal(arg1, arg2) {
return window['go']['api']['App']['OpenVaultPathExternal'](arg1, arg2); return window['go']['api']['App']['OpenVaultPathExternal'](arg1, arg2);
} }
export function OpenExternalURL(arg1, arg2) {
return window['go']['api']['App']['OpenExternalURL'](arg1, arg2);
}
export function OpenWorkbenchResource(arg1, arg2) { export function OpenWorkbenchResource(arg1, arg2) {
return window['go']['api']['App']['OpenWorkbenchResource'](arg1, arg2); return window['go']['api']['App']['OpenWorkbenchResource'](arg1, arg2);
} }
@@ -226,6 +238,10 @@ export function ReadPluginDataJSON(arg1, arg2) {
return window['go']['api']['App']['ReadPluginDataJSON'](arg1, arg2); return window['go']['api']['App']['ReadPluginDataJSON'](arg1, arg2);
} }
export function ReadPluginDataNDJSON(arg1, arg2) {
return window['go']['api']['App']['ReadPluginDataNDJSON'](arg1, arg2);
}
export function ReadPluginSetting(arg1, arg2) { export function ReadPluginSetting(arg1, arg2) {
return window['go']['api']['App']['ReadPluginSetting'](arg1, arg2); return window['go']['api']['App']['ReadPluginSetting'](arg1, arg2);
} }
@@ -254,6 +270,10 @@ export function RenameWorkspace(arg1, arg2) {
return window['go']['api']['App']['RenameWorkspace'](arg1, arg2); return window['go']['api']['App']['RenameWorkspace'](arg1, arg2);
} }
export function RepairWorkspaceIdentity(arg1, arg2) {
return window['go']['api']['App']['RepairWorkspaceIdentity'](arg1, arg2);
}
export function RenameWorkspaceNode(arg1, arg2) { export function RenameWorkspaceNode(arg1, arg2) {
return window['go']['api']['App']['RenameWorkspaceNode'](arg1, arg2); return window['go']['api']['App']['RenameWorkspaceNode'](arg1, arg2);
} }
@@ -262,10 +282,18 @@ export function RestoreVaultTrash(arg1, arg2, arg3) {
return window['go']['api']['App']['RestoreVaultTrash'](arg1, arg2, arg3); return window['go']['api']['App']['RestoreVaultTrash'](arg1, arg2, arg3);
} }
export function RestoreWorkspaceTrash(arg1, arg2) {
return window['go']['api']['App']['RestoreWorkspaceTrash'](arg1, arg2);
}
export function SelectDirectory() { export function SelectDirectory() {
return window['go']['api']['App']['SelectDirectory'](); return window['go']['api']['App']['SelectDirectory']();
} }
export function PurgeWorkspaceTrash(arg1) {
return window['go']['api']['App']['PurgeWorkspaceTrash'](arg1);
}
export function SelectVaultForOpen() { export function SelectVaultForOpen() {
return window['go']['api']['App']['SelectVaultForOpen'](); return window['go']['api']['App']['SelectVaultForOpen']();
} }
@@ -318,6 +346,10 @@ export function WritePluginDataJSON(arg1, arg2, arg3) {
return window['go']['api']['App']['WritePluginDataJSON'](arg1, arg2, arg3); return window['go']['api']['App']['WritePluginDataJSON'](arg1, arg2, arg3);
} }
export function WritePluginDataNDJSON(arg1, arg2, arg3) {
return window['go']['api']['App']['WritePluginDataNDJSON'](arg1, arg2, arg3);
}
export function WritePluginSetting(arg1, arg2, arg3) { export function WritePluginSetting(arg1, arg2, arg3) {
return window['go']['api']['App']['WritePluginSetting'](arg1, arg2, arg3); return window['go']['api']['App']['WritePluginSetting'](arg1, arg2, arg3);
} }
+39
View File
@@ -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"];
@@ -1256,6 +1272,7 @@ export namespace workspace {
} }
} }
export class Metadata { export class Metadata {
workspaceId?: string;
workspaceName: string; workspaceName: string;
createdFromTemplate?: TemplateSnapshot; createdFromTemplate?: TemplateSnapshot;
features?: Record<string, boolean>; features?: Record<string, boolean>;
@@ -1269,6 +1286,7 @@ export namespace workspace {
constructor(source: any = {}) { constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.workspaceId = source["workspaceId"];
this.workspaceName = source["workspaceName"]; this.workspaceName = source["workspaceName"];
this.createdFromTemplate = this.convertValues(source["createdFromTemplate"], TemplateSnapshot); this.createdFromTemplate = this.convertValues(source["createdFromTemplate"], TemplateSnapshot);
this.features = source["features"]; this.features = source["features"];
@@ -1311,6 +1329,7 @@ export namespace workspace {
} }
export class TrashResult { export class TrashResult {
workspaceId: string;
originalPath: string; originalPath: string;
trashPath: string; trashPath: string;
trashId: string; trashId: string;
@@ -1322,6 +1341,7 @@ export namespace workspace {
constructor(source: any = {}) { constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.workspaceId = source["workspaceId"];
this.originalPath = source["originalPath"]; this.originalPath = source["originalPath"];
this.trashPath = source["trashPath"]; this.trashPath = source["trashPath"];
this.trashId = source["trashId"]; this.trashId = source["trashId"];
@@ -1329,6 +1349,7 @@ export namespace workspace {
} }
} }
export class Workspace { export class Workspace {
id: string;
name: string; name: string;
rootPath: string; rootPath: string;
@@ -1338,9 +1359,27 @@ export namespace workspace {
constructor(source: any = {}) { constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"]; this.name = source["name"];
this.rootPath = source["rootPath"]; this.rootPath = source["rootPath"];
} }
} }
export class WorkspaceIdentity {
workspaceId: string;
rootPath: string;
state: string;
static createFrom(source: any = {}) {
return new WorkspaceIdentity(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.workspaceId = source["workspaceId"];
this.rootPath = source["rootPath"];
this.state = source["state"];
}
}
} }
+1 -1
View File
@@ -5,6 +5,7 @@ go 1.24.4
require ( require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/wailsapp/wails/v2 v2.12.0 github.com/wailsapp/wails/v2 v2.12.0
golang.org/x/net v0.35.0
) )
require ( require (
@@ -32,7 +33,6 @@ require (
github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.33.0 // indirect golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect golang.org/x/text v0.22.0 // indirect
) )
+676 -75
View File
@@ -9,7 +9,9 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"sync/atomic"
"time" "time"
"github.com/wailsapp/wails/v2/pkg/runtime" "github.com/wailsapp/wails/v2/pkg/runtime"
@@ -38,36 +40,50 @@ var newSyncClient = syncsvc.NewClient
var emitFrontendEvent = runtime.EventsEmit var emitFrontendEvent = runtime.EventsEmit
const pluginEventRuntimeName = "verstak:plugin-event" const pluginEventRuntimeName = "verstak:plugin-event"
const activityGlobalKey = "events:global" const activityPluginID = "verstak.activity"
const activityWorkspacePrefix = "events:workspace:" const activityRawDataName = "activity-events"
const maxActivityEvents = 250 const activitySessionHandlingKey = "activity-session-handling-v2"
const activitySessionHandledEvent = "activity.session.handled"
const maxActivityRawEvents = 10000
const maxActivityRawBytes = 8 * 1024 * 1024
const activityRetention = 60 * 24 * time.Hour
const browserInboxPluginID = "verstak.browser-inbox"
const browserInboxGlobalKey = "captures:global"
const browserInboxLegacyKey = "captures"
const browserInboxWorkspacePrefix = "captures:workspace:"
const browserInboxMutationEvent = "browser-inbox.storage.mutate"
const maxBrowserInboxCaptures = 100
const workspaceCreatedEventName = "workspace.created" const workspaceCreatedEventName = "workspace.created"
const workspaceRenamedEventName = "workspace.renamed" const workspaceRenamedEventName = "workspace.renamed"
const workspaceTrashedEventName = "workspace.trashed" const workspaceTrashedEventName = "workspace.trashed"
const workspaceRestoredEventName = "workspace.restored"
const workspacePurgedEventName = "workspace.purged"
const workspaceSelectedEventName = "workspace.selected" const workspaceSelectedEventName = "workspace.selected"
// App is the main application struct exposed to the Wails frontend. // App is the main application struct exposed to the Wails frontend.
type App struct { type App struct {
ctx context.Context ctx context.Context
capRegistry *capability.Registry capRegistry *capability.Registry
contribRegistry *contribution.Registry contribRegistry *contribution.Registry
permRegistry *permissions.Registry permRegistry *permissions.Registry
eventBus *events.Bus eventBus *events.Bus
plugins []plugin.Plugin plugins []plugin.Plugin
vault *vault.Vault vault *vault.Vault
storage *storage.Storage storage *storage.Storage
files *corefiles.Service files *corefiles.Service
externalOpen externalOpenService externalOpen externalOpenService
appSettings *appsettings.Manager appSettings *appsettings.Manager
pluginState *pluginstate.Manager pluginState *pluginstate.Manager
workbench *coreworkbench.Router workbench *coreworkbench.Router
workspace *workspace.Manager workspace *workspace.Manager
syncSvc *syncsvc.Service syncSvc *syncsvc.Service
browserReceiver *browserreceiver.Receiver browserReceiver *browserreceiver.Receiver
secretsSession *coresecrets.VaultSession secretsSession *coresecrets.VaultSession
fileWatcher *filewatcher.Service fileWatcher *filewatcher.Service
debug bool debug bool
activityEvents map[string]bool activityEvents map[string]bool
browserInboxEvents map[string]bool
browserInboxEnabled atomic.Bool
} }
type externalOpenService interface { type externalOpenService interface {
@@ -93,29 +109,35 @@ func NewApp(
debugEnabled bool, debugEnabled bool,
) *App { ) *App {
app := &App{ app := &App{
capRegistry: capReg, capRegistry: capReg,
contribRegistry: contribReg, contribRegistry: contribReg,
permRegistry: permReg, permRegistry: permReg,
eventBus: bus, eventBus: bus,
plugins: plugins, plugins: plugins,
vault: vaultService, vault: vaultService,
storage: storageService, storage: storageService,
files: filesService, files: filesService,
externalOpen: externalopen.NewService(), externalOpen: externalopen.NewService(),
appSettings: appSettingsMgr, appSettings: appSettingsMgr,
pluginState: pluginStateMgr, pluginState: pluginStateMgr,
workbench: coreworkbench.NewRouter(workbenchPrefsFromSettings(appSettingsMgr)), workbench: coreworkbench.NewRouter(workbenchPrefsFromSettings(appSettingsMgr)),
workspace: workspaceMgr, workspace: workspaceMgr,
syncSvc: syncService, syncSvc: syncService,
browserReceiver: browserReceiverService, browserReceiver: browserReceiverService,
fileWatcher: filewatcher.NewService(bus, 0), fileWatcher: filewatcher.NewService(bus, 0),
debug: debugEnabled, debug: debugEnabled,
activityEvents: make(map[string]bool), activityEvents: make(map[string]bool),
browserInboxEvents: make(map[string]bool),
} }
if app.syncSvc == nil { if app.syncSvc == nil {
app.rebindSyncService() app.rebindSyncService()
} }
app.ensureActivityProviderSubscriptions() app.ensureActivityProviderSubscriptions()
app.ensureBrowserInboxSubscriptions()
if app.browserReceiver != nil {
app.browserReceiver.SetPersistence(app.browserInboxAvailable, app.recordBrowserCapture)
app.browserReceiver.SetActivityPersistence(app.activityAvailable, app.recordBrowserActivityBatch)
}
app.startFileWatcherForOpenVault() app.startFileWatcherForOpenVault()
return app return app
} }
@@ -151,9 +173,337 @@ func (a *App) ensureWorkbench() *coreworkbench.Router {
func (a *App) Startup(ctx context.Context) { func (a *App) Startup(ctx context.Context) {
a.ctx = ctx a.ctx = ctx
a.ensureActivityProviderSubscriptions() a.ensureActivityProviderSubscriptions()
a.ensureBrowserInboxSubscriptions()
log.Printf("[api] App.Startup: initialized with %d plugins", len(a.plugins)) log.Printf("[api] App.Startup: initialized with %d plugins", len(a.plugins))
} }
func (a *App) ensureBrowserInboxSubscriptions() {
if a.eventBus == nil || a.storage == nil {
a.browserInboxEnabled.Store(false)
return
}
if _, err := a.requirePluginAccess(browserInboxPluginID, "storage.namespace"); err != nil {
a.browserInboxEnabled.Store(false)
return
}
a.browserInboxEnabled.Store(true)
if a.browserInboxEvents == nil {
a.browserInboxEvents = make(map[string]bool)
}
for _, eventName := range []string{browserInboxMutationEvent, workspaceRenamedEventName, workspaceTrashedEventName, workspaceRestoredEventName, workspacePurgedEventName} {
if a.browserInboxEvents[eventName] {
continue
}
a.browserInboxEvents[eventName] = true
a.eventBus.Subscribe(eventName, func(event events.Event) {
if event.Name == browserInboxMutationEvent {
if err := a.mutateBrowserInboxCapture(event); err != nil {
log.Printf("[api] browser inbox mutation failed: %v", err)
}
return
}
if err := a.updateBrowserInboxWorkspaceLifecycle(event); err != nil {
log.Printf("[api] browser inbox workspace lifecycle failed: %v", err)
}
})
}
}
func (a *App) browserInboxAvailable() bool {
if a == nil || a.storage == nil || a.vault == nil || !a.browserInboxEnabled.Load() || a.vault.GetVaultStatus() != vault.StatusOpen {
return false
}
return true
}
func (a *App) activityAvailable() bool {
if a == nil || a.storage == nil || a.vault == nil || a.vault.GetVaultStatus() != vault.StatusOpen {
return false
}
_, err := a.requirePluginAccess(activityPluginID, "storage.namespace")
return err == nil
}
func (a *App) recordBrowserActivityBatch(event events.Event) error {
if !a.activityAvailable() {
return fmt.Errorf("activity storage unavailable")
}
payload := eventPayloadMap(event.Payload)
batchID := firstPayloadText(payload, "batchId")
if batchID == "" {
return fmt.Errorf("batchId is empty")
}
entries, ok := payload["entries"].([]map[string]interface{})
if !ok || len(entries) == 0 {
return fmt.Errorf("activity batch entries are empty")
}
now := time.Now().UTC().Format(time.RFC3339Nano)
receivedAt := event.Timestamp
if receivedAt == "" {
receivedAt = now
}
records := make([]map[string]interface{}, 0, len(entries))
for index, entry := range entries {
hostname := firstPayloadText(entry, "hostname")
endedAt := firstPayloadText(entry, "endedAt")
if hostname == "" || endedAt == "" {
return fmt.Errorf("activity batch entry %d is invalid", index)
}
durationSeconds, _ := entry["durationSeconds"].(int64)
if durationSeconds == 0 {
if number, ok := entry["durationSeconds"].(float64); ok {
durationSeconds = int64(number)
}
}
records = append(records, map[string]interface{}{
"activityId": fmt.Sprintf("browser-domain:%s:%d", batchID, index),
"type": "browser.activity.domain",
"title": hostname,
"summary": fmt.Sprintf("%d min browser activity", durationSeconds/60),
"occurredAt": endedAt,
"receivedAt": receivedAt,
"sourcePluginId": "verstak-browser-extension",
"sourceBatchId": batchID,
"hostname": hostname,
"startedAt": firstPayloadText(entry, "startedAt"),
"endedAt": endedAt,
"durationSeconds": durationSeconds,
"workspaceRootPath": "",
"payload": map[string]interface{}{
"hostname": hostname,
"startedAt": firstPayloadText(entry, "startedAt"),
"endedAt": endedAt,
"durationSeconds": durationSeconds,
},
})
}
_, err := a.storage.AppendPluginDataNDJSON(activityPluginID, activityRawDataName, records, storage.NDJSONRetention{
TimestampField: "occurredAt",
MaxAge: activityRetention,
MaxEntries: maxActivityRawEvents,
MaxBytes: maxActivityRawBytes,
DeduplicateField: "sourceBatchId",
DeduplicateValue: batchID,
})
return err
}
func (a *App) recordBrowserCapture(event events.Event) error {
if !a.browserInboxAvailable() {
return fmt.Errorf("browser inbox unavailable")
}
capture := eventPayloadMap(event.Payload)
captureID := firstPayloadText(capture, "captureId")
if captureID == "" {
return fmt.Errorf("captureId is empty")
}
if firstPayloadText(capture, "kind") == "" {
capture["kind"] = strings.TrimPrefix(event.Name, "browser.capture.")
}
if firstPayloadText(capture, "capturedAt") == "" {
capture["capturedAt"] = event.Timestamp
}
if firstPayloadText(capture, "globalState") == "" {
capture["globalState"] = "inbox"
}
a.annotateBrowserCaptureWorkspace(capture)
capture["receivedAt"] = time.Now().UTC().Format(time.RFC3339Nano)
return a.updateBrowserInboxCaptures(func(captures []map[string]interface{}) []map[string]interface{} {
for _, stored := range captures {
if firstPayloadText(stored, "captureId") == captureID {
return captures
}
}
result := []map[string]interface{}{capture}
result = append(result, captures...)
return result
})
}
func (a *App) mutateBrowserInboxCapture(event events.Event) error {
payload := eventPayloadMap(event.Payload)
if firstPayloadText(payload, "pluginId") != browserInboxPluginID {
return fmt.Errorf("browser inbox mutation source is not authorized")
}
action := firstPayloadText(payload, "action")
switch action {
case "migrate", "assign", "archive", "restore", "delete", "processed":
default:
return fmt.Errorf("unsupported browser inbox mutation %q", action)
}
captureID := firstPayloadText(payload, "captureId")
captureIDs := make(map[string]bool)
if captureID != "" {
captureIDs[captureID] = true
}
if items, ok := payload["captureIds"].([]interface{}); ok {
for _, item := range items {
if id, ok := item.(string); ok && strings.TrimSpace(id) != "" {
captureIDs[strings.TrimSpace(id)] = true
}
}
}
if action != "migrate" && len(captureIDs) == 0 {
return fmt.Errorf("captureId is empty")
}
return a.updateBrowserInboxCaptures(func(captures []map[string]interface{}) []map[string]interface{} {
result := make([]map[string]interface{}, 0, len(captures))
for _, capture := range captures {
storedID := firstPayloadText(capture, "captureId")
if !captureIDs[storedID] {
result = append(result, capture)
continue
}
switch action {
case "delete":
continue
case "archive":
capture["globalState"] = "archived"
case "restore":
capture["globalState"] = "inbox"
case "assign":
workspaceRoot := firstPayloadText(payload, "workspaceRootPath")
capture["workspaceRootPath"] = workspaceRoot
capture["workspaceName"] = workspaceRoot
delete(capture, "workspaceId")
delete(capture, "workspaceTrashId")
a.annotateBrowserCaptureWorkspace(capture)
case "processed":
capture["processed"], _ = payload["processed"].(bool)
}
result = append(result, capture)
}
return result
})
}
func (a *App) annotateBrowserCaptureWorkspace(capture map[string]interface{}) {
if capture == nil {
return
}
workspaceRoot := firstPayloadText(capture, "workspaceRootPath")
if workspaceRoot == "" {
capture["workspaceState"] = "unassigned"
delete(capture, "workspaceId")
delete(capture, "workspaceTrashId")
return
}
if firstPayloadText(capture, "workspaceId") != "" {
if firstPayloadText(capture, "workspaceState") == "" {
capture["workspaceState"] = "active"
}
return
}
if a.workspace == nil {
capture["workspaceState"] = "unavailable"
return
}
identity, err := a.workspace.GetWorkspaceIdentity(workspaceRoot)
if err != nil {
capture["workspaceState"] = "unavailable"
return
}
capture["workspaceId"] = identity.WorkspaceID
capture["workspaceRootPath"] = identity.RootPath
capture["workspaceName"] = identity.RootPath
capture["workspaceState"] = identity.State
}
func (a *App) updateBrowserInboxWorkspaceLifecycle(event events.Event) error {
payload := eventPayloadMap(event.Payload)
workspaceID := firstPayloadText(payload, "workspaceId")
if workspaceID == "" {
return nil
}
return a.updateBrowserInboxCaptures(func(captures []map[string]interface{}) []map[string]interface{} {
for _, capture := range captures {
if firstPayloadText(capture, "workspaceId") != workspaceID {
continue
}
switch event.Name {
case workspaceRenamedEventName, workspaceRestoredEventName:
capture["workspaceRootPath"] = firstPayloadText(payload, "workspaceRootPath")
capture["workspaceName"] = firstPayloadText(payload, "workspaceName", "workspaceRootPath")
capture["workspaceState"] = "active"
delete(capture, "workspaceTrashId")
case workspaceTrashedEventName:
capture["workspaceState"] = "trashed"
capture["workspaceTrashId"] = firstPayloadText(payload, "trashId")
case workspacePurgedEventName:
capture["workspaceState"] = "orphaned"
delete(capture, "workspaceTrashId")
}
}
return captures
})
}
func (a *App) updateBrowserInboxCaptures(update func([]map[string]interface{}) []map[string]interface{}) error {
if !a.browserInboxAvailable() {
return fmt.Errorf("browser inbox unavailable")
}
return a.storage.UpdatePluginSettings(browserInboxPluginID, func(settings map[string]interface{}) error {
captures, legacyKeys := browserInboxCaptures(settings)
captures = update(captures)
if len(captures) > maxBrowserInboxCaptures {
captures = captures[:maxBrowserInboxCaptures]
}
stored := make([]interface{}, 0, len(captures))
for _, capture := range captures {
stored = append(stored, capture)
}
settings[browserInboxGlobalKey] = stored
for _, key := range legacyKeys {
settings[key] = []interface{}{}
}
return nil
})
}
func browserInboxCaptures(settings map[string]interface{}) ([]map[string]interface{}, []string) {
keys := []string{browserInboxGlobalKey, browserInboxLegacyKey}
for key := range settings {
if strings.HasPrefix(key, browserInboxWorkspacePrefix) {
keys = append(keys, key)
}
}
sort.Strings(keys[2:])
seen := make(map[string]bool)
legacyKeys := make([]string, 0, len(keys)-1)
var captures []map[string]interface{}
for _, key := range keys {
if key != browserInboxGlobalKey {
legacyKeys = append(legacyKeys, key)
}
workspaceRoot := ""
if strings.HasPrefix(key, browserInboxWorkspacePrefix) {
workspaceRoot, _ = url.PathUnescape(strings.TrimPrefix(key, browserInboxWorkspacePrefix))
}
items, _ := settings[key].([]interface{})
for _, item := range items {
original, ok := item.(map[string]interface{})
if !ok {
continue
}
capture := eventPayloadMap(original)
captureID := firstPayloadText(capture, "captureId")
if captureID == "" || seen[captureID] {
continue
}
seen[captureID] = true
if firstPayloadText(capture, "globalState") == "" {
capture["globalState"] = "inbox"
}
if firstPayloadText(capture, "workspaceRootPath") == "" && workspaceRoot != "" {
capture["workspaceRootPath"] = workspaceRoot
capture["workspaceName"] = workspaceRoot
}
captures = append(captures, capture)
}
}
return captures, legacyKeys
}
func (a *App) findPlugin(pluginID string) (*plugin.Plugin, error) { func (a *App) findPlugin(pluginID string) (*plugin.Plugin, error) {
for i := range a.plugins { for i := range a.plugins {
if a.plugins[i].Manifest.ID == pluginID { if a.plugins[i].Manifest.ID == pluginID {
@@ -216,6 +566,46 @@ func (a *App) ensureActivityProviderSubscriptions() {
}) })
} }
} }
if !a.activityEvents[activitySessionHandledEvent] {
a.activityEvents[activitySessionHandledEvent] = true
a.eventBus.Subscribe(activitySessionHandledEvent, func(event events.Event) {
if err := a.recordActivitySessionHandled(event); err != nil {
log.Printf("[api] activity session handling update failed: %v", err)
}
})
}
}
func (a *App) recordActivitySessionHandled(event events.Event) error {
if !a.activityAvailable() {
return fmt.Errorf("activity storage unavailable")
}
payload := eventPayloadMap(event.Payload)
if firstPayloadText(payload, "pluginId") != "verstak.journal" {
return fmt.Errorf("activity session handling source is not authorized")
}
sessionID := firstPayloadText(payload, "sessionId")
handledThrough := firstPayloadText(payload, "handledThrough")
status := firstPayloadText(payload, "status")
if sessionID == "" || handledThrough == "" || (status != "accepted" && status != "dismissed") {
return fmt.Errorf("activity session handling payload is invalid")
}
if _, err := time.Parse(time.RFC3339, handledThrough); err != nil {
return fmt.Errorf("activity session handledThrough is invalid")
}
return a.storage.UpdatePluginSettings(activityPluginID, func(settings map[string]interface{}) error {
handled, _ := settings[activitySessionHandlingKey].(map[string]interface{})
if handled == nil {
handled = make(map[string]interface{})
}
handled[sessionID] = map[string]interface{}{
"status": status,
"handledThrough": handledThrough,
"handledAt": time.Now().UTC().Format(time.RFC3339Nano),
}
settings[activitySessionHandlingKey] = handled
return nil
})
} }
func (a *App) recordActivityProviderEvent(event events.Event) { func (a *App) recordActivityProviderEvent(event events.Event) {
@@ -229,35 +619,20 @@ func (a *App) recordActivityProviderEvent(event events.Event) {
if _, err := a.requirePluginAccess(provider.PluginID, "storage.namespace"); err != nil { if _, err := a.requirePluginAccess(provider.PluginID, "storage.namespace"); err != nil {
continue continue
} }
if err := a.appendActivityEvent(provider.PluginID, activityFromEvent(event)); err != nil { if err := a.appendActivityEvent(provider.PluginID, a.activityFromEvent(event)); err != nil {
log.Printf("[api] activity provider %s failed to record %s: %v", provider.PluginID, event.Name, err) log.Printf("[api] activity provider %s failed to record %s: %v", provider.PluginID, event.Name, err)
} }
} }
} }
func (a *App) appendActivityEvent(pluginID string, activity map[string]interface{}) error { func (a *App) appendActivityEvent(pluginID string, activity map[string]interface{}) error {
settings, err := a.storage.ReadPluginSettings(pluginID) _, err := a.storage.AppendPluginDataNDJSON(pluginID, activityRawDataName, []map[string]interface{}{activity}, storage.NDJSONRetention{
if err != nil { TimestampField: "occurredAt",
return err MaxAge: activityRetention,
} MaxEntries: maxActivityRawEvents,
key := activityGlobalKey MaxBytes: maxActivityRawBytes,
if workspace, _ := activity["workspaceRootPath"].(string); strings.TrimSpace(workspace) != "" { })
key = activityWorkspacePrefix + url.QueryEscape(strings.TrimSpace(workspace)) return err
key = strings.ReplaceAll(key, "+", "%20")
}
eventsList := []interface{}{activity}
if existing, ok := settings[key].([]interface{}); ok {
eventsList = append(eventsList, existing...)
} else if existingMaps, ok := settings[key].([]map[string]interface{}); ok {
for _, item := range existingMaps {
eventsList = append(eventsList, item)
}
}
if len(eventsList) > maxActivityEvents {
eventsList = eventsList[:maxActivityEvents]
}
settings[key] = eventsList
return a.storage.WritePluginSettings(pluginID, settings)
} }
func activityFromEvent(event events.Event) map[string]interface{} { func activityFromEvent(event events.Event) map[string]interface{} {
@@ -289,6 +664,24 @@ func activityFromEvent(event events.Event) map[string]interface{} {
} }
} }
func (a *App) activityFromEvent(event events.Event) map[string]interface{} {
activity := activityFromEvent(event)
workspaceRoot := firstPayloadText(activity, "workspaceRootPath")
if workspaceRoot == "" || a == nil || a.workspace == nil {
activity["sessionScope"] = map[string]interface{}{"kind": "unassigned"}
return activity
}
identity, err := a.workspace.GetWorkspaceIdentity(workspaceRoot)
if err != nil {
activity["sessionScope"] = map[string]interface{}{"kind": "unassigned"}
return activity
}
activity["workspaceId"] = identity.WorkspaceID
activity["workspaceRootPath"] = identity.RootPath
activity["sessionScope"] = map[string]interface{}{"kind": "workspace", "workspaceId": identity.WorkspaceID}
return activity
}
func eventPayloadMap(payload interface{}) map[string]interface{} { func eventPayloadMap(payload interface{}) map[string]interface{} {
switch value := payload.(type) { switch value := payload.(type) {
case map[string]interface{}: case map[string]interface{}:
@@ -646,6 +1039,7 @@ func (a *App) ReloadPlugins() (int, string) {
a.plugins = plugins a.plugins = plugins
a.ensureActivityProviderSubscriptions() a.ensureActivityProviderSubscriptions()
a.ensureBrowserInboxSubscriptions()
var buf strings.Builder var buf strings.Builder
buf.WriteString("discovery complete") buf.WriteString("discovery complete")
@@ -823,6 +1217,40 @@ func (a *App) ReadPluginDataJSON(pluginID, name string) map[string]interface{} {
return data return data
} }
// ReadPluginDataNDJSON reads append-only plugin data without exposing the
// underlying vault path to plugin frontends.
func (a *App) ReadPluginDataNDJSON(pluginID, name string) []map[string]interface{} {
if _, err := a.requirePluginAccess(pluginID, "storage.namespace"); err != nil {
log.Printf("[api] ReadPluginDataNDJSON(%s, %s): %v", pluginID, name, err)
return []map[string]interface{}{}
}
if a.storage == nil {
return []map[string]interface{}{}
}
data, err := a.storage.ReadPluginDataNDJSON(pluginID, name)
if err != nil {
log.Printf("[api] ReadPluginDataNDJSON(%s, %s): %v", pluginID, name, err)
return []map[string]interface{}{}
}
return data
}
// WritePluginDataNDJSON replaces append-only data after an explicit user
// action, such as clearing activity history.
func (a *App) WritePluginDataNDJSON(pluginID, name string, data []map[string]interface{}) string {
if _, err := a.requirePluginAccess(pluginID, "storage.namespace"); err != nil {
return err.Error()
}
if a.storage == nil {
return "storage not initialized"
}
if err := a.storage.WritePluginDataNDJSON(pluginID, name, data); err != nil {
log.Printf("[api] WritePluginDataNDJSON(%s, %s): %v", pluginID, name, err)
return err.Error()
}
return ""
}
// WritePluginDataJSON writes a named JSON data file for a plugin. // WritePluginDataJSON writes a named JSON data file for a plugin.
func (a *App) WritePluginDataJSON(pluginID, name string, data map[string]interface{}) string { func (a *App) WritePluginDataJSON(pluginID, name string, data map[string]interface{}) string {
if _, err := a.requirePluginAccess(pluginID, "storage.namespace"); err != nil { if _, err := a.requirePluginAccess(pluginID, "storage.namespace"); err != nil {
@@ -1130,6 +1558,22 @@ func (a *App) OpenVaultPathExternal(pluginID, relativePath string) string {
return "" return ""
} }
// OpenExternalURL opens an HTTP(S) URL through the platform browser opener.
// This deliberately bypasses OS file associations for InternetShortcut files.
func (a *App) OpenExternalURL(pluginID, rawURL string) string {
if _, err := a.requirePluginAccess(pluginID, "files.openExternal"); err != nil {
return err.Error()
}
parsed, err := url.ParseRequestURI(strings.TrimSpace(rawURL))
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return "invalid HTTP(S) URL"
}
if err := a.externalOpenService().OpenPath(parsed.String()); err != nil {
return err.Error()
}
return ""
}
// ShowVaultPathInFolder reveals a vault-relative file or folder in the OS file manager. // ShowVaultPathInFolder reveals a vault-relative file or folder in the OS file manager.
func (a *App) ShowVaultPathInFolder(pluginID, relativePath string) string { func (a *App) ShowVaultPathInFolder(pluginID, relativePath string) string {
if _, err := a.requirePluginAccess(pluginID, "files.openExternal"); err != nil { if _, err := a.requirePluginAccess(pluginID, "files.openExternal"); err != nil {
@@ -1621,7 +2065,9 @@ func (a *App) GetAppSettings() map[string]interface{} {
"currentVaultPath": cfg.CurrentVaultPath, "currentVaultPath": cfg.CurrentVaultPath,
"recentVaults": cfg.RecentVaults, "recentVaults": cfg.RecentVaults,
"theme": cfg.Theme, "theme": cfg.Theme,
"language": cfg.Language,
"devMode": cfg.DevMode, "devMode": cfg.DevMode,
"debug": a.debug,
"userPluginsDir": cfg.UserPluginsDir, "userPluginsDir": cfg.UserPluginsDir,
"lastOpenedAt": cfg.LastOpenedAt, "lastOpenedAt": cfg.LastOpenedAt,
} }
@@ -1634,18 +2080,33 @@ func (a *App) UpdateAppSettings(patch map[string]interface{}) string {
} }
cfg := &appsettings.Config{} cfg := &appsettings.Config{}
hasConfigPatch := false
if v, ok := patch["theme"].(string); ok && v != "" { if v, ok := patch["theme"].(string); ok && v != "" {
cfg.Theme = v cfg.Theme = v
hasConfigPatch = true
} }
if v, ok := patch["devMode"].(bool); ok { if v, ok := patch["devMode"].(bool); ok {
cfg.DevMode = v cfg.DevMode = v
hasConfigPatch = true
} }
if v, ok := patch["userPluginsDir"].(string); ok && v != "" { if v, ok := patch["userPluginsDir"].(string); ok && v != "" {
cfg.UserPluginsDir = v cfg.UserPluginsDir = v
hasConfigPatch = true
} }
if err := a.appSettings.Update(cfg); err != nil { if hasConfigPatch {
return err.Error() if err := a.appSettings.Update(cfg); err != nil {
return err.Error()
}
}
if value, exists := patch["language"]; exists {
language, ok := value.(string)
if !ok {
return "language must be a string"
}
if err := a.appSettings.UpdateLanguage(language); err != nil {
return err.Error()
}
} }
return "" return ""
} }
@@ -1743,6 +2204,29 @@ func (a *App) ListWorkspaceTemplates() ([]workspace.WorkspaceTemplate, string) {
return a.workspace.ListWorkspaceTemplates(), "" return a.workspace.ListWorkspaceTemplates(), ""
} }
// ListWorkspaceIdentities returns durable workspace identities for relation-aware plugins.
func (a *App) ListWorkspaceIdentities() ([]workspace.WorkspaceIdentity, string) {
if a.workspace == nil {
return nil, "workspace not initialized"
}
identities, err := a.workspace.ListWorkspaceIdentities()
if err != nil {
return nil, err.Error()
}
return identities, ""
}
// RepairWorkspaceIdentity resolves a duplicated workspace marker without moving relations.
func (a *App) RepairWorkspaceIdentity(keepName, regenerateName string) string {
if a.workspace == nil {
return "workspace not initialized"
}
if err := a.workspace.RepairWorkspaceIdentity(keepName, regenerateName); err != nil {
return err.Error()
}
return ""
}
// CreateWorkspace creates a top-level physical workspace folder. // CreateWorkspace creates a top-level physical workspace folder.
func (a *App) CreateWorkspace(name, templateID string) (workspace.Workspace, string) { func (a *App) CreateWorkspace(name, templateID string) (workspace.Workspace, string) {
if a.workspace == nil { if a.workspace == nil {
@@ -1754,6 +2238,7 @@ func (a *App) CreateWorkspace(name, templateID string) (workspace.Workspace, str
} }
a.publishWorkspaceLifecycleEvent(workspaceCreatedEventName, map[string]interface{}{ a.publishWorkspaceLifecycleEvent(workspaceCreatedEventName, map[string]interface{}{
"operation": "create", "operation": "create",
"workspaceId": ws.ID,
"workspaceRootPath": ws.RootPath, "workspaceRootPath": ws.RootPath,
"workspaceName": ws.Name, "workspaceName": ws.Name,
"templateId": templateID, "templateId": templateID,
@@ -1769,8 +2254,13 @@ func (a *App) RenameWorkspace(oldName, newName string) string {
if err := a.workspace.RenameWorkspace(oldName, newName); err != nil { if err := a.workspace.RenameWorkspace(oldName, newName); err != nil {
return err.Error() return err.Error()
} }
identity, err := a.workspace.GetWorkspaceIdentity(newName)
if err != nil {
return err.Error()
}
a.publishWorkspaceLifecycleEvent(workspaceRenamedEventName, map[string]interface{}{ a.publishWorkspaceLifecycleEvent(workspaceRenamedEventName, map[string]interface{}{
"operation": "rename", "operation": "rename",
"workspaceId": identity.WorkspaceID,
"workspaceRootPath": newName, "workspaceRootPath": newName,
"workspaceName": newName, "workspaceName": newName,
"previousWorkspaceRootPath": oldName, "previousWorkspaceRootPath": oldName,
@@ -1790,6 +2280,7 @@ func (a *App) TrashWorkspace(name string) (workspace.TrashResult, string) {
} }
a.publishWorkspaceLifecycleEvent(workspaceTrashedEventName, map[string]interface{}{ a.publishWorkspaceLifecycleEvent(workspaceTrashedEventName, map[string]interface{}{
"operation": "trash", "operation": "trash",
"workspaceId": result.WorkspaceID,
"workspaceRootPath": name, "workspaceRootPath": name,
"workspaceName": name, "workspaceName": name,
"trashId": result.TrashID, "trashId": result.TrashID,
@@ -1799,6 +2290,47 @@ func (a *App) TrashWorkspace(name string) (workspace.TrashResult, string) {
return result, "" return result, ""
} }
// RestoreWorkspaceTrash restores a trashed workspace and publishes its durable identity.
func (a *App) RestoreWorkspaceTrash(trashID, targetName string) (workspace.Workspace, string) {
if a.workspace == nil {
return workspace.Workspace{}, "workspace not initialized"
}
restored, err := a.workspace.RestoreWorkspaceTrash(trashID, targetName)
if err != nil {
return workspace.Workspace{}, err.Error()
}
a.publishWorkspaceLifecycleEvent(workspaceRestoredEventName, map[string]interface{}{
"operation": "restore",
"workspaceId": restored.ID,
"workspaceRootPath": restored.RootPath,
"workspaceName": restored.Name,
"trashId": trashID,
})
return restored, ""
}
// PurgeWorkspaceTrash permanently removes a trashed workspace and publishes its former identity.
func (a *App) PurgeWorkspaceTrash(trashID string) string {
if a.workspace == nil {
return "workspace not initialized"
}
identity, err := a.workspace.GetWorkspaceTrashIdentity(trashID)
if err != nil {
return err.Error()
}
if err := a.workspace.PurgeWorkspaceTrash(trashID); err != nil {
return err.Error()
}
a.publishWorkspaceLifecycleEvent(workspacePurgedEventName, map[string]interface{}{
"operation": "purge",
"workspaceId": identity.WorkspaceID,
"workspaceRootPath": identity.RootPath,
"workspaceName": identity.RootPath,
"trashId": trashID,
})
return ""
}
// GetWorkspaceMetadata returns metadata or a generic fallback for a workspace. // GetWorkspaceMetadata returns metadata or a generic fallback for a workspace.
func (a *App) GetWorkspaceMetadata(name string) (workspace.Metadata, string) { func (a *App) GetWorkspaceMetadata(name string) (workspace.Metadata, string) {
if a.workspace == nil { if a.workspace == nil {
@@ -1832,9 +2364,15 @@ func (a *App) GetCurrentWorkspace() map[string]interface{} {
if err != nil { if err != nil {
return map[string]interface{}{"error": err.Error()} return map[string]interface{}{"error": err.Error()}
} }
identity, err := a.workspace.GetWorkspaceIdentity(node.Name)
if err != nil {
return map[string]interface{}{"error": err.Error()}
}
return map[string]interface{}{ return map[string]interface{}{
"name": node.Name, "id": identity.WorkspaceID,
"rootPath": node.RootPath, "workspaceId": identity.WorkspaceID,
"name": node.Name,
"rootPath": node.RootPath,
} }
} }
@@ -2064,18 +2602,81 @@ func (a *App) GetPluginFrontendInfo(pluginID string) map[string]interface{} {
return map[string]interface{}{"status": "no-frontend"} return map[string]interface{}{"status": "no-frontend"}
} }
return map[string]interface{}{ return map[string]interface{}{
"pluginId": p.Manifest.ID, "pluginId": p.Manifest.ID,
"name": p.Manifest.Name, "name": p.Manifest.Name,
"icon": p.Manifest.Icon, "icon": p.Manifest.Icon,
"version": p.Manifest.Version, "version": p.Manifest.Version,
"entry": p.Manifest.Frontend.Entry, "entry": p.Manifest.Frontend.Entry,
"style": p.Manifest.Frontend.Style, "style": p.Manifest.Frontend.Style,
"rootPath": p.RootPath, "localization": p.Manifest.Localization,
"rootPath": p.RootPath,
} }
} }
return map[string]interface{}{"status": "not-found"} return map[string]interface{}{"status": "not-found"}
} }
// GetPluginLocalization reads a locale catalog declared by the plugin manifest.
func (a *App) GetPluginLocalization(pluginID, locale string) (map[string]string, string) {
var selected *plugin.Plugin
for i := range a.plugins {
if a.plugins[i].Manifest.ID == pluginID {
selected = &a.plugins[i]
break
}
}
if selected == nil {
return nil, "plugin not found"
}
localization := selected.Manifest.Localization
if localization == nil {
return nil, "plugin does not declare localization"
}
catalogPath, ok := localization.Locales[locale]
if !ok {
return nil, fmt.Sprintf("locale %q is not declared", locale)
}
if err := plugin.ValidateLocalizationPath(catalogPath); err != nil {
return nil, err.Error()
}
absRoot, err := filepath.Abs(selected.RootPath)
if err != nil {
return nil, fmt.Sprintf("resolve plugin root: %v", err)
}
absPath, err := filepath.Abs(filepath.Join(absRoot, filepath.FromSlash(catalogPath)))
if err != nil {
return nil, fmt.Sprintf("resolve catalog path: %v", err)
}
if !pathInsideRoot(absRoot, absPath) {
return nil, "catalog path escapes plugin root"
}
resolvedPath, err := filepath.EvalSymlinks(absPath)
if err != nil {
return nil, fmt.Sprintf("failed to resolve catalog: %v", err)
}
if !pathInsideRoot(absRoot, resolvedPath) {
return nil, "catalog path escapes plugin root"
}
data, err := os.ReadFile(resolvedPath)
if err != nil {
return nil, fmt.Sprintf("failed to read catalog: %v", err)
}
catalog := map[string]string{}
if err := json.Unmarshal(data, &catalog); err != nil {
return nil, fmt.Sprintf("failed to parse catalog: %v", err)
}
return catalog, ""
}
func pathInsideRoot(root, candidate string) bool {
relative, err := filepath.Rel(root, candidate)
if err != nil {
return false
}
return relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
}
// GetPluginAssetContent reads a frontend asset file from a plugin directory. // GetPluginAssetContent reads a frontend asset file from a plugin directory.
// Security: validates that the assetPath is relative and does not escape the plugin root. // Security: validates that the assetPath is relative and does not escape the plugin root.
func (a *App) GetPluginAssetContent(pluginID, assetPath string) (string, string) { func (a *App) GetPluginAssetContent(pluginID, assetPath string) (string, string) {
+609 -10
View File
@@ -3,12 +3,14 @@ package api
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"net" "net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
@@ -343,6 +345,65 @@ func TestGetPluginAssetContent_NonexistentFile(t *testing.T) {
} }
} }
func TestGetPluginLocalizationReadsDeclaredCatalog(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "locales"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "locales", "ru.json"), []byte(`{"greeting":"Привет","count":"Всего: {count}"}`), 0o644); err != nil {
t.Fatal(err)
}
app := newTestApp(root)
app.plugins[0].Manifest.Localization = &plugin.LocalizationConfig{
DefaultLocale: "en",
Locales: map[string]string{
"en": "locales/en.json",
"ru": "locales/ru.json",
},
}
catalog, errStr := app.GetPluginLocalization("test.plugin", "ru")
if errStr != "" {
t.Fatalf("GetPluginLocalization: %s", errStr)
}
if catalog["greeting"] != "Привет" || catalog["count"] != "Всего: {count}" {
t.Fatalf("catalog = %#v", catalog)
}
}
func TestGetPluginLocalizationRejectsInvalidCatalogs(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "locales"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "locales", "bad.json"), []byte(`{"valid":"text","invalid":42}`), 0o644); err != nil {
t.Fatal(err)
}
app := newTestApp(root)
tests := []struct {
name string
config *plugin.LocalizationConfig
locale string
want string
}{
{"missing declaration", nil, "ru", "does not declare"},
{"unknown locale", &plugin.LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"en": "locales/en.json"}}, "ru", "not declared"},
{"traversal", &plugin.LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"en": "../outside.json"}}, "en", "traversal"},
{"backslash", &plugin.LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"en": `locales\bad.json`}}, "en", "backslash"},
{"non string value", &plugin.LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"en": "locales/bad.json"}}, "en", "parse"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
app.plugins[0].Manifest.Localization = tc.config
_, errStr := app.GetPluginLocalization("test.plugin", tc.locale)
if !strings.Contains(errStr, tc.want) {
t.Fatalf("error = %q, want substring %q", errStr, tc.want)
}
})
}
}
func TestFilesBridgeReadWriteListMoveTrash(t *testing.T) { func TestFilesBridgeReadWriteListMoveTrash(t *testing.T) {
app, root := newFilesTestApp(t, []string{"files.read", "files.write", "files.delete"}) app, root := newFilesTestApp(t, []string{"files.read", "files.write", "files.delete"})
@@ -532,21 +593,14 @@ func TestActivityProviderRecordsFileChangedWithoutMountedView(t *testing.T) {
t.Fatalf("WriteVaultTextFile: %s", errStr) t.Fatalf("WriteVaultTextFile: %s", errStr)
} }
settings, err := app.storage.ReadPluginSettings("verstak.activity") stored, err := app.storage.ReadPluginDataNDJSON("verstak.activity", activityRawDataName)
if err != nil { if err != nil {
t.Fatalf("ReadPluginSettings: %v", err) t.Fatalf("ReadPluginDataNDJSON: %v", err)
}
stored, ok := settings["events:workspace:Project"].([]interface{})
if !ok {
t.Fatalf("events:workspace:Project = %#v, want []interface{}", settings["events:workspace:Project"])
} }
if len(stored) != 1 { if len(stored) != 1 {
t.Fatalf("stored %d activity events, want 1", len(stored)) t.Fatalf("stored %d activity events, want 1", len(stored))
} }
activity, ok := stored[0].(map[string]interface{}) activity := stored[0]
if !ok {
t.Fatalf("activity = %#v, want map[string]interface{}", stored[0])
}
if activity["type"] != "file.changed" { if activity["type"] != "file.changed" {
t.Fatalf("activity type = %#v, want file.changed", activity["type"]) t.Fatalf("activity type = %#v, want file.changed", activity["type"])
} }
@@ -558,6 +612,441 @@ func TestActivityProviderRecordsFileChangedWithoutMountedView(t *testing.T) {
} }
} }
func TestBrowserActivityBatchPersistsWithoutMountedViewAndDeduplicates(t *testing.T) {
v := vault.NewVault(nil)
if err := v.CreateVault(t.TempDir()); err != nil {
t.Fatalf("CreateVault: %v", err)
}
app := &App{
storage: storage.New(v),
vault: v,
plugins: []plugin.Plugin{{
Manifest: plugin.Manifest{ID: activityPluginID, Permissions: []string{"storage.namespace"}},
Status: plugin.StatusLoaded,
Enabled: true,
}},
}
event := events.Event{
Name: "browser.activity.batch",
Timestamp: "2026-07-12T10:05:01Z",
Payload: map[string]interface{}{
"batchId": "batch-activity-1",
"entries": []map[string]interface{}{{
"hostname": "example.com", "startedAt": "2026-07-12T10:00:00Z", "endedAt": "2026-07-12T10:05:00Z", "durationSeconds": int64(300),
}},
},
}
if err := app.recordBrowserActivityBatch(event); err != nil {
t.Fatalf("recordBrowserActivityBatch: %v", err)
}
if err := app.recordBrowserActivityBatch(event); err != nil {
t.Fatalf("recordBrowserActivityBatch retry: %v", err)
}
records, err := app.storage.ReadPluginDataNDJSON(activityPluginID, activityRawDataName)
if err != nil {
t.Fatalf("ReadPluginDataNDJSON: %v", err)
}
if len(records) != 1 {
t.Fatalf("records = %+v, want one idempotent domain activity", records)
}
if records[0]["type"] != "browser.activity.domain" || records[0]["hostname"] != "example.com" {
t.Fatalf("record = %+v, want domain-only activity", records[0])
}
if _, ok := records[0]["url"]; ok {
t.Fatalf("record must not contain URL: %+v", records[0])
}
}
func TestBrowserInboxRecordsCaptureWithoutMountedView(t *testing.T) {
v := vault.NewVault(nil)
if err := v.CreateVault(t.TempDir()); err != nil {
t.Fatalf("CreateVault: %v", err)
}
bus := events.NewBus()
app := &App{
eventBus: bus,
storage: storage.New(v),
vault: v,
plugins: []plugin.Plugin{{
Manifest: plugin.Manifest{
ID: "verstak.browser-inbox",
Name: "Browser Inbox",
Permissions: []string{"storage.namespace"},
},
Status: plugin.StatusLoaded,
Enabled: true,
}},
}
app.ensureBrowserInboxSubscriptions()
if err := app.recordBrowserCapture(events.Event{
Name: "browser.capture.page",
Timestamp: "2026-07-11T12:00:00Z",
Payload: map[string]interface{}{
"captureId": "capture-background",
"capturedAt": "2026-07-11T11:59:00Z",
"kind": "page",
"url": "https://example.com/article",
"title": "Background capture",
"domain": "example.com",
},
}); err != nil {
t.Fatalf("RecordBrowserCapture: %v", err)
}
bus.Publish(events.Event{Name: browserInboxMutationEvent, Payload: map[string]interface{}{
"pluginId": browserInboxPluginID, "action": "assign", "captureId": "capture-background", "workspaceRootPath": "Project",
}})
bus.Publish(events.Event{Name: browserInboxMutationEvent, Payload: map[string]interface{}{
"pluginId": browserInboxPluginID, "action": "processed", "captureId": "capture-background", "processed": true,
}})
bus.Publish(events.Event{Name: browserInboxMutationEvent, Payload: map[string]interface{}{
"pluginId": browserInboxPluginID, "action": "archive", "captureId": "capture-background",
}})
if err := app.recordBrowserCapture(events.Event{
Name: "browser.capture.page",
Payload: map[string]interface{}{
"captureId": "capture-background",
"title": "Retried payload",
},
}); err != nil {
t.Fatalf("RecordBrowserCapture retry: %v", err)
}
settings, err := app.storage.ReadPluginSettings("verstak.browser-inbox")
if err != nil {
t.Fatalf("ReadPluginSettings: %v", err)
}
stored, ok := settings["captures:global"].([]interface{})
if !ok || len(stored) != 1 {
t.Fatalf("captures:global = %#v, want one capture", settings["captures:global"])
}
capture, ok := stored[0].(map[string]interface{})
if !ok {
t.Fatalf("capture = %#v, want map[string]interface{}", stored[0])
}
if capture["captureId"] != "capture-background" || capture["title"] != "Background capture" || capture["workspaceRootPath"] != "Project" || capture["processed"] != true || capture["globalState"] != "archived" {
t.Fatalf("stored capture = %#v", capture)
}
}
func TestBrowserInboxArchiveKeepsAssignmentUntilExplicitPermanentDelete(t *testing.T) {
v := vault.NewVault(nil)
if err := v.CreateVault(t.TempDir()); err != nil {
t.Fatalf("CreateVault: %v", err)
}
bus := events.NewBus()
app := &App{
eventBus: bus,
storage: storage.New(v),
vault: v,
plugins: []plugin.Plugin{{
Manifest: plugin.Manifest{ID: browserInboxPluginID, Permissions: []string{"storage.namespace"}},
Status: plugin.StatusLoaded,
Enabled: true,
}},
}
app.ensureBrowserInboxSubscriptions()
if err := app.recordBrowserCapture(events.Event{Name: "browser.capture.page", Payload: map[string]interface{}{
"captureId": "archive-1", "capturedAt": "2026-07-12T10:00:00Z", "url": "https://example.com", "workspaceRootPath": "Project",
}}); err != nil {
t.Fatalf("recordBrowserCapture: %v", err)
}
bus.Publish(events.Event{Name: browserInboxMutationEvent, Payload: map[string]interface{}{
"pluginId": browserInboxPluginID, "action": "archive", "captureId": "archive-1",
}})
settings, err := app.storage.ReadPluginSettings(browserInboxPluginID)
if err != nil {
t.Fatalf("ReadPluginSettings: %v", err)
}
captures, _ := settings[browserInboxGlobalKey].([]interface{})
if len(captures) != 1 {
t.Fatalf("captures after archive = %#v, want retained capture", captures)
}
archived := captures[0].(map[string]interface{})
if archived["workspaceRootPath"] != "Project" || archived["globalState"] != "archived" {
t.Fatalf("archived capture = %#v, want preserved assignment", archived)
}
bus.Publish(events.Event{Name: browserInboxMutationEvent, Payload: map[string]interface{}{
"pluginId": browserInboxPluginID, "action": "delete", "captureId": "archive-1", "permanent": true,
}})
settings, err = app.storage.ReadPluginSettings(browserInboxPluginID)
if err != nil {
t.Fatalf("ReadPluginSettings after delete: %v", err)
}
captures, _ = settings[browserInboxGlobalKey].([]interface{})
if len(captures) != 0 {
t.Fatalf("captures after permanent delete = %#v, want none", captures)
}
}
func TestBrowserInboxWorkspaceReferenceSurvivesRenameAndTrash(t *testing.T) {
v := vault.NewVault(nil)
if err := v.CreateVault(t.TempDir()); err != nil {
t.Fatalf("CreateVault: %v", err)
}
manager := workspace.NewManager(v.GetVaultPath())
created, err := manager.CreateWorkspace("Project", "minimal")
if err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
bus := events.NewBus()
app := &App{
eventBus: bus,
storage: storage.New(v),
vault: v,
workspace: manager,
plugins: []plugin.Plugin{{
Manifest: plugin.Manifest{ID: browserInboxPluginID, Permissions: []string{"storage.namespace"}},
Status: plugin.StatusLoaded,
Enabled: true,
}},
}
app.ensureBrowserInboxSubscriptions()
if err := app.recordBrowserCapture(events.Event{Name: "browser.capture.page", Payload: map[string]interface{}{
"captureId": "workspace-ref", "capturedAt": "2026-07-12T10:00:00Z", "url": "https://example.com", "workspaceRootPath": "Project",
}}); err != nil {
t.Fatalf("recordBrowserCapture: %v", err)
}
if errStr := app.RenameWorkspace("Project", "Client"); errStr != "" {
t.Fatalf("RenameWorkspace: %s", errStr)
}
if _, errStr := app.TrashWorkspace("Client"); errStr != "" {
t.Fatalf("TrashWorkspace: %s", errStr)
}
settings, err := app.storage.ReadPluginSettings(browserInboxPluginID)
if err != nil {
t.Fatalf("ReadPluginSettings: %v", err)
}
capture := settings[browserInboxGlobalKey].([]interface{})[0].(map[string]interface{})
if capture["workspaceId"] != created.ID || capture["workspaceRootPath"] != "Client" || capture["workspaceState"] != "trashed" || capture["workspaceTrashId"] == "" {
t.Fatalf("workspace reference = %#v", capture)
}
}
func TestOpenExternalURLUsesBrowserOpenService(t *testing.T) {
app, _ := newFilesTestApp(t, []string{"files.openExternal"})
opened := ""
app.externalOpen = newTestExternalOpenService(func(path string) error {
opened = path
return nil
})
if errStr := app.OpenExternalURL("files.plugin", "https://example.com/path"); errStr != "" {
t.Fatalf("OpenExternalURL: %s", errStr)
}
if opened != "https://example.com/path" {
t.Fatalf("opened = %q, want URL", opened)
}
if errStr := app.OpenExternalURL("files.plugin", "ftp://example.com"); errStr == "" {
t.Fatal("OpenExternalURL accepted unsupported URL scheme")
}
}
func TestJournalHandledSessionPersistsActivityWatermark(t *testing.T) {
v := vault.NewVault(nil)
if err := v.CreateVault(t.TempDir()); err != nil {
t.Fatalf("CreateVault: %v", err)
}
bus := events.NewBus()
app := &App{
eventBus: bus,
storage: storage.New(v),
vault: v,
contribRegistry: contribution.NewRegistry(),
plugins: []plugin.Plugin{
{Manifest: plugin.Manifest{ID: activityPluginID, Permissions: []string{"storage.namespace"}}, Status: plugin.StatusLoaded, Enabled: true},
{Manifest: plugin.Manifest{ID: "verstak.journal", Permissions: []string{"events.publish"}}, Status: plugin.StatusLoaded, Enabled: true},
},
}
app.ensureActivityProviderSubscriptions()
bus.Publish(events.Event{Name: activitySessionHandledEvent, Payload: map[string]interface{}{
"pluginId": "verstak.journal", "sessionId": "session-1", "handledThrough": "2026-07-12T10:30:00Z", "status": "accepted",
}})
settings, err := app.storage.ReadPluginSettings(activityPluginID)
if err != nil {
t.Fatalf("ReadPluginSettings: %v", err)
}
handled := settings[activitySessionHandlingKey].(map[string]interface{})
record := handled["session-1"].(map[string]interface{})
if record["status"] != "accepted" || record["handledThrough"] != "2026-07-12T10:30:00Z" {
t.Fatalf("handled record = %#v", record)
}
}
func TestBrowserInboxRejectsCaptureWithoutOpenVault(t *testing.T) {
v := vault.NewVault(nil)
app := &App{
storage: storage.New(v),
vault: v,
plugins: []plugin.Plugin{{
Manifest: plugin.Manifest{
ID: browserInboxPluginID,
Permissions: []string{"storage.namespace"},
},
Status: plugin.StatusLoaded,
Enabled: true,
}},
}
app.ensureBrowserInboxSubscriptions()
err := app.recordBrowserCapture(events.Event{
Name: "browser.capture.page",
Payload: map[string]interface{}{"captureId": "capture-no-vault"},
})
if err == nil || !strings.Contains(err.Error(), "unavailable") {
t.Fatalf("RecordBrowserCapture error = %v, want unavailable", err)
}
}
func TestBrowserInboxSerializesConcurrentCapturesAndMutations(t *testing.T) {
v := vault.NewVault(nil)
if err := v.CreateVault(t.TempDir()); err != nil {
t.Fatalf("CreateVault: %v", err)
}
bus := events.NewBus()
app := &App{
eventBus: bus,
storage: storage.New(v),
vault: v,
plugins: []plugin.Plugin{{
Manifest: plugin.Manifest{
ID: browserInboxPluginID,
Permissions: []string{"storage.namespace"},
},
Status: plugin.StatusLoaded,
Enabled: true,
}},
}
app.ensureBrowserInboxSubscriptions()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
if err := app.storage.WritePluginSetting(browserInboxPluginID, "domainBindings", map[string]interface{}{
"example.com": "Project",
}); err != nil {
t.Errorf("WritePluginSetting(domainBindings): %v", err)
}
}()
for i := 0; i < 50; i++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
if err := app.recordBrowserCapture(events.Event{
Name: "browser.capture.page",
Timestamp: "2026-07-11T12:00:00Z",
Payload: map[string]interface{}{
"captureId": fmt.Sprintf("capture-%02d", index),
"kind": "page",
"url": fmt.Sprintf("https://example.com/%d", index),
},
}); err != nil {
t.Errorf("RecordBrowserCapture(%d): %v", index, err)
}
}(i)
}
wg.Wait()
bus.Publish(events.Event{
Name: browserInboxMutationEvent,
Payload: map[string]interface{}{
"pluginId": browserInboxPluginID,
"action": "assign",
"captureId": "capture-00",
"workspaceRootPath": "Project",
},
})
bus.Publish(events.Event{
Name: browserInboxMutationEvent,
Payload: map[string]interface{}{
"pluginId": browserInboxPluginID,
"action": "delete",
"captureId": "capture-01",
},
})
settings, err := app.storage.ReadPluginSettings(browserInboxPluginID)
if err != nil {
t.Fatalf("ReadPluginSettings: %v", err)
}
stored, ok := settings[browserInboxGlobalKey].([]interface{})
if !ok || len(stored) != 49 {
t.Fatalf("captures:global contains %d captures, want 49", len(stored))
}
var assigned map[string]interface{}
for _, item := range stored {
capture := item.(map[string]interface{})
if capture["captureId"] == "capture-00" {
assigned = capture
}
if capture["captureId"] == "capture-01" {
t.Fatal("deleted capture remained in storage")
}
}
if assigned == nil || assigned["workspaceRootPath"] != "Project" {
t.Fatalf("assigned capture = %#v", assigned)
}
if settings["domainBindings"] == nil {
t.Fatal("concurrent domainBindings update was lost")
}
}
func TestBrowserInboxMutationMigratesAndClearsLegacyCaptures(t *testing.T) {
v := vault.NewVault(nil)
if err := v.CreateVault(t.TempDir()); err != nil {
t.Fatalf("CreateVault: %v", err)
}
bus := events.NewBus()
app := &App{
eventBus: bus,
storage: storage.New(v),
vault: v,
plugins: []plugin.Plugin{{
Manifest: plugin.Manifest{ID: browserInboxPluginID, Permissions: []string{"storage.namespace"}},
Status: plugin.StatusLoaded,
Enabled: true,
}},
}
if err := app.storage.WritePluginSettings(browserInboxPluginID, map[string]interface{}{
browserInboxLegacyKey: []interface{}{map[string]interface{}{
"captureId": "legacy-duplicate",
"title": "Stale legacy copy",
}},
browserInboxGlobalKey: []interface{}{map[string]interface{}{
"captureId": "legacy-duplicate",
"title": "Canonical copy",
"workspaceRootPath": "Project",
"processed": true,
}},
}); err != nil {
t.Fatalf("WritePluginSettings: %v", err)
}
app.ensureBrowserInboxSubscriptions()
bus.Publish(events.Event{Name: browserInboxMutationEvent, Payload: map[string]interface{}{
"pluginId": browserInboxPluginID,
"action": "migrate",
}})
settings, err := app.storage.ReadPluginSettings(browserInboxPluginID)
if err != nil {
t.Fatalf("ReadPluginSettings: %v", err)
}
legacy := settings[browserInboxLegacyKey].([]interface{})
if len(legacy) != 0 {
t.Fatalf("legacy captures = %#v, want empty", legacy)
}
stored := settings[browserInboxGlobalKey].([]interface{})
if len(stored) != 1 {
t.Fatalf("canonical captures = %#v, want one", stored)
}
capture := stored[0].(map[string]interface{})
if capture["title"] != "Canonical copy" || capture["workspaceRootPath"] != "Project" || capture["processed"] != true {
t.Fatalf("canonical capture = %#v", capture)
}
}
func TestActivityFromEventRedactsBinaryPayload(t *testing.T) { func TestActivityFromEventRedactsBinaryPayload(t *testing.T) {
activity := activityFromEvent(events.Event{ activity := activityFromEvent(events.Event{
Name: "browser.capture.file", Name: "browser.capture.file",
@@ -1294,6 +1783,37 @@ func assertSyncState(t *testing.T, service *syncsvc.Service, wantDeviceID, wantS
} }
} }
func TestAppSettingsLanguageAPI(t *testing.T) {
settings := appsettings.NewManager(filepath.Join(t.TempDir(), "config.json"))
if err := settings.Load(); err != nil {
t.Fatal(err)
}
if err := settings.Update(&appsettings.Config{Theme: "light", DevMode: true}); err != nil {
t.Fatal(err)
}
app := &App{appSettings: settings, debug: true}
if got := app.GetAppSettings()["language"]; got != "system" {
t.Fatalf("initial language = %#v, want system", got)
}
if got := app.GetAppSettings()["debug"]; got != true {
t.Fatalf("debug = %#v, want true", got)
}
if errStr := app.UpdateAppSettings(map[string]interface{}{"language": "ru"}); errStr != "" {
t.Fatalf("UpdateAppSettings language: %s", errStr)
}
got := app.GetAppSettings()
if got["language"] != "ru" || got["theme"] != "light" || got["devMode"] != true {
t.Fatalf("settings after language update = %#v", got)
}
if errStr := app.UpdateAppSettings(map[string]interface{}{"language": "de"}); errStr == "" {
t.Fatal("UpdateAppSettings accepted unsupported language")
}
if got := app.GetAppSettings()["language"]; got != "ru" {
t.Fatalf("language after rejected update = %#v, want ru", got)
}
}
func TestSetCurrentVaultInitializesWorkspaceWhenMissingAtStartup(t *testing.T) { func TestSetCurrentVaultInitializesWorkspaceWhenMissingAtStartup(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
vaultParent := filepath.Join(tmpDir, "vault-parent") vaultParent := filepath.Join(tmpDir, "vault-parent")
@@ -1493,6 +2013,10 @@ func TestWorkspaceAPIPublishesLifecycleEvents(t *testing.T) {
if errStr := app.SetCurrentWorkspace("Project"); errStr != "" { if errStr := app.SetCurrentWorkspace("Project"); errStr != "" {
t.Fatalf("SetCurrentWorkspace: %s", errStr) t.Fatalf("SetCurrentWorkspace: %s", errStr)
} }
currentID, ok := app.GetCurrentWorkspace()["workspaceId"].(string)
if !ok || currentID == "" {
t.Fatalf("GetCurrentWorkspace workspaceId = %#v", app.GetCurrentWorkspace()["workspaceId"])
}
if errStr := app.RenameWorkspace("Project", "Renamed"); errStr != "" { if errStr := app.RenameWorkspace("Project", "Renamed"); errStr != "" {
t.Fatalf("RenameWorkspace: %s", errStr) t.Fatalf("RenameWorkspace: %s", errStr)
} }
@@ -1503,6 +2027,10 @@ func TestWorkspaceAPIPublishesLifecycleEvents(t *testing.T) {
if got := received["workspace.created"]["workspaceRootPath"]; got != "Project" { if got := received["workspace.created"]["workspaceRootPath"]; got != "Project" {
t.Fatalf("workspace.created workspaceRootPath = %#v, want Project", got) t.Fatalf("workspace.created workspaceRootPath = %#v, want Project", got)
} }
createdID, _ := received["workspace.created"]["workspaceId"].(string)
if createdID == "" {
t.Fatal("workspace.created workspaceId is empty")
}
if got := received["workspace.created"]["templateId"]; got != "client-project" { if got := received["workspace.created"]["templateId"]; got != "client-project" {
t.Fatalf("workspace.created templateId = %#v, want client-project", got) t.Fatalf("workspace.created templateId = %#v, want client-project", got)
} }
@@ -1512,17 +2040,88 @@ func TestWorkspaceAPIPublishesLifecycleEvents(t *testing.T) {
if got := received["workspace.renamed"]["workspaceRootPath"]; got != "Renamed" { if got := received["workspace.renamed"]["workspaceRootPath"]; got != "Renamed" {
t.Fatalf("workspace.renamed workspaceRootPath = %#v, want Renamed", got) t.Fatalf("workspace.renamed workspaceRootPath = %#v, want Renamed", got)
} }
if got := received["workspace.renamed"]["workspaceId"]; got != createdID {
t.Fatalf("workspace.renamed workspaceId = %#v, want %q", got, createdID)
}
if got := received["workspace.renamed"]["previousWorkspaceRootPath"]; got != "Project" { if got := received["workspace.renamed"]["previousWorkspaceRootPath"]; got != "Project" {
t.Fatalf("workspace.renamed previousWorkspaceRootPath = %#v, want Project", got) t.Fatalf("workspace.renamed previousWorkspaceRootPath = %#v, want Project", got)
} }
if got := received["workspace.trashed"]["workspaceRootPath"]; got != "Renamed" { if got := received["workspace.trashed"]["workspaceRootPath"]; got != "Renamed" {
t.Fatalf("workspace.trashed workspaceRootPath = %#v, want Renamed", got) t.Fatalf("workspace.trashed workspaceRootPath = %#v, want Renamed", got)
} }
if got := received["workspace.trashed"]["workspaceId"]; got != createdID {
t.Fatalf("workspace.trashed workspaceId = %#v, want %q", got, createdID)
}
if got := received["workspace.trashed"]["trashPath"]; got == "" { if got := received["workspace.trashed"]["trashPath"]; got == "" {
t.Fatalf("workspace.trashed trashPath = %#v, want non-empty", got) t.Fatalf("workspace.trashed trashPath = %#v, want non-empty", got)
} }
} }
func TestWorkspaceIdentityAPIListsAndRepairsDuplicates(t *testing.T) {
app, vaultDir := newFilesTestApp(t, []string{"files.read"})
app.workspace = workspace.NewManager(vaultDir)
if err := app.workspace.Load(); err != nil {
t.Fatal(err)
}
if _, errStr := app.CreateWorkspace("Original", "default"); errStr != "" {
t.Fatalf("CreateWorkspace: %s", errStr)
}
identities, errStr := app.ListWorkspaceIdentities()
if errStr != "" {
t.Fatalf("ListWorkspaceIdentities: %s", errStr)
}
var original workspace.WorkspaceIdentity
for _, identity := range identities {
if identity.RootPath == "Original" {
original = identity
break
}
}
if original.WorkspaceID == "" || original.State != "active" {
t.Fatalf("identities = %+v", identities)
}
}
func TestWorkspaceTrashRestoreAndPurgePublishIdentityLifecycle(t *testing.T) {
app, vaultDir := newFilesTestApp(t, []string{"files.read"})
app.workspace = workspace.NewManager(vaultDir)
app.eventBus = events.NewBus()
if err := app.workspace.Load(); err != nil {
t.Fatal(err)
}
received := map[string]map[string]interface{}{}
for _, eventName := range []string{"workspace.restored", "workspace.purged"} {
name := eventName
app.eventBus.Subscribe(name, func(event events.Event) {
received[name], _ = event.Payload.(map[string]interface{})
})
}
if _, errStr := app.CreateWorkspace("Client", "default"); errStr != "" {
t.Fatalf("CreateWorkspace: %s", errStr)
}
trashed, errStr := app.TrashWorkspace("Client")
if errStr != "" {
t.Fatalf("TrashWorkspace: %s", errStr)
}
restored, errStr := app.RestoreWorkspaceTrash(trashed.TrashID, "Client-Restored")
if errStr != "" {
t.Fatalf("RestoreWorkspaceTrash: %s", errStr)
}
if got := received["workspace.restored"]["workspaceId"]; got != restored.ID {
t.Fatalf("restored workspaceId = %#v, want %q", got, restored.ID)
}
trashed, errStr = app.TrashWorkspace("Client-Restored")
if errStr != "" {
t.Fatalf("TrashWorkspace restored: %s", errStr)
}
if errStr := app.PurgeWorkspaceTrash(trashed.TrashID); errStr != "" {
t.Fatalf("PurgeWorkspaceTrash: %s", errStr)
}
if got := received["workspace.purged"]["workspaceId"]; got != restored.ID {
t.Fatalf("purged workspaceId = %#v, want %q", got, restored.ID)
}
}
func TestMoveWorkspaceNodeCompatibilityIsUnsupported(t *testing.T) { func TestMoveWorkspaceNodeCompatibilityIsUnsupported(t *testing.T) {
app, vaultDir := newFilesTestApp(t, []string{"files.read"}) app, vaultDir := newFilesTestApp(t, []string{"files.read"})
app.workspace = workspace.NewManager(vaultDir) app.workspace = workspace.NewManager(vaultDir)
+41
View File
@@ -21,6 +21,7 @@ type Config struct {
CurrentVaultPath string `json:"currentVaultPath"` CurrentVaultPath string `json:"currentVaultPath"`
RecentVaults []string `json:"recentVaults"` RecentVaults []string `json:"recentVaults"`
Theme string `json:"theme"` Theme string `json:"theme"`
Language string `json:"language"`
DevMode bool `json:"devMode"` DevMode bool `json:"devMode"`
UserPluginsDir string `json:"userPluginsDir"` UserPluginsDir string `json:"userPluginsDir"`
Workbench WorkbenchPreferences `json:"workbench,omitempty"` Workbench WorkbenchPreferences `json:"workbench,omitempty"`
@@ -30,6 +31,21 @@ type Config struct {
LastOpenedAt string `json:"lastOpenedAt"` LastOpenedAt string `json:"lastOpenedAt"`
} }
const (
LanguageSystem = "system"
LanguageEnglish = "en"
LanguageRussian = "ru"
)
func validLanguage(language string) bool {
switch language {
case LanguageSystem, LanguageEnglish, LanguageRussian:
return true
default:
return false
}
}
type WorkbenchPreferences struct { type WorkbenchPreferences struct {
DefaultTextEditorProvider string `json:"defaultTextEditorProvider,omitempty"` DefaultTextEditorProvider string `json:"defaultTextEditorProvider,omitempty"`
DefaultMarkdownEditorProvider string `json:"defaultMarkdownEditorProvider,omitempty"` DefaultMarkdownEditorProvider string `json:"defaultMarkdownEditorProvider,omitempty"`
@@ -121,6 +137,9 @@ func (m *Manager) Load() error {
if cfg.Theme == "" { if cfg.Theme == "" {
cfg.Theme = "dark" cfg.Theme = "dark"
} }
if !validLanguage(cfg.Language) {
cfg.Language = LanguageSystem
}
if cfg.RecentVaults == nil { if cfg.RecentVaults == nil {
cfg.RecentVaults = []string{} cfg.RecentVaults = []string{}
} }
@@ -179,6 +198,9 @@ func (m *Manager) Update(patch *Config) error {
if patch.Theme != "" { if patch.Theme != "" {
m.config.Theme = patch.Theme m.config.Theme = patch.Theme
} }
if patch.Language != "" && validLanguage(patch.Language) {
m.config.Language = patch.Language
}
if patch.UserPluginsDir != "" { if patch.UserPluginsDir != "" {
m.config.UserPluginsDir = patch.UserPluginsDir m.config.UserPluginsDir = patch.UserPluginsDir
} }
@@ -200,6 +222,23 @@ func (m *Manager) Update(patch *Config) error {
return m.saveLocked() return m.saveLocked()
} }
// UpdateLanguage validates and persists the installation-local UI language.
func (m *Manager) UpdateLanguage(language string) error {
language = strings.TrimSpace(language)
if !validLanguage(language) {
return fmt.Errorf("unsupported language %q", language)
}
m.mu.Lock()
defer m.mu.Unlock()
if m.config == nil {
m.config = defaultConfig()
}
m.config.Language = language
m.config.LastOpenedAt = time.Now().UTC().Format(time.RFC3339)
return m.saveLocked()
}
// UpdateSync replaces sync settings without changing unrelated app settings. // UpdateSync replaces sync settings without changing unrelated app settings.
func (m *Manager) UpdateSync(syncSettings SyncSettings) error { func (m *Manager) UpdateSync(syncSettings SyncSettings) error {
m.mu.Lock() m.mu.Lock()
@@ -287,6 +326,7 @@ func defaultConfig() *Config {
CurrentVaultPath: "", CurrentVaultPath: "",
RecentVaults: []string{}, RecentVaults: []string{},
Theme: "dark", Theme: "dark",
Language: LanguageSystem,
DevMode: false, DevMode: false,
UserPluginsDir: filepath.Join(os.Getenv("HOME"), ".config", "verstak", "plugins"), UserPluginsDir: filepath.Join(os.Getenv("HOME"), ".config", "verstak", "plugins"),
Workbench: WorkbenchPreferences{}, Workbench: WorkbenchPreferences{},
@@ -303,6 +343,7 @@ func copyConfig(c *Config) *Config {
CurrentVaultPath: c.CurrentVaultPath, CurrentVaultPath: c.CurrentVaultPath,
RecentVaults: recent, RecentVaults: recent,
Theme: c.Theme, Theme: c.Theme,
Language: c.Language,
DevMode: c.DevMode, DevMode: c.DevMode,
UserPluginsDir: c.UserPluginsDir, UserPluginsDir: c.UserPluginsDir,
Workbench: c.Workbench, Workbench: c.Workbench,
+47
View File
@@ -25,6 +25,53 @@ func TestLoad_DefaultCreation(t *testing.T) {
if cfg.CurrentVaultPath != "" { if cfg.CurrentVaultPath != "" {
t.Errorf("CurrentVaultPath: got %q, want empty", cfg.CurrentVaultPath) t.Errorf("CurrentVaultPath: got %q, want empty", cfg.CurrentVaultPath)
} }
if cfg.Language != "system" {
t.Errorf("Language: got %q, want %q", cfg.Language, "system")
}
}
func TestLoad_MissingLanguageDefaultsToSystem(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"schemaVersion":1,"theme":"dark","devMode":true}`), 0o600); err != nil {
t.Fatal(err)
}
m := NewManager(path)
if err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
if got := m.Get().Language; got != "system" {
t.Fatalf("Language = %q, want system", got)
}
}
func TestUpdateLanguagePersistsAndPreservesUnrelatedSettings(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
m := NewManager(path)
if err := m.Load(); err != nil {
t.Fatal(err)
}
if err := m.Update(&Config{Theme: "light", DevMode: true}); err != nil {
t.Fatal(err)
}
if err := m.UpdateLanguage("ru"); err != nil {
t.Fatalf("UpdateLanguage: %v", err)
}
reloaded := NewManager(path)
if err := reloaded.Load(); err != nil {
t.Fatal(err)
}
cfg := reloaded.Get()
if cfg.Language != "ru" || cfg.Theme != "light" || !cfg.DevMode {
t.Fatalf("settings after language update = %+v", cfg)
}
if err := reloaded.UpdateLanguage("de"); err == nil {
t.Fatal("UpdateLanguage(de) succeeded, want validation error")
}
if got := reloaded.Get().Language; got != "ru" {
t.Fatalf("Language after rejected update = %q, want ru", got)
}
} }
func TestLoad_CorruptConfig(t *testing.T) { func TestLoad_CorruptConfig(t *testing.T) {
+222 -9
View File
@@ -12,18 +12,20 @@ import (
"log" "log"
"net" "net"
"net/http" "net/http"
"net/url"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/verstak/verstak-desktop/internal/core/events" "github.com/verstak/verstak-desktop/internal/core/events"
"github.com/verstak/verstak-desktop/internal/core/hostname"
) )
const ( const (
capturePath = "/api/browser-inbox/v1/captures" capturePath = "/api/browser-inbox/v1/captures"
activityBatchPath = "/api/browser-activity/v1/batches"
DefaultAddr = "127.0.0.1:47731" DefaultAddr = "127.0.0.1:47731"
DefaultCaptureURL = "http://" + DefaultAddr + capturePath DefaultCaptureURL = "http://" + DefaultAddr + capturePath
DefaultActivityURL = "http://" + DefaultAddr + activityBatchPath
receiverTokenHeader = "X-Verstak-Receiver-Token" receiverTokenHeader = "X-Verstak-Receiver-Token"
) )
@@ -43,6 +45,9 @@ const (
maxFileTextBytes = 2 * 1024 * 1024 maxFileTextBytes = 2 * 1024 * 1024
maxFileBytes = 8 * 1024 * 1024 maxFileBytes = 8 * 1024 * 1024
maxFileDataBase64Bytes = 4 * ((maxFileBytes + 2) / 3) maxFileDataBase64Bytes = 4 * ((maxFileBytes + 2) / 3)
maxActivityBodyBytes = 256 * 1024
maxActivityEntries = 100
maxActivityDuration = 10 * time.Minute
) )
type Receiver struct { type Receiver struct {
@@ -55,8 +60,12 @@ type Receiver struct {
type WorkspaceProvider func() string type WorkspaceProvider func() string
type Options struct { type Options struct {
RequireToken bool RequireToken bool
ReceiverToken string ReceiverToken string
Available func() bool
Persist func(events.Event) error
ActivityAvailable func() bool
PersistActivity func(events.Event) error
} }
type Server struct { type Server struct {
@@ -105,6 +114,23 @@ type CaptureBrowser struct {
Name string `json:"name"` Name string `json:"name"`
} }
// ActivityBatchPayload contains only domain-level time accounting. It never
// accepts or emits page URLs, titles, content, or navigation history.
type ActivityBatchPayload struct {
SchemaVersion int `json:"schemaVersion"`
BatchID string `json:"batchId"`
CreatedAt string `json:"createdAt"`
Source string `json:"source"`
Entries []ActivityEntry `json:"entries"`
}
type ActivityEntry struct {
Hostname string `json:"hostname"`
StartedAt string `json:"startedAt"`
EndedAt string `json:"endedAt"`
DurationSeconds int64 `json:"durationSeconds"`
}
func New(bus *events.Bus, providers ...WorkspaceProvider) *Receiver { func New(bus *events.Bus, providers ...WorkspaceProvider) *Receiver {
return NewWithOptions(bus, Options{}, providers...) return NewWithOptions(bus, Options{}, providers...)
} }
@@ -128,6 +154,29 @@ func (r *Receiver) SetReceiverToken(token string) {
r.options.ReceiverToken = strings.TrimSpace(token) r.options.ReceiverToken = strings.TrimSpace(token)
} }
// SetPersistence configures durable capture storage and its availability gate.
func (r *Receiver) SetPersistence(available func() bool, persist func(events.Event) error) {
if r == nil {
return
}
r.optionsMu.Lock()
defer r.optionsMu.Unlock()
r.options.Available = available
r.options.Persist = persist
}
// SetActivityPersistence configures durable passive browser activity storage.
// An acknowledgement is sent only after persist returns successfully.
func (r *Receiver) SetActivityPersistence(available func() bool, persist func(events.Event) error) {
if r == nil {
return
}
r.optionsMu.Lock()
defer r.optionsMu.Unlock()
r.options.ActivityAvailable = available
r.options.PersistActivity = persist
}
func Start(addr string, receiver *Receiver) (*Server, error) { func Start(addr string, receiver *Receiver) (*Server, error) {
if receiver == nil { if receiver == nil {
return nil, fmt.Errorf("receiver is required") return nil, fmt.Errorf("receiver is required")
@@ -168,6 +217,10 @@ func (s *Server) Close() error {
func (r *Receiver) ServeHTTP(w http.ResponseWriter, req *http.Request) { func (r *Receiver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
if req.URL.Path == activityBatchPath {
r.serveActivityBatch(w, req)
return
}
if req.URL.Path != capturePath { if req.URL.Path != capturePath {
http.NotFound(w, req) http.NotFound(w, req)
return return
@@ -181,6 +234,11 @@ func (r *Receiver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
writeError(w, http.StatusUnauthorized, err.Error()) writeError(w, http.StatusUnauthorized, err.Error())
return return
} }
options := r.currentOptions()
if options.Available != nil && !options.Available() {
writeError(w, http.StatusServiceUnavailable, "browser inbox unavailable")
return
}
defer req.Body.Close() defer req.Body.Close()
decoder := json.NewDecoder(http.MaxBytesReader(w, req.Body, maxCaptureBodyBytes)) decoder := json.NewDecoder(http.MaxBytesReader(w, req.Body, maxCaptureBodyBytes))
@@ -209,17 +267,27 @@ func (r *Receiver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
} }
eventName := "browser.capture." + payload.Kind eventName := "browser.capture." + payload.Kind
if r.bus == nil || !r.bus.HasSubscribers(eventName) { if options.Persist == nil && (r.bus == nil || !r.bus.HasSubscribers(eventName)) {
writeError(w, http.StatusServiceUnavailable, "browser inbox unavailable") writeError(w, http.StatusServiceUnavailable, "browser inbox unavailable")
return return
} }
eventPayload := payload.EventPayload() eventPayload := payload.EventPayload()
r.annotateWorkspace(eventPayload) r.annotateWorkspace(eventPayload)
r.bus.Publish(events.Event{ event := events.Event{
Name: eventName, Name: eventName,
Timestamp: time.Now().UTC().Format(time.RFC3339Nano), Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
Payload: eventPayload, Payload: eventPayload,
}) }
if options.Persist != nil {
if err := options.Persist(event); err != nil {
log.Printf("[browserreceiver] persist %s: %v", payload.CaptureID, err)
writeError(w, http.StatusServiceUnavailable, "browser inbox unavailable")
return
}
}
if r.bus != nil {
r.bus.Publish(event)
}
w.WriteHeader(http.StatusAccepted) w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(map[string]string{ _ = json.NewEncoder(w).Encode(map[string]string{
@@ -228,6 +296,68 @@ func (r *Receiver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
}) })
} }
func (r *Receiver) serveActivityBatch(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed"})
return
}
if err := r.validateReceiverToken(req); err != nil {
writeError(w, http.StatusUnauthorized, err.Error())
return
}
options := r.currentOptions()
if options.ActivityAvailable == nil || !options.ActivityAvailable() || options.PersistActivity == nil {
writeError(w, http.StatusServiceUnavailable, "activity storage unavailable")
return
}
defer req.Body.Close()
decoder := json.NewDecoder(http.MaxBytesReader(w, req.Body, maxActivityBodyBytes))
var batch ActivityBatchPayload
if err := decoder.Decode(&batch); err != nil {
writeActivityDecodeError(w, err)
return
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
writeActivityDecodeError(w, err)
return
}
if err := batch.NormalizeAndValidate(); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
event := events.Event{
Name: "browser.activity.batch",
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
Payload: batch.EventPayload(),
}
if err := options.PersistActivity(event); err != nil {
log.Printf("[browserreceiver] persist activity %s: %v", batch.BatchID, err)
writeError(w, http.StatusServiceUnavailable, "activity storage unavailable")
return
}
if r.bus != nil {
r.bus.Publish(event)
}
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "accepted",
"batchId": batch.BatchID,
})
}
func (r *Receiver) currentOptions() Options {
if r == nil {
return Options{}
}
r.optionsMu.RLock()
defer r.optionsMu.RUnlock()
return r.options
}
func (r *Receiver) validateReceiverToken(req *http.Request) error { func (r *Receiver) validateReceiverToken(req *http.Request) error {
if r == nil { if r == nil {
return nil return nil
@@ -333,6 +463,80 @@ func (p CapturePayload) Validate() error {
return nil return nil
} }
// NormalizeAndValidate validates a batch before it is made durable and replaces
// each submitted hostname with the shared canonical A-label form.
func (p *ActivityBatchPayload) NormalizeAndValidate() error {
if p == nil || p.SchemaVersion != 1 {
return fmt.Errorf("unsupported schemaVersion")
}
if strings.TrimSpace(p.BatchID) == "" {
return fmt.Errorf("batchId is required")
}
if err := validateCaptureText(p.BatchID, "batchId", maxCaptureIDBytes); err != nil {
return err
}
if strings.TrimSpace(p.CreatedAt) == "" {
return fmt.Errorf("createdAt is required")
}
if _, err := time.Parse(time.RFC3339, p.CreatedAt); err != nil {
return fmt.Errorf("createdAt is invalid")
}
if err := validateCaptureText(p.CreatedAt, "createdAt", maxCapturedAtBytes); err != nil {
return err
}
if strings.TrimSpace(p.Source) == "" {
return fmt.Errorf("source is required")
}
if err := validateCaptureText(p.Source, "source", maxCaptureSourceBytes); err != nil {
return err
}
if len(p.Entries) == 0 || len(p.Entries) > maxActivityEntries {
return fmt.Errorf("entries must contain between 1 and %d items", maxActivityEntries)
}
for index := range p.Entries {
entry := &p.Entries[index]
canonical := hostname.NormalizeHostnameV1(entry.Hostname)
if canonical == "" {
return fmt.Errorf("entries[%d].hostname is invalid", index)
}
startedAt, err := time.Parse(time.RFC3339, entry.StartedAt)
if err != nil {
return fmt.Errorf("entries[%d].startedAt is invalid", index)
}
endedAt, err := time.Parse(time.RFC3339, entry.EndedAt)
if err != nil {
return fmt.Errorf("entries[%d].endedAt is invalid", index)
}
interval := endedAt.Sub(startedAt)
if interval <= 0 || interval > maxActivityDuration {
return fmt.Errorf("entries[%d] interval must be between 1 second and %s", index, maxActivityDuration)
}
if entry.DurationSeconds <= 0 || entry.DurationSeconds > int64(maxActivityDuration/time.Second) || time.Duration(entry.DurationSeconds)*time.Second > interval {
return fmt.Errorf("entries[%d].durationSeconds is invalid", index)
}
entry.Hostname = canonical
}
return nil
}
func (p ActivityBatchPayload) EventPayload() map[string]interface{} {
entries := make([]map[string]interface{}, 0, len(p.Entries))
for _, entry := range p.Entries {
entries = append(entries, map[string]interface{}{
"hostname": entry.Hostname,
"startedAt": entry.StartedAt,
"endedAt": entry.EndedAt,
"durationSeconds": entry.DurationSeconds,
})
}
return map[string]interface{}{
"batchId": strings.TrimSpace(p.BatchID),
"createdAt": strings.TrimSpace(p.CreatedAt),
"source": strings.TrimSpace(p.Source),
"entries": entries,
}
}
func (p CapturePayload) validateFile() error { func (p CapturePayload) validateFile() error {
if p.File == nil || strings.TrimSpace(p.File.Name) == "" { if p.File == nil || strings.TrimSpace(p.File.Name) == "" {
return fmt.Errorf("file.name is required") return fmt.Errorf("file.name is required")
@@ -413,10 +617,19 @@ func (p CapturePayload) EventPayload() map[string]interface{} {
} }
func captureDomain(rawURL, fallback string) string { func captureDomain(rawURL, fallback string) string {
if u, err := url.Parse(strings.TrimSpace(rawURL)); err == nil && u.Hostname() != "" { if normalized := hostname.NormalizeURLHostnameV1(rawURL); normalized != "" {
return u.Hostname() return normalized
} }
return strings.TrimSpace(fallback) return hostname.NormalizeHostnameV1(fallback)
}
func writeActivityDecodeError(w http.ResponseWriter, err error) {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
writeError(w, http.StatusRequestEntityTooLarge, "activity payload exceeds limit")
return
}
writeError(w, http.StatusBadRequest, "invalid JSON")
} }
func writeError(w http.ResponseWriter, status int, message string) { func writeError(w http.ResponseWriter, status int, message string) {
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -237,6 +238,101 @@ func TestReceiverLeavesCaptureUnassignedWithoutCurrentWorkspace(t *testing.T) {
} }
} }
func TestReceiverAcceptsDomainActivityBatchAfterDurablePersistence(t *testing.T) {
bus := events.NewBus()
received := make(chan events.Event, 1)
bus.Subscribe("browser.activity.batch", func(event events.Event) {
received <- event
})
persisted := 0
receiver := NewWithOptions(bus, Options{
ActivityAvailable: func() bool { return true },
PersistActivity: func(event events.Event) error {
persisted++
return nil
},
})
body := `{
"schemaVersion": 1,
"batchId": "batch-123",
"createdAt": "2026-07-12T10:05:00.000Z",
"source": "verstak-browser-extension",
"entries": [{
"hostname": "пример.рф",
"startedAt": "2026-07-12T10:00:00.000Z",
"endedAt": "2026-07-12T10:05:00.000Z",
"durationSeconds": 300
}]
}`
req := httptest.NewRequest(http.MethodPost, "/api/browser-activity/v1/batches", bytes.NewBufferString(body))
rec := httptest.NewRecorder()
receiver.ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusAccepted, rec.Body.String())
}
if persisted != 1 {
t.Fatalf("persisted = %d, want 1 before acknowledgement", persisted)
}
var response map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("response json: %v", err)
}
if response["batchId"] != "batch-123" || response["status"] != "accepted" {
t.Fatalf("response = %+v, want accepted batch-123", response)
}
event := <-received
payload, ok := event.Payload.(map[string]interface{})
if !ok {
t.Fatalf("event payload type = %T, want map[string]interface{}", event.Payload)
}
if payload["batchId"] != "batch-123" {
t.Fatalf("event payload = %+v, want batch id", payload)
}
entries, ok := payload["entries"].([]map[string]interface{})
if !ok || len(entries) != 1 || entries[0]["hostname"] != "xn--e1afmkfd.xn--p1ai" {
t.Fatalf("event entries = %+v, want one canonical hostname", payload["entries"])
}
if _, ok := payload["url"]; ok {
t.Fatalf("activity payload must not contain URL: %+v", payload)
}
}
func TestReceiverRejectsDomainActivityWithoutConsumerOrValidInterval(t *testing.T) {
for _, tc := range []struct {
name string
receiver *Receiver
body string
wantCode int
}{
{
name: "consumer unavailable",
receiver: New(events.NewBus()),
body: `{"schemaVersion":1,"batchId":"batch-a","createdAt":"2026-07-12T10:05:00Z","source":"verstak-browser-extension","entries":[{"hostname":"example.com","startedAt":"2026-07-12T10:00:00Z","endedAt":"2026-07-12T10:05:00Z","durationSeconds":300}]}`,
wantCode: http.StatusServiceUnavailable,
},
{
name: "duration too long",
receiver: NewWithOptions(events.NewBus(), Options{
ActivityAvailable: func() bool { return true },
PersistActivity: func(events.Event) error { return nil },
}),
body: `{"schemaVersion":1,"batchId":"batch-b","createdAt":"2026-07-12T10:05:00Z","source":"verstak-browser-extension","entries":[{"hostname":"example.com","startedAt":"2026-07-12T10:00:00Z","endedAt":"2026-07-12T10:20:01Z","durationSeconds":1201}]}`,
wantCode: http.StatusBadRequest,
},
} {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/browser-activity/v1/batches", strings.NewReader(tc.body))
rec := httptest.NewRecorder()
tc.receiver.ServeHTTP(rec, req)
if rec.Code != tc.wantCode {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, tc.wantCode, rec.Body.String())
}
})
}
}
func TestReceiverRequiresTokenWhenPaired(t *testing.T) { func TestReceiverRequiresTokenWhenPaired(t *testing.T) {
bus := events.NewBus() bus := events.NewBus()
received := make(chan events.Event, 1) received := make(chan events.Event, 1)
@@ -321,6 +417,61 @@ func TestReceiverAcceptsPairedToken(t *testing.T) {
} }
} }
func TestReceiverRejectsCaptureWhenInboxIsUnavailable(t *testing.T) {
bus := events.NewBus()
bus.Subscribe("browser.capture.page", func(event events.Event) {
t.Fatalf("unexpected event while inbox is unavailable: %#v", event)
})
receiver := NewWithOptions(bus, Options{
Available: func() bool { return false },
})
req := httptest.NewRequest(http.MethodPost, capturePath, strings.NewReader(`{
"schemaVersion": 1,
"captureId": "capture-no-vault",
"capturedAt": "2026-07-11T12:00:00Z",
"kind": "page",
"page": {"url": "https://example.com"}
}`))
res := httptest.NewRecorder()
receiver.ServeHTTP(res, req)
if res.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d; body=%s", res.Code, http.StatusServiceUnavailable, res.Body.String())
}
}
func TestReceiverDoesNotAcknowledgeFailedPersistence(t *testing.T) {
bus := events.NewBus()
published := false
bus.Subscribe("browser.capture.page", func(event events.Event) {
published = true
})
receiver := NewWithOptions(bus, Options{
Available: func() bool { return true },
Persist: func(event events.Event) error {
return errors.New("disk full")
},
})
req := httptest.NewRequest(http.MethodPost, capturePath, strings.NewReader(`{
"schemaVersion": 1,
"captureId": "capture-disk-full",
"capturedAt": "2026-07-11T12:00:00Z",
"kind": "page",
"page": {"url": "https://example.com"}
}`))
res := httptest.NewRecorder()
receiver.ServeHTTP(res, req)
if res.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d; body=%s", res.Code, http.StatusServiceUnavailable, res.Body.String())
}
if published {
t.Fatal("capture event was published after persistence failed")
}
}
func TestReceiverRotatesPairedToken(t *testing.T) { func TestReceiverRotatesPairedToken(t *testing.T) {
bus := events.NewBus() bus := events.NewBus()
bus.Subscribe("browser.capture.page", func(event events.Event) {}) bus.Subscribe("browser.capture.page", func(event events.Event) {})
+123
View File
@@ -0,0 +1,123 @@
package hostname
import (
"net/netip"
"net/url"
"strings"
"unicode"
"golang.org/x/net/idna"
)
const (
maxDNSHostnameLength = 253
maxDNSLabelLength = 63
)
// NormalizeHostnameV1 returns the canonical A-label hostname used by domain
// bindings. It accepts a bare DNS name, IPv4 address, bracketed IPv6 literal,
// localhost, or a single-label internal hostname. Invalid input returns "".
func NormalizeHostnameV1(input string) string {
value := strings.TrimSpace(input)
if value == "" || strings.IndexFunc(value, unicode.IsSpace) >= 0 || strings.ContainsAny(value, "\\/?#@") {
return ""
}
if strings.HasPrefix(value, "[") || strings.HasSuffix(value, "]") {
if !strings.HasPrefix(value, "[") || !strings.HasSuffix(value, "]") {
return ""
}
return normalizeIPv6(value[1 : len(value)-1])
}
if strings.Contains(value, ":") {
return ""
}
value = strings.TrimSuffix(value, ".")
if value == "" || strings.HasSuffix(value, ".") {
return ""
}
if isNumericHostname(value) {
return normalizeIPv4(value)
}
return normalizeDNS(value)
}
// NormalizeURLHostnameV1 returns the canonical hostname from an HTTP(S) URL.
// Ports, paths, credentials, and fragments are intentionally not preserved.
func NormalizeURLHostnameV1(input string) string {
value := strings.TrimSpace(input)
if value == "" {
return ""
}
parsed, err := url.ParseRequestURI(value)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return ""
}
host := parsed.Hostname()
if host == "" {
return ""
}
if address, err := netip.ParseAddr(host); err == nil && address.Is6() {
return address.String()
}
return NormalizeHostnameV1(host)
}
func normalizeIPv4(value string) string {
address, err := netip.ParseAddr(value)
if err != nil || !address.Is4() || address.String() != value {
return ""
}
return address.String()
}
func normalizeIPv6(value string) string {
address, err := netip.ParseAddr(value)
if err != nil || !address.Is6() {
return ""
}
return address.String()
}
func normalizeDNS(value string) string {
ascii, err := idna.Lookup.ToASCII(value)
if err != nil {
return ""
}
ascii = strings.ToLower(strings.TrimSuffix(ascii, "."))
if ascii == "" || strings.HasSuffix(ascii, ".") || len(ascii) > maxDNSHostnameLength {
return ""
}
for _, label := range strings.Split(ascii, ".") {
if len(label) == 0 || len(label) > maxDNSLabelLength || !isDNSLabel(label) {
return ""
}
}
return ascii
}
func isNumericHostname(value string) bool {
for _, char := range value {
if (char < '0' || char > '9') && char != '.' {
return false
}
}
return true
}
func isDNSLabel(label string) bool {
if !isASCIIAlphaNum(label[0]) || !isASCIIAlphaNum(label[len(label)-1]) {
return false
}
for _, char := range label {
if !isASCIIAlphaNum(byte(char)) && char != '-' {
return false
}
}
return true
}
func isASCIIAlphaNum(char byte) bool {
return char >= 'a' && char <= 'z' || char >= '0' && char <= '9'
}
+50
View File
@@ -0,0 +1,50 @@
package hostname
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
type normalizationVector struct {
Input string `json:"input"`
Output string `json:"output"`
}
type normalizationVectors struct {
Bare []normalizationVector `json:"bare"`
URL []normalizationVector `json:"url"`
}
func TestNormalizeHostnameV1Vectors(t *testing.T) {
vectors := loadVectors(t)
for _, vector := range vectors.Bare {
if got := NormalizeHostnameV1(vector.Input); got != vector.Output {
t.Errorf("NormalizeHostnameV1(%q) = %q, want %q", vector.Input, got, vector.Output)
}
}
}
func TestNormalizeURLHostnameV1Vectors(t *testing.T) {
vectors := loadVectors(t)
for _, vector := range vectors.URL {
if got := NormalizeURLHostnameV1(vector.Input); got != vector.Output {
t.Errorf("NormalizeURLHostnameV1(%q) = %q, want %q", vector.Input, got, vector.Output)
}
}
}
func loadVectors(t *testing.T) normalizationVectors {
t.Helper()
path := filepath.Join("testdata", "hostname-normalization-v1.json")
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var vectors normalizationVectors
if err := json.Unmarshal(data, &vectors); err != nil {
t.Fatalf("decode %s: %v", path, err)
}
return vectors
}
@@ -0,0 +1,34 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/mirivlad/verstak-sdk/main/schemas/hostname-normalization-v1.json",
"title": "Verstak canonical hostname normalization v1 test vectors",
"description": "Canonical hostnames are lowercase ASCII A-labels without a port or trailing DNS dot. Bare hostnames accept DNS names, IPv4, bracketed IPv6, localhost, and internal single-label names. URL inputs accept only HTTP(S). Invalid or excessively long input normalizes to an empty string.",
"version": 1,
"bare": [
{ "input": "example.com", "output": "example.com" },
{ "input": " Example.COM. ", "output": "example.com" },
{ "input": "пример.рф", "output": "xn--e1afmkfd.xn--p1ai" },
{ "input": "bücher.example", "output": "xn--bcher-kva.example" },
{ "input": "127.0.0.1", "output": "127.0.0.1" },
{ "input": "[2001:db8::1]", "output": "2001:db8::1" },
{ "input": "localhost", "output": "localhost" },
{ "input": "intranet", "output": "intranet" },
{ "input": "", "output": "" },
{ "input": "https://example.com", "output": "" },
{ "input": "example.com:443", "output": "" },
{ "input": "user@example.com", "output": "" },
{ "input": "bad host", "output": "" },
{ "input": "example..com", "output": "" },
{ "input": "example.com..", "output": "" },
{ "input": "127.000.000.001", "output": "" },
{ "input": "[2001:db8::1]:443", "output": "" },
{ "input": "a...............................................................example", "output": "" }
],
"url": [
{ "input": "https://пример.рф/path", "output": "xn--e1afmkfd.xn--p1ai" },
{ "input": "http://Example.COM.:8080/path", "output": "example.com" },
{ "input": "https://[2001:db8::1]/", "output": "2001:db8::1" },
{ "input": "ftp://example.com/path", "output": "" },
{ "input": "not a URL", "output": "" }
]
}
+69 -17
View File
@@ -7,28 +7,36 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
) )
// Manifest represents a Verstak plugin.json manifest. // Manifest represents a Verstak plugin.json manifest.
type Manifest struct { type Manifest struct {
SchemaVersion int `json:"schemaVersion"` SchemaVersion int `json:"schemaVersion"`
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Version string `json:"version"` Version string `json:"version"`
APIVersion string `json:"apiVersion"` APIVersion string `json:"apiVersion"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Source string `json:"source,omitempty"` Source string `json:"source,omitempty"`
Icon string `json:"icon,omitempty"` Icon string `json:"icon,omitempty"`
Provides []string `json:"provides"` Localization *LocalizationConfig `json:"localization,omitempty"`
Requires []string `json:"requires,omitempty"` Provides []string `json:"provides"`
OptionalRequires []string `json:"optionalRequires,omitempty"` Requires []string `json:"requires,omitempty"`
Permissions []string `json:"permissions"` OptionalRequires []string `json:"optionalRequires,omitempty"`
Frontend *FrontendConfig `json:"frontend,omitempty"` Permissions []string `json:"permissions"`
Backend *BackendConfig `json:"backend,omitempty"` Frontend *FrontendConfig `json:"frontend,omitempty"`
Migrations *MigrationConfig `json:"migrations,omitempty"` Backend *BackendConfig `json:"backend,omitempty"`
Contributes *Contributions `json:"contributes,omitempty"` Migrations *MigrationConfig `json:"migrations,omitempty"`
Sync *SyncConfig `json:"sync,omitempty"` Contributes *Contributions `json:"contributes,omitempty"`
Sync *SyncConfig `json:"sync,omitempty"`
}
// LocalizationConfig declares plugin-owned UI message catalogs.
type LocalizationConfig struct {
DefaultLocale string `json:"defaultLocale"`
Locales map[string]string `json:"locales"`
} }
// FrontendConfig describes the plugin's frontend bundle. // FrontendConfig describes the plugin's frontend bundle.
@@ -237,6 +245,25 @@ func ValidateManifest(m *Manifest) []string {
if len(m.Permissions) == 0 { if len(m.Permissions) == 0 {
errs.add("permissions must have at least one permission") errs.add("permissions must have at least one permission")
} }
if m.Localization != nil {
if !isValidLocaleTag(m.Localization.DefaultLocale) {
errs.add("localization.defaultLocale %q is not a valid lower-case locale", m.Localization.DefaultLocale)
}
if len(m.Localization.Locales) == 0 {
errs.add("localization.locales must contain at least one catalog")
}
if _, ok := m.Localization.Locales[m.Localization.DefaultLocale]; !ok {
errs.add("localization.defaultLocale %q must have a declared catalog", m.Localization.DefaultLocale)
}
for locale, catalogPath := range m.Localization.Locales {
if !isValidLocaleTag(locale) {
errs.add("localization locale %q is not a valid lower-case locale", locale)
}
if err := ValidateLocalizationPath(catalogPath); err != nil {
errs.add("localization locale %q: %v", locale, err)
}
}
}
if m.Contributes != nil { if m.Contributes != nil {
for i, provider := range m.Contributes.OpenProviders { for i, provider := range m.Contributes.OpenProviders {
if provider.ID == "" { if provider.ID == "" {
@@ -262,6 +289,31 @@ func ValidateManifest(m *Manifest) []string {
return errs.errors return errs.errors
} }
var localeTagPattern = regexp.MustCompile(`^[a-z]{2}(?:-[a-z0-9]+)*$`)
func isValidLocaleTag(locale string) bool {
return localeTagPattern.MatchString(locale)
}
// ValidateLocalizationPath accepts only slash-separated plugin-relative paths.
func ValidateLocalizationPath(catalogPath string) error {
if strings.TrimSpace(catalogPath) == "" {
return fmt.Errorf("catalog path is required")
}
if filepath.IsAbs(catalogPath) || filepath.VolumeName(catalogPath) != "" || strings.HasPrefix(catalogPath, "/") {
return fmt.Errorf("catalog path must be relative")
}
if strings.Contains(catalogPath, `\`) {
return fmt.Errorf("catalog path must not contain backslash")
}
for _, part := range strings.Split(catalogPath, "/") {
if part == ".." {
return fmt.Errorf("catalog path traversal is not allowed")
}
}
return nil
}
func isValidPluginID(id string) bool { func isValidPluginID(id string) bool {
if id == "" { if id == "" {
return false return false
+44
View File
@@ -233,6 +233,50 @@ func TestValidateManifest_OpenProviders(t *testing.T) {
} }
} }
func TestValidateManifest_Localization(t *testing.T) {
base := &Manifest{
SchemaVersion: 1,
ID: "localized.plugin",
Name: "Localized",
Version: "1.0.0",
APIVersion: "1.0",
Provides: []string{"localized.capability"},
Permissions: []string{"ui.register"},
Localization: &LocalizationConfig{
DefaultLocale: "en",
Locales: map[string]string{
"en": "locales/en.json",
"ru": "locales/ru.json",
},
},
}
if errs := ValidateManifest(base); len(errs) != 0 {
t.Fatalf("valid localization errors = %v", errs)
}
tests := []struct {
name string
localization *LocalizationConfig
want string
}{
{"missing default catalog", &LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"ru": "locales/ru.json"}}, "defaultLocale"},
{"invalid locale", &LocalizationConfig{DefaultLocale: "EN", Locales: map[string]string{"EN": "locales/en.json"}}, "locale"},
{"absolute path", &LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"en": "/tmp/en.json"}}, "relative"},
{"traversal", &LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"en": "../en.json"}}, "traversal"},
{"backslash", &LocalizationConfig{DefaultLocale: "en", Locales: map[string]string{"en": `locales\en.json`}}, "backslash"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
manifest := *base
manifest.Localization = tc.localization
combined := strings.Join(ValidateManifest(&manifest), "\n")
if !strings.Contains(combined, tc.want) {
t.Fatalf("errors = %q, want %q", combined, tc.want)
}
})
}
}
// TestDiscoverPlugins_MultipleDirs ensures discovery scans multiple directories. // TestDiscoverPlugins_MultipleDirs ensures discovery scans multiple directories.
func TestDiscoverPlugins_MultipleDirs(t *testing.T) { func TestDiscoverPlugins_MultipleDirs(t *testing.T) {
dir1 := t.TempDir() dir1 := t.TempDir()
+260 -10
View File
@@ -20,6 +20,17 @@ type Storage struct {
vault *vault.Vault vault *vault.Vault
} }
// NDJSONRetention bounds append-only plugin data. Records are compacted after
// a successful append so settings.json never becomes an event log.
type NDJSONRetention struct {
TimestampField string
MaxAge time.Duration
MaxEntries int
MaxBytes int64
DeduplicateField string
DeduplicateValue string
}
// New creates a new Storage instance backed by the given vault. // New creates a new Storage instance backed by the given vault.
func New(v *vault.Vault) *Storage { func New(v *vault.Vault) *Storage {
return &Storage{vault: v} return &Storage{vault: v}
@@ -84,13 +95,22 @@ func atomicWrite(path string, data []byte) error {
// ReadPluginSettings reads all settings for a plugin. // ReadPluginSettings reads all settings for a plugin.
// Returns empty map if settings.json does not exist. // Returns empty map if settings.json does not exist.
func (s *Storage) ReadPluginSettings(pluginID string) (map[string]interface{}, error) { func (s *Storage) ReadPluginSettings(pluginID string) (map[string]interface{}, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if err := validatePluginID(pluginID); err != nil { if err := validatePluginID(pluginID); err != nil {
return nil, err return nil, err
} }
var result map[string]interface{}
err := s.withOpenVault(func(vaultPath string) error {
var err error
result, err = readPluginSettingsAt(vaultPath, pluginID)
return err
})
return result, err
}
dir := s.vault.GetPluginSettingsPath(pluginID) func readPluginSettingsAt(vaultPath, pluginID string) (map[string]interface{}, error) {
path := filepath.Join(dir, "settings.json") path := filepath.Join(vaultPath, ".verstak", "plugin-settings", pluginID, "settings.json")
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
@@ -108,13 +128,18 @@ func (s *Storage) ReadPluginSettings(pluginID string) (map[string]interface{}, e
// WritePluginSettings writes all settings for a plugin atomically. // WritePluginSettings writes all settings for a plugin atomically.
func (s *Storage) WritePluginSettings(pluginID string, data map[string]interface{}) error { func (s *Storage) WritePluginSettings(pluginID string, data map[string]interface{}) error {
s.mu.Lock()
defer s.mu.Unlock()
if err := validatePluginID(pluginID); err != nil { if err := validatePluginID(pluginID); err != nil {
return err return err
} }
return s.withOpenVault(func(vaultPath string) error {
return writePluginSettingsAt(vaultPath, pluginID, data)
})
}
dir := s.vault.GetPluginSettingsPath(pluginID) func writePluginSettingsAt(vaultPath, pluginID string, data map[string]interface{}) error {
path := filepath.Join(dir, "settings.json") path := filepath.Join(vaultPath, ".verstak", "plugin-settings", pluginID, "settings.json")
encoded, err := json.MarshalIndent(data, "", " ") encoded, err := json.MarshalIndent(data, "", " ")
if err != nil { if err != nil {
return fmt.Errorf("failed to marshal settings for plugin %s: %w", pluginID, err) return fmt.Errorf("failed to marshal settings for plugin %s: %w", pluginID, err)
@@ -137,12 +162,39 @@ func (s *Storage) ReadPluginSetting(pluginID, key string) (interface{}, error) {
// WritePluginSetting writes a single setting key. // WritePluginSetting writes a single setting key.
func (s *Storage) WritePluginSetting(pluginID, key string, value interface{}) error { func (s *Storage) WritePluginSetting(pluginID, key string, value interface{}) error {
settings, err := s.ReadPluginSettings(pluginID) return s.UpdatePluginSettings(pluginID, func(settings map[string]interface{}) error {
if err != nil { settings[key] = value
return nil
})
}
// UpdatePluginSettings atomically reads, mutates, and writes a plugin's settings.
func (s *Storage) UpdatePluginSettings(pluginID string, update func(map[string]interface{}) error) error {
if update == nil {
return fmt.Errorf("settings update is nil")
}
s.mu.Lock()
defer s.mu.Unlock()
if err := validatePluginID(pluginID); err != nil {
return err return err
} }
settings[key] = value return s.withOpenVault(func(vaultPath string) error {
return s.WritePluginSettings(pluginID, settings) settings, err := readPluginSettingsAt(vaultPath, pluginID)
if err != nil {
return err
}
if err := update(settings); err != nil {
return err
}
return writePluginSettingsAt(vaultPath, pluginID, settings)
})
}
func (s *Storage) withOpenVault(operation func(string) error) error {
if s == nil || s.vault == nil {
return fmt.Errorf("vault is not initialized")
}
return s.vault.WithOpenPath(operation)
} }
// ─── Data JSON API ──────────────────────────────────────── // ─── Data JSON API ────────────────────────────────────────
@@ -193,6 +245,204 @@ func (s *Storage) WritePluginDataJSON(pluginID, name string, data map[string]int
return atomicWrite(path, encoded) return atomicWrite(path, encoded)
} }
// ReadPluginDataNDJSON reads an append-only named data file. A missing file is
// represented by an empty slice.
func (s *Storage) ReadPluginDataNDJSON(pluginID, name string) ([]map[string]interface{}, error) {
if err := validatePluginID(pluginID); err != nil {
return nil, err
}
if err := validateStorageName("data", name); err != nil {
return nil, err
}
s.mu.RLock()
defer s.mu.RUnlock()
var records []map[string]interface{}
err := s.withOpenVault(func(vaultPath string) error {
var err error
records, err = readPluginDataNDJSONAt(vaultPath, pluginID, name)
return err
})
return records, err
}
// AppendPluginDataNDJSON appends records durably and then applies bounded
// retention. It returns false without writing when the supplied idempotency
// value is already present in the retained log.
func (s *Storage) AppendPluginDataNDJSON(pluginID, name string, records []map[string]interface{}, retention NDJSONRetention) (bool, error) {
if err := validatePluginID(pluginID); err != nil {
return false, err
}
if err := validateStorageName("data", name); err != nil {
return false, err
}
if len(records) == 0 {
return false, fmt.Errorf("NDJSON records are empty")
}
if retention.MaxEntries < 0 || retention.MaxBytes < 0 || retention.MaxAge < 0 {
return false, fmt.Errorf("NDJSON retention values must not be negative")
}
s.mu.Lock()
defer s.mu.Unlock()
stored := false
err := s.withOpenVault(func(vaultPath string) error {
existing, err := readPluginDataNDJSONAt(vaultPath, pluginID, name)
if err != nil {
return err
}
if retention.DeduplicateField != "" && retention.DeduplicateValue != "" {
for _, record := range existing {
if fmt.Sprint(record[retention.DeduplicateField]) == retention.DeduplicateValue {
return nil
}
}
}
path := pluginDataNDJSONPath(vaultPath, pluginID, name)
if err := appendNDJSON(path, records); err != nil {
return err
}
stored = true
compacted := compactNDJSONRecords(append(existing, records...), retention, time.Now().UTC())
if !sameNDJSONRecords(append(existing, records...), compacted) {
return writeNDJSON(path, compacted)
}
return nil
})
return stored, err
}
// WritePluginDataNDJSON replaces a named append-only data file. It is reserved
// for explicit user actions such as clearing Activity, never normal event
// ingestion.
func (s *Storage) WritePluginDataNDJSON(pluginID, name string, records []map[string]interface{}) error {
if err := validatePluginID(pluginID); err != nil {
return err
}
if err := validateStorageName("data", name); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
return s.withOpenVault(func(vaultPath string) error {
return writeNDJSON(pluginDataNDJSONPath(vaultPath, pluginID, name), records)
})
}
func pluginDataNDJSONPath(vaultPath, pluginID, name string) string {
return filepath.Join(vaultPath, ".verstak", "plugin-data", pluginID, name+".ndjson")
}
func readPluginDataNDJSONAt(vaultPath, pluginID, name string) ([]map[string]interface{}, error) {
data, err := os.ReadFile(pluginDataNDJSONPath(vaultPath, pluginID, name))
if err != nil {
if os.IsNotExist(err) {
return []map[string]interface{}{}, nil
}
return nil, fmt.Errorf("failed to read NDJSON data %s for plugin %s: %w", name, pluginID, err)
}
if len(data) == 0 {
return []map[string]interface{}{}, nil
}
lines := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n")
records := make([]map[string]interface{}, 0, len(lines))
for index, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
var record map[string]interface{}
if err := json.Unmarshal([]byte(line), &record); err != nil {
return nil, fmt.Errorf("corrupt NDJSON data %s line %d for plugin %s: %w", name, index+1, pluginID, err)
}
records = append(records, record)
}
return records, nil
}
func appendNDJSON(path string, records []map[string]interface{}) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("failed to create NDJSON data directory: %w", err)
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("failed to open NDJSON data file: %w", err)
}
defer file.Close()
for _, record := range records {
encoded, err := json.Marshal(record)
if err != nil {
return fmt.Errorf("failed to marshal NDJSON record: %w", err)
}
if _, err := file.Write(append(encoded, '\n')); err != nil {
return fmt.Errorf("failed to append NDJSON record: %w", err)
}
}
if err := file.Sync(); err != nil {
return fmt.Errorf("failed to sync NDJSON data: %w", err)
}
return nil
}
func writeNDJSON(path string, records []map[string]interface{}) error {
data := make([]byte, 0)
for _, record := range records {
encoded, err := json.Marshal(record)
if err != nil {
return fmt.Errorf("failed to marshal NDJSON record: %w", err)
}
data = append(data, encoded...)
data = append(data, '\n')
}
return atomicWrite(path, data)
}
func compactNDJSONRecords(records []map[string]interface{}, retention NDJSONRetention, now time.Time) []map[string]interface{} {
kept := make([]map[string]interface{}, 0, len(records))
cutoff := now.Add(-retention.MaxAge)
for _, record := range records {
if retention.MaxAge > 0 && retention.TimestampField != "" {
if raw, ok := record[retention.TimestampField].(string); ok {
if timestamp, err := time.Parse(time.RFC3339, raw); err == nil && timestamp.Before(cutoff) {
continue
}
}
}
kept = append(kept, record)
}
if retention.MaxEntries > 0 && len(kept) > retention.MaxEntries {
kept = kept[len(kept)-retention.MaxEntries:]
}
if retention.MaxBytes > 0 {
for len(kept) > 0 && ndjsonSize(kept) > retention.MaxBytes {
kept = kept[1:]
}
}
return kept
}
func ndjsonSize(records []map[string]interface{}) int64 {
var total int64
for _, record := range records {
encoded, err := json.Marshal(record)
if err == nil {
total += int64(len(encoded) + 1)
}
}
return total
}
func sameNDJSONRecords(left, right []map[string]interface{}) bool {
if len(left) != len(right) {
return false
}
for index := range left {
leftJSON, leftErr := json.Marshal(left[index])
rightJSON, rightErr := json.Marshal(right[index])
if leftErr != nil || rightErr != nil || string(leftJSON) != string(rightJSON) {
return false
}
}
return true
}
// ─── Cache JSON API ─────────────────────────────────────── // ─── Cache JSON API ───────────────────────────────────────
// ReadPluginCacheJSON reads a named JSON cache file for a plugin. // ReadPluginCacheJSON reads a named JSON cache file for a plugin.
+91
View File
@@ -0,0 +1,91 @@
package storage
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestAppendPluginDataNDJSONCompactsAndDeduplicates(t *testing.T) {
s, vaultDir := newTestStorage(t)
retention := NDJSONRetention{
TimestampField: "occurredAt",
MaxAge: 60 * 24 * time.Hour,
MaxEntries: 2,
MaxBytes: 16 * 1024,
DeduplicateField: "sourceBatchId",
DeduplicateValue: "batch-1",
}
old := time.Now().UTC().Add(-61 * 24 * time.Hour).Format(time.RFC3339)
now := time.Now().UTC().Format(time.RFC3339)
stored, err := s.AppendPluginDataNDJSON("verstak.activity", "activity-events", []map[string]interface{}{
{"activityId": "old", "sourceBatchId": "old-batch", "occurredAt": old},
}, retention)
if err != nil || !stored {
t.Fatalf("old append = (%v, %v), want (true, nil)", stored, err)
}
stored, err = s.AppendPluginDataNDJSON("verstak.activity", "activity-events", []map[string]interface{}{
{"activityId": "current-1", "sourceBatchId": "batch-1", "occurredAt": now},
}, retention)
if err != nil || !stored {
t.Fatalf("first current append = (%v, %v), want (true, nil)", stored, err)
}
stored, err = s.AppendPluginDataNDJSON("verstak.activity", "activity-events", []map[string]interface{}{
{"activityId": "duplicate", "sourceBatchId": "batch-1", "occurredAt": now},
}, retention)
if err != nil || stored {
t.Fatalf("duplicate append = (%v, %v), want (false, nil)", stored, err)
}
for _, item := range []struct {
id string
batch string
}{
{id: "current-2", batch: "batch-3"},
{id: "current-3", batch: "batch-4"},
} {
retention.DeduplicateValue = item.batch
if stored, err := s.AppendPluginDataNDJSON("verstak.activity", "activity-events", []map[string]interface{}{
{"activityId": item.id, "sourceBatchId": item.batch, "occurredAt": now},
}, retention); err != nil || !stored {
t.Fatalf("append %s = (%v, %v), want (true, nil)", item.id, stored, err)
}
}
records, err := s.ReadPluginDataNDJSON("verstak.activity", "activity-events")
if err != nil {
t.Fatalf("ReadPluginDataNDJSON: %v", err)
}
if len(records) != 2 || records[0]["activityId"] != "current-2" || records[1]["activityId"] != "current-3" {
t.Fatalf("records = %+v, want current-2 and current-3", records)
}
if _, err := os.Stat(filepath.Join(vaultDir, "VerstakVault", ".verstak", "plugin-data", "verstak.activity", "activity-events.ndjson")); err != nil {
t.Fatalf("activity data file missing: %v", err)
}
}
func TestWritePluginDataNDJSONReplacesRecordsForExplicitUserClear(t *testing.T) {
s, _ := newTestStorage(t)
if _, err := s.AppendPluginDataNDJSON("verstak.activity", "activity-events", []map[string]interface{}{
{"activityId": "one", "occurredAt": time.Now().UTC().Format(time.RFC3339)},
{"activityId": "two", "occurredAt": time.Now().UTC().Format(time.RFC3339)},
}, NDJSONRetention{}); err != nil {
t.Fatalf("AppendPluginDataNDJSON: %v", err)
}
if err := s.WritePluginDataNDJSON("verstak.activity", "activity-events", []map[string]interface{}{
{"activityId": "two", "occurredAt": time.Now().UTC().Format(time.RFC3339)},
}); err != nil {
t.Fatalf("WritePluginDataNDJSON: %v", err)
}
records, err := s.ReadPluginDataNDJSON("verstak.activity", "activity-events")
if err != nil {
t.Fatalf("ReadPluginDataNDJSON: %v", err)
}
if len(records) != 1 || records[0]["activityId"] != "two" {
t.Fatalf("records = %+v, want only two", records)
}
}
+17
View File
@@ -70,6 +70,23 @@ func (v *Vault) GetVaultPath() string {
return v.path return v.path
} }
// WithOpenPath runs fn while holding a read lease on the currently open vault.
// CloseVault and OpenVault wait until the operation completes.
func (v *Vault) WithOpenPath(fn func(string) error) error {
if v == nil {
return fmt.Errorf("vault is not initialized")
}
if fn == nil {
return fmt.Errorf("vault path operation is nil")
}
v.mu.RLock()
defer v.mu.RUnlock()
if v.status != StatusOpen || strings.TrimSpace(v.path) == "" {
return fmt.Errorf("vault is not open")
}
return fn(v.path)
}
// GetVaultMeta returns the current vault metadata. // GetVaultMeta returns the current vault metadata.
func (v *Vault) GetVaultMeta() *VaultMeta { func (v *Vault) GetVaultMeta() *VaultMeta {
v.mu.RLock() v.mu.RLock()
+262 -3
View File
@@ -40,6 +40,7 @@ const (
// Workspace is a physical top-level workspace folder. // Workspace is a physical top-level workspace folder.
type Workspace struct { type Workspace struct {
ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
RootPath string `json:"rootPath"` RootPath string `json:"rootPath"`
} }
@@ -66,6 +67,7 @@ type WorkspaceTemplate struct {
// Metadata stores semantic workspace metadata that is not the source of truth // Metadata stores semantic workspace metadata that is not the source of truth
// for whether the workspace exists. // for whether the workspace exists.
type Metadata struct { type Metadata struct {
WorkspaceID string `json:"workspaceId,omitempty"`
WorkspaceName string `json:"workspaceName"` WorkspaceName string `json:"workspaceName"`
CreatedFromTemplate *TemplateSnapshot `json:"createdFromTemplate,omitempty"` CreatedFromTemplate *TemplateSnapshot `json:"createdFromTemplate,omitempty"`
Features map[string]bool `json:"features,omitempty"` Features map[string]bool `json:"features,omitempty"`
@@ -74,6 +76,12 @@ type Metadata struct {
UpdatedAt string `json:"updatedAt,omitempty"` UpdatedAt string `json:"updatedAt,omitempty"`
} }
const workspaceIdentityRelativePath = ".verstak/workspace.json"
type workspaceIdentityMarker struct {
WorkspaceID string `json:"workspaceId"`
}
// MetadataPatch updates metadata fields without replacing unspecified fields. // MetadataPatch updates metadata fields without replacing unspecified fields.
type MetadataPatch struct { type MetadataPatch struct {
Features map[string]bool `json:"features,omitempty"` Features map[string]bool `json:"features,omitempty"`
@@ -82,12 +90,20 @@ type MetadataPatch struct {
// TrashResult describes a workspace moved into the internal trash area. // TrashResult describes a workspace moved into the internal trash area.
type TrashResult struct { type TrashResult struct {
WorkspaceID string `json:"workspaceId"`
OriginalPath string `json:"originalPath"` OriginalPath string `json:"originalPath"`
TrashPath string `json:"trashPath"` TrashPath string `json:"trashPath"`
TrashID string `json:"trashId"` TrashID string `json:"trashId"`
DeletedAt string `json:"deletedAt"` DeletedAt string `json:"deletedAt"`
} }
// WorkspaceIdentity identifies an existing top-level workspace independently of its path.
type WorkspaceIdentity struct {
WorkspaceID string `json:"workspaceId"`
RootPath string `json:"rootPath"`
State string `json:"state"`
}
// WorkspaceNode is a compatibility shell view of a top-level workspace. // WorkspaceNode is a compatibility shell view of a top-level workspace.
// Path is deliberately not serialized; workspaceRootPath is derived from Name/ID. // Path is deliberately not serialized; workspaceRootPath is derived from Name/ID.
type WorkspaceNode struct { type WorkspaceNode struct {
@@ -301,7 +317,11 @@ func (m *Manager) ListWorkspaces() ([]Workspace, error) {
if !entry.IsDir() { if !entry.IsDir() {
continue continue
} }
workspaces = append(workspaces, Workspace{Name: name, RootPath: name}) workspaceID, err := ensureWorkspaceIdentity(filepath.Join(m.vaultDir, name))
if err != nil {
return nil, err
}
workspaces = append(workspaces, Workspace{ID: workspaceID, Name: name, RootPath: name})
} }
sort.Slice(workspaces, func(i, j int) bool { sort.Slice(workspaces, func(i, j int) bool {
return strings.ToLower(workspaces[i].Name) < strings.ToLower(workspaces[j].Name) return strings.ToLower(workspaces[i].Name) < strings.ToLower(workspaces[j].Name)
@@ -309,6 +329,84 @@ func (m *Manager) ListWorkspaces() ([]Workspace, error) {
return workspaces, nil return workspaces, nil
} }
// GetWorkspaceIdentity resolves the durable identity for an existing workspace.
func (m *Manager) GetWorkspaceIdentity(name string) (WorkspaceIdentity, error) {
name = strings.TrimSpace(name)
if err := validateWorkspaceName(name); err != nil {
return WorkspaceIdentity{}, err
}
full := filepath.Join(m.vaultDir, name)
if err := ensureExistingWorkspaceDir(full, name); err != nil {
return WorkspaceIdentity{}, err
}
workspaceID, err := ensureWorkspaceIdentity(full)
if err != nil {
return WorkspaceIdentity{}, err
}
return WorkspaceIdentity{WorkspaceID: workspaceID, RootPath: name, State: "active"}, nil
}
// ListWorkspaceIdentities returns durable identities and flags copied markers.
func (m *Manager) ListWorkspaceIdentities() ([]WorkspaceIdentity, error) {
workspaces, err := m.ListWorkspaces()
if err != nil {
return nil, err
}
counts := make(map[string]int, len(workspaces))
for _, workspace := range workspaces {
counts[workspace.ID]++
}
identities := make([]WorkspaceIdentity, 0, len(workspaces))
for _, workspace := range workspaces {
state := "active"
if counts[workspace.ID] > 1 {
state = "duplicate"
}
identities = append(identities, WorkspaceIdentity{
WorkspaceID: workspace.ID,
RootPath: workspace.RootPath,
State: state,
})
}
return identities, nil
}
// RepairWorkspaceIdentity keeps the identity on one copied workspace and regenerates the other.
func (m *Manager) RepairWorkspaceIdentity(keepName, regenerateName string) error {
keepName = strings.TrimSpace(keepName)
regenerateName = strings.TrimSpace(regenerateName)
if err := validateWorkspaceName(keepName); err != nil {
return err
}
if err := validateWorkspaceName(regenerateName); err != nil {
return err
}
if keepName == regenerateName {
return fmt.Errorf("workspace identity repair requires two workspaces")
}
keepPath := filepath.Join(m.vaultDir, keepName)
regeneratePath := filepath.Join(m.vaultDir, regenerateName)
if err := ensureExistingWorkspaceDir(keepPath, keepName); err != nil {
return err
}
if err := ensureExistingWorkspaceDir(regeneratePath, regenerateName); err != nil {
return err
}
keepID, err := ensureWorkspaceIdentity(keepPath)
if err != nil {
return err
}
regenerateID, err := ensureWorkspaceIdentity(regeneratePath)
if err != nil {
return err
}
if keepID != regenerateID {
return fmt.Errorf("workspaces do not share an identity")
}
_, err = writeWorkspaceIdentity(regeneratePath)
return err
}
// CreateWorkspace creates a top-level workspace folder and applies a template once. // CreateWorkspace creates a top-level workspace folder and applies a template once.
func (m *Manager) CreateWorkspace(name, templateID string) (Workspace, error) { func (m *Manager) CreateWorkspace(name, templateID string) (Workspace, error) {
name = strings.TrimSpace(name) name = strings.TrimSpace(name)
@@ -339,11 +437,16 @@ func (m *Manager) CreateWorkspace(name, templateID string) (Workspace, error) {
} }
}() }()
workspaceID, err := ensureWorkspaceIdentity(full)
if err != nil {
return Workspace{}, err
}
if err := applyTemplate(full, template); err != nil { if err := applyTemplate(full, template); err != nil {
return Workspace{}, err return Workspace{}, err
} }
now := time.Now().UTC().Format(time.RFC3339Nano) now := time.Now().UTC().Format(time.RFC3339Nano)
meta := Metadata{ meta := Metadata{
WorkspaceID: workspaceID,
WorkspaceName: name, WorkspaceName: name,
CreatedFromTemplate: &TemplateSnapshot{ CreatedFromTemplate: &TemplateSnapshot{
TemplateID: template.ID, TemplateID: template.ID,
@@ -362,7 +465,82 @@ func (m *Manager) CreateWorkspace(name, templateID string) (Workspace, error) {
} }
created = false created = false
return Workspace{Name: name, RootPath: name}, nil return Workspace{ID: workspaceID, Name: name, RootPath: name}, nil
}
func writeWorkspaceIdentity(workspacePath string) (string, error) {
workspaceID := uuid.NewString()
data, err := json.Marshal(workspaceIdentityMarker{WorkspaceID: workspaceID})
if err != nil {
return "", err
}
markerPath := filepath.Join(workspacePath, filepath.FromSlash(workspaceIdentityRelativePath))
if err := os.MkdirAll(filepath.Dir(markerPath), 0o755); err != nil {
return "", err
}
tmpPath := markerPath + ".tmp"
if err := os.WriteFile(tmpPath, data, 0o644); err != nil {
return "", err
}
if err := os.Rename(tmpPath, markerPath); err != nil {
_ = os.Remove(tmpPath)
return "", err
}
return workspaceID, nil
}
func ensureWorkspaceIdentity(workspacePath string) (string, error) {
workspaceID, err := readWorkspaceIdentity(workspacePath)
if os.IsNotExist(err) {
return writeWorkspaceIdentity(workspacePath)
}
if err != nil {
return "", err
}
return workspaceID, nil
}
func readWorkspaceIdentity(workspacePath string) (string, error) {
markerPath := filepath.Join(workspacePath, filepath.FromSlash(workspaceIdentityRelativePath))
data, err := os.ReadFile(markerPath)
if err != nil {
return "", err
}
var marker workspaceIdentityMarker
if err := json.Unmarshal(data, &marker); err != nil {
return "", err
}
if _, err := uuid.Parse(marker.WorkspaceID); err != nil {
return "", fmt.Errorf("invalid workspace identity: %w", err)
}
return marker.WorkspaceID, nil
}
// GetWorkspaceTrashIdentity reads a trashed workspace identity without restoring it.
func (m *Manager) GetWorkspaceTrashIdentity(trashID string) (WorkspaceIdentity, error) {
trashID = strings.TrimSpace(trashID)
if err := validateWorkspaceTrashID(trashID); err != nil {
return WorkspaceIdentity{}, err
}
trashDir := filepath.Join(m.vaultDir, ".verstak", "trash", "workspaces", trashID)
data, err := os.ReadFile(filepath.Join(trashDir, "metadata.json"))
if err != nil {
return WorkspaceIdentity{}, err
}
var trashMeta struct {
OriginalPath string `json:"originalPath"`
}
if err := json.Unmarshal(data, &trashMeta); err != nil {
return WorkspaceIdentity{}, err
}
if err := validateWorkspaceName(trashMeta.OriginalPath); err != nil {
return WorkspaceIdentity{}, err
}
workspaceID, err := readWorkspaceIdentity(filepath.Join(trashDir, trashMeta.OriginalPath))
if err != nil {
return WorkspaceIdentity{}, err
}
return WorkspaceIdentity{WorkspaceID: workspaceID, RootPath: trashMeta.OriginalPath, State: "trashed"}, nil
} }
// ListWorkspaceTemplates returns selectable built-ins in their presentation order. // ListWorkspaceTemplates returns selectable built-ins in their presentation order.
@@ -456,6 +634,10 @@ func (m *Manager) TrashWorkspace(name string) (TrashResult, error) {
if err := ensureExistingWorkspaceDir(full, name); err != nil { if err := ensureExistingWorkspaceDir(full, name); err != nil {
return TrashResult{}, err return TrashResult{}, err
} }
workspaceID, err := ensureWorkspaceIdentity(full)
if err != nil {
return TrashResult{}, err
}
deletedAt := time.Now().UTC().Format(time.RFC3339Nano) deletedAt := time.Now().UTC().Format(time.RFC3339Nano)
trashID := time.Now().UTC().Format("20060102T150405.000000000Z") + "-" + uuid.NewString() trashID := time.Now().UTC().Format("20060102T150405.000000000Z") + "-" + uuid.NewString()
@@ -468,7 +650,7 @@ func (m *Manager) TrashWorkspace(name string) (TrashResult, error) {
return TrashResult{}, err return TrashResult{}, err
} }
result := TrashResult{OriginalPath: name, TrashPath: trashRel, TrashID: trashID, DeletedAt: deletedAt} result := TrashResult{WorkspaceID: workspaceID, OriginalPath: name, TrashPath: trashRel, TrashID: trashID, DeletedAt: deletedAt}
trashMeta := map[string]string{ trashMeta := map[string]string{
"originalPath": name, "originalPath": name,
"trashPath": trashRel, "trashPath": trashRel,
@@ -500,6 +682,83 @@ func (m *Manager) TrashWorkspace(name string) (TrashResult, error) {
return result, nil return result, nil
} }
// RestoreWorkspaceTrash restores a trashed workspace under targetName without changing its identity.
func (m *Manager) RestoreWorkspaceTrash(trashID, targetName string) (Workspace, error) {
trashID = strings.TrimSpace(trashID)
targetName = strings.TrimSpace(targetName)
if err := validateWorkspaceTrashID(trashID); err != nil {
return Workspace{}, err
}
if err := validateWorkspaceName(targetName); err != nil {
return Workspace{}, err
}
trashDir := filepath.Join(m.vaultDir, ".verstak", "trash", "workspaces", trashID)
data, err := os.ReadFile(filepath.Join(trashDir, "metadata.json"))
if err != nil {
return Workspace{}, err
}
var trashMeta struct {
OriginalPath string `json:"originalPath"`
}
if err := json.Unmarshal(data, &trashMeta); err != nil {
return Workspace{}, err
}
if err := validateWorkspaceName(trashMeta.OriginalPath); err != nil {
return Workspace{}, err
}
payloadPath := filepath.Join(trashDir, trashMeta.OriginalPath)
if err := ensureExistingWorkspaceDir(payloadPath, trashMeta.OriginalPath); err != nil {
return Workspace{}, err
}
targetPath := filepath.Join(m.vaultDir, targetName)
if _, err := os.Lstat(targetPath); err == nil {
return Workspace{}, fmt.Errorf("conflict: %s", targetName)
} else if !os.IsNotExist(err) {
return Workspace{}, err
}
if err := os.Rename(payloadPath, targetPath); err != nil {
return Workspace{}, err
}
restored := true
defer func() {
if restored {
_ = os.Rename(targetPath, payloadPath)
}
}()
if err := moveIfExists(filepath.Join(trashDir, "workspace.metadata.json"), m.metadataPath(targetName)); err != nil {
return Workspace{}, err
}
workspaceID, err := ensureWorkspaceIdentity(targetPath)
if err != nil {
return Workspace{}, err
}
if err := os.RemoveAll(trashDir); err != nil {
return Workspace{}, err
}
restored = false
return Workspace{ID: workspaceID, Name: targetName, RootPath: targetName}, nil
}
func validateWorkspaceTrashID(trashID string) error {
if trashID == "" || strings.ContainsAny(trashID, `/\\`) || filepath.Clean(trashID) != trashID {
return fmt.Errorf("invalid workspace trash ID")
}
return nil
}
// PurgeWorkspaceTrash permanently removes a workspace trash entry.
func (m *Manager) PurgeWorkspaceTrash(trashID string) error {
trashID = strings.TrimSpace(trashID)
if err := validateWorkspaceTrashID(trashID); err != nil {
return err
}
trashDir := filepath.Join(m.vaultDir, ".verstak", "trash", "workspaces", trashID)
if _, err := os.Stat(filepath.Join(trashDir, "metadata.json")); err != nil {
return err
}
return os.RemoveAll(trashDir)
}
// GetWorkspaceMetadata returns stored metadata or safe generic metadata. // GetWorkspaceMetadata returns stored metadata or safe generic metadata.
func (m *Manager) GetWorkspaceMetadata(name string) (Metadata, error) { func (m *Manager) GetWorkspaceMetadata(name string) (Metadata, error) {
name = strings.TrimSpace(name) name = strings.TrimSpace(name)
+161
View File
@@ -292,6 +292,167 @@ func TestTrashWorkspaceMovesFolderToTrashAndRemovesFromList(t *testing.T) {
} }
} }
func TestCreateWorkspaceWritesDurableIdentityMarker(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
if _, err := m.CreateWorkspace("Client", "default"); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
markerPath := filepath.Join(vaultDir, "Client", ".verstak", "workspace.json")
data, err := os.ReadFile(markerPath)
if err != nil {
t.Fatalf("read identity marker: %v", err)
}
var marker map[string]string
if err := json.Unmarshal(data, &marker); err != nil {
t.Fatalf("decode identity marker: %v", err)
}
if marker["workspaceId"] == "" {
t.Fatalf("workspaceId = %q, want UUID", marker["workspaceId"])
}
}
func TestListWorkspacesKeepsIdentityAfterRename(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
created, err := m.CreateWorkspace("Client", "default")
if err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
if err := m.RenameWorkspace("Client", "Client-2026"); err != nil {
t.Fatalf("RenameWorkspace: %v", err)
}
workspaces, err := m.ListWorkspaces()
if err != nil {
t.Fatalf("ListWorkspaces: %v", err)
}
if len(workspaces) != 1 {
t.Fatalf("workspaces = %+v, want one", workspaces)
}
if workspaces[0].Name != "Client-2026" {
t.Fatalf("workspace name = %q, want Client-2026", workspaces[0].Name)
}
if workspaces[0].ID != created.ID {
t.Fatalf("workspace ID = %q, want %q", workspaces[0].ID, created.ID)
}
}
func TestListWorkspaceIdentitiesMarksCopiedMarkerAsDuplicate(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
if _, err := m.CreateWorkspace("Original", "default"); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
if err := os.Mkdir(filepath.Join(vaultDir, "Copied"), 0o755); err != nil {
t.Fatalf("mkdir copied: %v", err)
}
originalMarker := filepath.Join(vaultDir, "Original", ".verstak", "workspace.json")
copiedMarker := filepath.Join(vaultDir, "Copied", ".verstak", "workspace.json")
if err := os.MkdirAll(filepath.Dir(copiedMarker), 0o755); err != nil {
t.Fatalf("mkdir copied marker: %v", err)
}
data, err := os.ReadFile(originalMarker)
if err != nil {
t.Fatalf("read original marker: %v", err)
}
if err := os.WriteFile(copiedMarker, data, 0o644); err != nil {
t.Fatalf("write copied marker: %v", err)
}
identities, err := m.ListWorkspaceIdentities()
if err != nil {
t.Fatalf("ListWorkspaceIdentities: %v", err)
}
if len(identities) != 2 {
t.Fatalf("identities = %+v, want two", identities)
}
for _, identity := range identities {
if identity.State != "duplicate" {
t.Fatalf("identity %+v state = %q, want duplicate", identity, identity.State)
}
}
}
func TestRepairWorkspaceIdentityRegeneratesCopiedMarker(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
if _, err := m.CreateWorkspace("Original", "default"); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
if err := os.Mkdir(filepath.Join(vaultDir, "Copied"), 0o755); err != nil {
t.Fatalf("mkdir copied: %v", err)
}
originalMarker := filepath.Join(vaultDir, "Original", ".verstak", "workspace.json")
copiedMarker := filepath.Join(vaultDir, "Copied", ".verstak", "workspace.json")
if err := os.MkdirAll(filepath.Dir(copiedMarker), 0o755); err != nil {
t.Fatalf("mkdir copied marker: %v", err)
}
data, err := os.ReadFile(originalMarker)
if err != nil {
t.Fatalf("read original marker: %v", err)
}
if err := os.WriteFile(copiedMarker, data, 0o644); err != nil {
t.Fatalf("write copied marker: %v", err)
}
if err := m.RepairWorkspaceIdentity("Original", "Copied"); err != nil {
t.Fatalf("RepairWorkspaceIdentity: %v", err)
}
identities, err := m.ListWorkspaceIdentities()
if err != nil {
t.Fatalf("ListWorkspaceIdentities: %v", err)
}
if len(identities) != 2 || identities[0].State != "active" || identities[1].State != "active" {
t.Fatalf("identities after repair = %+v", identities)
}
if identities[0].WorkspaceID == identities[1].WorkspaceID {
t.Fatalf("repair kept duplicate ID %q", identities[0].WorkspaceID)
}
}
func TestRestoreWorkspaceTrashPreservesIdentity(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
created, err := m.CreateWorkspace("Client", "default")
if err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
trashed, err := m.TrashWorkspace("Client")
if err != nil {
t.Fatalf("TrashWorkspace: %v", err)
}
restored, err := m.RestoreWorkspaceTrash(trashed.TrashID, "Client-Restored")
if err != nil {
t.Fatalf("RestoreWorkspaceTrash: %v", err)
}
if restored.Name != "Client-Restored" || restored.ID != created.ID {
t.Fatalf("restored = %+v, want name Client-Restored and ID %q", restored, created.ID)
}
if _, err := os.Stat(filepath.Join(vaultDir, "Client-Restored", ".verstak", "workspace.json")); err != nil {
t.Fatalf("restored marker: %v", err)
}
}
func TestPurgeWorkspaceTrashRemovesPayload(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
if _, err := m.CreateWorkspace("Client", "default"); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
trashed, err := m.TrashWorkspace("Client")
if err != nil {
t.Fatalf("TrashWorkspace: %v", err)
}
if err := m.PurgeWorkspaceTrash(trashed.TrashID); err != nil {
t.Fatalf("PurgeWorkspaceTrash: %v", err)
}
if _, err := os.Stat(filepath.Join(vaultDir, ".verstak", "trash", "workspaces", trashed.TrashID)); !os.IsNotExist(err) {
t.Fatalf("workspace trash remains, stat err=%v", err)
}
}
func TestCreateAndRenameConflictsAreExplicit(t *testing.T) { func TestCreateAndRenameConflictsAreExplicit(t *testing.T) {
vaultDir := newVaultDir(t) vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, "Existing")) mustMkdir(t, filepath.Join(vaultDir, "Existing"))
+3 -2
View File
@@ -243,7 +243,7 @@ func main() {
} }
// ─── Wails App ─────────────────────────────────────────── // ─── Wails App ───────────────────────────────────────────
err = wails.Run(&options.App{ appOptions := &options.App{
Title: "Verstak", Title: "Verstak",
Width: 1200, Width: 1200,
Height: 800, Height: 800,
@@ -257,7 +257,8 @@ func main() {
Bind: []interface{}{ Bind: []interface{}{
app, app,
}, },
}) }
err = wails.Run(appOptions)
if err != nil { if err != nil {
log.Fatalf("Error: %v", err) log.Fatalf("Error: %v", err)
+11
View File
@@ -0,0 +1,11 @@
Package: verstak
Version: @VERSION@
Section: utils
Priority: optional
Architecture: amd64
Depends: libwebkit2gtk-4.1-0, libgtk-3-0t64 | libgtk-3-0
Maintainer: Verstak contributors <dev@verstak.app>
Homepage: https://github.com/mirivlad/verstak
Description: Local-first workspace
Verstak is a local-first workspace for files, notes, browser captures,
activity and work-journal entries.
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env sh
exec /opt/verstak/verstak-desktop "$@"
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env sh
set -eu
HERE="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
export APPDIR="$HERE"
export LD_LIBRARY_PATH="$HERE/usr/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
export GIO_EXTRA_MODULES="$HERE/usr/lib/gio/modules"
export GSETTINGS_SCHEMA_DIR="$HERE/usr/share/glib-2.0/schemas"
export GST_PLUGIN_SYSTEM_PATH_1_0="$HERE/usr/lib/gstreamer-1.0"
export WEBKIT_EXEC_PATH="$HERE/usr/lib/webkit2gtk-4.1"
cd "$HERE/usr/bin"
exec "$HERE/usr/bin/verstak-desktop" "$@"
+9
View File
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=Verstak
Comment=Local-first workspace
Exec=verstak-desktop %U
Icon=verstak
Terminal=false
Categories=Office;Utility;
StartupNotify=true
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<rect width="128" height="128" rx="26" fill="#1c2f4a"/>
<path d="M28 34h72v18H28zm0 31h72v29H28z" fill="#72d2b3"/>
<path d="M41 52v13m23-13v13m23-13v13" stroke="#1c2f4a" stroke-width="8"/>
</svg>

After

Width:  |  Height:  |  Size: 290 B

+5
View File
@@ -0,0 +1,5 @@
@echo off
setlocal
set "ROOT=%~dp0"
start "" "%ROOT%verstak-desktop.exe" %*
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OFFICIAL_PLUGINS="${VERSTAK_OFFICIAL_PLUGINS_DIR:-$ROOT/../verstak-official-plugins}"
OUTPUT="${VERSTAK_LINUX_BUNDLE_DIR:-$ROOT/build/linux-amd64}"
if [[ ! -d "$OFFICIAL_PLUGINS" ]]; then
echo "official plugins repository not found: $OFFICIAL_PLUGINS" >&2
exit 1
fi
echo "=== verstak desktop Linux amd64 bundle ==="
(cd "$OFFICIAL_PLUGINS" && ./scripts/build.sh)
"$ROOT/scripts/install-dev-plugins.sh"
"$ROOT/scripts/build.sh"
BINARY="$ROOT/build/bin/verstak-desktop"
if [[ ! -x "$BINARY" ]]; then
echo "desktop binary was not produced: $BINARY" >&2
exit 1
fi
rm -rf "$OUTPUT"
mkdir -p "$OUTPUT"
install -m 755 "$BINARY" "$OUTPUT/verstak-desktop"
install -m 644 "$ROOT/README.md" "$ROOT/LICENSE" "$OUTPUT/"
cp -R "$ROOT/plugins" "$OUTPUT/plugins"
chmod -R a+rX "$OUTPUT/plugins"
echo "Linux bundle: $OUTPUT"
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OFFICIAL_PLUGINS="${VERSTAK_OFFICIAL_PLUGINS_DIR:-$ROOT/../verstak-official-plugins}"
WINDOWS_PLUGIN_DIST="${VERSTAK_WINDOWS_PLUGIN_DIST:-$OFFICIAL_PLUGINS/dist-windows}"
WINDOWS_OUTPUT="${VERSTAK_WINDOWS_OUTPUT_DIR:-$ROOT/build/windows-amd64}"
WINDOWS_CC="${VERSTAK_WINDOWS_CC:-x86_64-w64-mingw32-gcc}"
if ! command -v "$WINDOWS_CC" >/dev/null; then
echo "Windows cross-compiler not found: $WINDOWS_CC" >&2
echo "Install x86_64-w64-mingw32-gcc (for example: sudo apt install gcc-mingw-w64-x86-64)." >&2
exit 1
fi
if [[ ! -d "$OFFICIAL_PLUGINS" ]]; then
echo "official plugins repository not found: $OFFICIAL_PLUGINS" >&2
exit 1
fi
WAILS="${WAILS_BIN:-}"
if [[ -z "$WAILS" ]]; then
if command -v wails >/dev/null; then
WAILS="wails"
elif [[ -n "$(go env GOBIN 2>/dev/null)" && -x "$(go env GOBIN)/wails" ]]; then
WAILS="$(go env GOBIN)/wails"
elif [[ -x "$(go env GOPATH)/bin/wails" ]]; then
WAILS="$(go env GOPATH)/bin/wails"
fi
fi
if [[ -z "$WAILS" ]]; then
echo "Wails CLI is required. Install it with: go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0" >&2
exit 1
fi
cd "$ROOT"
echo "=== verstak desktop Windows amd64 build ==="
if [[ ! -d "$ROOT/frontend/node_modules" ]]; then
if [[ -f "$ROOT/frontend/package-lock.json" ]]; then
(cd "$ROOT/frontend" && npm ci --no-audit --no-fund)
else
(cd "$ROOT/frontend" && npm install --no-audit --no-fund)
fi
fi
(cd "$ROOT/frontend" && npm run build)
go mod download
go test -count=1 ./...
"$OFFICIAL_PLUGINS/scripts/build-windows.sh"
if [[ ! -d "$WINDOWS_PLUGIN_DIST" ]]; then
echo "Windows plugin packages were not produced: $WINDOWS_PLUGIN_DIST" >&2
exit 1
fi
# Wails' -compiler option selects a Go binary, not a C compiler. Cross-CGO
# therefore has to be supplied through the standard Go environment instead.
# The portable archive uses the installed Evergreen WebView2 Runtime, so never
# compile an Evergreen downloader into the executable.
CC="$WINDOWS_CC" CGO_ENABLED=1 "$WAILS" build -clean -platform windows/amd64 \
-webview2 error -o verstak-desktop.exe
WINDOWS_BINARY="$ROOT/build/bin/verstak-desktop.exe"
if [[ ! -f "$WINDOWS_BINARY" ]]; then
echo "Windows executable was not produced: $WINDOWS_BINARY" >&2
exit 1
fi
rm -rf "$WINDOWS_OUTPUT"
mkdir -p "$WINDOWS_OUTPUT/plugins"
cp "$WINDOWS_BINARY" "$WINDOWS_OUTPUT/verstak-desktop.exe"
cp -R "$WINDOWS_PLUGIN_DIST/." "$WINDOWS_OUTPUT/plugins/"
echo "Windows test bundle: $WINDOWS_OUTPUT"
echo "Copy this directory to Windows and run verstak-desktop.exe."
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VERSION="${1:-}"
APPIMAGETOOL_URL="${APPIMAGETOOL_URL:-https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage}"
if [[ -z "$VERSION" || ! "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
echo "usage: $0 <version>" >&2
echo "example: $0 v0.1.0-alpha.1" >&2
exit 2
fi
if ! command -v ldd >/dev/null || ! command -v file >/dev/null; then
echo "ldd and file are required to bundle AppImage runtime libraries" >&2
exit 1
fi
"$ROOT/scripts/build-linux-bundle.sh"
APPIMAGETOOL="${APPIMAGETOOL_BIN:-$ROOT/build/tools/appimagetool-x86_64.AppImage}"
if [[ ! -x "$APPIMAGETOOL" ]]; then
if ! command -v curl >/dev/null; then
echo "appimagetool is missing; install curl or set APPIMAGETOOL_BIN" >&2
exit 1
fi
mkdir -p "$(dirname "$APPIMAGETOOL")"
curl --fail --location --show-error "$APPIMAGETOOL_URL" -o "$APPIMAGETOOL"
chmod +x "$APPIMAGETOOL"
fi
BUNDLE="${VERSTAK_LINUX_BUNDLE_DIR:-$ROOT/build/linux-amd64}"
APPDIR="$ROOT/build/appimage/AppDir"
RELEASE_ROOT="$ROOT/release"
ARCHIVE="$RELEASE_ROOT/verstak-linux-x86_64-$VERSION.AppImage"
WEBKIT_RUNTIME_DIR="${VERSTAK_WEBKIT_RUNTIME_DIR:-/usr/lib/x86_64-linux-gnu/webkit2gtk-4.1}"
if [[ ! -d "$WEBKIT_RUNTIME_DIR" ]] || [[ ! -x "$WEBKIT_RUNTIME_DIR/WebKitWebProcess" ]]; then
echo "WebKitWebProcess was not found at $WEBKIT_RUNTIME_DIR" >&2
echo "Set VERSTAK_WEBKIT_RUNTIME_DIR to the WebKitGTK 4.1 runtime directory." >&2
exit 1
fi
rm -rf "$APPDIR" "$ARCHIVE"
mkdir -p "$APPDIR/usr/bin" "$APPDIR/usr/lib" "$APPDIR/usr/share/applications" \
"$APPDIR/usr/share/icons/hicolor/scalable/apps"
install -m 755 "$ROOT/packaging/linux/AppRun" "$APPDIR/AppRun"
install -m 755 "$BUNDLE/verstak-desktop" "$APPDIR/usr/bin/verstak-desktop"
install -m 644 "$ROOT/packaging/linux/verstak.desktop" "$APPDIR/verstak.desktop"
install -m 644 "$ROOT/packaging/linux/verstak.desktop" "$APPDIR/usr/share/applications/verstak.desktop"
install -m 644 "$ROOT/packaging/linux/verstak.svg" "$APPDIR/verstak.svg"
install -m 644 "$ROOT/packaging/linux/verstak.svg" "$APPDIR/usr/share/icons/hicolor/scalable/apps/verstak.svg"
cp -R "$BUNDLE/plugins" "$APPDIR/usr/bin/plugins"
cp -a "$WEBKIT_RUNTIME_DIR" "$APPDIR/usr/lib/webkit2gtk-4.1"
copy_runtime_dir() {
local source="$1"
local target="$2"
if [[ -d "$source" ]]; then
mkdir -p "$target"
cp -a "$source/." "$target/"
fi
}
copy_runtime_dir /usr/lib/x86_64-linux-gnu/gio/modules "$APPDIR/usr/lib/gio/modules"
copy_runtime_dir /usr/lib/x86_64-linux-gnu/gstreamer-1.0 "$APPDIR/usr/lib/gstreamer-1.0"
copy_runtime_dir /usr/lib/x86_64-linux-gnu/gdk-pixbuf-2.0 "$APPDIR/usr/lib/gdk-pixbuf-2.0"
copy_runtime_dir /usr/share/glib-2.0/schemas "$APPDIR/usr/share/glib-2.0/schemas"
declare -A queued=()
queue_elf() {
local candidate="$1"
[[ -f "$candidate" ]] || return
if [[ "$(file --brief "$candidate")" != *ELF* ]]; then
return
fi
if [[ -z "${queued[$candidate]:-}" ]]; then
queued[$candidate]=1
printf '%s\n' "$candidate" >> "$APPDIR/.elf-queue"
fi
}
queue_elf "$APPDIR/usr/bin/verstak-desktop"
while IFS= read -r candidate; do
queue_elf "$candidate"
done < <(find "$APPDIR/usr/lib/webkit2gtk-4.1" "$APPDIR/usr/lib/gio/modules" \
"$APPDIR/usr/lib/gstreamer-1.0" "$APPDIR/usr/lib/gdk-pixbuf-2.0" -type f)
while IFS= read -r candidate; do
if ldd "$candidate" | grep -q 'not found'; then
echo "unresolved shared library in $candidate" >&2
exit 1
fi
while IFS= read -r library; do
case "$(basename "$library")" in
libc.so.6|libm.so.6|libpthread.so.0|librt.so.1|libdl.so.2|ld-linux-x86-64.so.2)
continue
;;
esac
bundled="$APPDIR/usr/lib/$(basename "$library")"
if [[ ! -e "$bundled" ]]; then
cp -aL "$library" "$bundled"
fi
queue_elf "$bundled"
done < <(ldd "$candidate" | awk '/=> \/[^ ]+/ { print $3 } /^\// { print $1 }')
done < "$APPDIR/.elf-queue"
rm -f "$APPDIR/.elf-queue"
if command -v glib-compile-schemas >/dev/null; then
glib-compile-schemas "$APPDIR/usr/share/glib-2.0/schemas"
fi
mkdir -p "$RELEASE_ROOT"
ARCH=x86_64 APPIMAGE_EXTRACT_AND_RUN=1 "$APPIMAGETOOL" "$APPDIR" "$ARCHIVE"
(cd "$RELEASE_ROOT" && find . -maxdepth 1 -type f \( -name '*.deb' -o -name '*.AppImage' -o -name '*.zip' \) \
-printf '%f\n' | LC_ALL=C sort | xargs -r sha256sum > SHA256SUMS)
echo "AppImage: $ARCHIVE"
echo "checksums: $RELEASE_ROOT/SHA256SUMS"
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VERSION="${1:-}"
if [[ -z "$VERSION" || ! "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
echo "usage: $0 <version>" >&2
echo "example: $0 v0.1.0-alpha.1" >&2
exit 2
fi
if ! command -v dpkg-deb >/dev/null; then
echo "dpkg-deb is required to create a Debian package" >&2
exit 1
fi
"$ROOT/scripts/build-linux-bundle.sh"
PACKAGE_VERSION="${VERSION#v}"
BUNDLE="${VERSTAK_LINUX_BUNDLE_DIR:-$ROOT/build/linux-amd64}"
STAGING="$ROOT/build/deb/verstak_$PACKAGE_VERSION"
RELEASE_ROOT="$ROOT/release"
ARCHIVE="$RELEASE_ROOT/verstak_${PACKAGE_VERSION}_amd64.deb"
rm -rf "$STAGING" "$ARCHIVE"
mkdir -p "$STAGING/DEBIAN" "$STAGING/opt/verstak" "$STAGING/usr/bin" \
"$STAGING/usr/share/applications" "$STAGING/usr/share/icons/hicolor/scalable/apps" \
"$STAGING/usr/share/doc/verstak"
sed "s/@VERSION@/$PACKAGE_VERSION/g" "$ROOT/packaging/deb/control" > "$STAGING/DEBIAN/control"
install -m 755 "$ROOT/packaging/deb/verstak" "$STAGING/usr/bin/verstak"
install -m 755 "$BUNDLE/verstak-desktop" "$STAGING/opt/verstak/verstak-desktop"
install -m 644 "$BUNDLE/README.md" "$BUNDLE/LICENSE" "$STAGING/usr/share/doc/verstak/"
install -m 644 "$ROOT/packaging/linux/verstak.desktop" "$STAGING/usr/share/applications/verstak.desktop"
install -m 644 "$ROOT/packaging/linux/verstak.svg" "$STAGING/usr/share/icons/hicolor/scalable/apps/verstak.svg"
cp -R "$BUNDLE/plugins" "$STAGING/opt/verstak/plugins"
mkdir -p "$RELEASE_ROOT"
dpkg-deb --root-owner-group --build "$STAGING" "$ARCHIVE"
(cd "$RELEASE_ROOT" && find . -maxdepth 1 -type f \( -name '*.deb' -o -name '*.AppImage' -o -name '*.zip' \) \
-printf '%f\n' | LC_ALL=C sort | xargs -r sha256sum > SHA256SUMS)
echo "Debian package: $ARCHIVE"
echo "checksums: $RELEASE_ROOT/SHA256SUMS"
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VERSION="${1:-}"
if [[ -z "$VERSION" || ! "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
echo "usage: $0 <version>" >&2
echo "example: $0 v0.1.0-alpha.1" >&2
exit 2
fi
for command in zip; do
if ! command -v "$command" >/dev/null; then
echo "$command is required to create the Windows portable archive" >&2
exit 1
fi
done
"$ROOT/scripts/build-windows.sh"
RELEASE_ROOT="$ROOT/release"
STAGING="$RELEASE_ROOT/verstak-windows-amd64-$VERSION"
ARCHIVE="$RELEASE_ROOT/verstak-windows-amd64-$VERSION.zip"
rm -rf "$STAGING" "$ARCHIVE"
mkdir -p "$STAGING"
cp -R "$ROOT/build/windows-amd64/." "$STAGING/"
install -m 644 "$ROOT/README.md" "$ROOT/LICENSE" "$STAGING/"
install -m 644 "$ROOT/packaging/windows/Verstak.cmd" "$STAGING/Verstak.cmd"
(cd "$RELEASE_ROOT" && zip -qr "$(basename "$ARCHIVE")" "$(basename "$STAGING")")
(cd "$RELEASE_ROOT" && find . -maxdepth 1 -type f \( -name '*.deb' -o -name '*.AppImage' -o -name '*.zip' \) \
-printf '%f\n' | LC_ALL=C sort | xargs -r sha256sum > SHA256SUMS)
echo "Windows portable archive: $ARCHIVE"
echo "checksums: $RELEASE_ROOT/SHA256SUMS"
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VERSION="${1:-}"
REPOSITORY="mirivlad/verstak"
RELEASE_SCRIPT="${VERSTAK_RELEASE_SCRIPT:-$ROOT/scripts/release.sh}"
RELEASE_DIR="${VERSTAK_RELEASE_DIR:-$ROOT/release}"
GIT_BIN="${GIT_BIN:-git}"
GH_BIN="${GH_BIN:-gh}"
cd "$ROOT"
if [[ -z "$VERSION" || ! "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
echo "usage: $0 <version>" >&2
echo "example: $0 v0.1.0-alpha.1" >&2
exit 2
fi
if ! command -v "$GH_BIN" >/dev/null; then
echo "gh CLI is required to publish a GitHub Release" >&2
exit 1
fi
if [[ "$("$GIT_BIN" branch --show-current)" != "main" ]]; then
echo "GitHub releases must be published from main" >&2
exit 1
fi
if [[ -n "$("$GIT_BIN" status --porcelain)" ]]; then
echo "working tree must be clean before publishing a release" >&2
exit 1
fi
"$GH_BIN" auth status
"$GIT_BIN" fetch origin main --tags
HEAD="$("$GIT_BIN" rev-parse HEAD)"
if [[ "$HEAD" != "$("$GIT_BIN" rev-parse origin/main)" ]]; then
echo "local main must match origin/main before publishing a release" >&2
exit 1
fi
"$RELEASE_SCRIPT" "$VERSION"
ASSETS=(
"$RELEASE_DIR/verstak_${VERSION#v}_amd64.deb"
"$RELEASE_DIR/verstak-linux-x86_64-$VERSION.AppImage"
"$RELEASE_DIR/verstak-windows-amd64-$VERSION.zip"
"$RELEASE_DIR/SHA256SUMS"
)
for asset in "${ASSETS[@]}"; do
if [[ ! -s "$asset" ]]; then
echo "required release asset is missing or empty: $asset" >&2
exit 1
fi
done
if "$GIT_BIN" rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then
if [[ "$("$GIT_BIN" rev-parse "${VERSION}^{commit}")" != "$HEAD" ]]; then
echo "existing tag $VERSION does not point at HEAD" >&2
exit 1
fi
else
"$GIT_BIN" tag -a "$VERSION" -m "Release $VERSION"
"$GIT_BIN" push origin "refs/tags/$VERSION"
fi
if "$GH_BIN" release view "$VERSION" --repo "$REPOSITORY" >/dev/null 2>&1; then
"$GH_BIN" release upload "$VERSION" "${ASSETS[@]}" --repo "$REPOSITORY" --clobber
else
RELEASE_OPTIONS=(--generate-notes --verify-tag)
if [[ "$VERSION" == *-* ]]; then
RELEASE_OPTIONS+=(--prerelease)
else
RELEASE_OPTIONS+=(--latest)
fi
"$GH_BIN" release create "$VERSION" "${ASSETS[@]}" \
--repo "$REPOSITORY" \
--title "Verstak $VERSION" \
"${RELEASE_OPTIONS[@]}"
fi
echo "GitHub release:"
"$GH_BIN" release view "$VERSION" --repo "$REPOSITORY" --json url --jq .url
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VERSION="${1:-}"
if [[ -z "$VERSION" || ! "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
echo "usage: $0 <version>" >&2
echo "example: $0 v0.1.0-alpha.1" >&2
exit 2
fi
echo "=== verstak desktop release $VERSION ==="
RELEASE_ROOT="$ROOT/release"
rm -rf "$RELEASE_ROOT"
mkdir -p "$RELEASE_ROOT"
"$ROOT/scripts/package-deb.sh" "$VERSION"
"$ROOT/scripts/package-appimage.sh" "$VERSION"
"$ROOT/scripts/package-windows-portable.sh" "$VERSION"
(cd "$RELEASE_ROOT" && find . -maxdepth 1 -type f \( -name '*.deb' -o -name '*.AppImage' -o -name '*.zip' \) \
-printf '%f\n' | LC_ALL=C sort | xargs -r sha256sum > SHA256SUMS)
echo "release assets: $RELEASE_ROOT"
echo "checksums: $RELEASE_ROOT/SHA256SUMS"
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BUILDER="$ROOT/scripts/build-windows.sh"
if [[ ! -x "$BUILDER" ]]; then
echo "Windows desktop builder is missing or not executable: $BUILDER" >&2
exit 1
fi
bash -n "$BUILDER"
grep -Fq -- '-platform windows/amd64' "$BUILDER"
grep -Fq 'CC="$WINDOWS_CC"' "$BUILDER"
grep -Fq 'CGO_ENABLED=1' "$BUILDER"
grep -Fq -- '-webview2 error' "$BUILDER"
grep -Fq 'dist-windows' "$BUILDER"
grep -Fq 'verstak-desktop.exe' "$BUILDER"
grep -Fq 'x86_64-w64-mingw32-gcc' "$BUILDER"
echo "Windows desktop build script contract passed"
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
for script in build-linux-bundle.sh package-deb.sh package-appimage.sh package-windows-portable.sh; do
path="$ROOT/scripts/$script"
if [[ ! -x "$path" ]]; then
echo "packaging script is missing or not executable: $path" >&2
exit 1
fi
bash -n "$path"
done
grep -Fq 'dpkg-deb' "$ROOT/scripts/package-deb.sh"
grep -Fq -- '--build' "$ROOT/scripts/package-deb.sh"
grep -Fq 'packaging/deb/control' "$ROOT/scripts/package-deb.sh"
grep -Fq 'libwebkit2gtk-4.1-0' "$ROOT/packaging/deb/control"
grep -Fq 'appimagetool' "$ROOT/scripts/package-appimage.sh"
grep -Fq 'WebKitWebProcess' "$ROOT/scripts/package-appimage.sh"
if grep -Fq 'FixedVersionRuntime' "$ROOT/scripts/package-windows-portable.sh"; then
echo "Windows portable archive must not bundle Fixed Version WebView2" >&2
exit 1
fi
if grep -Fq 'msedgewebview2.exe' "$ROOT/scripts/package-windows-portable.sh"; then
echo "Windows portable archive must not bundle WebView2 binaries" >&2
exit 1
fi
if [[ -e "$ROOT/platform_options_windows.go" ]]; then
echo "Windows runtime override must not be present" >&2
exit 1
fi
grep -Fq 'zip -qr' "$ROOT/scripts/package-windows-portable.sh"
grep -Fq 'LinkId=2124701' "$ROOT/README.md"
grep -Fq 'WebView2 Runtime' "$ROOT/README.md"
grep -Fq 'package-deb.sh' "$ROOT/scripts/release.sh"
grep -Fq 'package-appimage.sh' "$ROOT/scripts/release.sh"
grep -Fq 'package-windows-portable.sh' "$ROOT/scripts/release.sh"
grep -Fq 'chmod -R a+rX' "$ROOT/scripts/build-linux-bundle.sh"
echo "desktop package script contracts passed"
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PUBLISHER="$ROOT/scripts/publish-github-release.sh"
VERSION="v0.0.0-test"
REPOSITORY="mirivlad/verstak"
ASSET_NAMES=(
"verstak_0.0.0-test_amd64.deb"
"verstak-linux-x86_64-v0.0.0-test.AppImage"
"verstak-windows-amd64-v0.0.0-test.zip"
)
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
if [[ ! -x "$PUBLISHER" ]]; then
echo "publisher is missing or not executable: $PUBLISHER" >&2
exit 1
fi
mkdir -p "$WORK/bin" "$WORK/release" "$WORK/state"
LOG="$WORK/log"
export LOG
cat > "$WORK/release.sh" <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
printf 'release:%s\n' "$1" >> "$LOG"
printf 'deb\n' > "$VERSTAK_RELEASE_DIR/verstak_${1#v}_amd64.deb"
printf 'appimage\n' > "$VERSTAK_RELEASE_DIR/verstak-linux-x86_64-$1.AppImage"
printf 'zip\n' > "$VERSTAK_RELEASE_DIR/verstak-windows-amd64-$1.zip"
printf 'checksum\n' > "$VERSTAK_RELEASE_DIR/SHA256SUMS"
SCRIPT
chmod +x "$WORK/release.sh"
cat > "$WORK/bin/git" <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
if [[ "$PWD" != "${EXPECTED_ROOT:?}" ]]; then
echo "publisher did not enter repository root: $PWD" >&2
exit 1
fi
case "${1:-}" in
status) exit 0 ;;
branch) echo main ;;
fetch) printf 'fetch\n' >> "$LOG" ;;
rev-parse)
if [[ "${2:-}" == "-q" ]]; then
if [[ -f "$TEST_STATE/tag" ]]; then echo test-commit; exit 0; fi
exit 1
fi
echo test-commit
;;
tag) touch "$TEST_STATE/tag"; printf 'tag:%s\n' "${3:-}" >> "$LOG" ;;
push) printf 'push:%s:%s\n' "${2:-}" "${3:-}" >> "$LOG" ;;
*) echo "unexpected git invocation: $*" >&2; exit 1 ;;
esac
SCRIPT
chmod +x "$WORK/bin/git"
cat > "$WORK/bin/gh" <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
case "${1:-}:${2:-}" in
auth:status) printf 'auth\n' >> "$LOG" ;;
release:view)
if [[ -f "$TEST_STATE/release" ]]; then echo https://github.example/release; else exit 1; fi
;;
release:create) touch "$TEST_STATE/release"; printf 'create:%s\n' "$*" >> "$LOG" ;;
release:upload) printf 'upload:%s\n' "$*" >> "$LOG" ;;
*) echo "unexpected gh invocation: $*" >&2; exit 1 ;;
esac
SCRIPT
chmod +x "$WORK/bin/gh"
run_publisher() {
VERSTAK_RELEASE_SCRIPT="$WORK/release.sh" \
VERSTAK_RELEASE_DIR="$WORK/release" \
GIT_BIN="$WORK/bin/git" \
GH_BIN="$WORK/bin/gh" \
EXPECTED_ROOT="$ROOT" \
TEST_STATE="$WORK/state" \
"$PUBLISHER" "$VERSION"
}
run_publisher
grep -Fqx "release:$VERSION" "$LOG"
grep -Fqx "tag:$VERSION" "$LOG"
grep -Fqx "push:origin:refs/tags/$VERSION" "$LOG"
grep -F "release create $VERSION" "$LOG" >/dev/null
for asset in "${ASSET_NAMES[@]}"; do
grep -F "$asset" "$LOG" >/dev/null
done
grep -F "SHA256SUMS" "$LOG" >/dev/null
grep -F -- "--prerelease" "$LOG" >/dev/null
run_publisher
grep -F "release upload $VERSION" "$LOG" >/dev/null
echo "GitHub release publisher test passed"