Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
030f66d806 | ||
|
|
da76b89856 | ||
|
|
20952e8477 | ||
|
|
f4e911c14a | ||
|
|
47a8d565e8 | ||
|
|
581e6b27d8 | ||
|
|
ca5290015a | ||
|
|
ad0c00bade | ||
|
|
4db5a7a132 | ||
|
|
c567b229cc | ||
|
|
5a249dcf24 | ||
|
|
6b556c59d9 | ||
|
|
fca9fff4b4 | ||
|
|
bcf49ed32c | ||
|
|
39052ffe39 | ||
|
|
e1a6b2af64 | ||
|
|
5280d245ee | ||
|
|
477b372110 | ||
|
|
c129c1a2ac | ||
|
|
7f0cc308c2 | ||
|
|
8b1151e03a | ||
|
|
15f1f413d0 |
+2
-1
@@ -1,8 +1,9 @@
|
||||
# .gitignore
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
build/bin/verstak-desktop
|
||||
build/
|
||||
smoke-platform
|
||||
plugins/
|
||||
vendor/
|
||||
frontend/e2e-results/
|
||||
release/
|
||||
|
||||
@@ -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
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -9,13 +9,19 @@
|
||||
import WorkspaceHost from './lib/shell/WorkspaceHost.svelte';
|
||||
import * as App from '../wailsjs/go/api/App';
|
||||
import { debug } from './lib/log/debug.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { tick } from 'svelte';
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import { i18n } from './lib/i18n/index.js';
|
||||
|
||||
let currentView = 'workspace';
|
||||
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
|
||||
let needsVaultSelection = false;
|
||||
let loading = true;
|
||||
let locale = i18n.getLocale();
|
||||
const unsubscribeLocale = i18n.subscribe((nextLocale) => { locale = nextLocale; });
|
||||
$: tr = ((activeLocale) => (key, params, fallback) => {
|
||||
void activeLocale;
|
||||
return i18n.t(key, params, fallback);
|
||||
})(locale);
|
||||
|
||||
let activeView = null;
|
||||
let activeViewPluginId = '';
|
||||
@@ -48,6 +54,7 @@
|
||||
const name = workspaceName(workspace);
|
||||
return {
|
||||
id: name,
|
||||
workspaceId: workspace?.id || workspace?.workspaceId || '',
|
||||
type: workspace?.type || 'space',
|
||||
title: workspace?.title || name,
|
||||
name,
|
||||
@@ -212,6 +219,7 @@
|
||||
const settings = await App.GetAppSettings();
|
||||
debug.log('[App] checkVault: GetAppSettings returned', settings);
|
||||
flog('checkVault: GetAppSettings returned');
|
||||
if (settings?.debug) debug.enable();
|
||||
|
||||
debug.log('[App] checkVault: calling GetVaultStatus...');
|
||||
vaultStatus = await App.GetVaultStatus() || { status: 'unknown', path: '', vaultId: '' };
|
||||
@@ -375,11 +383,13 @@
|
||||
await checkVault();
|
||||
pushNavigation();
|
||||
});
|
||||
|
||||
onDestroy(unsubscribeLocale);
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="app-loading">
|
||||
<p>Loading Verstak...</p>
|
||||
<p>{tr('app.loading')}</p>
|
||||
</div>
|
||||
{:else if needsVaultSelection}
|
||||
<VaultSelection />
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
export default {
|
||||
'app.loading': 'Loading Verstak...',
|
||||
'settings.title': 'Settings',
|
||||
'settings.language': 'Language',
|
||||
'settings.language.system': 'System / Системный',
|
||||
'settings.language.en': 'English',
|
||||
'settings.language.ru': 'Русский',
|
||||
'settings.pluginManager': 'Plugin Manager',
|
||||
'statusBar.label': 'Status bar',
|
||||
'vault.label': 'Vault: {status}',
|
||||
'vault.status.open': 'open',
|
||||
'vault.status.closed': 'closed',
|
||||
'vault.status.unknown': 'unknown',
|
||||
'sidebar.tools': 'Tools',
|
||||
'sidebar.error.contributions': 'Failed to load plugin contributions',
|
||||
'sidebar.error.load': 'Failed to load sidebar',
|
||||
'sidebar.error.ui': 'Plugin UI error',
|
||||
'workspace.overview': 'Overview',
|
||||
'workspace.search': 'Workspace search',
|
||||
'workspace.tools': 'Workspace tools',
|
||||
'workspace.tool': 'Workspace tool',
|
||||
'workspace.emptyTools': 'No workspace tools available',
|
||||
'workspace.emptyToolsHint': 'Enable plugins with workspace tools or open Plugin Manager from settings.',
|
||||
'workspace.select': 'Select a workspace',
|
||||
'workspace.selectHint': 'Use the + button in Workspaces to add your first project.',
|
||||
'common.details': 'Details',
|
||||
'common.plugin': 'Plugin',
|
||||
'common.component': 'Component',
|
||||
'pluginView.error': 'Plugin view error',
|
||||
'pluginView.failed': 'Plugin UI failed',
|
||||
'pluginView.noVisual': 'This plugin does not provide a visual view yet.',
|
||||
'pluginView.viewId': 'View ID',
|
||||
'pluginView.bundleUnavailable': 'Frontend bundle unavailable',
|
||||
'pluginView.unavailable': 'View is unavailable.',
|
||||
'pluginView.select': 'Select a plugin view from the sidebar',
|
||||
'pluginView.selectHint': 'Plugin views will appear here',
|
||||
'common.retry': '⟳ Retry',
|
||||
'common.source': 'Source',
|
||||
'common.status': 'Status',
|
||||
'common.capability': 'Capability',
|
||||
'common.provider': 'Provider',
|
||||
'status.loaded': 'loaded',
|
||||
'status.degraded': 'degraded',
|
||||
'status.failed': 'failed',
|
||||
'status.disabled': 'disabled',
|
||||
'status.missing': 'missing',
|
||||
'pluginManager.reloading': '⟳ Reloading...',
|
||||
'pluginManager.reload': '⟳ Reload',
|
||||
'pluginManager.scanning': 'Scanning plugin directories...',
|
||||
'pluginManager.summary.plugins': '{count} plugin(s) discovered',
|
||||
'pluginManager.summary.capabilities': '{count} capabilities registered',
|
||||
'pluginManager.summary.permissions': '{count} permissions known',
|
||||
'pluginManager.scanSummary': 'Plugin scan summary',
|
||||
'pluginManager.health': 'Plugin Health',
|
||||
'pluginManager.permissionRisk': 'Permission Risk',
|
||||
'pluginManager.elevatedPermissions': '{count} plugins request elevated permissions',
|
||||
'pluginManager.none': 'No plugins found',
|
||||
'pluginManager.scannedDirs': 'Plugin directories scanned:',
|
||||
'pluginManager.userPlugins': 'user plugins',
|
||||
'pluginManager.bundledPlugins': 'bundled plugins (app directory)',
|
||||
'pluginManager.installHint': 'Place a plugin folder with plugin.json in one of these directories and click Reload.',
|
||||
'pluginManager.missingTitle': 'Missing Installed Plugins',
|
||||
'pluginManager.missingHint': 'These plugins are required by this vault but their packages are not installed locally.',
|
||||
'pluginManager.missingPackage': "This plugin is listed in the vault's desired plugins but the package is not installed.",
|
||||
'pluginManager.capabilityRegistry': 'Capability Registry ({count})',
|
||||
'pluginManager.settingsError': 'Settings Error',
|
||||
'pluginManager.pluginSettings': 'Plugin Settings',
|
||||
'pluginManager.settingsBundleUnavailable': 'Settings panel frontend bundle not available',
|
||||
'common.none': 'none',
|
||||
'common.unknown': 'unknown',
|
||||
'pluginCard.count.views': '{count} view(s)',
|
||||
'pluginCard.count.commands': '{count} command(s)',
|
||||
'pluginCard.count.searchProviders': '{count} search provider(s)',
|
||||
'pluginCard.count.sidebar': '{count} sidebar item(s)',
|
||||
'pluginCard.count.statusbar': '{count} status bar item(s)',
|
||||
'pluginCard.count.openProviders': '{count} 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',
|
||||
};
|
||||
@@ -0,0 +1,225 @@
|
||||
export default {
|
||||
'app.loading': 'Загрузка Верстака...',
|
||||
'settings.title': 'Настройки',
|
||||
'settings.language': 'Язык',
|
||||
'settings.language.system': 'System / Системный',
|
||||
'settings.language.en': 'English',
|
||||
'settings.language.ru': 'Русский',
|
||||
'settings.pluginManager': 'Менеджер плагинов',
|
||||
'statusBar.label': 'Строка состояния',
|
||||
'vault.label': 'Хранилище: {status}',
|
||||
'vault.status.open': 'открыто',
|
||||
'vault.status.closed': 'закрыто',
|
||||
'vault.status.unknown': 'неизвестно',
|
||||
'sidebar.tools': 'Инструменты',
|
||||
'sidebar.error.contributions': 'Не удалось загрузить элементы плагинов',
|
||||
'sidebar.error.load': 'Не удалось загрузить боковую панель',
|
||||
'sidebar.error.ui': 'Ошибка интерфейса плагина',
|
||||
'workspace.overview': 'Обзор',
|
||||
'workspace.search': 'Поиск в рабочем пространстве',
|
||||
'workspace.tools': 'Инструменты рабочего пространства',
|
||||
'workspace.tool': 'Инструмент рабочего пространства',
|
||||
'workspace.emptyTools': 'Нет доступных инструментов',
|
||||
'workspace.emptyToolsHint': 'Включите плагины с инструментами рабочего пространства или откройте Менеджер плагинов в настройках.',
|
||||
'workspace.select': 'Выберите рабочее пространство',
|
||||
'workspace.selectHint': 'Используйте кнопку + в разделе рабочих пространств, чтобы добавить первый проект.',
|
||||
'common.details': 'Подробнее',
|
||||
'common.plugin': 'Плагин',
|
||||
'common.component': 'Компонент',
|
||||
'pluginView.error': 'Ошибка представления плагина',
|
||||
'pluginView.failed': 'Ошибка интерфейса плагина',
|
||||
'pluginView.noVisual': 'Этот плагин пока не предоставляет визуальное представление.',
|
||||
'pluginView.viewId': 'ID представления',
|
||||
'pluginView.bundleUnavailable': 'Frontend bundle недоступен',
|
||||
'pluginView.unavailable': 'Представление недоступно.',
|
||||
'pluginView.select': 'Выберите представление плагина на боковой панели',
|
||||
'pluginView.selectHint': 'Представления плагинов появятся здесь',
|
||||
'common.retry': '⟳ Повторить',
|
||||
'common.source': 'Источник',
|
||||
'common.status': 'Состояние',
|
||||
'common.capability': 'Capability',
|
||||
'common.provider': 'Поставщик',
|
||||
'status.loaded': 'загружено',
|
||||
'status.degraded': 'ограничено',
|
||||
'status.failed': 'ошибка',
|
||||
'status.disabled': 'выключено',
|
||||
'status.missing': 'отсутствует',
|
||||
'pluginManager.reloading': '⟳ Обновление...',
|
||||
'pluginManager.reload': '⟳ Обновить',
|
||||
'pluginManager.scanning': 'Сканирование каталогов плагинов...',
|
||||
'pluginManager.summary.plugins': 'Обнаружено плагинов: {count}',
|
||||
'pluginManager.summary.capabilities': 'Зарегистрировано capabilities: {count}',
|
||||
'pluginManager.summary.permissions': 'Известно разрешений: {count}',
|
||||
'pluginManager.scanSummary': 'Сводка проверки плагинов',
|
||||
'pluginManager.health': 'Состояние плагинов',
|
||||
'pluginManager.permissionRisk': 'Риск разрешений',
|
||||
'pluginManager.elevatedPermissions': 'Плагинов с повышенными разрешениями: {count}',
|
||||
'pluginManager.none': 'Плагины не найдены',
|
||||
'pluginManager.scannedDirs': 'Проверенные каталоги плагинов:',
|
||||
'pluginManager.userPlugins': 'пользовательские плагины',
|
||||
'pluginManager.bundledPlugins': 'встроенные плагины (каталог приложения)',
|
||||
'pluginManager.installHint': 'Поместите папку плагина с plugin.json в один из этих каталогов и нажмите «Обновить».',
|
||||
'pluginManager.missingTitle': 'Отсутствующие установленные плагины',
|
||||
'pluginManager.missingHint': 'Эти плагины требуются хранилищу, но их пакеты не установлены на этом устройстве.',
|
||||
'pluginManager.missingPackage': 'Плагин указан среди желаемых плагинов хранилища, но его пакет не установлен.',
|
||||
'pluginManager.capabilityRegistry': 'Реестр capabilities ({count})',
|
||||
'pluginManager.settingsError': 'Ошибка настроек',
|
||||
'pluginManager.pluginSettings': 'Настройки плагина',
|
||||
'pluginManager.settingsBundleUnavailable': 'Frontend bundle панели настроек недоступен',
|
||||
'common.none': 'нет',
|
||||
'common.unknown': 'неизвестно',
|
||||
'pluginCard.count.views': 'представлений: {count}',
|
||||
'pluginCard.count.commands': 'команд: {count}',
|
||||
'pluginCard.count.searchProviders': 'поставщиков поиска: {count}',
|
||||
'pluginCard.count.sidebar': 'элементов боковой панели: {count}',
|
||||
'pluginCard.count.statusbar': 'элементов строки состояния: {count}',
|
||||
'pluginCard.count.openProviders': 'обработчиков открытия: {count}',
|
||||
'pluginCard.count.workspace': 'инструментов рабочего пространства: {count}',
|
||||
'pluginCard.degraded': 'Плагин работает, но некоторые необязательные capabilities недоступны.',
|
||||
'pluginCard.name': 'Название',
|
||||
'pluginCard.contributions': 'Элементы интерфейса',
|
||||
'pluginCard.technicalDetails': 'Технические сведения',
|
||||
'pluginCard.apiVersion': 'Версия API',
|
||||
'pluginCard.root': 'Корневой каталог',
|
||||
'pluginCard.provides': 'Предоставляет',
|
||||
'pluginCard.requires': 'Требует',
|
||||
'pluginCard.missingRequired': 'Отсутствуют обязательные capabilities',
|
||||
'pluginCard.optionalRequires': 'Необязательные зависимости',
|
||||
'pluginCard.optionalUnavailable': 'Необязательные capabilities недоступны — плагин работает в ограниченном режиме',
|
||||
'pluginCard.permissions': 'Разрешения',
|
||||
'pluginCard.enabling': '⟳ Включение...',
|
||||
'pluginCard.enable': '▶ Включить',
|
||||
'pluginCard.disabling': '⟳ Выключение...',
|
||||
'pluginCard.disable': '⏸ Выключить',
|
||||
'pluginCard.openVault': 'Откройте хранилище для управления состоянием плагина',
|
||||
'pluginCard.missingUiPermission': 'Плагин добавляет элементы интерфейса, но не имеет разрешения ui.register',
|
||||
'common.loading': 'Загрузка...',
|
||||
'common.browse': 'Обзор...',
|
||||
'vaultSelection.chooseNew': 'Выберите или введите папку для нового хранилища.',
|
||||
'vaultSelection.createError': 'Не удалось создать хранилище: {error}',
|
||||
'vaultSelection.openError': 'Не удалось открыть хранилище: {error}',
|
||||
'vaultSelection.chooseExisting': 'Выберите или введите существующее хранилище.',
|
||||
'vaultSelection.subtitle': 'Выберите хранилище, чтобы начать работу',
|
||||
'vaultSelection.createTitle': 'Создать новое хранилище',
|
||||
'vaultSelection.createHint': 'Создайте локальную папку хранилища для рабочих пространств и проектов.',
|
||||
'vaultSelection.pathPlaceholder': 'Выберите или введите путь...',
|
||||
'vaultSelection.creating': 'Создание...',
|
||||
'vaultSelection.create': 'Создать хранилище',
|
||||
'vaultSelection.openTitle': 'Открыть существующее хранилище',
|
||||
'vaultSelection.openHint': 'Используйте хранилище, которое уже находится на этом компьютере.',
|
||||
'vaultSelection.opening': 'Открытие...',
|
||||
'vaultSelection.open': 'Открыть',
|
||||
'vaultSelection.recent': 'Недавние хранилища',
|
||||
'common.cancel': 'Отмена',
|
||||
'common.close': 'Закрыть',
|
||||
'workspaceTree.title': 'Рабочие пространства',
|
||||
'workspaceTree.new': 'Новое рабочее пространство',
|
||||
'workspaceTree.nameRequired': 'Введите название',
|
||||
'workspaceTree.chooseTemplate': 'Выберите шаблон рабочего пространства',
|
||||
'workspaceTree.saveRename': 'Сохранить новое название',
|
||||
'workspaceTree.rename': 'Переименовать рабочее пространство',
|
||||
'workspaceTree.trash': 'Переместить рабочее пространство в корзину',
|
||||
'workspaceTree.create': 'Создать рабочее пространство',
|
||||
'workspaceTree.namePlaceholder': 'Название рабочего пространства',
|
||||
'workspaceTree.template': 'Шаблон',
|
||||
'workspaceTree.creating': 'Создание...',
|
||||
'command.openOverview': 'Открыть обзор',
|
||||
'command.openFiles': 'Открыть файлы',
|
||||
'command.openActivity': 'Открыть активность',
|
||||
'command.openBrowserInbox': 'Открыть входящие из браузера',
|
||||
'command.createMarkdown': 'Создать файл Markdown',
|
||||
'command.createText': 'Создать текстовый файл',
|
||||
'command.syncNow': 'Синхронизировать сейчас',
|
||||
'command.openSyncSettings': 'Открыть настройки синхронизации',
|
||||
'command.openPluginManager': 'Открыть менеджер плагинов',
|
||||
'command.handled': 'Выполнено: {title}',
|
||||
'command.result': '{title}: {status}',
|
||||
'command.statusHandled': 'выполнено',
|
||||
'command.palette': 'Палитра команд',
|
||||
'command.run': 'Выполнить команду',
|
||||
'command.none': 'Команды не найдены',
|
||||
'search.indexing': 'Индексирование...',
|
||||
'search.placeholder': 'Поиск',
|
||||
'search.global': 'Глобальный поиск',
|
||||
'search.noResults': 'Ничего не найдено',
|
||||
'search.type.workspace': 'Рабочее пространство',
|
||||
'search.type.tool': 'Инструмент',
|
||||
'search.type.folder': 'Папка',
|
||||
'search.type.file': 'Файл',
|
||||
'search.type.journal': 'Журнал',
|
||||
'search.type.browserInbox': 'Входящие из браузера',
|
||||
'search.type.activity': 'Активность',
|
||||
'workbench.noProvider': 'Нет обработчика',
|
||||
'workbench.noViewer': 'Нет доступного средства просмотра или редактора',
|
||||
'workbench.noResource': 'Ресурс не открыт',
|
||||
'bundle.noFrontend': 'У плагина нет frontend bundle',
|
||||
'bundle.notFound': 'Плагин не найден',
|
||||
'bundle.loadFailed': 'Не удалось загрузить bundle: {error}',
|
||||
'bundle.emptyContent': 'пустое содержимое',
|
||||
'bundle.executionError': 'Ошибка выполнения bundle: {error}',
|
||||
'bundle.registrationMissing': 'Bundle загружен, но вызов VerstakPluginRegister не обнаружен',
|
||||
'bundle.componentMissing': 'Компонент «{component}» не найден в bundle. Доступны: {available}',
|
||||
'bundle.mountError': 'Ошибка подключения компонента: {error}',
|
||||
'bundle.mountUnavailable': 'Контейнер для подключения недоступен',
|
||||
'bundle.unexpectedError': 'Непредвиденная ошибка: {error}',
|
||||
'bundle.unknownError': 'Неизвестная ошибка',
|
||||
'bundle.frontendEntry': 'Точка входа frontend',
|
||||
'bundle.availableComponents': 'Доступные компоненты',
|
||||
'bundle.loading': 'Загрузка bundle плагина...',
|
||||
'overview.filter.all': 'Все',
|
||||
'overview.filter.notes': 'Заметки',
|
||||
'overview.filter.files': 'Файлы',
|
||||
'overview.filter.captures': 'Сохранённое',
|
||||
'overview.filter.journal': 'Журнал',
|
||||
'overview.notes': 'Заметки',
|
||||
'overview.files': 'Файлы',
|
||||
'overview.captures': 'Сохранённое',
|
||||
'overview.activity': 'Активность',
|
||||
'overview.journal': 'Журнал',
|
||||
'overview.attention': 'Требует внимания',
|
||||
'overview.openNotes': 'Открыть заметки',
|
||||
'overview.openFiles': 'Открыть файлы',
|
||||
'overview.reviewInbox': 'Разобрать входящие',
|
||||
'overview.viewActivity': 'Открыть активность',
|
||||
'overview.openJournal': 'Открыть журнал',
|
||||
'overview.openTodos': 'Открыть задачи',
|
||||
'overview.reviewCandidate': 'Просмотреть запись',
|
||||
'overview.reviewPending': 'Разобрать ожидающее',
|
||||
'overview.count.totalRecent.one': 'Всего: {total} · недавних изменений: {recent}',
|
||||
'overview.count.totalRecent.many': 'Всего: {total} · недавних изменений: {recent}',
|
||||
'overview.count.recentChanges.one': 'Недавних изменений: {count}',
|
||||
'overview.count.recentChanges.many': 'Недавних изменений: {count}',
|
||||
'overview.count.captures.one': 'На разбор: {count}',
|
||||
'overview.count.captures.many': 'На разбор: {count}',
|
||||
'overview.count.events.one': 'Событий: {count}',
|
||||
'overview.count.events.many': 'Событий: {count}',
|
||||
'overview.count.journal.one': 'Записей журнала: {count}',
|
||||
'overview.count.journal.many': 'Записей журнала: {count}',
|
||||
'overview.count.pending.one': 'Ожидают: {count}',
|
||||
'overview.count.pending.many': 'Ожидают: {count}',
|
||||
'overview.loadingContext': 'Загрузка данных рабочего пространства...',
|
||||
'overview.lastActive': 'Последняя активность: {time}',
|
||||
'overview.noRecentActivity': 'В рабочем пространстве пока нет недавней активности',
|
||||
'overview.refresh': 'Обновить',
|
||||
'overview.summary': 'Сводка рабочего пространства',
|
||||
'overview.continue': 'Продолжить работу',
|
||||
'overview.continueHint': 'Вернитесь к следующему полезному делу в этом рабочем пространстве.',
|
||||
'overview.loadingSignals': 'Загрузка рабочих данных...',
|
||||
'overview.noResume': 'Пока неясно, с чего продолжить',
|
||||
'overview.noResumeHint': 'Здесь появятся недавние заметки, файлы, сохранённое и записи журнала.',
|
||||
'overview.recentChanges': 'Недавние изменения',
|
||||
'overview.recentChangesHint': 'Последняя важная активность в этом рабочем пространстве.',
|
||||
'overview.recentFilter': 'Фильтр недавних изменений',
|
||||
'overview.loadingRecent': 'Загрузка недавних изменений...',
|
||||
'overview.noChanges': 'Для этого фильтра пока нет важных изменений.',
|
||||
'overview.attentionHint': 'Неразобранное сохранённое, срочные задачи и возможные записи журнала.',
|
||||
'overview.loadingPending': 'Загрузка ожидающих элементов...',
|
||||
'overview.noPending': 'Нет неразобранного сохранённого, срочных задач или возможных записей журнала.',
|
||||
'overview.keyResources': 'Ключевые ресурсы',
|
||||
'overview.time.none': 'Время не указано',
|
||||
'overview.time.now': 'Только что',
|
||||
'overview.time.minutes': '{count} мин. назад',
|
||||
'overview.time.hours': '{count} ч. назад',
|
||||
'overview.time.yesterday': 'Вчера',
|
||||
'overview.time.days': '{count} дн. назад',
|
||||
};
|
||||
@@ -0,0 +1,200 @@
|
||||
import englishShellCatalog from './catalogs/en.js';
|
||||
import russianShellCatalog from './catalogs/ru.js';
|
||||
|
||||
const LANGUAGE_PREFERENCES = new Set(['system', 'en', 'ru']);
|
||||
const CONTRIBUTION_TEXT_FIELDS = {
|
||||
views: 'title',
|
||||
commands: 'title',
|
||||
settingsPanels: 'title',
|
||||
sidebarItems: 'title',
|
||||
fileActions: 'label',
|
||||
noteActions: 'label',
|
||||
contextMenuEntries: 'label',
|
||||
searchProviders: 'label',
|
||||
statusBarItems: 'label',
|
||||
openProviders: 'title',
|
||||
workspaceItems: 'title',
|
||||
};
|
||||
|
||||
function normalizeSystemLanguages(languages) {
|
||||
const values = Array.isArray(languages) ? languages : [languages];
|
||||
return values.map((value) => String(value || '').trim().toLowerCase()).filter(Boolean);
|
||||
}
|
||||
|
||||
export function resolveLocale(preference, systemLanguages = []) {
|
||||
if (!LANGUAGE_PREFERENCES.has(preference)) {
|
||||
throw new Error(`unsupported language preference: ${preference}`);
|
||||
}
|
||||
if (preference === 'en' || preference === 'ru') return preference;
|
||||
return normalizeSystemLanguages(systemLanguages).some((locale) => locale === 'ru' || locale.startsWith('ru-'))
|
||||
? 'ru'
|
||||
: 'en';
|
||||
}
|
||||
|
||||
function interpolate(message, params) {
|
||||
if (!params) return message;
|
||||
return message.replace(/\{([A-Za-z0-9_.-]+)\}/g, (placeholder, name) => (
|
||||
Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : placeholder
|
||||
));
|
||||
}
|
||||
|
||||
function browserLanguages() {
|
||||
if (typeof navigator === 'undefined') return ['en'];
|
||||
if (Array.isArray(navigator.languages) && navigator.languages.length > 0) return navigator.languages;
|
||||
return [navigator.language || 'en'];
|
||||
}
|
||||
|
||||
export function createI18n(options = {}) {
|
||||
const shellCatalogs = options.shellCatalogs || { en: {}, ru: {} };
|
||||
let systemLanguages = options.systemLanguages || browserLanguages;
|
||||
let catalogLoader = options.loadPluginCatalog || (async () => ({}));
|
||||
let preference = 'system';
|
||||
let locale = 'en';
|
||||
const listeners = new Set();
|
||||
const pluginConfigs = new Map();
|
||||
const pluginCatalogs = new Map();
|
||||
|
||||
function configure(next = {}) {
|
||||
if (next.systemLanguages) systemLanguages = next.systemLanguages;
|
||||
if (next.loadPluginCatalog) catalogLoader = next.loadPluginCatalog;
|
||||
}
|
||||
|
||||
function notify() {
|
||||
listeners.forEach((listener) => listener(locale));
|
||||
}
|
||||
|
||||
async function initialize(initialPreference = 'system') {
|
||||
preference = LANGUAGE_PREFERENCES.has(initialPreference) ? initialPreference : 'system';
|
||||
locale = resolveLocale(preference, systemLanguages());
|
||||
}
|
||||
|
||||
async function loadCatalog(pluginId, catalogLocale) {
|
||||
if (!catalogLocale) return;
|
||||
let catalogs = pluginCatalogs.get(pluginId);
|
||||
if (!catalogs) {
|
||||
catalogs = new Map();
|
||||
pluginCatalogs.set(pluginId, catalogs);
|
||||
}
|
||||
if (catalogs.has(catalogLocale)) return;
|
||||
const messages = await catalogLoader(pluginId, catalogLocale);
|
||||
catalogs.set(catalogLocale, { ...(messages || {}) });
|
||||
}
|
||||
|
||||
async function loadPlugin(pluginId, localization) {
|
||||
if (!pluginId || !localization || !localization.defaultLocale || !localization.locales) return;
|
||||
pluginConfigs.set(pluginId, localization);
|
||||
const requested = localization.locales[locale] ? locale : '';
|
||||
const defaultLocale = localization.defaultLocale;
|
||||
await Promise.all(Array.from(new Set([requested, defaultLocale].filter(Boolean))).map((item) => loadCatalog(pluginId, item)));
|
||||
}
|
||||
|
||||
async function setLanguagePreference(nextPreference) {
|
||||
const nextLocale = resolveLocale(nextPreference, systemLanguages());
|
||||
preference = nextPreference;
|
||||
locale = nextLocale;
|
||||
await Promise.all(Array.from(pluginConfigs.entries()).map(([pluginId, config]) => (
|
||||
loadPlugin(pluginId, config).catch((error) => {
|
||||
console.warn(`[i18n] failed to load ${pluginId} catalog for ${nextLocale}:`, error);
|
||||
})
|
||||
)));
|
||||
notify();
|
||||
}
|
||||
|
||||
function t(key, params, fallback) {
|
||||
const message = shellCatalogs[locale]?.[key]
|
||||
?? shellCatalogs.en?.[key]
|
||||
?? fallback
|
||||
?? key;
|
||||
return interpolate(message, params);
|
||||
}
|
||||
|
||||
function translatePlugin(pluginId, key, params, fallback) {
|
||||
const config = pluginConfigs.get(pluginId);
|
||||
const catalogs = pluginCatalogs.get(pluginId);
|
||||
const message = catalogs?.get(locale)?.[key]
|
||||
?? catalogs?.get(config?.defaultLocale)?.[key]
|
||||
?? fallback
|
||||
?? key;
|
||||
return interpolate(message, params);
|
||||
}
|
||||
|
||||
function localizeContributions(pluginId, contributions) {
|
||||
if (!contributions) return contributions;
|
||||
const localized = { ...contributions };
|
||||
Object.entries(CONTRIBUTION_TEXT_FIELDS).forEach(([point, field]) => {
|
||||
if (!Array.isArray(contributions[point])) return;
|
||||
localized[point] = contributions[point].map((item) => ({
|
||||
...item,
|
||||
[field]: translatePlugin(
|
||||
pluginId,
|
||||
`contributions.${point}.${item.id}.${field}`,
|
||||
undefined,
|
||||
item[field],
|
||||
),
|
||||
}));
|
||||
});
|
||||
return localized;
|
||||
}
|
||||
|
||||
function localizePlugin(plugin) {
|
||||
if (!plugin) return plugin;
|
||||
const hasStateWrapper = !!plugin.manifest;
|
||||
const manifest = hasStateWrapper ? plugin.manifest : plugin;
|
||||
const pluginId = manifest.id;
|
||||
const localizedManifest = {
|
||||
...manifest,
|
||||
name: translatePlugin(pluginId, 'manifest.name', undefined, manifest.name),
|
||||
description: translatePlugin(pluginId, 'manifest.description', undefined, manifest.description),
|
||||
contributes: localizeContributions(pluginId, manifest.contributes),
|
||||
};
|
||||
return hasStateWrapper ? { ...plugin, manifest: localizedManifest } : localizedManifest;
|
||||
}
|
||||
|
||||
function localizeContributionSummary(summary) {
|
||||
if (!summary) return summary;
|
||||
const localized = { ...summary };
|
||||
Object.entries(CONTRIBUTION_TEXT_FIELDS).forEach(([point, field]) => {
|
||||
if (!Array.isArray(summary[point])) return;
|
||||
localized[point] = summary[point].map((item) => ({
|
||||
...item,
|
||||
[field]: translatePlugin(
|
||||
item.pluginId,
|
||||
`contributions.${point}.${item.id}.${field}`,
|
||||
undefined,
|
||||
item[field],
|
||||
),
|
||||
}));
|
||||
});
|
||||
return localized;
|
||||
}
|
||||
|
||||
function subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
listener(locale);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
return {
|
||||
configure,
|
||||
initialize,
|
||||
getLanguagePreference: () => preference,
|
||||
getLocale: () => locale,
|
||||
setLanguagePreference,
|
||||
t,
|
||||
subscribe,
|
||||
loadPlugin,
|
||||
translatePlugin,
|
||||
localizeContributions,
|
||||
localizePlugin,
|
||||
localizeContributionSummary,
|
||||
};
|
||||
}
|
||||
|
||||
export const i18n = createI18n({
|
||||
shellCatalogs: {
|
||||
en: englishShellCatalog,
|
||||
ru: russianShellCatalog,
|
||||
},
|
||||
});
|
||||
|
||||
export const t = (...args) => i18n.t(...args);
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import * as App from '../../../wailsjs/go/api/App';
|
||||
import Icon from '../ui/Icon.svelte';
|
||||
import { i18n } from '../i18n/index.js';
|
||||
|
||||
import { createPluginAPI } from './VerstakPluginAPI.js';
|
||||
|
||||
@@ -18,6 +19,13 @@
|
||||
let currentComponent = null;
|
||||
let currentAPI = null;
|
||||
let currentPropsKey = '';
|
||||
let locale = i18n.getLocale();
|
||||
let unsubscribeLocale = null;
|
||||
|
||||
$: tr = ((activeLocale) => (key, params, fallback) => {
|
||||
void activeLocale;
|
||||
return i18n.t(key, params, fallback);
|
||||
})(locale);
|
||||
|
||||
$: activePluginId = pluginId || viewPluginId;
|
||||
$: activeComponent = componentId;
|
||||
@@ -32,9 +40,14 @@
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribeLocale?.();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
unsubscribeLocale = i18n.subscribe((nextLocale) => locale = nextLocale);
|
||||
});
|
||||
|
||||
function cleanup() {
|
||||
if (currentAPI && typeof currentAPI.dispose === 'function') {
|
||||
try {
|
||||
@@ -93,11 +106,17 @@
|
||||
if (!info || info.status === 'no-frontend' || info.status === 'not-found') {
|
||||
loadState = 'error';
|
||||
errorText = info.status === 'no-frontend'
|
||||
? 'Plugin has no frontend bundle'
|
||||
: 'Plugin not found';
|
||||
? tr('bundle.noFrontend')
|
||||
: tr('bundle.notFound');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await i18n.loadPlugin(pId, info.localization);
|
||||
} catch (catalogError) {
|
||||
console.warn(`[PluginBundleHost] localization unavailable for ${pId}:`, catalogError);
|
||||
}
|
||||
|
||||
// Check if bundle already loaded for this plugin
|
||||
const reg = window.__VERSTAK_PLUGIN_REGISTRY__ || {};
|
||||
if (!reg[pId]) {
|
||||
@@ -106,7 +125,7 @@
|
||||
const content = assetResult.value;
|
||||
if (assetResult.error || !content) {
|
||||
loadState = 'error';
|
||||
errorText = 'Failed to load bundle: ' + (assetResult.error || 'empty content');
|
||||
errorText = tr('bundle.loadFailed', { error: assetResult.error || tr('bundle.emptyContent') });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -117,7 +136,7 @@
|
||||
fn();
|
||||
} catch (e) {
|
||||
loadState = 'error';
|
||||
errorText = 'Bundle execution error: ' + e.message;
|
||||
errorText = tr('bundle.executionError', { error: e.message });
|
||||
console.error('[PluginBundleHost] bundle exec error:', e);
|
||||
return;
|
||||
}
|
||||
@@ -125,7 +144,7 @@
|
||||
// Verify registration happened
|
||||
if (!window.__VERSTAK_PLUGIN_REGISTRY__[pId]) {
|
||||
loadState = 'error';
|
||||
errorText = 'Bundle loaded but no VerstakPluginRegister call detected';
|
||||
errorText = tr('bundle.registrationMissing');
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -135,8 +154,10 @@
|
||||
const comp = components[compId];
|
||||
if (!comp || !comp.mount) {
|
||||
loadState = 'error';
|
||||
errorText = 'Component "' + compId + '" not found in bundle. Available: '
|
||||
+ (Object.keys(components).join(', ') || 'none');
|
||||
errorText = tr('bundle.componentMissing', {
|
||||
component: compId,
|
||||
available: Object.keys(components).join(', ') || tr('common.none'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -156,16 +177,16 @@
|
||||
errorText = '';
|
||||
} catch (e) {
|
||||
loadState = 'error';
|
||||
errorText = 'Component mount error: ' + e.message;
|
||||
errorText = tr('bundle.mountError', { error: e.message });
|
||||
console.error('[PluginBundleHost] mount error:', e);
|
||||
}
|
||||
} else {
|
||||
loadState = 'error';
|
||||
errorText = 'Mount container not available';
|
||||
errorText = tr('bundle.mountUnavailable');
|
||||
}
|
||||
} catch (e) {
|
||||
loadState = 'error';
|
||||
errorText = 'Unexpected error: ' + (e.message || e);
|
||||
errorText = tr('bundle.unexpectedError', { error: e.message || e });
|
||||
console.error('[PluginBundleHost] error:', e);
|
||||
}
|
||||
}
|
||||
@@ -180,22 +201,22 @@
|
||||
<div class="plugin-bundle-host">
|
||||
{#if loadState === 'idle'}
|
||||
<div class="host-state idle">
|
||||
<p>Select a plugin view from the sidebar</p>
|
||||
<p>{tr('pluginView.select')}</p>
|
||||
</div>
|
||||
|
||||
{:else if loadState === 'error'}
|
||||
<div class="host-state error">
|
||||
<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">
|
||||
<p><strong>Plugin:</strong> {currentPluginId || 'unknown'}</p>
|
||||
<p><strong>Component:</strong> {currentComponent || 'unknown'}</p>
|
||||
<p class="error-message">{errorText || 'Unknown error'}</p>
|
||||
<p><strong>{tr('common.plugin')}:</strong> {currentPluginId || tr('common.unknown')}</p>
|
||||
<p><strong>{tr('common.component')}:</strong> {currentComponent || tr('common.unknown')}</p>
|
||||
<p class="error-message">{errorText || tr('bundle.unknownError')}</p>
|
||||
{#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 getComponentList().length > 0}
|
||||
<p class="error-meta">Available components: {getComponentList().join(', ')}</p>
|
||||
<p class="error-meta">{tr('bundle.availableComponents')}: {getComponentList().join(', ')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -204,7 +225,7 @@
|
||||
{#if loadState === 'loading'}
|
||||
<div class="host-state loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading plugin bundle...</p>
|
||||
<p>{tr('bundle.loading')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as App from '../../../wailsjs/go/api/App';
|
||||
import { i18n } from '../i18n/index.js';
|
||||
|
||||
window.__VERSTAK_PLUGIN_REGISTRY__ = window.__VERSTAK_PLUGIN_REGISTRY__ || {};
|
||||
window.__VERSTAK_EVENT_HANDLERS__ = window.__VERSTAK_EVENT_HANDLERS__ || {};
|
||||
@@ -144,6 +145,24 @@ export function createPluginAPI(pluginId) {
|
||||
return {
|
||||
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: {
|
||||
openSettings: function(panelId) {
|
||||
assertActive('ui.openSettings');
|
||||
@@ -217,12 +236,10 @@ export function createPluginAPI(pluginId) {
|
||||
if (!key) {
|
||||
throw new Error('settings.write requires a key');
|
||||
}
|
||||
const settings = await this.read();
|
||||
settings[key] = value;
|
||||
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) {
|
||||
assertActive('settings.writeAll');
|
||||
@@ -245,6 +262,26 @@ export function createPluginAPI(pluginId) {
|
||||
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) {
|
||||
assertActive('storage.data.write(' + name + ')');
|
||||
if (!name) {
|
||||
@@ -392,6 +429,12 @@ export function createPluginAPI(pluginId) {
|
||||
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) {
|
||||
assertActive('files.showInFolder(' + relativePath + ')');
|
||||
return callBackendErrorString(pluginId, 'files.showInFolder(' + relativePath + ')', function() {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script>
|
||||
import Icon from '../ui/Icon.svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { i18n } from '../i18n/index.js';
|
||||
export let p = {};
|
||||
export let capabilities = [];
|
||||
export let permissions = [];
|
||||
@@ -8,6 +10,13 @@
|
||||
export let settingsPanels = [];
|
||||
export let onEnable = () => {};
|
||||
export let onDisable = () => {};
|
||||
let locale = i18n.getLocale();
|
||||
const unsubscribeLocale = i18n.subscribe((nextLocale) => { locale = nextLocale; });
|
||||
$: tr = ((activeLocale) => (key, params, fallback) => {
|
||||
void activeLocale;
|
||||
return i18n.t(key, params, fallback);
|
||||
})(locale);
|
||||
onDestroy(unsubscribeLocale);
|
||||
|
||||
$: m = p.manifest || {};
|
||||
$: pluginId = m.id || 'unknown';
|
||||
@@ -39,14 +48,14 @@
|
||||
|
||||
$: contribSummary = (() => {
|
||||
const parts = [];
|
||||
if (contribCounts.views > 0) parts.push(contribCounts.views + ' view' + (contribCounts.views !== 1 ? 's' : ''));
|
||||
if (contribCounts.commands > 0) parts.push(contribCounts.commands + ' command' + (contribCounts.commands !== 1 ? 's' : ''));
|
||||
if (contribCounts.searchProviders > 0) parts.push(contribCounts.searchProviders + ' searchProvider' + (contribCounts.searchProviders !== 1 ? 's' : ''));
|
||||
if (contribCounts.sidebar > 0) parts.push(contribCounts.sidebar + ' sidebar' + (contribCounts.sidebar !== 1 ? 's' : ''));
|
||||
if (contribCounts.statusbar > 0) parts.push(contribCounts.statusbar + ' statusbar' + (contribCounts.statusbar !== 1 ? 's' : ''));
|
||||
if (contribCounts.openProviders > 0) parts.push(contribCounts.openProviders + ' openProvider' + (contribCounts.openProviders !== 1 ? 's' : ''));
|
||||
if (contribCounts.workspaceItems > 0) parts.push(contribCounts.workspaceItems + ' workspace' + (contribCounts.workspaceItems !== 1 ? 's' : ''));
|
||||
return parts.length > 0 ? parts.join(', ') : 'none';
|
||||
if (contribCounts.views > 0) parts.push(tr('pluginCard.count.views', { count: contribCounts.views }));
|
||||
if (contribCounts.commands > 0) parts.push(tr('pluginCard.count.commands', { count: contribCounts.commands }));
|
||||
if (contribCounts.searchProviders > 0) parts.push(tr('pluginCard.count.searchProviders', { count: contribCounts.searchProviders }));
|
||||
if (contribCounts.sidebar > 0) parts.push(tr('pluginCard.count.sidebar', { count: contribCounts.sidebar }));
|
||||
if (contribCounts.statusbar > 0) parts.push(tr('pluginCard.count.statusbar', { count: contribCounts.statusbar }));
|
||||
if (contribCounts.openProviders > 0) parts.push(tr('pluginCard.count.openProviders', { count: contribCounts.openProviders }));
|
||||
if (contribCounts.workspaceItems > 0) parts.push(tr('pluginCard.count.workspace', { count: contribCounts.workspaceItems }));
|
||||
return parts.length > 0 ? parts.join(', ') : tr('common.none');
|
||||
})();
|
||||
|
||||
$: dangerousPermissions = (m.permissions || []).filter(name => {
|
||||
@@ -81,11 +90,11 @@
|
||||
<strong>{pluginId}</strong>
|
||||
<span class="version">v{m.version || '?'}</span>
|
||||
</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>
|
||||
|
||||
{#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 m.description}
|
||||
@@ -94,34 +103,34 @@
|
||||
|
||||
<div class="card-meta">
|
||||
<div class="meta-row">
|
||||
<span class="label">Name:</span>
|
||||
<span class="label">{tr('pluginCard.name')}:</span>
|
||||
<span>{m.name || '-'}</span>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="label">Source:</span>
|
||||
<span>{m.source || 'unknown'}</span>
|
||||
<span class="label">{tr('common.source')}:</span>
|
||||
<span>{m.source || tr('common.unknown')}</span>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="label">Contributions:</span>
|
||||
<span class="label">{tr('pluginCard.contributions')}:</span>
|
||||
<span>{contribSummary}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="plugin-details">
|
||||
<summary>Technical details</summary>
|
||||
<summary>{tr('pluginCard.technicalDetails')}</summary>
|
||||
<div class="card-meta technical-meta">
|
||||
<div class="meta-row">
|
||||
<span class="label">API Version:</span>
|
||||
<span class="label">{tr('pluginCard.apiVersion')}:</span>
|
||||
<span>{m.apiVersion || '-'}</span>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="label">Root:</span>
|
||||
<span class="label">{tr('pluginCard.root')}:</span>
|
||||
<span class="path">{p.rootPath || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<span class="section-title">Provides</span>
|
||||
<span class="section-title">{tr('pluginCard.provides')}</span>
|
||||
<div class="tags">
|
||||
{#each m.provides || [] as cap}
|
||||
<span class="tag provides">{cap}</span>
|
||||
@@ -131,7 +140,7 @@
|
||||
|
||||
{#if m.requires && m.requires.length > 0}
|
||||
<div class="section">
|
||||
<span class="section-title">Requires</span>
|
||||
<span class="section-title">{tr('pluginCard.requires')}</span>
|
||||
<div class="tags">
|
||||
{#each m.requires as req}
|
||||
{@const found = capabilities.some(c => c.name === req)}
|
||||
@@ -142,14 +151,14 @@
|
||||
{/each}
|
||||
</div>
|
||||
{#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}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if m.optionalRequires && m.optionalRequires.length > 0}
|
||||
<div class="section">
|
||||
<span class="section-title">Optional Requires</span>
|
||||
<span class="section-title">{tr('pluginCard.optionalRequires')}</span>
|
||||
<div class="tags">
|
||||
{#each m.optionalRequires as opt}
|
||||
{@const found = capabilities.some(c => c.name === opt)}
|
||||
@@ -160,14 +169,14 @@
|
||||
{/each}
|
||||
</div>
|
||||
{#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}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if m.permissions && m.permissions.length > 0}
|
||||
<div class="section">
|
||||
<span class="section-title">Permissions</span>
|
||||
<span class="section-title">{tr('pluginCard.permissions')}</span>
|
||||
<div class="tags">
|
||||
{#each m.permissions as perm}
|
||||
{@const isDangerous = dangerousPermissions.includes(perm)}
|
||||
@@ -190,28 +199,28 @@
|
||||
<div class="card-actions">
|
||||
{#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'}>
|
||||
<Icon name="gear" size={14} /> Settings
|
||||
<Icon name="gear" size={14} /> {tr('settings.title')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if vaultOpen && canToggle}
|
||||
{#if isDisabled}
|
||||
<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>
|
||||
{:else}
|
||||
<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>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if !vaultOpen && canToggle}
|
||||
<span class="vault-hint">Open a vault to manage plugin state</span>
|
||||
<span class="vault-hint">{tr('pluginCard.openVault')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Permission warnings -->
|
||||
{#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}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
import Icon from '../ui/Icon.svelte';
|
||||
import PluginCard from './PluginCard.svelte';
|
||||
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import { GetPlugins, GetCapabilities, GetPermissions, GetContributions, ReloadPlugins, GetVaultStatus, GetVaultPluginState, EnablePlugin, DisablePlugin, ReadPluginSettings, WritePluginSettings, GetPluginFrontendInfo, WriteFrontendLog } from '../../../wailsjs/go/api/App';
|
||||
import { debug } from '../log/debug.js';
|
||||
import { i18n } from '../i18n/index.js';
|
||||
|
||||
let plugins = [];
|
||||
let capabilities = [];
|
||||
@@ -20,6 +21,12 @@
|
||||
let settingsError = null;
|
||||
let settingsPluginInfo = null;
|
||||
let lastOpenedKey = '';
|
||||
let locale = i18n.getLocale();
|
||||
let unsubscribeLocale = null;
|
||||
$: tr = ((activeLocale) => (key, params, fallback) => {
|
||||
void activeLocale;
|
||||
return i18n.t(key, params, fallback);
|
||||
})(locale);
|
||||
|
||||
// Per-action loading state — shows feedback on specific buttons without hiding the whole list
|
||||
let actionFeedback = {}; // { [pluginId]: 'enabling' | 'disabling' | null }
|
||||
@@ -109,7 +116,10 @@
|
||||
try {
|
||||
debug.log('[PluginManager] loadAll: calling GetPlugins...');
|
||||
const p = await GetPlugins();
|
||||
plugins = p || [];
|
||||
await Promise.all((p || []).map((plugin) => (
|
||||
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
|
||||
)));
|
||||
plugins = (p || []).map((plugin) => i18n.localizePlugin(plugin));
|
||||
debug.log('[PluginManager] loadAll: GetPlugins returned', plugins.length, 'plugins');
|
||||
for (var i = 0; i < plugins.length; i++) {
|
||||
debug.log('[PluginManager] loadAll: plugin[' + i + ']:', plugins[i].manifest?.id, 'status:', plugins[i].status, 'enabled:', plugins[i].enabled);
|
||||
@@ -133,7 +143,7 @@
|
||||
vaultStatus = v || { status: 'unknown', path: '', vaultId: '' };
|
||||
capabilities = caps || [];
|
||||
permissions = perms || [];
|
||||
contributions = contribs || {};
|
||||
contributions = i18n.localizeContributionSummary(contribs || {});
|
||||
debug.log('[PluginManager] loadAll: vault=' + vaultStatus.status + ' caps=' + capabilities.length + ' perms=' + permissions.length);
|
||||
WriteFrontendLog('PluginManager', 'loadAll: vault=' + vaultStatus.status + ' caps=' + capabilities.length + ' perms=' + permissions.length);
|
||||
} catch (e) {
|
||||
@@ -156,7 +166,18 @@
|
||||
WriteFrontendLog('PluginManager', 'loadAll: END, loading=false');
|
||||
}
|
||||
|
||||
onMount(() => { loadAll(); });
|
||||
onMount(() => {
|
||||
unsubscribeLocale = i18n.subscribe((nextLocale) => {
|
||||
const changed = locale !== nextLocale;
|
||||
locale = nextLocale;
|
||||
if (changed) loadAll();
|
||||
});
|
||||
loadAll();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (unsubscribeLocale) unsubscribeLocale();
|
||||
});
|
||||
|
||||
async function reload() {
|
||||
debug.log('[PluginManager] reload: START');
|
||||
@@ -272,48 +293,48 @@
|
||||
|
||||
<header>
|
||||
<div class="header-left">
|
||||
<h2>Plugin Manager</h2>
|
||||
<h2>{tr('settings.pluginManager')}</h2>
|
||||
{#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'}>
|
||||
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>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="reload-btn" on:click={reload} type="button" disabled={loading || reloading}>
|
||||
{reloading ? '⟳ Reloading...' : '⟳ Reload'}
|
||||
{reloading ? tr('pluginManager.reloading') : tr('pluginManager.reload')}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">Scanning plugin directories...</div>
|
||||
<div class="loading">{tr('pluginManager.scanning')}</div>
|
||||
{:else if error}
|
||||
<div class="error">
|
||||
<Icon name="warning" size={24} class="error-icon" />
|
||||
<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>
|
||||
{:else}
|
||||
<div class="summary">
|
||||
<span class="badge">{totalPlugins} plugin(s) discovered</span>
|
||||
<span class="badge">{totalCaps} capabilities registered</span>
|
||||
<span class="badge">{totalPerms} permissions known</span>
|
||||
<span class="badge">{tr('pluginManager.summary.plugins', { count: totalPlugins })}</span>
|
||||
<span class="badge">{tr('pluginManager.summary.capabilities', { count: totalCaps })}</span>
|
||||
<span class="badge">{tr('pluginManager.summary.permissions', { count: totalPerms })}</span>
|
||||
</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">
|
||||
<div class="scan-card-title">Plugin Health</div>
|
||||
<div class="scan-card-title">{tr('pluginManager.health')}</div>
|
||||
<div class="scan-metrics">
|
||||
<span data-plugin-status-summary="loaded"><strong>{statusSummary.loaded}</strong> loaded</span>
|
||||
<span data-plugin-status-summary="degraded"><strong>{statusSummary.degraded}</strong> degraded</span>
|
||||
<span data-plugin-status-summary="failed"><strong>{statusSummary.failed}</strong> failed</span>
|
||||
<span data-plugin-status-summary="disabled"><strong>{statusSummary.disabled}</strong> disabled</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> {tr('status.degraded')}</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> {tr('status.disabled')}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
</section>
|
||||
</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"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p>No plugins found</p>
|
||||
<p class="hint">Plugin directories scanned:</p>
|
||||
<p>{tr('pluginManager.none')}</p>
|
||||
<p class="hint">{tr('pluginManager.scannedDirs')}</p>
|
||||
<ul class="hint-list">
|
||||
<li><code>~/.config/verstak/plugins/</code> — user plugins</li>
|
||||
<li><code>./plugins/</code> — bundled plugins (app directory)</li>
|
||||
<li><code>~/.config/verstak/plugins/</code> — {tr('pluginManager.userPlugins')}</li>
|
||||
<li><code>./plugins/</code> — {tr('pluginManager.bundledPlugins')}</li>
|
||||
</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>
|
||||
{:else}
|
||||
<div class="plugin-list">
|
||||
@@ -343,8 +364,8 @@
|
||||
|
||||
{#if missingInstalled.length > 0}
|
||||
<div class="missing-section">
|
||||
<h3>Missing Installed Plugins</h3>
|
||||
<p class="missing-hint">These plugins are required by this vault but their packages are not installed locally.</p>
|
||||
<h3>{tr('pluginManager.missingTitle')}</h3>
|
||||
<p class="missing-hint">{tr('pluginManager.missingHint')}</p>
|
||||
<div class="plugin-list">
|
||||
{#each missingInstalled as mp}
|
||||
<div class="plugin-card missing-card">
|
||||
@@ -354,12 +375,12 @@
|
||||
<strong>{mp.id}</strong>
|
||||
{#if mp.version}<span class="version">v{mp.version}</span>{/if}
|
||||
</div>
|
||||
<span class="status-badge" style="color: #e94560">missing</span>
|
||||
<span class="status-badge" style="color: #e94560">{tr('status.missing')}</span>
|
||||
</div>
|
||||
<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'}
|
||||
<span class="source-hint">Source: {mp.source}</span>
|
||||
<span class="source-hint">{tr('common.source')}: {mp.source}</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
@@ -370,10 +391,10 @@
|
||||
|
||||
{#if capabilities.length > 0}
|
||||
<details class="registry-section">
|
||||
<summary>Capability Registry ({totalCaps})</summary>
|
||||
<summary>{tr('pluginManager.capabilityRegistry', { count: totalCaps })}</summary>
|
||||
<table>
|
||||
<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>
|
||||
<tbody>
|
||||
{#each capabilities as cap}
|
||||
@@ -394,9 +415,9 @@
|
||||
{#key `settings-${settingsPluginId}`}
|
||||
{#if settingsError}
|
||||
<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">
|
||||
<h3>Settings Error</h3>
|
||||
<h3>{tr('pluginManager.settingsError')}</h3>
|
||||
<button class="modal-close" on:click={closeSettings} type="button">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@@ -406,21 +427,21 @@
|
||||
</div>
|
||||
{:else if settingsPanel}
|
||||
<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">
|
||||
<h3>{settingsPanel.title}</h3>
|
||||
<button class="modal-close" on:click={closeSettings} type="button">✕</button>
|
||||
</div>
|
||||
<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}
|
||||
<PluginBundleHost
|
||||
pluginId={settingsPluginId}
|
||||
componentId={settingsPanel.component || settingsPanel.id}
|
||||
/>
|
||||
{:else}
|
||||
<p class="settings-hint">Component: <code>{settingsPanel.component}</code></p>
|
||||
<p class="placeholder">Settings panel frontend bundle not available</p>
|
||||
<p class="settings-hint">{tr('common.component')}: <code>{settingsPanel.component}</code></p>
|
||||
<p class="placeholder">{tr('pluginManager.settingsBundleUnavailable')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script>
|
||||
import { onDestroy, tick } from 'svelte';
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import * as App from '../../../wailsjs/go/api/App';
|
||||
import { executePluginCommand } from '../plugin-host/VerstakPluginAPI.js';
|
||||
import { i18n } from '../i18n/index.js';
|
||||
|
||||
let open = false;
|
||||
let query = '';
|
||||
@@ -10,18 +11,25 @@
|
||||
let inputEl = null;
|
||||
let statusMessage = '';
|
||||
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 shellCommands = [
|
||||
{ id: 'verstak.shell.open-overview', title: 'Open Overview', 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-activity', title: 'Open Activity', 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.create-markdown', title: 'Create Markdown File', 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.sync-now', title: 'Sync Now', 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-plugin-manager', title: 'Open Plugin Manager', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 90, shellAction: 'plugin-manager' },
|
||||
$: shellCommands = [
|
||||
{ id: 'verstak.shell.open-overview', title: tr('command.openOverview'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 10, shellAction: 'overview' },
|
||||
{ id: 'verstak.shell.open-files', title: tr('command.openFiles'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 20, shellAction: 'files' },
|
||||
{ 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: tr('command.openBrowserInbox'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 40, shellAction: 'browser-inbox' },
|
||||
{ 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: tr('command.createText'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 60, shellAction: 'create-text' },
|
||||
{ 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: tr('command.openSyncSettings'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 80, shellAction: 'sync-settings' },
|
||||
{ id: 'verstak.shell.open-plugin-manager', title: tr('command.openPluginManager'), pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 90, shellAction: 'plugin-manager' },
|
||||
];
|
||||
|
||||
$: normalizedQuery = query.trim().toLowerCase();
|
||||
@@ -44,14 +52,21 @@
|
||||
App.GetContributions().catch(() => ({})),
|
||||
]);
|
||||
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) => {
|
||||
const plugin = pluginById.get(command.pluginId);
|
||||
if (!plugin) return false;
|
||||
return !inactiveStatuses.has(plugin.status);
|
||||
})
|
||||
.map((command) => {
|
||||
const plugin = pluginById.get(command.pluginId);
|
||||
const plugin = localizedPluginById.get(command.pluginId);
|
||||
return {
|
||||
...command,
|
||||
pluginName: plugin?.manifest?.name || command.pluginId,
|
||||
@@ -170,14 +185,14 @@
|
||||
if (command.shellAction) {
|
||||
await runShellCommand(command);
|
||||
closePalette();
|
||||
setStatus('success', `${command.title || command.id} handled`);
|
||||
setStatus('success', tr('command.handled', { title: command.title || command.id }));
|
||||
return;
|
||||
}
|
||||
const result = await executePluginCommand(command.pluginId, command.id, {
|
||||
source: 'command-palette',
|
||||
});
|
||||
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) {
|
||||
setStatus('error', `${command.title || command.id}: ${err?.message || String(err)}`);
|
||||
}
|
||||
@@ -221,7 +236,16 @@
|
||||
window.addEventListener('keydown', onWindowKeydown);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
unsubscribeLocale = i18n.subscribe((nextLocale) => {
|
||||
const changed = locale !== nextLocale;
|
||||
locale = nextLocale;
|
||||
if (changed && open) loadCommands();
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribeLocale?.();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('keydown', onWindowKeydown);
|
||||
window.clearTimeout(setStatus.timer);
|
||||
@@ -240,19 +264,19 @@
|
||||
|
||||
{#if open}
|
||||
<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
|
||||
bind:this={inputEl}
|
||||
bind:value={query}
|
||||
class="command-palette-input"
|
||||
data-command-palette-input
|
||||
placeholder="Run command"
|
||||
aria-label="Run command"
|
||||
placeholder={tr('command.run')}
|
||||
aria-label={tr('command.run')}
|
||||
/>
|
||||
|
||||
<div class="command-palette-list" role="listbox">
|
||||
{#if filteredCommands.length === 0}
|
||||
<div class="command-palette-empty">No commands</div>
|
||||
<div class="command-palette-empty">{tr('command.none')}</div>
|
||||
{:else}
|
||||
{#each filteredCommands as command, index}
|
||||
<button
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import * as App from '../../../wailsjs/go/api/App';
|
||||
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 FILE_INDEX_LIMIT = 220;
|
||||
@@ -16,12 +17,26 @@
|
||||
let loading = true;
|
||||
let searchTimer = null;
|
||||
let buildSeq = 0;
|
||||
let locale = i18n.getLocale();
|
||||
|
||||
$: tr = ((activeLocale) => (key, params, fallback) => {
|
||||
void activeLocale;
|
||||
return i18n.t(key, params, fallback);
|
||||
})(locale);
|
||||
|
||||
$: scheduleSearch(query);
|
||||
|
||||
onMount(() => {
|
||||
const unsubscribeLocale = i18n.subscribe((nextLocale) => {
|
||||
const changed = locale !== nextLocale;
|
||||
locale = nextLocale;
|
||||
if (changed) buildIndex();
|
||||
});
|
||||
buildIndex();
|
||||
return () => clearTimeout(searchTimer);
|
||||
return () => {
|
||||
unsubscribeLocale();
|
||||
clearTimeout(searchTimer);
|
||||
};
|
||||
});
|
||||
|
||||
function normalize(value) {
|
||||
@@ -160,8 +175,9 @@
|
||||
nodes.forEach(node => {
|
||||
next.push({
|
||||
type: 'Workspace',
|
||||
typeLabel: tr('search.type.workspace'),
|
||||
title: workspaceTitle(node),
|
||||
subtitle: 'Workspace',
|
||||
subtitle: tr('search.type.workspace'),
|
||||
keywords: `${node.id || ''} ${node.rootPath || ''}`,
|
||||
rank: 10,
|
||||
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();
|
||||
(contributions.views || []).forEach(view => {
|
||||
if (view.pluginId && !viewByPluginId.has(view.pluginId)) viewByPluginId.set(view.pluginId, view);
|
||||
@@ -178,6 +202,7 @@
|
||||
(contributions.sidebarItems || []).forEach(item => {
|
||||
next.push({
|
||||
type: 'Tool',
|
||||
typeLabel: tr('search.type.tool'),
|
||||
title: item.title || item.id,
|
||||
subtitle: item.pluginId || '',
|
||||
keywords: `${item.id || ''} ${item.view || ''}`,
|
||||
@@ -194,6 +219,7 @@
|
||||
const snippet = await readFileSnippet(path);
|
||||
next.push({
|
||||
type: entry.type === 'folder' ? 'Folder' : 'File',
|
||||
typeLabel: tr(entry.type === 'folder' ? 'search.type.folder' : 'search.type.file'),
|
||||
title: path.split('/').pop() || path,
|
||||
subtitle: path,
|
||||
keywords: snippet,
|
||||
@@ -205,9 +231,9 @@
|
||||
}
|
||||
|
||||
const pluginItems = await Promise.all([
|
||||
indexPluginSettings('verstak.journal', 'Journal', 50, viewByPluginId.get('verstak.journal'), nodes),
|
||||
indexPluginSettings('verstak.browser-inbox', 'Browser Inbox', 55, viewByPluginId.get('verstak.browser-inbox'), nodes),
|
||||
indexPluginSettings('verstak.activity', 'Activity', 60, viewByPluginId.get('verstak.activity'), nodes),
|
||||
indexPluginSettings('verstak.journal', tr('search.type.journal'), 50, viewByPluginId.get('verstak.journal'), nodes),
|
||||
indexPluginSettings('verstak.browser-inbox', tr('search.type.browserInbox'), 55, viewByPluginId.get('verstak.browser-inbox'), nodes),
|
||||
indexPluginSettings('verstak.activity', tr('search.type.activity'), 60, viewByPluginId.get('verstak.activity'), nodes),
|
||||
]);
|
||||
|
||||
if (seq !== buildSeq) return;
|
||||
@@ -293,8 +319,8 @@
|
||||
on:focus={handleFocus}
|
||||
on:blur={() => setTimeout(() => focused = false, 120)}
|
||||
type="search"
|
||||
placeholder={loading ? 'Indexing...' : 'Search'}
|
||||
aria-label="Global search"
|
||||
placeholder={loading ? tr('search.indexing') : tr('search.placeholder')}
|
||||
aria-label={tr('search.global')}
|
||||
data-global-search-input
|
||||
/>
|
||||
</div>
|
||||
@@ -310,11 +336,11 @@
|
||||
on:mousedown|preventDefault={() => openResult(item)}
|
||||
>
|
||||
<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>
|
||||
{/each}
|
||||
{: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}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import GlobalSearch from './GlobalSearch.svelte';
|
||||
import Icon from '../ui/Icon.svelte';
|
||||
import { debug } from '../log/debug.js';
|
||||
import { i18n } from '../i18n/index.js';
|
||||
|
||||
export let showGlobalSearch = true;
|
||||
|
||||
@@ -16,6 +17,13 @@
|
||||
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
|
||||
let sidebarItems = [];
|
||||
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';
|
||||
|
||||
@@ -31,14 +39,18 @@
|
||||
App.GetVaultStatus().catch(() => ({ status: 'unknown', path: '', vaultId: '' })),
|
||||
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;
|
||||
debug.log('[Sidebar] onMount: plugins=' + plugins.length + ' vault=' + vaultStatus.status);
|
||||
flog('onMount: plugins=' + plugins.length + ' vault=' + vaultStatus.status);
|
||||
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);
|
||||
if (!plugin) return false;
|
||||
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));
|
||||
flog('onMount: ERROR: ' + String(e));
|
||||
console.error('[Sidebar] load error:', e);
|
||||
errorMessage = 'Failed to load sidebar';
|
||||
errorMessage = tr('sidebar.error.load');
|
||||
}
|
||||
debug.log('[Sidebar] onMount: END');
|
||||
flog('onMount: END');
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
unsubscribeLocale = i18n.subscribe((nextLocale) => {
|
||||
const changed = locale !== nextLocale;
|
||||
locale = nextLocale;
|
||||
if (changed) loadSidebar();
|
||||
});
|
||||
loadSidebar();
|
||||
window.addEventListener('verstak:plugins-changed', loadSidebar);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (unsubscribeLocale) unsubscribeLocale();
|
||||
window.removeEventListener('verstak:plugins-changed', loadSidebar);
|
||||
});
|
||||
|
||||
@@ -85,7 +103,7 @@
|
||||
|
||||
{#if sidebarItems.length > 0}
|
||||
<div class="sidebar-section">
|
||||
<span class="section-label">Tools</span>
|
||||
<span class="section-label">{tr('sidebar.tools')}</span>
|
||||
{#each sidebarItems as item}
|
||||
<button
|
||||
class="nav-item plugin-item vt-list-row"
|
||||
@@ -107,7 +125,7 @@
|
||||
{#if errorMessage}
|
||||
<span class="sidebar-error">
|
||||
<Icon name="warning" size={10} class="sidebar-error-icon" />
|
||||
Plugin UI error
|
||||
{tr('sidebar.error.ui')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -2,26 +2,41 @@
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import * as App from '../../../wailsjs/go/api/App';
|
||||
import Icon from '../ui/Icon.svelte';
|
||||
import { i18n } from '../i18n/index.js';
|
||||
|
||||
let items = [];
|
||||
let settingsPanels = [];
|
||||
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
|
||||
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');
|
||||
$: centerItems = items.filter((item) => item.position === 'center');
|
||||
$: rightItems = items.filter((item) => item.position === 'right');
|
||||
$: 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']);
|
||||
|
||||
async function loadStatusBar() {
|
||||
const [plugins, contributions, vault] = await Promise.all([
|
||||
const [rawPlugins, rawContributions, vault] = await Promise.all([
|
||||
App.GetPlugins().catch(() => []),
|
||||
App.GetContributions().catch(() => ({})),
|
||||
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]));
|
||||
vaultStatus = vault || { status: 'unknown', path: '', vaultId: '' };
|
||||
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) {
|
||||
event.stopPropagation();
|
||||
settingsOpen = !settingsOpen;
|
||||
@@ -66,6 +91,12 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
unsubscribeLocale = i18n.subscribe((nextLocale) => {
|
||||
const changed = locale !== nextLocale;
|
||||
locale = nextLocale;
|
||||
selectedLanguage = i18n.getLanguagePreference();
|
||||
if (changed) loadStatusBar();
|
||||
});
|
||||
loadStatusBar();
|
||||
window.addEventListener('verstak:plugins-changed', loadStatusBar);
|
||||
window.addEventListener('verstak:vault-opened', loadStatusBar);
|
||||
@@ -73,13 +104,14 @@
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (unsubscribeLocale) unsubscribeLocale();
|
||||
window.removeEventListener('verstak:plugins-changed', loadStatusBar);
|
||||
window.removeEventListener('verstak:vault-opened', loadStatusBar);
|
||||
window.removeEventListener('click', closeSettings);
|
||||
});
|
||||
</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">
|
||||
<span
|
||||
class="vault-status"
|
||||
@@ -132,7 +164,7 @@
|
||||
class="settings-button"
|
||||
class:active={settingsOpen}
|
||||
type="button"
|
||||
title="Settings"
|
||||
title={tr('settings.title')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={settingsOpen}
|
||||
data-settings-menu-button
|
||||
@@ -143,6 +175,22 @@
|
||||
</button>
|
||||
{#if settingsOpen}
|
||||
<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
|
||||
class="settings-menu-item"
|
||||
type="button"
|
||||
@@ -151,7 +199,7 @@
|
||||
on:click={openPluginManager}
|
||||
>
|
||||
<Icon name="puzzle" size={14} class="settings-menu-icon" />
|
||||
<span>Plugin Manager</span>
|
||||
<span>{tr('settings.pluginManager')}</span>
|
||||
</button>
|
||||
{#if settingsPanels.length > 0}
|
||||
<div class="settings-menu-separator"></div>
|
||||
@@ -320,6 +368,25 @@
|
||||
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 {
|
||||
height: 1px;
|
||||
margin: 0.25rem 0.2rem;
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
<script>
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
|
||||
import * as App from '../../../wailsjs/go/api/App';
|
||||
import { i18n } from '../i18n/index.js';
|
||||
|
||||
export let workspaceRootPath = '';
|
||||
export let availableTools = [];
|
||||
|
||||
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([
|
||||
'workspace.selected',
|
||||
'case.selected',
|
||||
@@ -37,6 +31,20 @@
|
||||
let totalNotes = 0;
|
||||
let loadedWorkspaceRoot = '';
|
||||
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);
|
||||
$: hasTodos = hasTool('todo', availableTools);
|
||||
@@ -55,18 +63,21 @@
|
||||
$: attentionActionKind = needsAttention[0]?.actionKind || 'browser-inbox';
|
||||
$: lastActive = lastActiveDate([...recentChanges, ...continueItems], captures, journalEntries, todos);
|
||||
$: summaryItems = [
|
||||
{ key: 'notes', label: 'Notes', count: totalNotes, detail: totalAndRecentLabel(totalNotes, noteRecentChanges), actionKind: 'notes', actionLabel: 'Open Notes' },
|
||||
{ key: 'files', label: 'Files', count: fileRecentChanges, detail: countLabel(fileRecentChanges, 'recent change'), actionKind: 'files', actionLabel: 'Open Files' },
|
||||
{ key: 'captures', label: 'Captures', count: unprocessedCaptures.length, detail: captureReviewLabel(unprocessedCaptures.length), actionKind: 'browser-inbox', actionLabel: 'Review Inbox' },
|
||||
{ key: 'activity', label: 'Activity', count: activityEvents.length, detail: countLabel(activityEvents.length, 'recorded event'), actionKind: 'activity', actionLabel: 'View Activity' },
|
||||
{ key: 'journal', label: 'Journal', count: journalEntries.length, detail: journalEntryLabel(journalEntries.length), actionKind: 'journal', actionLabel: 'Open Journal' },
|
||||
{ key: 'attention', label: 'Needs attention', count: needsAttention.length, detail: countLabel(needsAttention.length, 'pending item'), actionKind: attentionActionKind, actionLabel: 'Review pending items' },
|
||||
{ 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: tr('overview.files'), count: fileRecentChanges, detail: countText('overview.count.recentChanges', fileRecentChanges), actionKind: 'files', actionLabel: tr('overview.openFiles') },
|
||||
{ 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: tr('overview.activity'), count: activityEvents.length, detail: countText('overview.count.events', activityEvents.length), actionKind: 'activity', actionLabel: tr('overview.viewActivity') },
|
||||
{ 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: tr('overview.attention'), count: needsAttention.length, detail: countText('overview.count.pending', needsAttention.length), actionKind: attentionActionKind, actionLabel: tr('overview.reviewPending') },
|
||||
];
|
||||
|
||||
onMount(() => {
|
||||
unsubscribeLocale = i18n.subscribe((nextLocale) => locale = nextLocale);
|
||||
toolProbe += 1;
|
||||
});
|
||||
|
||||
onDestroy(() => unsubscribeLocale?.());
|
||||
|
||||
$: if (workspaceRootPath && workspaceRootPath !== loadedWorkspaceRoot) {
|
||||
loadOverview();
|
||||
}
|
||||
@@ -125,6 +136,7 @@
|
||||
].flatMap(key => normalizeRows(settings?.[key])).filter(item => {
|
||||
const tagged = String(item.workspaceRootPath || item.workspaceName || item.workspaceNodeId || '').trim();
|
||||
if (tagged !== workspace) return false;
|
||||
if (String(item.globalState || 'inbox') === 'archived') return false;
|
||||
const captureId = String(item.captureId || '');
|
||||
if (!captureId || seen.has(captureId)) return !captureId;
|
||||
seen.add(captureId);
|
||||
@@ -245,17 +257,17 @@
|
||||
|
||||
function relativeTime(value) {
|
||||
const ms = timeMs({ occurredAt: value });
|
||||
if (!ms) return 'No timestamp';
|
||||
if (!ms) return tr('overview.time.none');
|
||||
const diff = Date.now() - ms;
|
||||
if (diff < 0) return absoluteTime(value);
|
||||
const minute = 60 * 1000;
|
||||
const hour = 60 * minute;
|
||||
const day = 24 * hour;
|
||||
if (diff < minute) return 'Just now';
|
||||
if (diff < hour) return `${Math.floor(diff / minute)} min ago`;
|
||||
if (diff < day) return `${Math.floor(diff / hour)}h ago`;
|
||||
if (diff < 2 * day) return 'Yesterday';
|
||||
if (diff < 7 * day) return `${Math.floor(diff / day)} days ago`;
|
||||
if (diff < minute) return tr('overview.time.now');
|
||||
if (diff < hour) return tr('overview.time.minutes', { count: Math.floor(diff / minute) });
|
||||
if (diff < day) return tr('overview.time.hours', { count: Math.floor(diff / hour) });
|
||||
if (diff < 2 * day) return tr('overview.time.yesterday');
|
||||
if (diff < 7 * day) return tr('overview.time.days', { count: Math.floor(diff / day) });
|
||||
const date = new Date(ms);
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: date.getFullYear() === new Date().getFullYear() ? undefined : 'numeric' });
|
||||
}
|
||||
@@ -291,11 +303,11 @@
|
||||
}
|
||||
|
||||
function actionForCategory(category) {
|
||||
if (category === 'notes') return { kind: 'notes', label: 'Open Notes' };
|
||||
if (category === 'files') return { kind: 'files', label: 'Open Files' };
|
||||
if (category === 'captures') return { kind: 'browser-inbox', label: 'Review Inbox' };
|
||||
if (category === 'journal') return { kind: 'journal', label: 'Open Journal' };
|
||||
return { kind: 'activity', label: 'View Activity' };
|
||||
if (category === 'notes') return { kind: 'notes', label: tr('overview.openNotes') };
|
||||
if (category === 'files') return { kind: 'files', label: tr('overview.openFiles') };
|
||||
if (category === 'captures') return { kind: 'browser-inbox', label: tr('overview.reviewInbox') };
|
||||
if (category === 'journal') return { kind: 'journal', label: tr('overview.openJournal') };
|
||||
return { kind: 'activity', label: tr('overview.viewActivity') };
|
||||
}
|
||||
|
||||
function buildRecentChanges(events, captureRows, journalRows) {
|
||||
@@ -323,7 +335,7 @@
|
||||
time: timeValue(item),
|
||||
absolute: absoluteTime(timeValue(item)),
|
||||
actionKind: 'browser-inbox',
|
||||
actionLabel: 'Review Inbox',
|
||||
actionLabel: tr('overview.reviewInbox'),
|
||||
}));
|
||||
const journalItems = journalRows.map(item => ({
|
||||
id: item.entryId || `journal:${timeValue(item)}`,
|
||||
@@ -333,7 +345,7 @@
|
||||
time: timeValue(item),
|
||||
absolute: absoluteTime(timeValue(item)),
|
||||
actionKind: 'journal',
|
||||
actionLabel: 'Open Journal',
|
||||
actionLabel: tr('overview.openJournal'),
|
||||
}));
|
||||
return sortByTime([...activityItems, ...captureItems, ...journalItems]).slice(0, 12);
|
||||
}
|
||||
@@ -374,7 +386,7 @@
|
||||
time: todoTimeValue(item),
|
||||
absolute: absoluteTime(todoTimeValue(item)),
|
||||
actionKind: 'todo',
|
||||
actionLabel: 'Open Todos',
|
||||
actionLabel: tr('overview.openTodos'),
|
||||
}));
|
||||
const captureCandidates = sortByTime(captureRows).map(item => ({
|
||||
id: item.captureId || `capture:${timeValue(item)}`,
|
||||
@@ -384,7 +396,7 @@
|
||||
time: timeValue(item),
|
||||
absolute: absoluteTime(timeValue(item)),
|
||||
actionKind: 'browser-inbox',
|
||||
actionLabel: 'Review Inbox',
|
||||
actionLabel: tr('overview.reviewInbox'),
|
||||
}));
|
||||
const noteCandidates = sortByTime(events)
|
||||
.filter(item => isResumeEvent(item) && activityCategory(item) === 'notes')
|
||||
@@ -400,7 +412,7 @@
|
||||
time: timeValue(item),
|
||||
absolute: absoluteTime(timeValue(item)),
|
||||
actionKind: 'journal',
|
||||
actionLabel: 'Open Journal',
|
||||
actionLabel: tr('overview.openJournal'),
|
||||
}));
|
||||
return [...todoCandidates, ...captureCandidates, ...noteCandidates, ...fileCandidates, ...journalCandidates].slice(0, 4);
|
||||
}
|
||||
@@ -411,14 +423,14 @@
|
||||
title: captureTitle(item),
|
||||
meta: `${item.kind || 'capture'} · ${itemTimeLabel(item)}`,
|
||||
actionKind: 'browser-inbox',
|
||||
actionLabel: 'Review Inbox',
|
||||
actionLabel: tr('overview.reviewInbox'),
|
||||
}));
|
||||
const candidateItems = sortByTime(candidates).slice(0, 4).map(item => ({
|
||||
id: item.candidateId || `work-session:${timeValue(item)}`,
|
||||
title: 'Possible journal entry',
|
||||
meta: `Workspace: ${item.workspaceRootPath || workspaceRootPath || 'Unknown'} · ${item.estimatedMinutes || 0} min · ${item.activityCount || (item.activityIds || []).length || 0} activities`,
|
||||
actionKind: 'journal',
|
||||
actionLabel: 'Review candidate',
|
||||
actionLabel: tr('overview.reviewCandidate'),
|
||||
toolRequest: { type: 'work-session-candidate', candidate: 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),
|
||||
meta: `${todoAttentionState(item)}${item.dueAt ? ` · Due ${item.dueAt}` : ''}`,
|
||||
actionKind: 'todo',
|
||||
actionLabel: 'Open Todos',
|
||||
actionLabel: tr('overview.openTodos'),
|
||||
}));
|
||||
return [...todoItems, ...captureItems, ...candidateItems].slice(0, 6);
|
||||
}
|
||||
@@ -481,7 +493,7 @@
|
||||
title: overview.name || fileName(overview.relativePath) || 'Overview.md',
|
||||
meta: overview.relativePath || 'Workspace overview note',
|
||||
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();
|
||||
loadedWorkspaceRoot = workspaceAtStart;
|
||||
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.activity'),
|
||||
App.ReadPluginDataNDJSON
|
||||
? App.ReadPluginDataNDJSON('verstak.activity', 'activity-events')
|
||||
.then(value => decodeTuple(value, []))
|
||||
.catch(() => [])
|
||||
: Promise.resolve([]),
|
||||
readPluginSettings('verstak.journal'),
|
||||
readPluginSettings('verstak.todo'),
|
||||
loadWorkspaceResources(),
|
||||
@@ -500,11 +517,10 @@
|
||||
if (workspaceAtStart !== String(workspaceRootPath || '').trim()) return;
|
||||
|
||||
captures = browserCaptureRowsForWorkspace(browserSettings);
|
||||
activityEvents = rowsFor(activitySettings, [
|
||||
workspaceKey('events:workspace:'),
|
||||
'events:global',
|
||||
'events',
|
||||
]);
|
||||
activityEvents = normalizeRows(activityRecords).filter(item => {
|
||||
const tagged = String(item.workspaceRootPath || item.workspaceName || item.workspaceNodeId || '').trim();
|
||||
return !tagged || tagged === workspaceAtStart;
|
||||
});
|
||||
journalEntries = rowsFor(journalSettings, [
|
||||
workspaceKey('worklog:workspace:'),
|
||||
'worklog',
|
||||
@@ -523,24 +539,24 @@
|
||||
}
|
||||
</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>
|
||||
<h2>Overview</h2>
|
||||
<h2>{tr('workspace.overview')}</h2>
|
||||
<p title={lastActive ? absoluteTime(lastActive) : ''}>
|
||||
{#if loading}
|
||||
Loading workspace context...
|
||||
{tr('overview.loadingContext')}
|
||||
{:else if lastActive}
|
||||
Last active {relativeTime(lastActive)}
|
||||
{tr('overview.lastActive', { time: relativeTime(lastActive) })}
|
||||
{:else}
|
||||
No recent workspace activity
|
||||
{tr('overview.noRecentActivity')}
|
||||
{/if}
|
||||
</p>
|
||||
</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 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}
|
||||
<button
|
||||
type="button"
|
||||
@@ -553,7 +569,7 @@
|
||||
>
|
||||
<strong>{loading ? '...' : item.count}</strong>
|
||||
<span>{item.label}</span>
|
||||
<small>{loading ? 'Loading...' : item.detail}</small>
|
||||
<small>{loading ? tr('common.loading') : item.detail}</small>
|
||||
<em>{item.actionLabel}</em>
|
||||
</button>
|
||||
{/each}
|
||||
@@ -563,11 +579,11 @@
|
||||
<main class="overview-main">
|
||||
<section class="today-resume overview-continue" data-overview-section="continue">
|
||||
<div class="today-resume-copy overview-continue-copy">
|
||||
<span>Continue working</span>
|
||||
<h3>Pick up the next useful item in this workspace.</h3>
|
||||
<span>{tr('overview.continue')}</span>
|
||||
<h3>{tr('overview.continueHint')}</h3>
|
||||
</div>
|
||||
{#if loading}
|
||||
<p class="today-empty compact">Loading workspace signals...</p>
|
||||
<p class="today-empty compact">{tr('overview.loadingSignals')}</p>
|
||||
{:else if continueItems.length}
|
||||
<div class="overview-continue-list">
|
||||
{#each continueItems as item}
|
||||
@@ -588,8 +604,8 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overview-continue-empty">
|
||||
<strong>No clear resume point yet</strong>
|
||||
<p>Recent notes, files, captures, and journal entries will appear here.</p>
|
||||
<strong>{tr('overview.noResume')}</strong>
|
||||
<p>{tr('overview.noResumeHint')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -597,10 +613,10 @@
|
||||
<section class="today-panel overview-panel overview-recent" data-overview-section="recent">
|
||||
<div class="today-panel-head overview-panel-head">
|
||||
<div>
|
||||
<h3>Recent changes</h3>
|
||||
<p>Latest meaningful activity in this workspace.</p>
|
||||
<h3>{tr('overview.recentChanges')}</h3>
|
||||
<p>{tr('overview.recentChangesHint')}</p>
|
||||
</div>
|
||||
<div class="overview-filters" aria-label="Recent changes filter">
|
||||
<div class="overview-filters" aria-label={tr('overview.recentFilter')}>
|
||||
{#each FILTERS as filter}
|
||||
<button
|
||||
type="button"
|
||||
@@ -615,7 +631,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{#if loading}
|
||||
<p class="today-empty">Loading recent changes...</p>
|
||||
<p class="today-empty">{tr('overview.loadingRecent')}</p>
|
||||
{:else if filteredRecentChanges.length}
|
||||
<div class="today-list overview-list">
|
||||
{#each filteredRecentChanges as item}
|
||||
@@ -635,7 +651,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="today-empty">No meaningful changes for this filter yet.</p>
|
||||
<p class="today-empty">{tr('overview.noChanges')}</p>
|
||||
{/if}
|
||||
</section>
|
||||
</main>
|
||||
@@ -645,12 +661,12 @@
|
||||
<section class="today-panel overview-panel" data-overview-section="attention">
|
||||
<div class="today-panel-head overview-panel-head">
|
||||
<div>
|
||||
<h3>Needs attention</h3>
|
||||
<p>Pending captures, urgent todos, and possible journal entries.</p>
|
||||
<h3>{tr('overview.attention')}</h3>
|
||||
<p>{tr('overview.attentionHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if loading}
|
||||
<p class="today-empty">Loading pending items...</p>
|
||||
<p class="today-empty">{tr('overview.loadingPending')}</p>
|
||||
{:else if needsAttention.length}
|
||||
<div class="today-list overview-list compact">
|
||||
{#each needsAttention as item}
|
||||
@@ -662,7 +678,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
{: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}
|
||||
</section>
|
||||
{/if}
|
||||
@@ -670,7 +686,7 @@
|
||||
{#if keyResources.length}
|
||||
<section class="today-panel overview-panel secondary" data-overview-section="key-resources">
|
||||
<div class="today-panel-head overview-panel-head">
|
||||
<h3>Key resources</h3>
|
||||
<h3>{tr('overview.keyResources')}</h3>
|
||||
</div>
|
||||
<div class="today-list overview-list compact">
|
||||
{#each keyResources as item}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import * as App from '../../../wailsjs/go/api/App';
|
||||
import Icon from '../ui/Icon.svelte';
|
||||
import { i18n } from '../i18n/index.js';
|
||||
|
||||
let appSettings = {};
|
||||
let recentVaults = [];
|
||||
@@ -11,6 +12,13 @@
|
||||
let opening = false;
|
||||
let newVaultPath = '';
|
||||
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 () => {
|
||||
try {
|
||||
@@ -39,20 +47,20 @@
|
||||
async function createVault() {
|
||||
error = '';
|
||||
if (!newVaultPath.trim()) {
|
||||
error = 'Choose or enter a folder for the new vault.';
|
||||
error = tr('vaultSelection.chooseNew');
|
||||
return;
|
||||
}
|
||||
creating = true;
|
||||
try {
|
||||
const createErr = await App.CreateVault(newVaultPath.trim());
|
||||
if (createErr) {
|
||||
error = 'Could not create vault: ' + createErr;
|
||||
error = tr('vaultSelection.createError', { error: createErr });
|
||||
creating = false;
|
||||
return;
|
||||
}
|
||||
const openErr = await App.OpenVault(newVaultPath.trim());
|
||||
if (openErr) {
|
||||
error = 'Could not open vault: ' + openErr;
|
||||
error = tr('vaultSelection.openError', { error: openErr });
|
||||
creating = false;
|
||||
return;
|
||||
}
|
||||
@@ -70,14 +78,14 @@
|
||||
async function openExistingVault() {
|
||||
error = '';
|
||||
if (!openVaultPath.trim()) {
|
||||
error = 'Choose or enter an existing vault.';
|
||||
error = tr('vaultSelection.chooseExisting');
|
||||
return;
|
||||
}
|
||||
opening = true;
|
||||
try {
|
||||
const openErr = await App.OpenVault(openVaultPath.trim());
|
||||
if (openErr) {
|
||||
error = 'Could not open vault: ' + openErr;
|
||||
error = tr('vaultSelection.openError', { error: openErr });
|
||||
opening = false;
|
||||
return;
|
||||
}
|
||||
@@ -98,7 +106,7 @@
|
||||
try {
|
||||
const openErr = await App.OpenVault(path);
|
||||
if (openErr) {
|
||||
error = 'Could not open vault: ' + openErr;
|
||||
error = tr('vaultSelection.openError', { error: openErr });
|
||||
opening = false;
|
||||
return;
|
||||
}
|
||||
@@ -117,7 +125,7 @@
|
||||
{#if loading}
|
||||
<div class="vault-selection">
|
||||
<div class="vault-selection-inner">
|
||||
<p class="loading-text">Loading...</p>
|
||||
<p class="loading-text">{tr('common.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -130,7 +138,7 @@
|
||||
<line x1="9" y1="14" x2="15" y2="14"/>
|
||||
</svg>
|
||||
<h1>Verstak</h1>
|
||||
<p class="subtitle">Choose a vault to start working</p>
|
||||
<p class="subtitle">{tr('vaultSelection.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
@@ -142,43 +150,43 @@
|
||||
|
||||
<div class="actions">
|
||||
<div class="action-card">
|
||||
<h3>Create a new vault</h3>
|
||||
<p class="hint">Create a local vault folder for workspaces and projects.</p>
|
||||
<h3>{tr('vaultSelection.createTitle')}</h3>
|
||||
<p class="hint">{tr('vaultSelection.createHint')}</p>
|
||||
<div class="input-row">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newVaultPath}
|
||||
placeholder="Choose or enter a path..."
|
||||
placeholder={tr('vaultSelection.pathPlaceholder')}
|
||||
disabled={creating}
|
||||
/>
|
||||
<button class="btn-secondary" on:click={browseNewVault} type="button" disabled={creating}>
|
||||
Browse...
|
||||
{tr('common.browse')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button class="btn-primary" on:click={createVault} type="button" disabled={creating}>
|
||||
{creating ? 'Creating...' : 'Create vault'}
|
||||
{creating ? tr('vaultSelection.creating') : tr('vaultSelection.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-card">
|
||||
<h3>Open an existing vault</h3>
|
||||
<p class="hint">Use a vault that is already on this computer.</p>
|
||||
<h3>{tr('vaultSelection.openTitle')}</h3>
|
||||
<p class="hint">{tr('vaultSelection.openHint')}</p>
|
||||
<div class="input-row">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={openVaultPath}
|
||||
placeholder="Choose or enter a path..."
|
||||
placeholder={tr('vaultSelection.pathPlaceholder')}
|
||||
disabled={opening}
|
||||
/>
|
||||
<button class="btn-secondary" on:click={browseOpenVault} type="button" disabled={opening}>
|
||||
Browse...
|
||||
{tr('common.browse')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -186,7 +194,7 @@
|
||||
|
||||
{#if recentVaults.length > 0}
|
||||
<div class="recent-section">
|
||||
<h3>Recent vaults</h3>
|
||||
<h3>{tr('vaultSelection.recent')}</h3>
|
||||
<ul class="recent-list">
|
||||
{#each recentVaults as path}
|
||||
<li>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script>
|
||||
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 Icon from '../ui/Icon.svelte';
|
||||
import { i18n } from '../i18n/index.js';
|
||||
|
||||
export let activeView = null;
|
||||
export let activeViewPluginId = null;
|
||||
@@ -10,18 +11,40 @@
|
||||
let views = [];
|
||||
let plugins = [];
|
||||
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 {
|
||||
const [contribs, pluginList] = await Promise.all([
|
||||
App.GetContributions().catch(() => ({ views: [] })),
|
||||
App.GetPlugins().catch(() => []),
|
||||
]);
|
||||
views = contribs.views || [];
|
||||
plugins = pluginList;
|
||||
await Promise.all((pluginList || []).map((plugin) => (
|
||||
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
|
||||
)));
|
||||
views = i18n.localizeContributionSummary(contribs || {}).views || [];
|
||||
plugins = (pluginList || []).map((plugin) => i18n.localizePlugin(plugin));
|
||||
} catch (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);
|
||||
@@ -39,7 +62,7 @@
|
||||
}
|
||||
|
||||
function onHostError(e) {
|
||||
renderError = e.detail?.message || 'Plugin view error';
|
||||
renderError = e.detail?.message || tr('pluginView.error');
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -49,9 +72,9 @@
|
||||
<div class="error-boundary">
|
||||
<div class="error-fallback vt-inline-alert error">
|
||||
<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">
|
||||
<summary>Details</summary>
|
||||
<summary>{tr('common.details')}</summary>
|
||||
<p class="error-text">{renderError}</p>
|
||||
</details>
|
||||
</div>
|
||||
@@ -72,14 +95,14 @@
|
||||
/>
|
||||
{:else}
|
||||
<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">
|
||||
<summary>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">View ID:</span> <code>{currentView.id}</code></p>
|
||||
<p class="placeholder-info"><span class="placeholder-key">Component:</span> <code>{currentView.component}</code></p>
|
||||
<summary>{tr('common.details')}</summary>
|
||||
<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">{tr('pluginView.viewId')}:</span> <code>{currentView.id}</code></p>
|
||||
<p class="placeholder-info"><span class="placeholder-key">{tr('common.component')}:</span> <code>{currentView.component}</code></p>
|
||||
</details>
|
||||
<p class="placeholder-badge vt-badge">Frontend bundle unavailable</p>
|
||||
<p class="placeholder-badge vt-badge">{tr('pluginView.bundleUnavailable')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -87,12 +110,12 @@
|
||||
</div>
|
||||
{:else if activeView}
|
||||
<div class="view-container empty">
|
||||
<p>View is unavailable.</p>
|
||||
<p>{tr('pluginView.unavailable')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="view-container empty">
|
||||
<p>Select a plugin view from the sidebar</p>
|
||||
<p class="sub">Plugin views will appear here</p>
|
||||
<p>{tr('pluginView.select')}</p>
|
||||
<p class="sub">{tr('pluginView.selectHint')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/key}
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
<script>
|
||||
import { onDestroy } from 'svelte';
|
||||
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
|
||||
import Icon from '../ui/Icon.svelte';
|
||||
import { i18n } from '../i18n/index.js';
|
||||
import { debug } from '../log/debug.js';
|
||||
|
||||
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 || '';
|
||||
$: providerComponent = openedResource?.providerComponent || '';
|
||||
@@ -24,26 +34,30 @@
|
||||
function closeWorkbench() {
|
||||
window.dispatchEvent(new CustomEvent('verstak:close-workbench', { cancelable: true }));
|
||||
}
|
||||
|
||||
onDestroy(unsubscribeLocale);
|
||||
</script>
|
||||
|
||||
<div class="workbench-host vt-page">
|
||||
{#if openedResource?.status === 'no-provider'}
|
||||
<div class="workbench-header vt-page-header">
|
||||
<span class="workbench-title vt-page-title">{resourcePath}</span>
|
||||
<span class="workbench-provider vt-badge">No provider</span>
|
||||
<button class="close-btn btn-ghost btn-icon" type="button" title="Close" aria-label="Close" on:click={closeWorkbench}>
|
||||
<span class="workbench-provider vt-badge">{tr('workbench.noProvider')}</span>
|
||||
<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} />
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
{:else if openedResource}
|
||||
<div class="workbench-header vt-page-header">
|
||||
<span class="workbench-title vt-page-title">{resourcePath}</span>
|
||||
<span class="workbench-provider vt-badge accent">{providerId}</span>
|
||||
<button class="close-btn btn-ghost btn-icon" type="button" title="Close" aria-label="Close" on:click={closeWorkbench}>
|
||||
{#if debug.isEnabled() && providerId}
|
||||
<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} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -58,7 +72,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import TodaySurface from './TodaySurface.svelte';
|
||||
import * as App from '../../../wailsjs/go/api/App';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { i18n } from '../i18n/index.js';
|
||||
|
||||
export let selectedWorkspaceName = '';
|
||||
export let nodes = [];
|
||||
@@ -20,8 +21,14 @@
|
||||
let requestedToolRequest = null;
|
||||
let activeToolRequest = null;
|
||||
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.
|
||||
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([
|
||||
['notes', 10],
|
||||
@@ -36,6 +43,7 @@
|
||||
|
||||
$: selectedWorkspace = nodes.find(n => n.id === selectedWorkspaceName || n.name === selectedWorkspaceName || n.rootPath === selectedWorkspaceName) || null;
|
||||
$: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || '';
|
||||
$: workspaceId = selectedWorkspace?.workspaceId || '';
|
||||
$: workspaceTitle = selectedWorkspace?.title || selectedWorkspace?.name || selectedWorkspace?.id || selectedWorkspaceName;
|
||||
$: workspaceType = selectedWorkspace?.type || 'workspace';
|
||||
$: if (workspaceRootPath !== metadataWorkspaceRoot) {
|
||||
@@ -66,10 +74,16 @@
|
||||
$: if (selectedWorkspaceName) loadTools();
|
||||
|
||||
onMount(() => {
|
||||
unsubscribeLocale = i18n.subscribe((nextLocale) => {
|
||||
const changed = locale !== nextLocale;
|
||||
locale = nextLocale;
|
||||
if (changed && selectedWorkspaceName) loadTools();
|
||||
});
|
||||
window.addEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (unsubscribeLocale) unsubscribeLocale();
|
||||
window.removeEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool);
|
||||
});
|
||||
|
||||
@@ -163,8 +177,11 @@
|
||||
App.GetContributions().catch(() => ({})),
|
||||
App.GetPlugins().catch(() => []),
|
||||
]);
|
||||
contributions = c || {};
|
||||
plugins = p || [];
|
||||
await Promise.all((p || []).map((plugin) => (
|
||||
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
|
||||
)));
|
||||
contributions = i18n.localizeContributionSummary(c || {});
|
||||
plugins = (p || []).map((plugin) => i18n.localizePlugin(plugin));
|
||||
|
||||
const enabledIds = new Set(
|
||||
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-type vt-badge accent">{workspaceType}</span>
|
||||
</div>
|
||||
<div class="workspace-search" aria-label="Workspace search">
|
||||
<div class="workspace-search" aria-label={tr('workspace.search')}>
|
||||
<GlobalSearch />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#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)}
|
||||
<button
|
||||
class="vt-tab"
|
||||
@@ -207,7 +224,7 @@
|
||||
</button>
|
||||
{/each}
|
||||
</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.shell}
|
||||
<TodaySurface
|
||||
@@ -219,21 +236,21 @@
|
||||
<PluginBundleHost
|
||||
pluginId={activeTool.pluginId}
|
||||
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}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="workspace-empty vt-empty-state">
|
||||
<p class="vt-empty-title">No workspace tools available</p>
|
||||
<p class="workspace-hint">Enable plugins with workspace tools or open Plugin Manager from settings.</p>
|
||||
<p class="vt-empty-title">{tr('workspace.emptyTools')}</p>
|
||||
<p class="workspace-hint">{tr('workspace.emptyToolsHint')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="workspace-empty vt-empty-state">
|
||||
<p class="vt-empty-title">Select a workspace</p>
|
||||
<p class="workspace-hint">Use the + button in Workspaces to add your first project.</p>
|
||||
<p class="vt-empty-title">{tr('workspace.select')}</p>
|
||||
<p class="workspace-hint">{tr('workspace.selectHint')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import * as App from '../../../wailsjs/go/api/App';
|
||||
import Icon from '../ui/Icon.svelte';
|
||||
import { i18n } from '../i18n/index.js';
|
||||
|
||||
let loading = true;
|
||||
let localError = '';
|
||||
@@ -24,14 +25,26 @@
|
||||
let renamingId = '';
|
||||
let renameValue = '';
|
||||
let busyId = '';
|
||||
let locale = i18n.getLocale();
|
||||
let unsubscribeLocale = null;
|
||||
$: tr = ((activeLocale) => (key, params, fallback) => {
|
||||
void activeLocale;
|
||||
return i18n.t(key, params, fallback);
|
||||
})(locale);
|
||||
|
||||
onMount(() => {
|
||||
unsubscribeLocale = i18n.subscribe((nextLocale) => {
|
||||
const changed = locale !== nextLocale;
|
||||
locale = nextLocale;
|
||||
if (changed) loadWorkspaceTemplates();
|
||||
});
|
||||
loadWorkspaces();
|
||||
loadWorkspaceTemplates();
|
||||
window.addEventListener('verstak:workspace-active-changed', onActiveWorkspaceChanged);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (unsubscribeLocale) unsubscribeLocale();
|
||||
window.removeEventListener('verstak:workspace-active-changed', onActiveWorkspaceChanged);
|
||||
});
|
||||
|
||||
@@ -67,7 +80,10 @@
|
||||
return;
|
||||
}
|
||||
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 name = plugin?.manifest?.name;
|
||||
if (id && name) names[id] = name;
|
||||
@@ -154,11 +170,11 @@
|
||||
async function doCreate() {
|
||||
const name = newWorkspaceName.trim();
|
||||
if (!name) {
|
||||
createError = 'Name is required';
|
||||
createError = tr('workspaceTree.nameRequired');
|
||||
return;
|
||||
}
|
||||
if (!selectedTemplate) {
|
||||
createError = 'Choose a workspace template';
|
||||
createError = tr('workspaceTree.chooseTemplate');
|
||||
return;
|
||||
}
|
||||
creating = true;
|
||||
@@ -246,12 +262,12 @@
|
||||
|
||||
<div class="wt">
|
||||
<div class="wt-header">
|
||||
<span class="wt-title">Workspaces</span>
|
||||
<button class="wt-btn" on:click={openCreateDialog} title="New workspace" type="button">+</button>
|
||||
<span class="wt-title">{tr('workspaceTree.title')}</span>
|
||||
<button class="wt-btn" on:click={openCreateDialog} title={tr('workspaceTree.new')} type="button">+</button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="wt-loading">Loading...</div>
|
||||
<div class="wt-loading">{tr('common.loading')}</div>
|
||||
{:else if localError}
|
||||
<div class="wt-error">{localError}</div>
|
||||
{/if}
|
||||
@@ -272,14 +288,14 @@
|
||||
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={cancelRename} title="Cancel rename" type="button" disabled={busyId === id}>Cancel</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={tr('common.cancel')} type="button" disabled={busyId === id}>{tr('common.cancel')}</button>
|
||||
{:else}
|
||||
<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} />
|
||||
</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} />
|
||||
</button>
|
||||
{/if}
|
||||
@@ -289,20 +305,20 @@
|
||||
</div>
|
||||
|
||||
{#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-header">
|
||||
<div>
|
||||
<h2>New workspace</h2>
|
||||
<h2>{tr('workspaceTree.new')}</h2>
|
||||
</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>
|
||||
<label class="workspace-create-field">
|
||||
<span>Name</span>
|
||||
<input data-workspace-name type="text" bind:value={newWorkspaceName} placeholder="Workspace name" disabled={creating} on:keydown={(event) => event.key === 'Enter' && doCreate()} />
|
||||
<span>{tr('pluginCard.name')}</span>
|
||||
<input data-workspace-name type="text" bind:value={newWorkspaceName} placeholder={tr('workspaceTree.namePlaceholder')} disabled={creating} on:keydown={(event) => event.key === 'Enter' && doCreate()} />
|
||||
</label>
|
||||
<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}>
|
||||
{#each workspaceTemplates as template (template.id)}
|
||||
<option value={template.id}>{template.name}</option>
|
||||
@@ -323,8 +339,8 @@
|
||||
<p class="workspace-create-error" data-workspace-create-error role="alert">{createError}</p>
|
||||
{/if}
|
||||
<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" on:click={closeCreateDialog} type="button" disabled={creating}>Cancel</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}>{tr('common.cancel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -283,6 +283,64 @@
|
||||
}
|
||||
};
|
||||
|
||||
var russianPluginNames = {
|
||||
'verstak.platform-test': 'Тест платформы',
|
||||
'verstak.default-editor': 'Стандартный редактор',
|
||||
'verstak.files': 'Файлы',
|
||||
'verstak.notes': 'Заметки',
|
||||
'verstak.sync': 'Синхронизация',
|
||||
'verstak.activity': 'Активность',
|
||||
'verstak.journal': 'Журнал',
|
||||
'verstak.browser-inbox': 'Входящие из браузера',
|
||||
'verstak.search': 'Поиск',
|
||||
'verstak.trash': 'Корзина',
|
||||
'verstak.todo': 'Задачи',
|
||||
'verstak.secrets': 'Секреты'
|
||||
};
|
||||
var russianContributionLabels = {
|
||||
'verstak.platform-test.diagnostics': 'Диагностика платформы',
|
||||
'verstak.platform-test.run-tests': 'Запустить тесты платформы',
|
||||
'verstak.platform-test.show-version': 'Показать сведения о версии',
|
||||
'verstak.platform-test.sidebar': 'Тест платформы',
|
||||
'verstak.platform-test.status': '[OK] Все тесты пройдены',
|
||||
'verstak.platform-test.settings': 'Настройки теста платформы',
|
||||
'verstak.platform-test.markdown-diagnostic': 'Диагностика Markdown платформы',
|
||||
'verstak.search.searchVaultText': 'Искать текст в хранилище',
|
||||
'verstak.search.vault-text': 'Поиск по тексту хранилища'
|
||||
};
|
||||
Object.keys(pluginStates).forEach(function (pluginId) {
|
||||
var manifest = pluginStates[pluginId].manifest;
|
||||
manifest.localization = {
|
||||
defaultLocale: 'en',
|
||||
locales: { en: 'locales/en.json', ru: 'locales/ru.json' }
|
||||
};
|
||||
});
|
||||
|
||||
function mockPluginCatalog(pluginId, locale) {
|
||||
var state = pluginStates[pluginId];
|
||||
if (!state || !state.manifest) return {};
|
||||
var manifest = state.manifest;
|
||||
var translatedName = locale === 'ru' ? (russianPluginNames[pluginId] || manifest.name) : manifest.name;
|
||||
var catalog = {
|
||||
'manifest.name': translatedName,
|
||||
'manifest.description': manifest.description || ''
|
||||
};
|
||||
var contributionFields = {
|
||||
views: 'title', commands: 'title', settingsPanels: 'title', sidebarItems: 'title',
|
||||
fileActions: 'label', noteActions: 'label', contextMenuEntries: 'label',
|
||||
searchProviders: 'label', statusBarItems: 'label', openProviders: 'title', workspaceItems: 'title'
|
||||
};
|
||||
Object.keys(contributionFields).forEach(function (point) {
|
||||
var field = contributionFields[point];
|
||||
((manifest.contributes || {})[point] || []).forEach(function (item) {
|
||||
catalog['contributions.' + point + '.' + item.id + '.' + field] = locale === 'ru'
|
||||
? (russianContributionLabels[item.id] || translatedName)
|
||||
: item[field];
|
||||
});
|
||||
});
|
||||
return catalog;
|
||||
}
|
||||
|
||||
var vaultStatus = { status: 'open', path: '/tmp/verstak-test/vault', vaultId: 'test-vault-001' };
|
||||
var 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');
|
||||
@@ -291,7 +349,11 @@
|
||||
vaultPluginState.desiredPlugins.push({ id: 'verstak.todo', version: '0.1.0', source: 'official' });
|
||||
vaultPluginState.enabledPlugins.push('verstak.secrets');
|
||||
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 openedResources = [];
|
||||
var pluginSettings = {
|
||||
@@ -3263,6 +3325,9 @@
|
||||
}
|
||||
return Promise.resolve({});
|
||||
},
|
||||
GetPluginLocalization: function (pluginId, locale) {
|
||||
return Promise.resolve([mockPluginCatalog(pluginId, locale), '']);
|
||||
},
|
||||
ReadPluginSettings: function (pluginId) {
|
||||
return Promise.resolve([Object.assign({}, pluginSettings[pluginId] || {}), '']);
|
||||
},
|
||||
@@ -3276,11 +3341,28 @@
|
||||
var data = (pluginData[pluginId] && pluginData[pluginId][name]) || {};
|
||||
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) {
|
||||
pluginData[pluginId] = pluginData[pluginId] || {};
|
||||
pluginData[pluginId][name] = Object.assign({}, data || {});
|
||||
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) {
|
||||
return openWorkbenchResource(pluginId, request || {}, '');
|
||||
},
|
||||
@@ -3613,6 +3695,13 @@
|
||||
window.__wailsMockExternalOpens = externalOpens.slice();
|
||||
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) {
|
||||
var err = requirePluginPermission(pluginId, 'files.openExternal');
|
||||
if (err) return Promise.resolve(err);
|
||||
@@ -3736,7 +3825,11 @@
|
||||
OpenVault: function () { return Promise.resolve(null); },
|
||||
CloseVault: function () { return Promise.resolve(null); },
|
||||
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(''); },
|
||||
WriteFrontendLog: function () { return Promise.resolve(); },
|
||||
|
||||
@@ -4066,7 +4159,8 @@
|
||||
vaultPluginState.desiredPlugins.push({ id: 'verstak.todo', version: '0.1.0', source: 'official' });
|
||||
vaultPluginState.enabledPlugins.push('verstak.secrets');
|
||||
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 = {};
|
||||
openedResources = [];
|
||||
pluginSettings = { 'verstak.platform-test': { savedText: 'initial value' } };
|
||||
|
||||
+28
-2
@@ -1,7 +1,33 @@
|
||||
import App from './App.svelte';
|
||||
import * as Backend from '../wailsjs/go/api/App';
|
||||
import { i18n } from './lib/i18n/index.js';
|
||||
|
||||
const app = new App({
|
||||
target: document.getElementById('app'),
|
||||
function unpack(result) {
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
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 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');
|
||||
fs.writeFileSync(tempPath, source);
|
||||
|
||||
@@ -59,6 +76,17 @@ if (!api.contributions || typeof api.contributions.list !== 'function') {
|
||||
if (!api.commands || typeof api.commands.executeFor !== 'function') {
|
||||
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');
|
||||
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)}`);
|
||||
}
|
||||
|
||||
api.dispose();
|
||||
if (localeListeners.size !== 0) {
|
||||
throw new Error('api.i18n locale subscription was not disposed');
|
||||
}
|
||||
|
||||
console.log('plugin api contributions smoke passed');
|
||||
|
||||
Vendored
+16
@@ -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 GetPluginLocalization(arg1:string,arg2:string):Promise<Record<string, string>|string>;
|
||||
|
||||
export function GetPlugins():Promise<Array<plugin.Plugin>>;
|
||||
|
||||
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 ListWorkspaceIdentities():Promise<Array<workspace.WorkspaceIdentity>|string>;
|
||||
|
||||
export function ListWorkspaceTemplates():Promise<Array<workspace.WorkspaceTemplate>|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 OpenExternalURL(arg1:string,arg2:string):Promise<string>;
|
||||
|
||||
export function OpenWorkbenchResource(arg1:string,arg2:Record<string, any>):Promise<workbench.OpenResourceResult|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 ReadPluginDataNDJSON(arg1:string,arg2:string):Promise<Array<Record<string, any>>>;
|
||||
|
||||
export function ReadPluginSetting(arg1:string,arg2:string):Promise<any>;
|
||||
|
||||
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 RepairWorkspaceIdentity(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 RestoreWorkspaceTrash(arg1:string,arg2:string):Promise<workspace.Workspace|string>;
|
||||
|
||||
export function SelectDirectory():Promise<string>;
|
||||
|
||||
export function PurgeWorkspaceTrash(arg1:string):Promise<string>;
|
||||
|
||||
export function SelectVaultForOpen():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 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 WritePluginSettings(arg1:string,arg2:Record<string, any>):Promise<string>;
|
||||
|
||||
@@ -82,6 +82,10 @@ export function 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() {
|
||||
return window['go']['api']['App']['GetPlugins']();
|
||||
}
|
||||
@@ -130,6 +134,10 @@ export function ListWorkspaces() {
|
||||
return window['go']['api']['App']['ListWorkspaces']();
|
||||
}
|
||||
|
||||
export function ListWorkspaceIdentities() {
|
||||
return window['go']['api']['App']['ListWorkspaceIdentities']();
|
||||
}
|
||||
|
||||
export function ListWorkspaceTemplates() {
|
||||
return window['go']['api']['App']['ListWorkspaceTemplates']();
|
||||
}
|
||||
@@ -150,6 +158,10 @@ export function 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) {
|
||||
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);
|
||||
}
|
||||
|
||||
export function ReadPluginDataNDJSON(arg1, arg2) {
|
||||
return window['go']['api']['App']['ReadPluginDataNDJSON'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function 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);
|
||||
}
|
||||
|
||||
export function RepairWorkspaceIdentity(arg1, arg2) {
|
||||
return window['go']['api']['App']['RepairWorkspaceIdentity'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function 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);
|
||||
}
|
||||
|
||||
export function RestoreWorkspaceTrash(arg1, arg2) {
|
||||
return window['go']['api']['App']['RestoreWorkspaceTrash'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function SelectDirectory() {
|
||||
return window['go']['api']['App']['SelectDirectory']();
|
||||
}
|
||||
|
||||
export function PurgeWorkspaceTrash(arg1) {
|
||||
return window['go']['api']['App']['PurgeWorkspaceTrash'](arg1);
|
||||
}
|
||||
|
||||
export function 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);
|
||||
}
|
||||
|
||||
export function WritePluginDataNDJSON(arg1, arg2, arg3) {
|
||||
return window['go']['api']['App']['WritePluginDataNDJSON'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function WritePluginSetting(arg1, arg2, arg3) {
|
||||
return window['go']['api']['App']['WritePluginSetting'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
namespaces?: string[];
|
||||
participate?: boolean;
|
||||
@@ -955,6 +969,7 @@ export namespace plugin {
|
||||
description?: string;
|
||||
source?: string;
|
||||
icon?: string;
|
||||
localization?: LocalizationConfig;
|
||||
provides: string[];
|
||||
requires?: string[];
|
||||
optionalRequires?: string[];
|
||||
@@ -979,6 +994,7 @@ export namespace plugin {
|
||||
this.description = source["description"];
|
||||
this.source = source["source"];
|
||||
this.icon = source["icon"];
|
||||
this.localization = this.convertValues(source["localization"], LocalizationConfig);
|
||||
this.provides = source["provides"];
|
||||
this.requires = source["requires"];
|
||||
this.optionalRequires = source["optionalRequires"];
|
||||
@@ -1256,6 +1272,7 @@ export namespace workspace {
|
||||
}
|
||||
}
|
||||
export class Metadata {
|
||||
workspaceId?: string;
|
||||
workspaceName: string;
|
||||
createdFromTemplate?: TemplateSnapshot;
|
||||
features?: Record<string, boolean>;
|
||||
@@ -1269,6 +1286,7 @@ export namespace workspace {
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.workspaceId = source["workspaceId"];
|
||||
this.workspaceName = source["workspaceName"];
|
||||
this.createdFromTemplate = this.convertValues(source["createdFromTemplate"], TemplateSnapshot);
|
||||
this.features = source["features"];
|
||||
@@ -1311,6 +1329,7 @@ export namespace workspace {
|
||||
}
|
||||
|
||||
export class TrashResult {
|
||||
workspaceId: string;
|
||||
originalPath: string;
|
||||
trashPath: string;
|
||||
trashId: string;
|
||||
@@ -1322,6 +1341,7 @@ export namespace workspace {
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.workspaceId = source["workspaceId"];
|
||||
this.originalPath = source["originalPath"];
|
||||
this.trashPath = source["trashPath"];
|
||||
this.trashId = source["trashId"];
|
||||
@@ -1329,6 +1349,7 @@ export namespace workspace {
|
||||
}
|
||||
}
|
||||
export class Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
rootPath: string;
|
||||
|
||||
@@ -1338,9 +1359,27 @@ export namespace workspace {
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.name = source["name"];
|
||||
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"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ go 1.24.4
|
||||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/wailsapp/wails/v2 v2.12.0
|
||||
golang.org/x/net v0.35.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -32,7 +33,6 @@ require (
|
||||
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // 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/text v0.22.0 // indirect
|
||||
)
|
||||
|
||||
+676
-75
@@ -9,7 +9,9 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
@@ -38,36 +40,50 @@ var newSyncClient = syncsvc.NewClient
|
||||
var emitFrontendEvent = runtime.EventsEmit
|
||||
|
||||
const pluginEventRuntimeName = "verstak:plugin-event"
|
||||
const activityGlobalKey = "events:global"
|
||||
const activityWorkspacePrefix = "events:workspace:"
|
||||
const maxActivityEvents = 250
|
||||
const activityPluginID = "verstak.activity"
|
||||
const activityRawDataName = "activity-events"
|
||||
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 workspaceRenamedEventName = "workspace.renamed"
|
||||
const workspaceTrashedEventName = "workspace.trashed"
|
||||
const workspaceRestoredEventName = "workspace.restored"
|
||||
const workspacePurgedEventName = "workspace.purged"
|
||||
const workspaceSelectedEventName = "workspace.selected"
|
||||
|
||||
// App is the main application struct exposed to the Wails frontend.
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
capRegistry *capability.Registry
|
||||
contribRegistry *contribution.Registry
|
||||
permRegistry *permissions.Registry
|
||||
eventBus *events.Bus
|
||||
plugins []plugin.Plugin
|
||||
vault *vault.Vault
|
||||
storage *storage.Storage
|
||||
files *corefiles.Service
|
||||
externalOpen externalOpenService
|
||||
appSettings *appsettings.Manager
|
||||
pluginState *pluginstate.Manager
|
||||
workbench *coreworkbench.Router
|
||||
workspace *workspace.Manager
|
||||
syncSvc *syncsvc.Service
|
||||
browserReceiver *browserreceiver.Receiver
|
||||
secretsSession *coresecrets.VaultSession
|
||||
fileWatcher *filewatcher.Service
|
||||
debug bool
|
||||
activityEvents map[string]bool
|
||||
ctx context.Context
|
||||
capRegistry *capability.Registry
|
||||
contribRegistry *contribution.Registry
|
||||
permRegistry *permissions.Registry
|
||||
eventBus *events.Bus
|
||||
plugins []plugin.Plugin
|
||||
vault *vault.Vault
|
||||
storage *storage.Storage
|
||||
files *corefiles.Service
|
||||
externalOpen externalOpenService
|
||||
appSettings *appsettings.Manager
|
||||
pluginState *pluginstate.Manager
|
||||
workbench *coreworkbench.Router
|
||||
workspace *workspace.Manager
|
||||
syncSvc *syncsvc.Service
|
||||
browserReceiver *browserreceiver.Receiver
|
||||
secretsSession *coresecrets.VaultSession
|
||||
fileWatcher *filewatcher.Service
|
||||
debug bool
|
||||
activityEvents map[string]bool
|
||||
browserInboxEvents map[string]bool
|
||||
browserInboxEnabled atomic.Bool
|
||||
}
|
||||
|
||||
type externalOpenService interface {
|
||||
@@ -93,29 +109,35 @@ func NewApp(
|
||||
debugEnabled bool,
|
||||
) *App {
|
||||
app := &App{
|
||||
capRegistry: capReg,
|
||||
contribRegistry: contribReg,
|
||||
permRegistry: permReg,
|
||||
eventBus: bus,
|
||||
plugins: plugins,
|
||||
vault: vaultService,
|
||||
storage: storageService,
|
||||
files: filesService,
|
||||
externalOpen: externalopen.NewService(),
|
||||
appSettings: appSettingsMgr,
|
||||
pluginState: pluginStateMgr,
|
||||
workbench: coreworkbench.NewRouter(workbenchPrefsFromSettings(appSettingsMgr)),
|
||||
workspace: workspaceMgr,
|
||||
syncSvc: syncService,
|
||||
browserReceiver: browserReceiverService,
|
||||
fileWatcher: filewatcher.NewService(bus, 0),
|
||||
debug: debugEnabled,
|
||||
activityEvents: make(map[string]bool),
|
||||
capRegistry: capReg,
|
||||
contribRegistry: contribReg,
|
||||
permRegistry: permReg,
|
||||
eventBus: bus,
|
||||
plugins: plugins,
|
||||
vault: vaultService,
|
||||
storage: storageService,
|
||||
files: filesService,
|
||||
externalOpen: externalopen.NewService(),
|
||||
appSettings: appSettingsMgr,
|
||||
pluginState: pluginStateMgr,
|
||||
workbench: coreworkbench.NewRouter(workbenchPrefsFromSettings(appSettingsMgr)),
|
||||
workspace: workspaceMgr,
|
||||
syncSvc: syncService,
|
||||
browserReceiver: browserReceiverService,
|
||||
fileWatcher: filewatcher.NewService(bus, 0),
|
||||
debug: debugEnabled,
|
||||
activityEvents: make(map[string]bool),
|
||||
browserInboxEvents: make(map[string]bool),
|
||||
}
|
||||
if app.syncSvc == nil {
|
||||
app.rebindSyncService()
|
||||
}
|
||||
app.ensureActivityProviderSubscriptions()
|
||||
app.ensureBrowserInboxSubscriptions()
|
||||
if app.browserReceiver != nil {
|
||||
app.browserReceiver.SetPersistence(app.browserInboxAvailable, app.recordBrowserCapture)
|
||||
app.browserReceiver.SetActivityPersistence(app.activityAvailable, app.recordBrowserActivityBatch)
|
||||
}
|
||||
app.startFileWatcherForOpenVault()
|
||||
return app
|
||||
}
|
||||
@@ -151,9 +173,337 @@ func (a *App) ensureWorkbench() *coreworkbench.Router {
|
||||
func (a *App) Startup(ctx context.Context) {
|
||||
a.ctx = ctx
|
||||
a.ensureActivityProviderSubscriptions()
|
||||
a.ensureBrowserInboxSubscriptions()
|
||||
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) {
|
||||
for i := range a.plugins {
|
||||
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) {
|
||||
@@ -229,35 +619,20 @@ func (a *App) recordActivityProviderEvent(event events.Event) {
|
||||
if _, err := a.requirePluginAccess(provider.PluginID, "storage.namespace"); err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) appendActivityEvent(pluginID string, activity map[string]interface{}) error {
|
||||
settings, err := a.storage.ReadPluginSettings(pluginID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := activityGlobalKey
|
||||
if workspace, _ := activity["workspaceRootPath"].(string); strings.TrimSpace(workspace) != "" {
|
||||
key = activityWorkspacePrefix + url.QueryEscape(strings.TrimSpace(workspace))
|
||||
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)
|
||||
_, err := a.storage.AppendPluginDataNDJSON(pluginID, activityRawDataName, []map[string]interface{}{activity}, storage.NDJSONRetention{
|
||||
TimestampField: "occurredAt",
|
||||
MaxAge: activityRetention,
|
||||
MaxEntries: maxActivityRawEvents,
|
||||
MaxBytes: maxActivityRawBytes,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
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{} {
|
||||
switch value := payload.(type) {
|
||||
case map[string]interface{}:
|
||||
@@ -646,6 +1039,7 @@ func (a *App) ReloadPlugins() (int, string) {
|
||||
|
||||
a.plugins = plugins
|
||||
a.ensureActivityProviderSubscriptions()
|
||||
a.ensureBrowserInboxSubscriptions()
|
||||
|
||||
var buf strings.Builder
|
||||
buf.WriteString("discovery complete")
|
||||
@@ -823,6 +1217,40 @@ func (a *App) ReadPluginDataJSON(pluginID, name string) map[string]interface{} {
|
||||
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.
|
||||
func (a *App) WritePluginDataJSON(pluginID, name string, data map[string]interface{}) string {
|
||||
if _, err := a.requirePluginAccess(pluginID, "storage.namespace"); err != nil {
|
||||
@@ -1130,6 +1558,22 @@ func (a *App) OpenVaultPathExternal(pluginID, relativePath string) string {
|
||||
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.
|
||||
func (a *App) ShowVaultPathInFolder(pluginID, relativePath string) string {
|
||||
if _, err := a.requirePluginAccess(pluginID, "files.openExternal"); err != nil {
|
||||
@@ -1621,7 +2065,9 @@ func (a *App) GetAppSettings() map[string]interface{} {
|
||||
"currentVaultPath": cfg.CurrentVaultPath,
|
||||
"recentVaults": cfg.RecentVaults,
|
||||
"theme": cfg.Theme,
|
||||
"language": cfg.Language,
|
||||
"devMode": cfg.DevMode,
|
||||
"debug": a.debug,
|
||||
"userPluginsDir": cfg.UserPluginsDir,
|
||||
"lastOpenedAt": cfg.LastOpenedAt,
|
||||
}
|
||||
@@ -1634,18 +2080,33 @@ func (a *App) UpdateAppSettings(patch map[string]interface{}) string {
|
||||
}
|
||||
|
||||
cfg := &appsettings.Config{}
|
||||
hasConfigPatch := false
|
||||
if v, ok := patch["theme"].(string); ok && v != "" {
|
||||
cfg.Theme = v
|
||||
hasConfigPatch = true
|
||||
}
|
||||
if v, ok := patch["devMode"].(bool); ok {
|
||||
cfg.DevMode = v
|
||||
hasConfigPatch = true
|
||||
}
|
||||
if v, ok := patch["userPluginsDir"].(string); ok && v != "" {
|
||||
cfg.UserPluginsDir = v
|
||||
hasConfigPatch = true
|
||||
}
|
||||
|
||||
if err := a.appSettings.Update(cfg); err != nil {
|
||||
return err.Error()
|
||||
if hasConfigPatch {
|
||||
if err := a.appSettings.Update(cfg); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
}
|
||||
if value, exists := patch["language"]; exists {
|
||||
language, ok := value.(string)
|
||||
if !ok {
|
||||
return "language must be a string"
|
||||
}
|
||||
if err := a.appSettings.UpdateLanguage(language); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1743,6 +2204,29 @@ func (a *App) ListWorkspaceTemplates() ([]workspace.WorkspaceTemplate, string) {
|
||||
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.
|
||||
func (a *App) CreateWorkspace(name, templateID string) (workspace.Workspace, string) {
|
||||
if a.workspace == nil {
|
||||
@@ -1754,6 +2238,7 @@ func (a *App) CreateWorkspace(name, templateID string) (workspace.Workspace, str
|
||||
}
|
||||
a.publishWorkspaceLifecycleEvent(workspaceCreatedEventName, map[string]interface{}{
|
||||
"operation": "create",
|
||||
"workspaceId": ws.ID,
|
||||
"workspaceRootPath": ws.RootPath,
|
||||
"workspaceName": ws.Name,
|
||||
"templateId": templateID,
|
||||
@@ -1769,8 +2254,13 @@ func (a *App) RenameWorkspace(oldName, newName string) string {
|
||||
if err := a.workspace.RenameWorkspace(oldName, newName); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
identity, err := a.workspace.GetWorkspaceIdentity(newName)
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
a.publishWorkspaceLifecycleEvent(workspaceRenamedEventName, map[string]interface{}{
|
||||
"operation": "rename",
|
||||
"workspaceId": identity.WorkspaceID,
|
||||
"workspaceRootPath": newName,
|
||||
"workspaceName": newName,
|
||||
"previousWorkspaceRootPath": oldName,
|
||||
@@ -1790,6 +2280,7 @@ func (a *App) TrashWorkspace(name string) (workspace.TrashResult, string) {
|
||||
}
|
||||
a.publishWorkspaceLifecycleEvent(workspaceTrashedEventName, map[string]interface{}{
|
||||
"operation": "trash",
|
||||
"workspaceId": result.WorkspaceID,
|
||||
"workspaceRootPath": name,
|
||||
"workspaceName": name,
|
||||
"trashId": result.TrashID,
|
||||
@@ -1799,6 +2290,47 @@ func (a *App) TrashWorkspace(name string) (workspace.TrashResult, string) {
|
||||
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.
|
||||
func (a *App) GetWorkspaceMetadata(name string) (workspace.Metadata, string) {
|
||||
if a.workspace == nil {
|
||||
@@ -1832,9 +2364,15 @@ func (a *App) GetCurrentWorkspace() map[string]interface{} {
|
||||
if err != nil {
|
||||
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{}{
|
||||
"name": node.Name,
|
||||
"rootPath": node.RootPath,
|
||||
"id": identity.WorkspaceID,
|
||||
"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{}{
|
||||
"pluginId": p.Manifest.ID,
|
||||
"name": p.Manifest.Name,
|
||||
"icon": p.Manifest.Icon,
|
||||
"version": p.Manifest.Version,
|
||||
"entry": p.Manifest.Frontend.Entry,
|
||||
"style": p.Manifest.Frontend.Style,
|
||||
"rootPath": p.RootPath,
|
||||
"pluginId": p.Manifest.ID,
|
||||
"name": p.Manifest.Name,
|
||||
"icon": p.Manifest.Icon,
|
||||
"version": p.Manifest.Version,
|
||||
"entry": p.Manifest.Frontend.Entry,
|
||||
"style": p.Manifest.Frontend.Style,
|
||||
"localization": p.Manifest.Localization,
|
||||
"rootPath": p.RootPath,
|
||||
}
|
||||
}
|
||||
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.
|
||||
// Security: validates that the assetPath is relative and does not escape the plugin root.
|
||||
func (a *App) GetPluginAssetContent(pluginID, assetPath string) (string, string) {
|
||||
|
||||
+609
-10
@@ -3,12 +3,14 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"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) {
|
||||
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)
|
||||
}
|
||||
|
||||
settings, err := app.storage.ReadPluginSettings("verstak.activity")
|
||||
stored, err := app.storage.ReadPluginDataNDJSON("verstak.activity", activityRawDataName)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPluginSettings: %v", err)
|
||||
}
|
||||
stored, ok := settings["events:workspace:Project"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("events:workspace:Project = %#v, want []interface{}", settings["events:workspace:Project"])
|
||||
t.Fatalf("ReadPluginDataNDJSON: %v", err)
|
||||
}
|
||||
if len(stored) != 1 {
|
||||
t.Fatalf("stored %d activity events, want 1", len(stored))
|
||||
}
|
||||
activity, ok := stored[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("activity = %#v, want map[string]interface{}", stored[0])
|
||||
}
|
||||
activity := stored[0]
|
||||
if activity["type"] != "file.changed" {
|
||||
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) {
|
||||
activity := activityFromEvent(events.Event{
|
||||
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) {
|
||||
tmpDir := t.TempDir()
|
||||
vaultParent := filepath.Join(tmpDir, "vault-parent")
|
||||
@@ -1493,6 +2013,10 @@ func TestWorkspaceAPIPublishesLifecycleEvents(t *testing.T) {
|
||||
if errStr := app.SetCurrentWorkspace("Project"); 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 != "" {
|
||||
t.Fatalf("RenameWorkspace: %s", errStr)
|
||||
}
|
||||
@@ -1503,6 +2027,10 @@ func TestWorkspaceAPIPublishesLifecycleEvents(t *testing.T) {
|
||||
if got := received["workspace.created"]["workspaceRootPath"]; got != "Project" {
|
||||
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" {
|
||||
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" {
|
||||
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" {
|
||||
t.Fatalf("workspace.renamed previousWorkspaceRootPath = %#v, want Project", got)
|
||||
}
|
||||
if got := received["workspace.trashed"]["workspaceRootPath"]; got != "Renamed" {
|
||||
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 == "" {
|
||||
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) {
|
||||
app, vaultDir := newFilesTestApp(t, []string{"files.read"})
|
||||
app.workspace = workspace.NewManager(vaultDir)
|
||||
|
||||
@@ -21,6 +21,7 @@ type Config struct {
|
||||
CurrentVaultPath string `json:"currentVaultPath"`
|
||||
RecentVaults []string `json:"recentVaults"`
|
||||
Theme string `json:"theme"`
|
||||
Language string `json:"language"`
|
||||
DevMode bool `json:"devMode"`
|
||||
UserPluginsDir string `json:"userPluginsDir"`
|
||||
Workbench WorkbenchPreferences `json:"workbench,omitempty"`
|
||||
@@ -30,6 +31,21 @@ type Config struct {
|
||||
LastOpenedAt string `json:"lastOpenedAt"`
|
||||
}
|
||||
|
||||
const (
|
||||
LanguageSystem = "system"
|
||||
LanguageEnglish = "en"
|
||||
LanguageRussian = "ru"
|
||||
)
|
||||
|
||||
func validLanguage(language string) bool {
|
||||
switch language {
|
||||
case LanguageSystem, LanguageEnglish, LanguageRussian:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type WorkbenchPreferences struct {
|
||||
DefaultTextEditorProvider string `json:"defaultTextEditorProvider,omitempty"`
|
||||
DefaultMarkdownEditorProvider string `json:"defaultMarkdownEditorProvider,omitempty"`
|
||||
@@ -121,6 +137,9 @@ func (m *Manager) Load() error {
|
||||
if cfg.Theme == "" {
|
||||
cfg.Theme = "dark"
|
||||
}
|
||||
if !validLanguage(cfg.Language) {
|
||||
cfg.Language = LanguageSystem
|
||||
}
|
||||
if cfg.RecentVaults == nil {
|
||||
cfg.RecentVaults = []string{}
|
||||
}
|
||||
@@ -179,6 +198,9 @@ func (m *Manager) Update(patch *Config) error {
|
||||
if patch.Theme != "" {
|
||||
m.config.Theme = patch.Theme
|
||||
}
|
||||
if patch.Language != "" && validLanguage(patch.Language) {
|
||||
m.config.Language = patch.Language
|
||||
}
|
||||
if patch.UserPluginsDir != "" {
|
||||
m.config.UserPluginsDir = patch.UserPluginsDir
|
||||
}
|
||||
@@ -200,6 +222,23 @@ func (m *Manager) Update(patch *Config) error {
|
||||
return m.saveLocked()
|
||||
}
|
||||
|
||||
// UpdateLanguage validates and persists the installation-local UI language.
|
||||
func (m *Manager) UpdateLanguage(language string) error {
|
||||
language = strings.TrimSpace(language)
|
||||
if !validLanguage(language) {
|
||||
return fmt.Errorf("unsupported language %q", language)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.config == nil {
|
||||
m.config = defaultConfig()
|
||||
}
|
||||
m.config.Language = language
|
||||
m.config.LastOpenedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
return m.saveLocked()
|
||||
}
|
||||
|
||||
// UpdateSync replaces sync settings without changing unrelated app settings.
|
||||
func (m *Manager) UpdateSync(syncSettings SyncSettings) error {
|
||||
m.mu.Lock()
|
||||
@@ -287,6 +326,7 @@ func defaultConfig() *Config {
|
||||
CurrentVaultPath: "",
|
||||
RecentVaults: []string{},
|
||||
Theme: "dark",
|
||||
Language: LanguageSystem,
|
||||
DevMode: false,
|
||||
UserPluginsDir: filepath.Join(os.Getenv("HOME"), ".config", "verstak", "plugins"),
|
||||
Workbench: WorkbenchPreferences{},
|
||||
@@ -303,6 +343,7 @@ func copyConfig(c *Config) *Config {
|
||||
CurrentVaultPath: c.CurrentVaultPath,
|
||||
RecentVaults: recent,
|
||||
Theme: c.Theme,
|
||||
Language: c.Language,
|
||||
DevMode: c.DevMode,
|
||||
UserPluginsDir: c.UserPluginsDir,
|
||||
Workbench: c.Workbench,
|
||||
|
||||
@@ -25,6 +25,53 @@ func TestLoad_DefaultCreation(t *testing.T) {
|
||||
if cfg.CurrentVaultPath != "" {
|
||||
t.Errorf("CurrentVaultPath: got %q, want empty", cfg.CurrentVaultPath)
|
||||
}
|
||||
if cfg.Language != "system" {
|
||||
t.Errorf("Language: got %q, want %q", cfg.Language, "system")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MissingLanguageDefaultsToSystem(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"schemaVersion":1,"theme":"dark","devMode":true}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m := NewManager(path)
|
||||
if err := m.Load(); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if got := m.Get().Language; got != "system" {
|
||||
t.Fatalf("Language = %q, want system", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateLanguagePersistsAndPreservesUnrelatedSettings(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
m := NewManager(path)
|
||||
if err := m.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.Update(&Config{Theme: "light", DevMode: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.UpdateLanguage("ru"); err != nil {
|
||||
t.Fatalf("UpdateLanguage: %v", err)
|
||||
}
|
||||
|
||||
reloaded := NewManager(path)
|
||||
if err := reloaded.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := reloaded.Get()
|
||||
if cfg.Language != "ru" || cfg.Theme != "light" || !cfg.DevMode {
|
||||
t.Fatalf("settings after language update = %+v", cfg)
|
||||
}
|
||||
if err := reloaded.UpdateLanguage("de"); err == nil {
|
||||
t.Fatal("UpdateLanguage(de) succeeded, want validation error")
|
||||
}
|
||||
if got := reloaded.Get().Language; got != "ru" {
|
||||
t.Fatalf("Language after rejected update = %q, want ru", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_CorruptConfig(t *testing.T) {
|
||||
|
||||
@@ -12,18 +12,20 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/verstak/verstak-desktop/internal/core/events"
|
||||
"github.com/verstak/verstak-desktop/internal/core/hostname"
|
||||
)
|
||||
|
||||
const (
|
||||
capturePath = "/api/browser-inbox/v1/captures"
|
||||
activityBatchPath = "/api/browser-activity/v1/batches"
|
||||
DefaultAddr = "127.0.0.1:47731"
|
||||
DefaultCaptureURL = "http://" + DefaultAddr + capturePath
|
||||
DefaultActivityURL = "http://" + DefaultAddr + activityBatchPath
|
||||
receiverTokenHeader = "X-Verstak-Receiver-Token"
|
||||
)
|
||||
|
||||
@@ -43,6 +45,9 @@ const (
|
||||
maxFileTextBytes = 2 * 1024 * 1024
|
||||
maxFileBytes = 8 * 1024 * 1024
|
||||
maxFileDataBase64Bytes = 4 * ((maxFileBytes + 2) / 3)
|
||||
maxActivityBodyBytes = 256 * 1024
|
||||
maxActivityEntries = 100
|
||||
maxActivityDuration = 10 * time.Minute
|
||||
)
|
||||
|
||||
type Receiver struct {
|
||||
@@ -55,8 +60,12 @@ type Receiver struct {
|
||||
type WorkspaceProvider func() string
|
||||
|
||||
type Options struct {
|
||||
RequireToken bool
|
||||
ReceiverToken string
|
||||
RequireToken bool
|
||||
ReceiverToken string
|
||||
Available func() bool
|
||||
Persist func(events.Event) error
|
||||
ActivityAvailable func() bool
|
||||
PersistActivity func(events.Event) error
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -105,6 +114,23 @@ type CaptureBrowser struct {
|
||||
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 {
|
||||
return NewWithOptions(bus, Options{}, providers...)
|
||||
}
|
||||
@@ -128,6 +154,29 @@ func (r *Receiver) SetReceiverToken(token string) {
|
||||
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) {
|
||||
if receiver == nil {
|
||||
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) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if req.URL.Path == activityBatchPath {
|
||||
r.serveActivityBatch(w, req)
|
||||
return
|
||||
}
|
||||
if req.URL.Path != capturePath {
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
@@ -181,6 +234,11 @@ func (r *Receiver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
writeError(w, http.StatusUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
options := r.currentOptions()
|
||||
if options.Available != nil && !options.Available() {
|
||||
writeError(w, http.StatusServiceUnavailable, "browser inbox unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
defer req.Body.Close()
|
||||
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
|
||||
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")
|
||||
return
|
||||
}
|
||||
eventPayload := payload.EventPayload()
|
||||
r.annotateWorkspace(eventPayload)
|
||||
r.bus.Publish(events.Event{
|
||||
event := events.Event{
|
||||
Name: eventName,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
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)
|
||||
_ = 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 {
|
||||
if r == nil {
|
||||
return nil
|
||||
@@ -333,6 +463,80 @@ func (p CapturePayload) Validate() error {
|
||||
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 {
|
||||
if p.File == nil || strings.TrimSpace(p.File.Name) == "" {
|
||||
return fmt.Errorf("file.name is required")
|
||||
@@ -413,10 +617,19 @@ func (p CapturePayload) EventPayload() map[string]interface{} {
|
||||
}
|
||||
|
||||
func captureDomain(rawURL, fallback string) string {
|
||||
if u, err := url.Parse(strings.TrimSpace(rawURL)); err == nil && u.Hostname() != "" {
|
||||
return u.Hostname()
|
||||
if normalized := hostname.NormalizeURLHostnameV1(rawURL); normalized != "" {
|
||||
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) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"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) {
|
||||
bus := events.NewBus()
|
||||
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) {
|
||||
bus := events.NewBus()
|
||||
bus.Subscribe("browser.capture.page", func(event events.Event) {})
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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": "" }
|
||||
]
|
||||
}
|
||||
@@ -7,28 +7,36 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Manifest represents a Verstak plugin.json manifest.
|
||||
type Manifest struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
APIVersion string `json:"apiVersion"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Provides []string `json:"provides"`
|
||||
Requires []string `json:"requires,omitempty"`
|
||||
OptionalRequires []string `json:"optionalRequires,omitempty"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Frontend *FrontendConfig `json:"frontend,omitempty"`
|
||||
Backend *BackendConfig `json:"backend,omitempty"`
|
||||
Migrations *MigrationConfig `json:"migrations,omitempty"`
|
||||
Contributes *Contributions `json:"contributes,omitempty"`
|
||||
Sync *SyncConfig `json:"sync,omitempty"`
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
APIVersion string `json:"apiVersion"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Localization *LocalizationConfig `json:"localization,omitempty"`
|
||||
Provides []string `json:"provides"`
|
||||
Requires []string `json:"requires,omitempty"`
|
||||
OptionalRequires []string `json:"optionalRequires,omitempty"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Frontend *FrontendConfig `json:"frontend,omitempty"`
|
||||
Backend *BackendConfig `json:"backend,omitempty"`
|
||||
Migrations *MigrationConfig `json:"migrations,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.
|
||||
@@ -237,6 +245,25 @@ func ValidateManifest(m *Manifest) []string {
|
||||
if len(m.Permissions) == 0 {
|
||||
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 {
|
||||
for i, provider := range m.Contributes.OpenProviders {
|
||||
if provider.ID == "" {
|
||||
@@ -262,6 +289,31 @@ func ValidateManifest(m *Manifest) []string {
|
||||
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 {
|
||||
if id == "" {
|
||||
return false
|
||||
|
||||
@@ -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.
|
||||
func TestDiscoverPlugins_MultipleDirs(t *testing.T) {
|
||||
dir1 := t.TempDir()
|
||||
|
||||
+260
-10
@@ -20,6 +20,17 @@ type Storage struct {
|
||||
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.
|
||||
func New(v *vault.Vault) *Storage {
|
||||
return &Storage{vault: v}
|
||||
@@ -84,13 +95,22 @@ func atomicWrite(path string, data []byte) error {
|
||||
// ReadPluginSettings reads all settings for a plugin.
|
||||
// Returns empty map if settings.json does not exist.
|
||||
func (s *Storage) ReadPluginSettings(pluginID string) (map[string]interface{}, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if err := validatePluginID(pluginID); err != nil {
|
||||
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)
|
||||
path := filepath.Join(dir, "settings.json")
|
||||
|
||||
func readPluginSettingsAt(vaultPath, pluginID string) (map[string]interface{}, error) {
|
||||
path := filepath.Join(vaultPath, ".verstak", "plugin-settings", pluginID, "settings.json")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
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.
|
||||
func (s *Storage) WritePluginSettings(pluginID string, data map[string]interface{}) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := validatePluginID(pluginID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.withOpenVault(func(vaultPath string) error {
|
||||
return writePluginSettingsAt(vaultPath, pluginID, data)
|
||||
})
|
||||
}
|
||||
|
||||
dir := s.vault.GetPluginSettingsPath(pluginID)
|
||||
path := filepath.Join(dir, "settings.json")
|
||||
|
||||
func writePluginSettingsAt(vaultPath, pluginID string, data map[string]interface{}) error {
|
||||
path := filepath.Join(vaultPath, ".verstak", "plugin-settings", pluginID, "settings.json")
|
||||
encoded, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
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.
|
||||
func (s *Storage) WritePluginSetting(pluginID, key string, value interface{}) error {
|
||||
settings, err := s.ReadPluginSettings(pluginID)
|
||||
if err != nil {
|
||||
return s.UpdatePluginSettings(pluginID, func(settings map[string]interface{}) error {
|
||||
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
|
||||
}
|
||||
settings[key] = value
|
||||
return s.WritePluginSettings(pluginID, settings)
|
||||
return s.withOpenVault(func(vaultPath string) error {
|
||||
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 ────────────────────────────────────────
|
||||
@@ -193,6 +245,204 @@ func (s *Storage) WritePluginDataJSON(pluginID, name string, data map[string]int
|
||||
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 ───────────────────────────────────────
|
||||
|
||||
// ReadPluginCacheJSON reads a named JSON cache file for a plugin.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,23 @@ func (v *Vault) GetVaultPath() string {
|
||||
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.
|
||||
func (v *Vault) GetVaultMeta() *VaultMeta {
|
||||
v.mu.RLock()
|
||||
|
||||
@@ -40,6 +40,7 @@ const (
|
||||
|
||||
// Workspace is a physical top-level workspace folder.
|
||||
type Workspace struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
RootPath string `json:"rootPath"`
|
||||
}
|
||||
@@ -66,6 +67,7 @@ type WorkspaceTemplate struct {
|
||||
// Metadata stores semantic workspace metadata that is not the source of truth
|
||||
// for whether the workspace exists.
|
||||
type Metadata struct {
|
||||
WorkspaceID string `json:"workspaceId,omitempty"`
|
||||
WorkspaceName string `json:"workspaceName"`
|
||||
CreatedFromTemplate *TemplateSnapshot `json:"createdFromTemplate,omitempty"`
|
||||
Features map[string]bool `json:"features,omitempty"`
|
||||
@@ -74,6 +76,12 @@ type Metadata struct {
|
||||
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.
|
||||
type MetadataPatch struct {
|
||||
Features map[string]bool `json:"features,omitempty"`
|
||||
@@ -82,12 +90,20 @@ type MetadataPatch struct {
|
||||
|
||||
// TrashResult describes a workspace moved into the internal trash area.
|
||||
type TrashResult struct {
|
||||
WorkspaceID string `json:"workspaceId"`
|
||||
OriginalPath string `json:"originalPath"`
|
||||
TrashPath string `json:"trashPath"`
|
||||
TrashID string `json:"trashId"`
|
||||
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.
|
||||
// Path is deliberately not serialized; workspaceRootPath is derived from Name/ID.
|
||||
type WorkspaceNode struct {
|
||||
@@ -301,7 +317,11 @@ func (m *Manager) ListWorkspaces() ([]Workspace, error) {
|
||||
if !entry.IsDir() {
|
||||
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 {
|
||||
return strings.ToLower(workspaces[i].Name) < strings.ToLower(workspaces[j].Name)
|
||||
@@ -309,6 +329,84 @@ func (m *Manager) ListWorkspaces() ([]Workspace, error) {
|
||||
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.
|
||||
func (m *Manager) CreateWorkspace(name, templateID string) (Workspace, error) {
|
||||
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 {
|
||||
return Workspace{}, err
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
meta := Metadata{
|
||||
WorkspaceID: workspaceID,
|
||||
WorkspaceName: name,
|
||||
CreatedFromTemplate: &TemplateSnapshot{
|
||||
TemplateID: template.ID,
|
||||
@@ -362,7 +465,82 @@ func (m *Manager) CreateWorkspace(name, templateID string) (Workspace, error) {
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -456,6 +634,10 @@ func (m *Manager) TrashWorkspace(name string) (TrashResult, error) {
|
||||
if err := ensureExistingWorkspaceDir(full, name); err != nil {
|
||||
return TrashResult{}, err
|
||||
}
|
||||
workspaceID, err := ensureWorkspaceIdentity(full)
|
||||
if err != nil {
|
||||
return TrashResult{}, err
|
||||
}
|
||||
|
||||
deletedAt := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
trashID := time.Now().UTC().Format("20060102T150405.000000000Z") + "-" + uuid.NewString()
|
||||
@@ -468,7 +650,7 @@ func (m *Manager) TrashWorkspace(name string) (TrashResult, error) {
|
||||
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{
|
||||
"originalPath": name,
|
||||
"trashPath": trashRel,
|
||||
@@ -500,6 +682,83 @@ func (m *Manager) TrashWorkspace(name string) (TrashResult, error) {
|
||||
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.
|
||||
func (m *Manager) GetWorkspaceMetadata(name string) (Metadata, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
|
||||
@@ -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) {
|
||||
vaultDir := newVaultDir(t)
|
||||
mustMkdir(t, filepath.Join(vaultDir, "Existing"))
|
||||
|
||||
@@ -243,7 +243,7 @@ func main() {
|
||||
}
|
||||
|
||||
// ─── Wails App ───────────────────────────────────────────
|
||||
err = wails.Run(&options.App{
|
||||
appOptions := &options.App{
|
||||
Title: "Verstak",
|
||||
Width: 1200,
|
||||
Height: 800,
|
||||
@@ -257,7 +257,8 @@ func main() {
|
||||
Bind: []interface{}{
|
||||
app,
|
||||
},
|
||||
})
|
||||
}
|
||||
err = wails.Run(appOptions)
|
||||
|
||||
if err != nil {
|
||||
log.Fatalf("Error: %v", err)
|
||||
|
||||
@@ -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.
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env sh
|
||||
exec /opt/verstak/verstak-desktop "$@"
|
||||
Executable
+13
@@ -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" "$@"
|
||||
@@ -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
|
||||
@@ -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 |
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
setlocal
|
||||
set "ROOT=%~dp0"
|
||||
|
||||
start "" "%ROOT%verstak-desktop.exe" %*
|
||||
Executable
+31
@@ -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"
|
||||
Executable
+75
@@ -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."
|
||||
Executable
+118
@@ -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"
|
||||
Executable
+44
@@ -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"
|
||||
Executable
+35
@@ -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"
|
||||
Executable
+81
@@ -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
|
||||
Executable
+26
@@ -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"
|
||||
Executable
+21
@@ -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"
|
||||
Executable
+41
@@ -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"
|
||||
Executable
+100
@@ -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"
|
||||
Reference in New Issue
Block a user