Compare commits

..

No commits in common. "main" and "v0.1.0-alpha.1" have entirely different histories.

72 changed files with 653 additions and 3838 deletions

1
.gitignore vendored
View File

@ -2,7 +2,6 @@
frontend/node_modules/ frontend/node_modules/
frontend/dist/ frontend/dist/
build/ build/
verstak-desktop-res.syso
smoke-platform smoke-platform
plugins/ plugins/
vendor/ vendor/

433
README.md
View File

@ -1,341 +1,208 @@
<div align="center">
<img src="packaging/linux/verstak.svg" width="112" alt="Verstak logo">
# Verstak # Verstak
### Keep the context of your work together — locally. 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.
Files, notes, links, browser captures, activity and work history > **Alpha software.** Use a disposable vault while evaluating it. APIs, storage
in one extensible local-first workspace. > formats and packaging can change before the first stable release.
**English** · [Русский](README.ru.md) ## Components
[![Release](https://img.shields.io/github/v/release/mirivlad/verstak?include_prereleases\&label=release)](https://github.com/mirivlad/verstak/releases) The public alpha is split into small repositories. Keep their `main` branches
![Status](https://img.shields.io/badge/status-alpha-orange) in the same release line when building from source.
![Platforms](https://img.shields.io/badge/platform-Linux%20%7C%20Windows-blue)
[![License](https://img.shields.io/github/license/mirivlad/verstak)](LICENSE)
[Download](https://github.com/mirivlad/verstak/releases/latest) · | Component | Repository | What it is for |
[Documentation](https://github.com/mirivlad/verstak-docs) · | --- | --- | --- |
[Report an issue](https://github.com/mirivlad/verstak/issues) | 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. |
</div> No server account or browser extension is needed for a local desktop vault.
> [!WARNING] ## Build the desktop and official plugins
> Verstak is currently **alpha software**. APIs, storage formats and packaging may change before the first stable release. Use a test vault and keep backups of important data.
## What is Verstak? 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.
Verstak is a local-first workbench that keeps the context around your work in one place. Clone the repositories as siblings. The directory names below are intentional:
the desktop helper finds the official-plugin checkout at
A **workspace** can represent almost anything: `../verstak-official-plugins`.
* a software project;
* a client or customer;
* a server or infrastructure site;
* a device under repair;
* an article or research topic;
* a training course;
* a personal long-term project.
Normally, information about such work is scattered across directories, notes, browser tabs, task managers, password managers, terminal history and memory.
Verstak brings it together in a local **vault** that remains under your control.
No account, cloud service or sync server is required for local use.
## Verstak in use
| Overview: return to a workspace and see its recent work, captures and items that need attention. | Files: manage ordinary folders and documents inside a workspace. |
| --- | --- |
| ![Verstak Overview in a test vault](docs/screenshots/overview.png) | ![Verstak workspace files in a test vault](docs/screenshots/workspace-files.png) |
| Notes: keep Markdown notes next to the workspace they describe. | Journal: record a completed session and retain its context. |
| --- | --- |
| ![Verstak notes in a test vault](docs/screenshots/notes.png) | ![Verstak journal in a test vault](docs/screenshots/journal.png) |
## Main features
| Feature | Description |
| ------------------------ | -------------------------------------------------------------------------------------------- |
| **Workspaces** | Organize files, notes and activity around durable projects, clients and other areas of work. |
| **Files** | Browse and manage ordinary files stored inside your vault. |
| **Notes** | Create Markdown notes, overview pages and links between related information. |
| **Overview** | Quickly return to recent work and see what may need attention. |
| **Browser Inbox** | Send pages, links, selections and files from a browser into Verstak. |
| **Activity and Journal** | Reconstruct work sessions and turn selected activity into journal entries. |
| **Todo** | Keep optional task lists inside individual workspaces and across the whole vault. |
| **Search** | Search across notes, files and supported plugin data. |
| **Trash** | Restore deleted items or remove them permanently from one central location. |
| **Secrets** | Keep credentials and access information connected to the relevant workspace. |
| **Templates** | Create repeatable workspace structures for common types of work. |
| **Plugins** | Add, replace or disable tools without turning Verstak into one rigid monolithic application. |
| **Optional sync** | Synchronize vaults between devices using a self-hosted Verstak Sync Server. |
## Download and install
Download the latest build from the
[GitHub Releases page](https://github.com/mirivlad/verstak/releases/latest).
Release packages already include the matching official plugins.
| System | Download | Installation |
| --------------------------------- | ----------------------------------------- | --------------------------------------------- |
| Debian 13 / Ubuntu 24.04 or newer | `verstak_<version>_amd64.deb` | Install through APT. |
| Other x86_64 Linux distributions | `verstak-linux-x86_64-<version>.AppImage` | Make the file executable and run it. |
| Windows 10/11 x64 | `verstak-windows-amd64-<version>.zip` | Extract the archive and launch `Verstak.cmd`. |
### Debian and Ubuntu
```bash
sudo apt install ./verstak_*_amd64.deb
```
Launch Verstak from the application menu or run:
```bash
verstak
```
### AppImage
```bash
chmod +x verstak-linux-x86_64-*.AppImage
./verstak-linux-x86_64-*.AppImage
```
On a system without FUSE support:
```bash
APPIMAGE_EXTRACT_AND_RUN=1 ./verstak-linux-x86_64-*.AppImage
```
### Windows portable version
1. Download `verstak-windows-amd64-<version>.zip`.
2. Extract it to a local directory.
3. Run `Verstak.cmd`.
Do not run Verstak directly from inside the ZIP archive or from a network share.
Verstak uses the Microsoft WebView2 Runtime. It is already included with Windows 11 and most current Windows 10 installations. If Verstak does not start, install the
[Microsoft WebView2 Runtime x64](https://go.microsoft.com/fwlink/p/?LinkId=2124701).
## Background and tray mode
Closing the main window keeps Verstak running in the system tray. Use **Show Verstak** to bring it back and **Quit** to exit the application completely. This lets scheduled Todo reminders continue while the window is hidden.
### Verify a download
Each release includes a `SHA256SUMS` file.
On Linux:
```bash
sha256sum -c SHA256SUMS --ignore-missing
```
## First start
### 1. Create or open a vault
A **vault** is the root directory where Verstak stores your work.
Choose a writable local directory. For example:
```text
Documents/
└── Verstak/
```
Your files and notes remain ordinary files that can be accessed without Verstak.
### 2. Create your first workspace
Inside the vault, create a workspace for a project, client, server, device or another area of work.
For example:
```text
Home server
Customer Alpha
Verstak development
3D printer
Training course
```
### 3. Add context
Open the workspace and add the information needed to return to it later:
* notes and decisions;
* documents and source files;
* useful links;
* access information;
* journal entries;
* tasks;
* materials captured from the browser.
### 4. Return through Overview
The Overview screen shows recent changes, unfinished captures, activity and other useful entry points back into your work.
## How Verstak stores data
Verstak follows several principles:
* your vault is local and works without an account or internet connection;
* files and notes remain readable outside the application;
* synchronization is optional and is not the source of truth;
* the user should understand where data is stored and what happens to it;
* application tools are provided by plugins rather than being permanently embedded into the core.
The desktop application loads plugins from a `plugins/` directory located beside the executable.
## Browser integration
The optional browser extension can send the following into Browser Inbox:
* the current page;
* selected text;
* a link;
* a downloaded or selected file.
Repository:
[mirivlad/verstak-browser-extension](https://github.com/mirivlad/verstak-browser-extension)
To connect the extension:
1. Open Browser Inbox settings in Verstak.
2. Copy the Receiver URL and Pairing Token.
3. Paste them into the extension settings.
4. Save the settings and send a test page.
Passive domain activity tracking is disabled by default. When enabled, it sends bounded time totals by normalized domain. It does not send page contents, keystrokes or full browsing history.
## Optional synchronization
Local use does not require a server.
For synchronization between devices, deploy the optional self-hosted
[Verstak Sync Server](https://github.com/mirivlad/verstak-sync-server).
Each vault is connected separately. The local vault remains the primary copy of your data.
## Build from source
### Requirements
* Go 1.24 or newer;
* Node.js 20 or newer with npm;
* Python 3;
* Git;
* Wails v2 build dependencies;
* WebKitGTK development packages on Linux.
* Ayatana AppIndicator development files on Linux (`sudo apt install libayatana-appindicator3-dev`).
See the
[Wails installation documentation](https://wails.io/docs/gettingstarted/installation/)
for distribution-specific dependencies.
### Clone the repositories
The desktop, SDK and official plugin repositories must be cloned as sibling directories:
```text ```text
verstak-workspace/ verstak-workspace/
├── verstak/ ├── verstak/
├── verstak-sdk/ ├── verstak-sdk/
└── verstak-official-plugins/ ├── verstak-official-plugins/
└── verstak-browser-extension/ # optional for browser integration
``` ```
```bash ```bash
mkdir verstak-workspace git clone https://github.com/mirivlad/verstak.git verstak
cd verstak-workspace 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.git git clone https://github.com/mirivlad/verstak-browser-extension.git verstak-browser-extension
git clone https://github.com/mirivlad/verstak-sdk.git
git clone https://github.com/mirivlad/verstak-official-plugins.git
```
### Build
```bash
cd verstak-sdk
./scripts/build.sh
cd ../verstak-official-plugins
./scripts/build.sh
cd verstak-sdk && ./scripts/build.sh
cd ../verstak-official-plugins && ./scripts/build.sh
cd ../verstak cd ../verstak
./scripts/install-dev-plugins.sh ./scripts/install-dev-plugins.sh
./scripts/build.sh ./scripts/build.sh
``` ```
The resulting application will be located at: `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 ```bash
./build/bin/verstak-desktop ./build/bin/verstak-desktop
``` ```
Run it with additional diagnostics: 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 ```bash
./build/bin/verstak-desktop --debug ./build/bin/verstak-desktop --debug
``` ```
## Build release packages locally ## Portable test artifacts
These commands create local artifacts in `release/`. They do not publish a GitHub Release. These commands make local alpha artifacts in `release/`. They do not create a
GitHub Release.
### Debian package
```bash ```bash
# Debian 13 / Ubuntu 24.04 or later. APT installs WebKitGTK dependencies.
./scripts/package-deb.sh v0.1.0-alpha.1 ./scripts/package-deb.sh v0.1.0-alpha.1
```
### AppImage # Portable Linux x86_64 AppImage with bundled WebKitGTK runtime and plugins.
```bash
./scripts/package-appimage.sh v0.1.0-alpha.1 ./scripts/package-appimage.sh v0.1.0-alpha.1
``` ```
### Windows portable ZIP Install the Debian package with `sudo apt install ./release/verstak_*.deb`,
then launch `verstak`. Run the AppImage directly after making it executable:
The Windows archive can be cross-compiled on Linux using MinGW: ```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 ```bash
sudo apt install gcc-mingw-w64-x86-64 zip sudo apt install gcc-mingw-w64-x86-64 zip
./scripts/package-windows-portable.sh v0.1.0-alpha.1 ./scripts/package-windows-portable.sh v0.1.0-alpha.1
``` ```
## Project repositories 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.
| Repository | Purpose | Each format is listed in `release/SHA256SUMS` after packaging.
| ---------------------------------------------------------------------------------- | ------------------------------------------------------ |
| [verstak](https://github.com/mirivlad/verstak) | Desktop application, core platform and UI shell |
| [verstak-official-plugins](https://github.com/mirivlad/verstak-official-plugins) | Official Files, Notes, Journal, Todo and other plugins |
| [verstak-sdk](https://github.com/mirivlad/verstak-sdk) | TypeScript SDK, schemas and plugin contracts |
| [verstak-browser-extension](https://github.com/mirivlad/verstak-browser-extension) | Browser capture and optional activity integration |
| [verstak-sync-server](https://github.com/mirivlad/verstak-sync-server) | Optional self-hosted synchronization server |
| [verstak-docs](https://github.com/mirivlad/verstak-docs) | Product and platform architecture documentation |
Compatible components should be built from the same release line. ## First local vault
## Development status 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.
Verstak is under active development. ## Browser extension
The current alpha is intended for testing, feedback and experimentation. Backward compatibility is not guaranteed until the first stable release. 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).
Bug reports and feature discussions are welcome in To connect it to the desktop application:
[GitHub Issues](https://github.com/mirivlad/verstak/issues).
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 ## License
Copyright © 2026 Verstak contributors. Copyright © 2026 Verstak contributors. Licensed under
[GNU AGPLv3 or later](LICENSE).
Verstak is licensed under the
[GNU Affero General Public License v3.0 or later](LICENSE).

View File

@ -1,340 +0,0 @@
<div align="center">
<img src="packaging/linux/verstak.svg" width="112" alt="Логотип Верстака">
# Верстак
### Рабочий контекст остаётся рядом — и хранится локально.
Файлы, заметки, ссылки, материалы из браузера, активность и история работы
в одном расширяемом рабочем пространстве.
[English](README.md) · **Русский**
[![Релиз](https://img.shields.io/github/v/release/mirivlad/verstak?include_prereleases\&label=release)](https://github.com/mirivlad/verstak/releases)
![Статус](https://img.shields.io/badge/status-alpha-orange)
![Платформы](https://img.shields.io/badge/platform-Linux%20%7C%20Windows-blue)
[![Лицензия](https://img.shields.io/github/license/mirivlad/verstak)](LICENSE)
[Скачать](https://github.com/mirivlad/verstak/releases/latest) ·
[Документация](https://github.com/mirivlad/verstak-docs) ·
[Сообщить о проблеме](https://github.com/mirivlad/verstak/issues)
</div>
> [!WARNING]
> Верстак пока находится на стадии **alpha**. До первого стабильного выпуска могут меняться API, форматы хранения и состав пакетов. Для знакомства используйте тестовый vault и сохраняйте резервные копии важных данных.
## Что такое Верстак
Верстак — это local-first рабочая среда, которая удерживает в одном месте контекст вокруг ваших дел.
**Делом** может быть что угодно:
* программный проект;
* клиент или заказчик;
* сервер или площадка;
* ремонтируемое устройство;
* статья или исследование;
* учебный курс;
* личный долгосрочный проект.
Обычно информация о таком деле расползается по папкам, заметкам, браузерным вкладкам, менеджерам задач, хранилищам паролей, истории терминала и памяти.
Верстак собирает её в один локальный **vault**, который остаётся под вашим контролем.
Для локальной работы не нужны учётная запись, облачный сервис или собственный сервер.
## Верстак в работе
| Обзор: возвращает к делу и показывает недавнюю работу, входящие и то, что требует внимания. | Файлы: обычные папки и документы прямо внутри дела. |
| --- | --- |
| ![Экран «Обзор» Верстака в тестовом vault](docs/screenshots/overview.png) | ![Файлы дела в тестовом vault Верстака](docs/screenshots/workspace-files.png) |
| Заметки: Markdown-заметки остаются рядом с делом, которое они описывают. | Журнал: фиксирует завершённую работу и её контекст. |
| --- | --- |
| ![Заметки Верстака в тестовом vault](docs/screenshots/notes.png) | ![Журнал Верстака в тестовом vault](docs/screenshots/journal.png) |
## Основные возможности
| Возможность | Что она даёт |
| ------------------------ | ---------------------------------------------------------------------------------------------- |
| **Дела** | Объединяют файлы, заметки и историю работы вокруг проектов, клиентов и других направлений. |
| **Файлы** | Позволяют работать с обычными файлами, расположенными внутри vault. |
| **Заметки** | Markdown-заметки, обзорные страницы и связи между материалами. |
| **Обзор** | Помогает быстро вернуться к недавней работе и увидеть то, что требует внимания. |
| **Входящие из браузера** | Принимают страницы, ссылки, выделенный текст и файлы из браузера. |
| **Активность и журнал** | Помогают восстановить рабочие сессии и превратить выбранную активность в записи журнала. |
| **Задачи** | Необязательные списки задач внутри отдельных дел и во всём vault. |
| **Поиск** | Ищет по заметкам, файлам и данным поддерживаемых плагинов. |
| **Корзина** | Позволяет восстановить удалённое или окончательно удалить его из одного общего раздела. |
| **Секреты** | Хранит доступы и учётные данные рядом с делом, к которому они относятся. |
| **Шаблоны** | Создают повторяемую структуру для однотипных дел. |
| **Плагины** | Позволяют добавлять, заменять и отключать инструменты, не превращая Верстак в жёсткий монолит. |
| **Синхронизация** | Необязательно синхронизирует vault между устройствами через собственный сервер. |
## Скачивание и установка
Готовые сборки находятся на странице
[GitHub Releases](https://github.com/mirivlad/verstak/releases/latest).
В релизные пакеты уже входят совместимые версии официальных плагинов. Устанавливать их отдельно не требуется.
| Система | Какой файл скачать | Как установить |
| -------------------------------- | ---------------------------------------- | -------------------------------------------- |
| Debian 13 / Ubuntu 24.04 и новее | `verstak_<версия>_amd64.deb` | Установить через APT. |
| Другие дистрибутивы Linux x86_64 | `verstak-linux-x86_64-<версия>.AppImage` | Сделать файл исполняемым и запустить. |
| Windows 10/11 x64 | `verstak-windows-amd64-<версия>.zip` | Распаковать архив и запустить `Verstak.cmd`. |
### Debian и Ubuntu
```bash
sudo apt install ./verstak_*_amd64.deb
```
После установки запустите Верстак из меню приложений или командой:
```bash
verstak
```
### AppImage
```bash
chmod +x verstak-linux-x86_64-*.AppImage
./verstak-linux-x86_64-*.AppImage
```
Если в системе нет поддержки FUSE:
```bash
APPIMAGE_EXTRACT_AND_RUN=1 ./verstak-linux-x86_64-*.AppImage
```
### Портативная версия для Windows
1. Скачайте `verstak-windows-amd64-<версия>.zip`.
2. Распакуйте архив в локальную папку.
3. Запустите `Verstak.cmd`.
Не запускайте Верстак прямо из ZIP-архива или с сетевого диска.
Верстак использует Microsoft WebView2 Runtime. Он уже присутствует в Windows 11 и большинстве актуальных установок Windows 10. Если приложение не запускается, установите
[Microsoft WebView2 Runtime x64](https://go.microsoft.com/fwlink/p/?LinkId=2124701).
## Фоновая работа и трей
Закрытие главного окна не завершает Верстак: приложение остаётся в системном трее. Выберите **Show Verstak**, чтобы вернуть окно, или **Quit**, чтобы полностью завершить приложение. Благодаря этому напоминания Todo продолжают работать, пока окно скрыто.
### Проверка скачанного файла
В каждый релиз входит файл `SHA256SUMS`.
Проверка в Linux:
```bash
sha256sum -c SHA256SUMS --ignore-missing
```
## Первый запуск
### 1. Создайте или откройте vault
**Vault** — это корневая папка, в которой Верстак хранит ваши дела.
Выберите доступный для записи локальный каталог. Например:
```text
Документы/
└── Верстак/
```
Файлы и заметки остаются обычными файлами. Их можно открыть и без Верстака.
### 2. Создайте первое дело
Внутри vault создайте дело для проекта, клиента, сервера, устройства или другого направления работы.
Например:
```text
Домашний сервер
Заказчик Альфа
Разработка Верстака
3D-принтер
Учебный курс
```
### 3. Соберите контекст
Добавьте в дело всё, что поможет вернуться к нему через неделю, месяц или год:
* заметки и принятые решения;
* документы и исходные файлы;
* полезные ссылки;
* данные для доступа;
* записи журнала;
* задачи;
* материалы, отправленные из браузера.
### 4. Возвращайтесь через Обзор
Экран «Обзор» показывает недавние изменения, необработанные входящие, активность и другие подходящие точки для продолжения работы.
## Как Верстак хранит данные
Верстак придерживается нескольких принципов:
* vault хранится локально и доступен без учётной записи и интернета;
* файлы и заметки остаются читаемыми вне приложения;
* синхронизация является дополнением, а не источником истины;
* пользователь должен понимать, где находятся его данные и что с ними происходит;
* инструменты реализуются плагинами, а не навсегда встраиваются в ядро.
Настольное приложение загружает плагины из каталога `plugins/`, расположенного рядом с исполняемым файлом.
## Интеграция с браузером
Необязательное расширение позволяет отправлять во «Входящие из браузера»:
* текущую страницу;
* выделенный текст;
* ссылку;
* выбранный или скачанный файл.
Репозиторий расширения:
[mirivlad/verstak-browser-extension](https://github.com/mirivlad/verstak-browser-extension)
Чтобы подключить расширение:
1. Откройте настройки «Входящих из браузера» в Верстаке.
2. Скопируйте адрес приёмника и токен сопряжения.
3. Вставьте их в настройки расширения.
4. Сохраните настройки и отправьте тестовую страницу.
Пассивный учёт активности по доменам по умолчанию отключён. После включения расширение передаёт только ограниченные интервалы времени для нормализованных доменов. Оно не отправляет содержимое страниц, нажатия клавиш или полную историю посещений.
## Необязательная синхронизация
Для локального использования сервер не нужен.
Для синхронизации между устройствами можно развернуть собственный
[Verstak Sync Server](https://github.com/mirivlad/verstak-sync-server).
Каждый vault подключается отдельно. Основной копией данных остаётся локальный vault.
## Сборка из исходного кода
### Требования
* Go 1.24 или новее;
* Node.js 20 или новее и npm;
* Python 3;
* Git;
* зависимости для сборки Wails v2;
* пакеты разработки WebKitGTK в Linux.
* файлы разработки Ayatana AppIndicator в Linux (`sudo apt install libayatana-appindicator3-dev`).
Зависимости для конкретного дистрибутива перечислены в
[документации Wails](https://wails.io/docs/gettingstarted/installation/).
### Клонирование репозиториев
Desktop, SDK и официальные плагины должны находиться в соседних каталогах:
```text
verstak-workspace/
├── verstak/
├── verstak-sdk/
└── verstak-official-plugins/
```
```bash
mkdir verstak-workspace
cd verstak-workspace
git clone https://github.com/mirivlad/verstak.git
git clone https://github.com/mirivlad/verstak-sdk.git
git clone https://github.com/mirivlad/verstak-official-plugins.git
```
### Сборка
```bash
cd verstak-sdk
./scripts/build.sh
cd ../verstak-official-plugins
./scripts/build.sh
cd ../verstak
./scripts/install-dev-plugins.sh
./scripts/build.sh
```
Готовое приложение появится по адресу:
```bash
./build/bin/verstak-desktop
```
Запуск с дополнительной диагностикой:
```bash
./build/bin/verstak-desktop --debug
```
## Локальная сборка релизных пакетов
Следующие команды создают пакеты в каталоге `release/`, но не публикуют GitHub Release.
### Пакет Debian
```bash
./scripts/package-deb.sh v0.1.0-alpha.1
```
### AppImage
```bash
./scripts/package-appimage.sh v0.1.0-alpha.1
```
### Портативный ZIP для Windows
Windows-версию можно собрать в Linux при помощи MinGW:
```bash
sudo apt install gcc-mingw-w64-x86-64 zip
./scripts/package-windows-portable.sh v0.1.0-alpha.1
```
## Репозитории проекта
| Репозиторий | Назначение |
| ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| [verstak](https://github.com/mirivlad/verstak) | Настольное приложение, ядро платформы и оболочка интерфейса |
| [verstak-official-plugins](https://github.com/mirivlad/verstak-official-plugins) | Официальные плагины файлов, заметок, журнала, задач и других инструментов |
| [verstak-sdk](https://github.com/mirivlad/verstak-sdk) | TypeScript SDK, схемы и контракты плагинов |
| [verstak-browser-extension](https://github.com/mirivlad/verstak-browser-extension) | Отправка материалов из браузера и необязательный учёт активности |
| [verstak-sync-server](https://github.com/mirivlad/verstak-sync-server) | Необязательный собственный сервер синхронизации |
| [verstak-docs](https://github.com/mirivlad/verstak-docs) | Продуктовая и архитектурная документация |
Совместимые компоненты следует собирать из одной релизной линии.
## Состояние разработки
Верстак активно развивается.
Текущая alpha предназначена для тестирования, обратной связи и экспериментов. До первого стабильного выпуска обратная совместимость не гарантируется.
Сообщения об ошибках и предложения принимаются в
[GitHub Issues](https://github.com/mirivlad/verstak/issues).
## Лицензия
Copyright © 2026 Verstak contributors.
Верстак распространяется на условиях
[GNU Affero General Public License v3.0 или новее](LICENSE).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

View File

@ -1,341 +0,0 @@
# Native Notifications, Tray, and Public README Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Deliver native Todo reminders and Windows/Linux tray operation, then publish updated alpha artifacts and public README documentation with real application screenshots.
**Architecture:** Desktop core stores and delivers plugin-owned notification schedules and controls the tray lifecycle. Todo uses the public plugin API to replace its desired reminders. A small core tray adapter keeps the Wails process alive after window close. Documentation uses the supplied English and Russian README sources plus screenshots from an actual test vault.
**Tech Stack:** Go 1.24, Wails v2.12 runtime notifications, `getlantern/systray`, plain JavaScript plugin API, Node smoke tests, Playwright, bash packaging.
## Global constraints
- Support Windows and Linux only; no background daemon after an explicit Quit.
- Closing a window hides it; only tray **Quit** ends the process.
- `verstak.todo` requires `verstak/core/notifications/v1` and `notifications.schedule`.
- Plugins cannot receive Wails runtime access.
- Keep Wails generated bindings out of commits.
- Commit each independent change and immediately push it to GitHub and `mirror`.
---
### Task 1: Persisted notification scheduler
**Files:**
- Create: `internal/core/notifications/manager.go`
- Create: `internal/core/notifications/store.go`
- Create: `internal/core/notifications/manager_test.go`
**Interfaces:**
- `type Request struct { ID, DueAt, Title, Body string }`
- `type Item struct { PluginID, ID, DueAt, Title, Body, SentForDueAt string }`
- `type Sender interface { Send(context.Context, Item) error }`
- `Manager.Replace(pluginID string, requests []Request) error`, `Clear(pluginID string) error`, `Start(context.Context)`, and `Stop()`.
- Persistent state: `<vault>/.verstak/notifications/schedules.json`.
- [ ] **Step 1: Add failing scheduler tests**
```go
func TestTickRetriesFailedSendAndAcknowledgesOneDelivery(t *testing.T) {
sender := &fakeSender{err: errors.New("unavailable")}
m := newTestManager(t, sender, time.Date(2026, 7, 14, 12, 0, 0, 0, time.UTC))
requireNoError(t, m.Replace("verstak.todo", []Request{{ID: "todo-1", DueAt: "2026-07-14T11:00:00Z", Title: "Reminder"}}))
m.Tick(context.Background())
if sender.calls != 1 || m.Items()[0].SentForDueAt != "" { t.Fatal("failed send was acknowledged") }
sender.err = nil
m.Tick(context.Background())
m.Tick(context.Background())
if sender.calls != 2 || m.Items()[0].SentForDueAt != "2026-07-14T11:00:00Z" { t.Fatal("delivery was not exactly once") }
}
```
Also test stale records are removed by `Replace`, changing `DueAt` rearms a record, state reloads from disk, and a past-due unsent item is sent once.
- [ ] **Step 2: Confirm red**
Run: `GOCACHE=/tmp/verstak-go-cache go test ./internal/core/notifications -count=1`
Expected: fails because the package does not exist.
- [ ] **Step 3: Implement the minimal manager**
Use one mutex, injected clock/sender, an atomic same-directory temp-file rename, and a 30-second ticker. Preserve `SentForDueAt` only if the requested due time is unchanged. Validate non-empty unique IDs, RFC3339 UTC dates, at most 500 requests, and bounded text. Mark sent only after `Sender.Send` succeeds.
- [ ] **Step 4: Verify and commit**
Run:
```bash
gofmt -w internal/core/notifications/*.go
GOCACHE=/tmp/verstak-go-cache go test ./internal/core/notifications ./internal/core/... -count=1
git diff --check
```
Commit/push:
```bash
git add internal/core/notifications
git commit -m "feat: add persisted notification scheduler"
git push https://github.com/mirivlad/verstak.git main
git push mirror main
```
### Task 2: Permission-gated core/plugin notification API
**Files:**
- Modify: `main.go`
- Modify: `internal/api/app.go`
- Modify: `internal/api/app_test.go`
- Modify: `internal/core/permissions/registry.go`
- Modify: `frontend/src/lib/plugin-host/VerstakPluginAPI.js`
- Modify: `frontend/tests/plugin-api-contributions-test.mjs`
**Interfaces:**
- Core capability: `verstak/core/notifications/v1`.
- Permission: `notifications.schedule`, non-dangerous.
- Bound methods: `ReplacePluginNotifications(pluginID string, items []notifications.Request) string` and `ClearPluginNotifications(pluginID string) string`.
- Plugin methods: `api.notifications.replace(items)` and `api.notifications.clear()`.
- [ ] **Step 1: Add failing backend and host-API tests**
```go
func TestReplacePluginNotificationsRequiresCapabilityAndPermission(t *testing.T) {
app, scheduler := newNotificationTestApp(t, manifestWithoutNotificationPermission)
if got := app.ReplacePluginNotifications("example", []notifications.Request{{ID: "r", DueAt: "2026-07-14T10:00:00Z", Title: "R"}}); got == "" {
t.Fatal("missing permission was accepted")
}
if scheduler.replaceCalls != 0 { t.Fatalf("replace calls = %d", scheduler.replaceCalls) }
}
```
The JS test mocks `App.ReplacePluginNotifications`, calls `api.notifications.replace`, and asserts both plugin ID forwarding and a namespaced rejection for a non-empty backend error.
- [ ] **Step 2: Confirm red**
```bash
GOCACHE=/tmp/verstak-go-cache go test ./internal/api -run TestReplacePluginNotificationsRequiresCapabilityAndPermission -count=1
node frontend/tests/plugin-api-contributions-test.mjs
```
Expected: missing API methods.
- [ ] **Step 3: Implement guarded bridge**
Register the capability in `main.go`, construct the scheduler, and guard bound calls with both existing access helpers before forwarding. Add `notifications.schedule` to the registry. Add the public host methods using `callBackendErrorString`; do not expose `window.runtime` to plugins.
- [ ] **Step 4: Verify and commit**
```bash
gofmt -w main.go internal/api/app.go internal/api/app_test.go internal/core/permissions/registry.go
GOCACHE=/tmp/verstak-go-cache go test ./internal/api -count=1
node frontend/tests/plugin-api-contributions-test.mjs
git diff --check
```
```bash
git add main.go internal/api/app.go internal/api/app_test.go internal/core/permissions/registry.go frontend/src/lib/plugin-host/VerstakPluginAPI.js frontend/tests/plugin-api-contributions-test.mjs
git commit -m "feat: expose scheduled notifications to plugins"
git push https://github.com/mirivlad/verstak.git main
git push mirror main
```
### Task 3: Todo reminder synchronization and Wails lifecycle
**Files:**
- Modify: `main.go`
- Modify: `internal/api/app.go`
- Modify: `internal/api/app_test.go`
- Modify: `../verstak-official-plugins/plugins/todo/plugin.json`
- Modify: `../verstak-official-plugins/plugins/todo/frontend/src/index.js`
- Modify: `../verstak-official-plugins/plugins/todo/locales/en.json`
- Modify: `../verstak-official-plugins/plugins/todo/locales/ru.json`
- Modify: `../verstak-official-plugins/scripts/smoke-todo-plugin.js`
**Interfaces:**
- `App.DomReady(ctx)` initializes Wails notifications before starting the scheduler.
- `App.Shutdown(ctx)` stops it before Wails cleanup.
- Todo replaces the complete reminder list only after Todo persistence succeeds and after Todo data loads.
- [ ] **Step 1: Add failing lifecycle and Todo smoke tests**
The App test uses function variables for Wails calls and proves initialize → start and stop → cleanup order. The Todo smoke mock records `notifications.replace`; it asserts one open reminder request with the Todo ID and a later empty replacement after completion or deletion.
- [ ] **Step 2: Confirm red**
```bash
GOCACHE=/tmp/verstak-go-cache go test ./internal/api -run 'TestDomReadyInitializesNotificationsBeforeScheduler|TestShutdownStopsNotifications' -count=1
cd ../verstak-official-plugins && node scripts/smoke-todo-plugin.js
```
Expected: lifecycle methods and schedule calls are absent.
- [ ] **Step 3: Implement lifecycle and Todo desired state**
Use `runtime.InitializeNotifications`, `runtime.SendNotification`, and `runtime.CleanupNotifications` only in core. Register `OnDomReady`/ `OnShutdown` in Wails options. Todo maps open valid `reminderAt` values to ISO UTC using `new Date(todo.reminderAt).toISOString()`, localizes title/body, and calls `api.notifications.replace`. A scheduling failure reports status but never rolls back already-saved Todo data.
- [ ] **Step 4: Verify and commit both repositories**
```bash
GOCACHE=/tmp/verstak-go-cache go test ./internal/api -count=1
cd ../verstak-official-plugins && node scripts/smoke-todo-plugin.js && ./scripts/check.sh
```
```bash
cd ../verstak-desktop
git add main.go internal/api/app.go internal/api/app_test.go
git commit -m "feat: start native notifications with desktop app"
git push https://github.com/mirivlad/verstak.git main
git push mirror main
cd ../verstak-official-plugins
git add plugins/todo scripts/smoke-todo-plugin.js
git commit -m "feat: schedule native Todo reminders"
git push https://github.com/mirivlad/verstak-official-plugins.git main
git push mirror main
```
### Task 4: Windows/Linux tray, close policy, and single instance
**Files:**
- Create: `internal/shell/tray/controller.go`
- Create: `internal/shell/tray/controller_test.go`
- Modify: `main.go`
- Modify: `internal/api/app.go`
- Modify: `internal/api/app_test.go`
- Modify: `go.mod`
- Modify: `go.sum`
**Interfaces:**
- `tray.Start(icon []byte, actions Actions) error`, with `Actions.Show` and `Actions.Quit`.
- `App.BeforeClose(ctx) bool` hides and returns true unless `App.Quit()` made shutdown explicit.
- `App.ShowWindow()` reveals the existing window.
- [ ] **Step 1: Add failing close-policy and tray action tests**
```go
func TestBeforeCloseHidesWindowUntilExplicitQuit(t *testing.T) {
app, window := newWindowTestApp(t)
if prevent := app.BeforeClose(context.Background()); !prevent || window.hideCalls != 1 { t.Fatal("ordinary close must hide") }
app.Quit()
if prevent := app.BeforeClose(context.Background()); prevent { t.Fatal("explicit quit was prevented") }
}
```
Use a fake tray adapter to assert exactly two labels, **Show Verstak** and **Quit**, and one callback invocation per click.
- [ ] **Step 2: Confirm red**
```bash
GOCACHE=/tmp/verstak-go-cache go test ./internal/api -run TestBeforeCloseHidesWindowUntilExplicitQuit -count=1
GOCACHE=/tmp/verstak-go-cache go test ./internal/shell/tray -count=1
```
Expected: absent packages/methods.
- [ ] **Step 3: Implement adapter and Wails wiring**
Add `github.com/getlantern/systray@v1.2.2`. The production adapter calls nonblocking `systray.Register`, sets compact source-controlled PNG bytes derived from the existing tracked logo, and starts goroutines for the two click channels. `main.go` registers `OnBeforeClose` and uses `options.SingleInstanceLock` whose second launch calls `app.ShowWindow`. Do not embed ignored Wails-generated files from `build/`.
- [ ] **Step 4: Verify and commit**
```bash
gofmt -w main.go internal/api/app.go internal/api/app_test.go internal/shell/tray/*.go
GOCACHE=/tmp/verstak-go-cache go test ./internal/shell/tray ./internal/api -count=1
./scripts/build.sh
ldd build/bin/verstak-desktop | grep -E 'ayatana-appindicator|appindicator'
git diff --check
```
```bash
git add main.go internal/api/app.go internal/api/app_test.go internal/shell/tray go.mod go.sum
git commit -m "feat: keep desktop app in system tray"
git push https://github.com/mirivlad/verstak.git main
git push mirror main
```
### Task 5: Packaging and public README screenshots
**Files:**
- Modify: `packaging/deb/control`
- Modify: `scripts/build.sh`
- Modify: `scripts/package-appimage.sh`
- Modify: `scripts/test-package-formats.sh`
- Modify: `README.md`
- Create: `README.ru.md`
- Create: `docs/screenshots/overview.png`
- Create: `docs/screenshots/workspace-files-notes.png`
- Create: `docs/screenshots/activity-journal.png`
- [ ] **Step 1: Add failing package-contract checks**
Add assertions for `libayatana-appindicator3-dev` in the Linux build guidance, `libayatana-appindicator3-1` in Debian dependencies, and `ayatana-appindicator` in the AppImage packing verification.
- [ ] **Step 2: Confirm red**
Run: `./scripts/test-package-formats.sh`
Expected: the first tray dependency assertion fails.
- [ ] **Step 3: Implement portable package support**
Declare the Debian runtime dependency. Make the Linux build error name the appindicator development header. Require the dynamically discovered appindicator library to appear in AppDir after the existing `ldd` traversal. Keep the Windows system-WebView2 policy unchanged.
- [ ] **Step 4: Install the supplied public README sources**
Replace this repository's `README.md` with the supplied English source at the workspace root and add the supplied Russian source as `README.ru.md`. Add an image strip after “What is Verstak?” / “Что такое Верстак?” that links to three tracked PNG screenshots and uses matching localized alt text.
- [ ] **Step 5: Produce real screenshots**
Build and run the desktop app against `/home/mirivlad/Nextcloud/Verstak/VerstakVault` on the active X display. Capture:
1. Overview showing recent work and entry points;
2. a populated workspace Files/Notes view;
3. Activity with a suggested session and its Journal review.
Use `scrot` or ImageMagick `import`, crop only surrounding desktop chrome, inspect every PNG visually, and do not add generated mockups. Remove all non-user-safe text from the test vault before capture.
- [ ] **Step 6: Verify documentation and package contracts, commit, push**
```bash
./scripts/test-package-formats.sh
git diff --check
test -s docs/screenshots/overview.png
test -s docs/screenshots/workspace-files-notes.png
test -s docs/screenshots/activity-journal.png
```
```bash
git add packaging/deb/control scripts/build.sh scripts/package-appimage.sh scripts/test-package-formats.sh README.md README.ru.md docs/screenshots
git commit -m "docs: add public README and product screenshots"
git push https://github.com/mirivlad/verstak.git main
git push mirror main
```
### Task 6: Complete verification and GitHub alpha releases
**Files:**
- No source changes expected unless verification exposes a defect.
- [ ] **Step 1: Run all checks**
```bash
GOCACHE=/tmp/verstak-go-cache ./scripts/test.sh
./scripts/check.sh
./scripts/test-package-formats.sh
./scripts/test-build-windows.sh
cd ../verstak-official-plugins && ./scripts/check.sh && ./scripts/test-package-portable.sh && ./scripts/test-publish-github-release.sh
```
- [ ] **Step 2: Build and checksum both alpha releases**
```bash
cd ../verstak-official-plugins && ./scripts/release.sh v0.1.0-alpha.2 && (cd release && sha256sum --check SHA256SUMS)
cd ../verstak-desktop && ./scripts/release.sh v0.1.0-alpha.2 && (cd release && sha256sum --check SHA256SUMS)
```
- [ ] **Step 3: Publish and inspect releases**
```bash
cd ../verstak-official-plugins && ./scripts/publish-github-release.sh v0.1.0-alpha.2
cd ../verstak-desktop && ./scripts/publish-github-release.sh v0.1.0-alpha.2
gh release view v0.1.0-alpha.2 -R mirivlad/verstak-official-plugins
gh release view v0.1.0-alpha.2 -R mirivlad/verstak
```
The final report distinguishes automated verification from the two required real desktop manual checks: tray close/show/quit and a near-future Todo notification while the window is hidden.

View File

@ -1,174 +0,0 @@
# Native Notifications and System Tray Design
**Status:** approved for implementation on 2026-07-14
## Goal
Deliver Todo reminders as native notifications on Windows and Linux, and keep
Verstak running in the system tray when its main window is closed. The feature
must work in the portable Windows archive, Debian package, and AppImage.
## Scope
- The desktop core owns notification delivery, scheduling, and tray lifetime.
- `verstak.todo` owns the Todo-specific reminder policy and text.
- No new official notifications plugin is introduced. A dynamic plugin cannot
call Wails directly, so it cannot be the native-notification transport.
- macOS is out of scope for this alpha. The interfaces remain platform-neutral
where that costs nothing.
## Tray behavior
On Windows and Linux, a tray icon is registered before the Wails event loop
starts. Its menu contains exactly two actions:
1. **Show Verstak** — shows and focuses the existing main window.
2. **Quit** — exits the process deliberately.
Closing the main window with its window-manager close control hides the window
and keeps the process, plugins, local browser receiver, and reminder scheduler
alive. It does not terminate the application. The quit action temporarily
allows the close lifecycle to finish and then exits normally.
The app has a single-instance lock. If a user launches the executable while an
instance is hidden in the tray, the existing instance shows its window instead
of creating a second process.
The implementation uses `github.com/getlantern/systray` through a small
`internal/shell/tray` adapter. It uses `Register`, rather than its blocking
`Run`, so Wails remains the owner of the GUI event loop. A compact PNG derived
from the tracked project logo is encoded in the tray package, so a clean build
does not depend on ignored Wails-generated icon files. The library accepts PNG
icon bytes on both target platforms.
## Notification capability and permission
The core registers the capability:
```
verstak/core/notifications/v1
```
Plugins that use it must both require that capability and declare the
`notifications.schedule` permission. The plugin-host API exposes only two
operations within the calling plugin namespace:
```
api.notifications.replace(items)
api.notifications.clear()
```
`replace` is an atomic desired-state replacement, not an append operation. An
item contains a plugin-local stable `id`, an ISO-8601 UTC `dueAt`, a title, and
a body. The core supplies the plugin ID and rejects calls from disabled,
missing-permission, or undeclared-capability plugins. It also validates empty
IDs, duplicate IDs, invalid timestamps, and unsafe oversized text.
No plugin can send arbitrary immediate native notifications or address another
plugin's schedules in this alpha.
## Scheduler and persistence
`internal/core/notifications` persists one canonical schedule file at:
```
<vault>/.verstak/notifications/schedules.json
```
Each record contains `{pluginId, id, dueAt, title, body, sentForDueAt}`.
The composite `(pluginId, id)` is unique. Replacing an item with the same due
time preserves `sentForDueAt`; changing `dueAt` clears it. Replacing a plugin's
list removes its stale records. This provides deterministic cancellation for
completed, deleted, and rescheduled Todos.
The manager starts after Wails reaches `OnDomReady`, initializes Wails native
notifications, and evaluates the persisted schedule immediately and then at
least every 30 seconds. A sender is injected behind an interface for unit
tests. After a successful delivery, the manager atomically records
`sentForDueAt`. A delivery error leaves the schedule pending and is logged for
a later retry.
An expired record that has not been sent is delivered once after the next app
start. A record already sent for its current due time is never sent again.
Completely quitting Verstak stops the scheduler: no separate daemon or OS
background service is added. Hiding the window in the tray does **not** stop it.
The core calls `CleanupNotifications` during shutdown, including on Linux where
it releases the D-Bus connection.
## Todo behavior
`verstak.todo` adds the core notifications capability to `requires` and adds
the `notifications.schedule` permission. After every successful Todo storage
write, it derives the complete desired reminder list:
- include only open Todos with a valid `reminderAt`;
- use the Todo ID as the stable notification ID;
- convert local `datetime-local` input to an ISO-8601 UTC instant;
- use the Todo title in the notification body and locale-aware reminder text;
- call `api.notifications.replace` with the full list.
The same replacement runs after loading persisted Todos, so a transient
schedule-write failure repairs itself next time the Todo view is opened. A
schedule API failure does not roll back Todo data; the UI reports the failure
instead. The existing in-view overdue/reminder badge remains useful context and
is not removed.
## Packaging
`getlantern/systray` requires CGO. The Windows release build already uses
`x86_64-w64-mingw32-gcc`; the Windows packaging tests must compile it with the
tray dependency included. Linux build instructions add
`libayatana-appindicator3-dev`. The Debian package declares the corresponding
runtime dependency `libayatana-appindicator3-1`.
The existing AppImage packager traverses `ldd` for the desktop executable and
copies non-glibc runtime libraries. Its verification is extended to prove that
the appindicator library is present in the AppDir when the tray implementation
is compiled in.
## Public README and product screenshots
The workspace-root `README.md` and `README.ru.md` supplied by the maintainer
become the public repository documents: the English source replaces this
repository's `README.md`, and the Russian source is committed as
`README.ru.md`. Their existing language links and public-release instructions
are retained.
Three screenshots are captured from the real desktop application using the
test vault, then committed under `docs/screenshots/`:
1. `overview.png` — returning to recent work and useful next actions;
2. `workspace-files-notes.png` — ordinary vault files and Markdown notes in a
workspace;
3. `activity-journal.png` — review of a factual activity session as a Journal
entry.
Both README variants include the same three images with localized alt text.
They are factual UI captures, not generated illustrative mockups. Capture data
must be limited to the disposable test vault and inspected before commit so no
credentials or personal content are published.
## Test and manual verification
Automated tests cover:
- schedule replacement, cancellation, rescheduling, persistence, one-time
overdue delivery, failed-send retry, and permission/capability rejection;
- Todo desired-list derivation and calls after create/edit/status/delete;
- close policy: ordinary close hides, explicit quit permits shutdown;
- tray controller action wiring and second-instance window reveal;
- Linux/Windows build scripts and package dependency expectations.
Manual smoke tests are required because neither unit tests nor Playwright can
assert a real desktop notification area or OS toast:
1. On Linux and Windows, start Verstak, close its window, use the tray menu to
reveal it, and use **Quit** to terminate it.
2. Set a Todo reminder for a near future time, hide the window in the tray, and
observe one native notification.
3. Quit before a future reminder, relaunch after it expires, and observe one
overdue notification with no duplicate on the next scheduler scan.
4. Inspect all three README screenshots at their committed size and confirm
that they show only intended test-vault data and explain the feature named
in their localized captions.

View File

@ -27,38 +27,6 @@ test.describe('Activity workflow', () => {
await expect(activity.locator('[data-activity-action="clear"]')).toBeDisabled(); await expect(activity.locator('[data-activity-action="clear"]')).toBeDisabled();
}); });
test('clearing activity requires destructive confirmation for the current case', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.activity', {
'events:workspace:Project': [{
activityId: 'activity-to-clear',
occurredAt: '2026-06-30T08:00:00.000Z',
type: 'note.saved',
title: 'Keep until confirmed',
workspaceRootPath: 'Project',
}],
});
});
await page.getByRole('tab', { name: 'Activity' }).click();
const activity = page.locator('.activity-root');
await expect(activity.locator('.activity-count')).toHaveText('1 event');
await activity.locator('[data-activity-action="clear"]').click();
const confirmation = activity.locator('[data-activity-clear-confirmation]');
await expect(confirmation).toBeVisible();
await expect(confirmation).toContainText(/Project|Дело/);
await expect(activity.locator('.activity-count')).toHaveText('1 event');
await confirmation.locator('[data-activity-clear-cancel]').click();
await expect(confirmation).toHaveCount(0);
await expect(activity.locator('.activity-count')).toHaveText('1 event');
await activity.locator('[data-activity-action="clear"]').click();
await activity.locator('[data-activity-clear-confirm]').click();
await expect(activity.locator('.activity-count')).toHaveText('0 events');
});
test('workspace activity keeps raw events and renders factual work session candidates', async ({ page }) => { test('workspace activity keeps raw events and renders factual work session candidates', async ({ page }) => {
await page.evaluate(async () => { await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.activity', { await window.go.api.App.WritePluginSettings('verstak.activity', {
@ -107,7 +75,7 @@ test.describe('Activity workflow', () => {
const candidate = candidateSection.locator('[data-work-session-candidate]'); const candidate = candidateSection.locator('[data-work-session-candidate]');
await expect(candidate).toHaveCount(1); await expect(candidate).toHaveCount(1);
await expect(candidate).toContainText('Workspace: Project'); await expect(candidate).toContainText('Workspace: Project');
await expect(candidate).toContainText('Estimated duration: 10 min'); await expect(candidate).toContainText('Estimated duration: 20 min');
await expect(candidate).toContainText('Activities: 2'); await expect(candidate).toContainText('Activities: 2');
await expect(candidate).not.toContainText('Project work on'); await expect(candidate).not.toContainText('Project work on');
await expect(candidate.locator('[data-work-session-action="review"]')).toBeVisible(); await expect(candidate.locator('[data-work-session-action="review"]')).toBeVisible();
@ -143,10 +111,10 @@ test.describe('Activity workflow', () => {
const journal = page.locator('.journal-root'); const journal = page.locator('.journal-root');
await expect(journal).toBeVisible({ timeout: 10000 }); await expect(journal).toBeVisible({ timeout: 10000 });
await expect(journal.locator('[data-journal-candidate]')).toContainText('Workspace: Project'); await expect(journal.locator('[data-journal-candidate]')).toContainText('Workspace: Project');
await expect(journal.locator('[data-journal-candidate]')).toContainText('Estimated duration: 10 min'); await expect(journal.locator('[data-journal-candidate]')).toContainText('Estimated duration: 20 min');
await expect(journal.locator('[data-journal-input="title"]')).toHaveValue(''); await expect(journal.locator('[data-journal-input="title"]')).toHaveValue('');
await expect(journal.locator('[data-journal-input="summary"]')).toHaveValue(''); await expect(journal.locator('[data-journal-input="summary"]')).toHaveValue('');
await expect(journal.locator('[data-journal-input="minutes"]')).toHaveValue('10'); await expect(journal.locator('[data-journal-input="minutes"]')).toHaveValue('20');
const activityInputs = journal.locator('[data-journal-candidate-activity]'); const activityInputs = journal.locator('[data-journal-candidate-activity]');
await expect(activityInputs).toHaveCount(2); await expect(activityInputs).toHaveCount(2);
@ -158,7 +126,7 @@ test.describe('Activity workflow', () => {
await journal.locator('[data-journal-action="save-entry"]').click(); await journal.locator('[data-journal-action="save-entry"]').click();
await expect(journal).toContainText('Review research capture'); await expect(journal).toContainText('Review research capture');
await expect(journal).toContainText('10 min'); await expect(journal).toContainText('20 min');
const stored = await page.evaluate(async () => { const stored = await page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.journal'); const result = await window.go.api.App.ReadPluginSettings('verstak.journal');
return Array.isArray(result) ? result[0]['worklog:workspace:Project'] : result['worklog:workspace:Project']; return Array.isArray(result) ? result[0]['worklog:workspace:Project'] : result['worklog:workspace:Project'];

View File

@ -1,7 +1,7 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js'; import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('Browser workflow', () => { test.describe('Browser Inbox workflow', () => {
let consoleCollector; let consoleCollector;
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
@ -16,14 +16,14 @@ test.describe('Browser workflow', () => {
}); });
test('global inbox explains empty capture flow and exposes assignment filters', async ({ page }) => { test('global inbox explains empty capture flow and exposes assignment filters', async ({ page }) => {
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' }).click(); await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser Inbox' }).click();
const inbox = page.locator('.browser-inbox-root'); const inbox = page.locator('.browser-inbox-root');
await expect(inbox).toBeVisible({ timeout: 10000 }); await expect(inbox).toBeVisible({ timeout: 10000 });
await expect(inbox.locator('.browser-inbox-title')).toHaveText('Browser'); await expect(inbox.locator('.browser-inbox-title')).toContainText('Browser Inbox');
await expect(inbox.locator('.browser-inbox-count')).toHaveText('0 items'); await expect(inbox.locator('.browser-inbox-count')).toHaveText('0 items');
await expect(inbox.locator('.browser-inbox-empty')).toContainText('No browser materials yet'); await expect(inbox.locator('.browser-inbox-empty')).toContainText('No browser captures yet');
await expect(inbox.locator('.browser-inbox-empty')).toContainText('Send a page, selection, or link from the extension'); await expect(inbox.locator('.browser-inbox-empty')).toContainText('send a page, selection, link, or file from the browser extension');
await expect(inbox.locator('[data-browser-inbox-filter="status"]')).toBeVisible(); await expect(inbox.locator('[data-browser-inbox-filter="status"]')).toBeVisible();
await expect(inbox.locator('[data-browser-inbox-filter="workspace"]')).toBeVisible(); await expect(inbox.locator('[data-browser-inbox-filter="workspace"]')).toBeVisible();
await expect(inbox.locator('[data-browser-inbox-action="clear"]')).toBeDisabled(); await expect(inbox.locator('[data-browser-inbox-action="clear"]')).toBeDisabled();
@ -47,7 +47,7 @@ test.describe('Browser workflow', () => {
}], }],
}); });
}); });
await page.getByRole('tab', { name: 'Browser' }).click(); await page.getByRole('tab', { name: 'Browser Inbox' }).click();
const inbox = page.locator('.browser-inbox-root'); const inbox = page.locator('.browser-inbox-root');
await expect(inbox.locator('.browser-inbox-count')).toHaveText('1 item'); await expect(inbox.locator('.browser-inbox-count')).toHaveText('1 item');
@ -94,7 +94,7 @@ test.describe('Browser workflow', () => {
], ],
}); });
}); });
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' }).click(); await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser Inbox' }).click();
const inbox = page.locator('.browser-inbox-root'); const inbox = page.locator('.browser-inbox-root');
await expect(inbox.locator('.browser-inbox-count')).toHaveText('3 items'); await expect(inbox.locator('.browser-inbox-count')).toHaveText('3 items');

View File

@ -124,20 +124,4 @@ test.describe('Command Palette', () => {
await expect(page.locator('.modal[aria-label="Plugin Settings"]')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.modal[aria-label="Plugin Settings"]')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.modal-header h3')).toContainText('Sync'); await expect(page.locator('.modal-header h3')).toContainText('Sync');
}); });
test('keeps technical command failures out of the palette status', async ({ page }) => {
await page.evaluate(() => {
window.go.api.App.PluginSyncNow = () => Promise.resolve('[plugin:verstak.sync] sync.now failed: internal service code 42');
});
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
const palette = page.locator('.command-palette');
await palette.locator('[data-command-palette-input]').fill('sync now');
await page.keyboard.press('Enter');
const status = page.locator('[data-command-palette-status="error"]');
await expect(status).toContainText('Could not run Sync Now. Please try again.');
await expect(status).not.toContainText('[plugin:');
await expect(status).not.toContainText('sync.now');
});
}); });

View File

@ -36,69 +36,6 @@ test.describe('F: Default Editor Plugin', () => {
await expect(textarea).toHaveValue('Buy groceries\nWrite tests'); await expect(textarea).toHaveValue('Buy groceries\nWrite tests');
}); });
test('secret link opens its exact secret and closing it restores the note preview', async ({ page }) => {
const notePath = 'Notes/Secret Link.md';
const noteContent = '# Secret link\n\n[Target secret](verstak-secret://target.secret)';
await page.evaluate(async ({ notePath, noteContent }) => {
const writeError = await window.go.api.App.WriteVaultTextFile(
'verstak.platform-test',
notePath,
noteContent,
{ createIfMissing: true, overwrite: true },
);
if (writeError) throw new Error(writeError);
const [result, openError] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
kind: 'vault-file',
path: notePath,
extension: '.md',
context: { sourceView: 'notes', isInsideNotesFolder: true, notesMode: true },
});
if (openError) throw new Error(openError);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
}, { notePath, noteContent });
const note = page.locator('[data-editor-mode="notes-markdown"]');
await expect(note).toBeVisible({ timeout: 10000 });
await expect(note.locator('[data-preview]')).toContainText('Target secret');
await note.locator('.secret-link').click();
const secrets = page.locator('.secrets-root');
await expect(secrets).toBeVisible({ timeout: 10000 });
await expect(secrets.locator('.secrets-item.active .secrets-item-title')).toHaveText('Target secret');
await expect(secrets.locator('.secrets-card h2')).toHaveText('Target secret');
await page.locator('.workbench-host .close-btn').click();
await expect(note).toBeVisible({ timeout: 10000 });
await expect(note.locator('[data-preview]')).toContainText('Target secret');
await expect(note.locator('[data-editor-textarea]')).toHaveCount(0);
await expect(note.locator('[data-save-state]')).toHaveText('');
const storedContent = await page.evaluate(async ({ notePath }) => {
const [content, readError] = await window.go.api.App.ReadVaultTextFile('verstak.platform-test', notePath);
if (readError) throw new Error(readError);
return content;
}, { notePath });
expect(storedContent).toBe(noteContent);
});
test('unavailable secret does not fall back to the first secret', async ({ page }) => {
await page.evaluate(async () => {
const [result, openError] = await window.go.api.App.OpenWorkbenchResource('verstak.default-editor', {
kind: 'secret',
path: 'missing.secret',
mode: 'view',
});
if (openError) throw new Error(openError);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
const secrets = page.locator('.secrets-root');
await expect(secrets).toBeVisible({ timeout: 10000 });
await expect(secrets.locator('.secrets-item.active')).toHaveCount(0);
await expect(secrets.locator('.secrets-status.error')).toContainText(/unavailable|недоступен/i);
});
test('editor supports markdown toolbar split save reopen and revert', async ({ page }) => { test('editor supports markdown toolbar split save reopen and revert', async ({ page }) => {
await page.evaluate(async () => { await page.evaluate(async () => {
const err = await window.go.api.App.WriteVaultTextFile( const err = await window.go.api.App.WriteVaultTextFile(

View File

@ -1,35 +0,0 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test('global Journal creates an entry in the selected Deal', async ({ page }) => {
const consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.journal', {
'worklog:workspace:Project': [{ entryId: 'existing-project-entry', workspaceRootPath: 'Project', date: '2026-07-14', title: 'Existing entry', minutes: 5 }],
});
});
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Journal' }).click();
const journal = page.locator('.journal-root');
await expect(journal.locator('.journal-title')).toHaveText('Journal');
await journal.locator('[data-journal-action="add"]').click();
await journal.locator('[data-journal-input="workspaceRootPath"]').selectOption('Project');
await journal.locator('[data-journal-input="title"]').fill('Prepare project handoff');
await journal.locator('[data-journal-input="minutes"]').fill('30');
await journal.locator('[data-journal-action="save-entry"]').click();
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.journal');
const settings = Array.isArray(result) ? result[0] : result;
return settings['worklog:workspace:Project']?.[0]?.title;
})).toBe('Prepare project handoff');
await expect(journal).toContainText('Prepare project handoff');
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await page.getByRole('tab', { name: 'Journal' }).click();
await expect(page.locator('.journal-root')).toContainText('Prepare project handoff');
consoleCollector.assertNoErrors();
});

View File

@ -1,50 +0,0 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test('global Secrets lists, filters, and creates Deal-scoped secrets', async ({ page }) => {
const consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
await page.evaluate(async () => {
const [record, error] = await window.go.api.App.PluginSecretsWrite('verstak.secrets', {
id: 'project.global-secret',
title: 'Project API',
username: 'project-user',
value: 'project-value',
scope: { kind: 'workspace', workspaceRootPath: 'Project' },
});
if (error || !record?.id) throw new Error(error || 'could not create a project secret');
});
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Secrets' }).click();
const secrets = page.locator('.secrets-root');
await expect(secrets).toBeVisible({ timeout: 10000 });
await expect(secrets).toContainText('First secret');
await expect(secrets).toContainText('Project API');
await secrets.locator('[data-secret-scope-filter]').selectOption('workspace:Project');
await expect(secrets).toContainText('Project API');
await expect(secrets).not.toContainText('First secret');
await secrets.locator('[data-secret-search]').fill('no matching secret');
await expect(secrets).toContainText('No secrets');
await secrets.locator('[data-secret-search]').fill('');
await secrets.locator('[data-secret-scope-filter]').selectOption('all');
await secrets.getByRole('button', { name: 'New' }).click();
await secrets.locator('[data-secret-title]').fill('Project deployment token');
await secrets.locator('[data-secret-value]').fill('deployment-token-value');
await secrets.locator('[data-secret-scope]').selectOption('workspace');
await secrets.locator('[data-secret-workspace]').selectOption('Project');
await secrets.locator('[data-secret-save]').click();
await expect.poll(async () => page.evaluate(async () => {
const [records, error] = await window.go.api.App.PluginSecretsList('verstak.secrets');
if (error) throw new Error(error);
const record = records.find((item) => item.title === 'Project deployment token');
return record && [record.scope?.kind, record.scope?.workspaceRootPath].join('|');
})).toBe('workspace|Project');
consoleCollector.assertNoErrors();
});

View File

@ -30,45 +30,18 @@ test.describe('B: Sidebar opens plugin view by item.view', () => {
test('Sidebar item exists with correct label', async ({ page }) => { test('Sidebar item exists with correct label', async ({ page }) => {
await expect(page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' })).not.toBeVisible(); await expect(page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' })).not.toBeVisible();
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Activity' })).toBeVisible(); await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Activity' })).toBeVisible();
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' })).toBeVisible(); await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser Inbox' })).toBeVisible();
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }); const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
await expect(sidebarItem).toBeVisible(); await expect(sidebarItem).toBeVisible();
}); });
test('Global Activity and Browser sidebar items open plugin views', async ({ page }) => { test('Global Activity and Browser Inbox sidebar items open plugin views', async ({ page }) => {
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Activity' }).click(); await page.locator('.sidebar .plugin-item').filter({ hasText: 'Activity' }).click();
await expect(page.locator('.view-container .view-header h2')).toHaveText('Activity', { timeout: 10000 }); await expect(page.locator('.view-container .view-header h2')).toHaveText('Activity', { timeout: 10000 });
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' }).click(); await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser Inbox' }).click();
await expect(page.locator('.view-container .view-header h2')).toHaveText('Browser', { timeout: 10000 }); await expect(page.locator('.view-container .view-header h2')).toHaveText('Browser Inbox', { timeout: 10000 });
});
test('selected global tool remains visibly active through navigation and sidebar reloads', async ({ page }) => {
const activity = page.locator('.sidebar .plugin-item').filter({ hasText: 'Activity' });
const browserInbox = page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' });
await activity.click();
await expect(activity).toHaveAttribute('aria-current', 'page');
await expect(activity).toHaveClass(/is-active/);
await page.evaluate(() => {
window.dispatchEvent(new CustomEvent('verstak:plugins-changed'));
});
await expect(activity).toHaveAttribute('aria-current', 'page');
await page.evaluate(() => {
window.dispatchEvent(new CustomEvent('verstak:open-view', {
detail: { viewId: 'verstak.browser-inbox.view', pluginId: 'verstak.browser-inbox' },
}));
});
await expect(page.locator('.view-container .view-header h2')).toHaveText('Browser', { timeout: 10000 });
await expect(browserInbox).toHaveAttribute('aria-current', 'page');
await expect(browserInbox).toHaveClass(/is-active/);
await expect(activity).not.toHaveAttribute('aria-current', 'page');
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await expect(browserInbox).not.toHaveAttribute('aria-current', 'page');
}); });
test('Click sidebar item opens diagnostics view by view ID, not sidebar ID', async ({ page }) => { test('Click sidebar item opens diagnostics view by view ID, not sidebar ID', async ({ page }) => {

View File

@ -27,10 +27,7 @@ test.describe('Todo plugin workflow', () => {
await todos.locator('[data-todo-input="description"]').fill('Collect factual review notes.'); await todos.locator('[data-todo-input="description"]').fill('Collect factual review notes.');
await todos.locator('[data-todo-input="priority"]').selectOption('high'); await todos.locator('[data-todo-input="priority"]').selectOption('high');
await todos.locator('[data-todo-input="dueAt"]').fill('2000-01-01'); await todos.locator('[data-todo-input="dueAt"]').fill('2000-01-01');
await todos.locator('[data-todo-input="reminderDate"]').fill('2000-01-01'); await todos.locator('[data-todo-input="reminderAt"]').fill('2000-01-01T09:00');
const reminderTime = todos.locator('[data-todo-input="reminderTime"]');
await expect(reminderTime).toHaveAttribute('type', 'text');
await reminderTime.fill('09:30');
await todos.locator('[data-todo-action="save"]').click(); await todos.locator('[data-todo-action="save"]').click();
await expect(todos).toContainText('Overdue'); await expect(todos).toContainText('Overdue');
@ -39,23 +36,13 @@ test.describe('Todo plugin workflow', () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.todo'); const result = await window.go.api.App.ReadPluginSettings('verstak.todo');
const settings = Array.isArray(result) ? result[0] : result; const settings = Array.isArray(result) ? result[0] : result;
const todo = settings['todos:global'].find((item) => item.title === 'Prepare project review'); const todo = settings['todos:global'].find((item) => item.title === 'Prepare project review');
return todo && [todo.workspaceRootPath, todo.priority, todo.dueAt, todo.reminderDate, todo.reminderAt].join('|'); return todo && [todo.workspaceRootPath, todo.priority, todo.dueAt, todo.reminderAt].join('|');
})).toBe('Project|high|2000-01-01|2000-01-01|2000-01-01T09:30'); })).toBe('Project|high|2000-01-01|2000-01-01T09:00');
await todos.locator('[data-todo-action="edit"]').click(); await todos.locator('[data-todo-action="edit"]').click();
await todos.locator('[data-todo-input="title"]').fill('Prepare project review updated'); await todos.locator('[data-todo-input="title"]').fill('Prepare project review updated');
await todos.locator('[data-todo-input="reminderTime"]').fill('not-a-time');
await todos.locator('[data-todo-action="save"]').click();
await expect(todos).toContainText('Enter a valid reminder time');
await todos.locator('[data-todo-input="reminderTime"]').fill('');
await todos.locator('[data-todo-action="save"]').click(); await todos.locator('[data-todo-action="save"]').click();
await expect(todos).toContainText('Prepare project review updated'); await expect(todos).toContainText('Prepare project review updated');
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.todo');
const settings = Array.isArray(result) ? result[0] : result;
const todo = settings['todos:global'].find((item) => item.title === 'Prepare project review updated');
return todo && [todo.reminderDate, todo.reminderAt].join('|');
})).toBe('2000-01-01|');
await todos.locator('[data-todo-action="mark-done"]').click(); await todos.locator('[data-todo-action="mark-done"]').click();
await expect(todos.locator('[data-todo-action="create-journal-entry"]')).toBeVisible(); await expect(todos.locator('[data-todo-action="create-journal-entry"]')).toBeVisible();

View File

@ -18,7 +18,7 @@ test.describe('UX follow-up fixes', () => {
await expect(search).toBeVisible(); await expect(search).toBeVisible();
await expect(page.locator('.sidebar [data-global-search-input]')).toBeVisible(); await expect(page.locator('.sidebar [data-global-search-input]')).toBeVisible();
await page.locator('.sidebar .nav-item').filter({ hasText: 'Browser' }).click(); await page.locator('.sidebar .nav-item').filter({ hasText: 'Browser Inbox' }).click();
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
await expect(search).toBeVisible(); await expect(search).toBeVisible();
}); });
@ -67,7 +67,7 @@ test.describe('UX follow-up fixes', () => {
const search = page.locator('[data-global-search-input]'); const search = page.locator('[data-global-search-input]');
await search.fill('Research Search Result'); await search.fill('Research Search Result');
const result = page.locator('[data-global-search-result-type="Browser"]').filter({ hasText: 'Research Search Result' }); const result = page.locator('[data-global-search-result-type="Browser Inbox"]').filter({ hasText: 'Research Search Result' });
await expect(result).toBeVisible({ timeout: 10000 }); await expect(result).toBeVisible({ timeout: 10000 });
await result.click(); await result.click();

View File

@ -38,11 +38,6 @@ test.describe('UX P0 shell flow', () => {
await expect(page.locator('.plugin-manager')).toHaveCount(0); await expect(page.locator('.plugin-manager')).toHaveCount(0);
}); });
test('Deal header does not expose the internal workspace type badge', async ({ page }) => {
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.workspace-type')).toHaveCount(0);
});
test('status bar plugin contribution failures do not render large error panels', async ({ page }) => { test('status bar plugin contribution failures do not render large error panels', async ({ page }) => {
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('Plugin View Error')).toHaveCount(0); await expect(page.getByText('Plugin View Error')).toHaveCount(0);

View File

@ -56,7 +56,7 @@ test.describe('UX Overview workspace flow', () => {
await page.getByRole('tab', { name: 'Overview' }).click(); await page.getByRole('tab', { name: 'Overview' }).click();
await overview.locator('[data-overview-summary="captures"]').click(); await overview.locator('[data-overview-summary="captures"]').click();
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveAttribute('aria-selected', 'true'); await expect(page.getByRole('tab', { name: 'Browser Inbox' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
await page.getByRole('tab', { name: 'Overview' }).click(); await page.getByRole('tab', { name: 'Overview' }).click();
@ -70,7 +70,7 @@ test.describe('UX Overview workspace flow', () => {
await page.getByRole('tab', { name: 'Overview' }).click(); await page.getByRole('tab', { name: 'Overview' }).click();
await overview.locator('[data-overview-summary="attention"]').click(); await overview.locator('[data-overview-summary="attention"]').click();
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveAttribute('aria-selected', 'true'); await expect(page.getByRole('tab', { name: 'Browser Inbox' })).toHaveAttribute('aria-selected', 'true');
}); });
test('Overview keeps the Notes total factual when the Files plugin is unavailable', async ({ page }) => { test('Overview keeps the Notes total factual when the Files plugin is unavailable', async ({ page }) => {
@ -80,54 +80,6 @@ test.describe('UX Overview workspace flow', () => {
await expect(page.locator('[data-overview-summary="notes"]')).toContainText('1 total'); await expect(page.locator('[data-overview-summary="notes"]')).toContainText('1 total');
}); });
test('Overview hides Browser cards and actions when the current Deal does not include it', async ({ page }) => {
await page.locator('button[title="New Deal"]').click();
const modal = page.locator('[data-workspace-create-modal]');
await modal.locator('[data-workspace-name]').fill('MinimalOverview');
await modal.locator('[data-workspace-template]').selectOption('minimal');
await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveCount(0);
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
'captures:global': [{
captureId: 'hidden-browser-capture',
capturedAt: '2026-07-14T08:00:00.000Z',
kind: 'page',
title: 'Inbox material must stay hidden',
workspaceRootPath: 'MinimalOverview',
}],
});
});
const overview = page.locator('[data-overview-root]');
await overview.locator('[data-overview-action="refresh"]').click();
await expect(overview.locator('[data-overview-summary="captures"]')).toHaveCount(0);
await expect(overview.locator('[data-overview-action="browser-inbox"]')).toHaveCount(0);
await expect(overview).not.toContainText('Inbox material must stay hidden');
});
test('Overview refreshes when Browser is disabled through plugin state changes', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
'captures:global': [{
captureId: 'disabled-browser-capture',
capturedAt: '2026-07-14T08:00:00.000Z',
kind: 'page',
title: 'Disabled inbox material',
workspaceRootPath: 'Project',
}],
});
window.__wailsMock.setPluginStatus('verstak.browser-inbox', 'disabled', false);
window.dispatchEvent(new CustomEvent('verstak:plugins-changed'));
});
const overview = page.locator('[data-overview-root]');
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveCount(0);
await expect(overview.locator('[data-overview-summary="captures"]')).toHaveCount(0);
await expect(overview.locator('[data-overview-action="browser-inbox"]')).toHaveCount(0);
});
test('Overview prioritizes resume work and filters meaningful recent changes', async ({ page }) => { test('Overview prioritizes resume work and filters meaningful recent changes', async ({ page }) => {
await page.evaluate(async () => { await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', { await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
@ -248,10 +200,10 @@ test.describe('UX Overview workspace flow', () => {
await expect(overview.locator('[data-overview-summary="attention"]')).toContainText('3'); await expect(overview.locator('[data-overview-summary="attention"]')).toContainText('3');
const attention = overview.locator('[data-overview-section="attention"]'); const attention = overview.locator('[data-overview-section="attention"]');
await expect(attention).toContainText('Possible journal entry'); await expect(attention).toContainText('Possible journal entry');
await expect(attention).toContainText('Deal: Project · 10 min · 2 activities'); await expect(attention).toContainText('Workspace: Project · 10 min · 2 activities');
await attention.locator('.overview-attention-row', { hasText: 'Possible journal entry' }).getByRole('button', { name: 'Review candidate' }).click(); await attention.locator('.overview-attention-row', { hasText: 'Possible journal entry' }).getByRole('button', { name: 'Review candidate' }).click();
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true'); await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.journal-root [data-journal-candidate]')).toContainText('Deal: Project'); await expect(page.locator('.journal-root [data-journal-candidate]')).toContainText('Workspace: Project');
await page.locator('.journal-modal-actions').getByRole('button', { name: 'Cancel' }).click(); await page.locator('.journal-modal-actions').getByRole('button', { name: 'Cancel' }).click();
await page.getByRole('tab', { name: 'Overview' }).click(); await page.getByRole('tab', { name: 'Overview' }).click();
@ -264,7 +216,7 @@ test.describe('UX Overview workspace flow', () => {
await expect(candidates.nth(3)).toContainText('Changed file "draft.md"'); await expect(candidates.nth(3)).toContainText('Changed file "draft.md"');
await candidates.nth(0).click(); await candidates.nth(0).click();
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveAttribute('aria-selected', 'true'); await expect(page.getByRole('tab', { name: 'Browser Inbox' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
await page.getByRole('tab', { name: 'Overview' }).click(); await page.getByRole('tab', { name: 'Overview' }).click();
@ -295,28 +247,4 @@ test.describe('UX Overview workspace flow', () => {
await expect(recent).toContainText('Added journal entry "Write project summary"'); await expect(recent).toContainText('Added journal entry "Write project summary"');
await expect(recent).not.toContainText('Changed file "draft.md"'); await expect(recent).not.toContainText('Changed file "draft.md"');
}); });
test('Overview localizes activity labels without exposing internal event names', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.activity', {
'events:workspace:Project': [{
activityId: 'overview-russian-note',
occurredAt: '2026-06-30T08:25:00.000Z',
type: 'note.saved',
title: 'Локализация',
summary: 'Project/Notes/Localization.md',
workspaceRootPath: 'Project',
}],
});
});
await page.locator('[data-settings-menu-button]').click();
await page.locator('[data-settings-language="ru"]').click();
const overview = page.locator('[data-overview-root]');
await overview.locator('[data-overview-action="refresh"]').click();
const recent = overview.locator('[data-overview-section="recent"]');
await expect(recent).toContainText('Изменена заметка «Локализация»');
await expect(recent).not.toContainText('note.saved');
await expect(recent).not.toContainText('Edited note');
});
}); });

View File

@ -16,7 +16,7 @@ test.describe('Workspace templates', () => {
}); });
async function openCreateModal(page) { async function openCreateModal(page) {
await page.locator('button[title="New Deal"]').click(); await page.locator('button[title="New workspace"]').click();
const modal = page.locator('[data-workspace-create-modal]'); const modal = page.locator('[data-workspace-create-modal]');
await expect(modal).toBeVisible(); await expect(modal).toBeVisible();
return modal; return modal;
@ -24,24 +24,22 @@ test.describe('Workspace templates', () => {
test('creation modal validates names, shows template tools, and persists the selected snapshot', async ({ page }) => { test('creation modal validates names, shows template tools, and persists the selected snapshot', async ({ page }) => {
const modal = await openCreateModal(page); const modal = await openCreateModal(page);
const templateSelect = modal.locator('[data-workspace-template]'); await expect(modal.locator('[data-workspace-template]')).toHaveValue('default');
await expect(templateSelect).toHaveValue('default');
await expect(templateSelect).toHaveCSS('appearance', 'none');
await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Notes'); await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Notes');
await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Browser'); await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Browser Inbox');
await modal.getByRole('button', { name: 'Create Deal' }).click(); await modal.getByRole('button', { name: 'Create workspace' }).click();
await expect(modal.locator('[data-workspace-create-error]')).toContainText('Name is required'); await expect(modal.locator('[data-workspace-create-error]')).toContainText('Name is required');
await modal.locator('[data-workspace-name]').fill('bad/name'); await modal.locator('[data-workspace-name]').fill('bad/name');
await modal.getByRole('button', { name: 'Create Deal' }).click(); await modal.getByRole('button', { name: 'Create workspace' }).click();
await expect(modal.locator('[data-workspace-create-error]')).toContainText('Could not create the Deal. Please try again.'); await expect(modal.locator('[data-workspace-create-error]')).toContainText('invalid-workspace-name');
await modal.locator('[data-workspace-name]').fill('ProjectPlan'); await modal.locator('[data-workspace-name]').fill('ProjectPlan');
await modal.locator('[data-workspace-template]').selectOption('project'); await modal.locator('[data-workspace-template]').selectOption('project');
await expect(modal.locator('[data-workspace-template-description]')).toContainText('Project planning'); await expect(modal.locator('[data-workspace-template-description]')).toContainText('Project planning');
await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Todos'); await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Todos');
await modal.getByRole('button', { name: 'Create Deal' }).click(); await modal.getByRole('button', { name: 'Create workspace' }).click();
await expect(page.locator('.wt-label').filter({ hasText: 'ProjectPlan' })).toBeVisible(); await expect(page.locator('.wt-label').filter({ hasText: 'ProjectPlan' })).toBeVisible();
await expect.poll(async () => page.evaluate(async () => { await expect.poll(async () => page.evaluate(async () => {
@ -65,7 +63,7 @@ test.describe('Workspace templates', () => {
const modal = await openCreateModal(page); const modal = await openCreateModal(page);
await modal.locator('[data-workspace-name]').fill('MinimalSpace'); await modal.locator('[data-workspace-name]').fill('MinimalSpace');
await modal.locator('[data-workspace-template]').selectOption('minimal'); await modal.locator('[data-workspace-template]').selectOption('minimal');
await modal.getByRole('button', { name: 'Create Deal' }).click(); await modal.getByRole('button', { name: 'Create workspace' }).click();
await expect(page.getByRole('tab', { name: 'Overview' })).toBeVisible(); await expect(page.getByRole('tab', { name: 'Overview' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Notes' })).toBeVisible(); await expect(page.getByRole('tab', { name: 'Notes' })).toBeVisible();
@ -74,40 +72,21 @@ test.describe('Workspace templates', () => {
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveCount(0); await expect(page.getByRole('tab', { name: 'Journal' })).toHaveCount(0);
await expect(page.getByRole('tab', { name: 'Secrets' })).toHaveCount(0); await expect(page.getByRole('tab', { name: 'Secrets' })).toHaveCount(0);
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Todos' })).toBeVisible(); await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Todos' })).toBeVisible();
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' })).toBeVisible(); await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser Inbox' })).toBeVisible();
});
test('template explains an unavailable plugin and warns after incomplete creation', async ({ page }) => {
await page.evaluate(() => window.__wailsMock.setPluginStatus('verstak.todo', 'disabled', false));
const modal = await openCreateModal(page);
await modal.locator('[data-workspace-template]').selectOption('project');
const todo = modal.locator('[data-workspace-template-tool="verstak.todo"]');
await expect(todo).toContainText('Todos');
await expect(todo).toContainText('Plugin is disabled');
await expect(todo).toHaveAttribute('data-template-tool-status', 'unavailable');
await modal.locator('[data-workspace-name]').fill('ProjectWithWarning');
await modal.getByRole('button', { name: 'Create Deal' }).click();
const warning = page.locator('[data-workspace-template-warning]');
await expect(warning).toContainText('ProjectWithWarning');
await expect(warning).toContainText('Todos');
await expect(warning).toContainText('Plugin is disabled');
}); });
test('Admin shows Secrets when available and missing workspace plugins degrade without breaking tabs', async ({ page }) => { test('Admin shows Secrets when available and missing workspace plugins degrade without breaking tabs', async ({ page }) => {
let modal = await openCreateModal(page); let modal = await openCreateModal(page);
await modal.locator('[data-workspace-name]').fill('AdminSpace'); await modal.locator('[data-workspace-name]').fill('AdminSpace');
await modal.locator('[data-workspace-template]').selectOption('admin'); await modal.locator('[data-workspace-template]').selectOption('admin');
await modal.getByRole('button', { name: 'Create Deal' }).click(); await modal.getByRole('button', { name: 'Create workspace' }).click();
await expect(page.getByRole('tab', { name: 'Secrets' })).toBeVisible(); await expect(page.getByRole('tab', { name: 'Secrets' })).toBeVisible();
await page.evaluate(() => window.__wailsMock.setPluginStatus('verstak.todo', 'disabled', false)); await page.evaluate(() => window.__wailsMock.setPluginStatus('verstak.todo', 'disabled', false));
modal = await openCreateModal(page); modal = await openCreateModal(page);
await modal.locator('[data-workspace-name]').fill('ProjectWithoutTodo'); await modal.locator('[data-workspace-name]').fill('ProjectWithoutTodo');
await modal.locator('[data-workspace-template]').selectOption('project'); await modal.locator('[data-workspace-template]').selectOption('project');
await modal.getByRole('button', { name: 'Create Deal' }).click(); await modal.getByRole('button', { name: 'Create workspace' }).click();
await expect(page.getByRole('tab', { name: 'Notes' })).toBeVisible(); await expect(page.getByRole('tab', { name: 'Notes' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Files' })).toBeVisible(); await expect(page.getByRole('tab', { name: 'Files' })).toBeVisible();

View File

@ -219,7 +219,7 @@
const settings = await App.GetAppSettings(); const settings = await App.GetAppSettings();
debug.log('[App] checkVault: GetAppSettings returned', settings); debug.log('[App] checkVault: GetAppSettings returned', settings);
flog('checkVault: GetAppSettings returned'); flog('checkVault: GetAppSettings returned');
if (settings?.debug) debug.enable({ persist: false }); if (settings?.debug) debug.enable();
debug.log('[App] checkVault: calling GetVaultStatus...'); debug.log('[App] checkVault: calling GetVaultStatus...');
vaultStatus = await App.GetVaultStatus() || { status: 'unknown', path: '', vaultId: '' }; vaultStatus = await App.GetVaultStatus() || { status: 'unknown', path: '', vaultId: '' };
@ -395,11 +395,7 @@
<VaultSelection /> <VaultSelection />
{:else} {:else}
<main> <main>
<Sidebar <Sidebar showGlobalSearch={currentView !== 'workspace' && currentView !== 'workspace-empty'} />
showGlobalSearch={currentView !== 'workspace' && currentView !== 'workspace-empty'}
{activeView}
{activeViewPluginId}
/>
<CommandPalette /> <CommandPalette />
<section class="content-shell"> <section class="content-shell">

View File

@ -16,13 +16,13 @@ export default {
'sidebar.error.load': 'Failed to load sidebar', 'sidebar.error.load': 'Failed to load sidebar',
'sidebar.error.ui': 'Plugin UI error', 'sidebar.error.ui': 'Plugin UI error',
'workspace.overview': 'Overview', 'workspace.overview': 'Overview',
'workspace.search': 'Search in this Deal', 'workspace.search': 'Workspace search',
'workspace.tools': 'Deal tools', 'workspace.tools': 'Workspace tools',
'workspace.tool': 'Deal tool', 'workspace.tool': 'Workspace tool',
'workspace.emptyTools': 'No Deal tools available', 'workspace.emptyTools': 'No workspace tools available',
'workspace.emptyToolsHint': 'Enable plugins with Deal tools or open Plugin Manager from settings.', 'workspace.emptyToolsHint': 'Enable plugins with workspace tools or open Plugin Manager from settings.',
'workspace.select': 'Select a Deal', 'workspace.select': 'Select a workspace',
'workspace.selectHint': 'Use the + button in Deals to add your first project.', 'workspace.selectHint': 'Use the + button in Workspaces to add your first project.',
'common.details': 'Details', 'common.details': 'Details',
'common.plugin': 'Plugin', 'common.plugin': 'Plugin',
'common.component': 'Component', 'common.component': 'Component',
@ -74,7 +74,7 @@ export default {
'pluginCard.count.sidebar': '{count} sidebar item(s)', 'pluginCard.count.sidebar': '{count} sidebar item(s)',
'pluginCard.count.statusbar': '{count} status bar item(s)', 'pluginCard.count.statusbar': '{count} status bar item(s)',
'pluginCard.count.openProviders': '{count} openProviders', 'pluginCard.count.openProviders': '{count} openProviders',
'pluginCard.count.workspace': '{count} Deal tool(s)', 'pluginCard.count.workspace': '{count} workspace item(s)',
'pluginCard.degraded': 'Plugin is usable, but some optional capabilities are unavailable.', 'pluginCard.degraded': 'Plugin is usable, but some optional capabilities are unavailable.',
'pluginCard.name': 'Name', 'pluginCard.name': 'Name',
'pluginCard.contributions': 'Contributions', 'pluginCard.contributions': 'Contributions',
@ -96,12 +96,12 @@ export default {
'common.loading': 'Loading...', 'common.loading': 'Loading...',
'common.browse': 'Browse...', 'common.browse': 'Browse...',
'vaultSelection.chooseNew': 'Choose or enter a folder for the new vault.', 'vaultSelection.chooseNew': 'Choose or enter a folder for the new vault.',
'vaultSelection.createError': 'Could not create the vault. Please try again.', 'vaultSelection.createError': 'Could not create vault: {error}',
'vaultSelection.openError': 'Could not open the vault. Please try again.', 'vaultSelection.openError': 'Could not open vault: {error}',
'vaultSelection.chooseExisting': 'Choose or enter an existing vault.', 'vaultSelection.chooseExisting': 'Choose or enter an existing vault.',
'vaultSelection.subtitle': 'Choose a vault to start working', 'vaultSelection.subtitle': 'Choose a vault to start working',
'vaultSelection.createTitle': 'Create a new vault', 'vaultSelection.createTitle': 'Create a new vault',
'vaultSelection.createHint': 'Create a local vault folder for Deals and projects.', 'vaultSelection.createHint': 'Create a local vault folder for workspaces and projects.',
'vaultSelection.pathPlaceholder': 'Choose or enter a path...', 'vaultSelection.pathPlaceholder': 'Choose or enter a path...',
'vaultSelection.creating': 'Creating...', 'vaultSelection.creating': 'Creating...',
'vaultSelection.create': 'Create vault', 'vaultSelection.create': 'Create vault',
@ -112,45 +112,21 @@ export default {
'vaultSelection.recent': 'Recent vaults', 'vaultSelection.recent': 'Recent vaults',
'common.cancel': 'Cancel', 'common.cancel': 'Cancel',
'common.close': 'Close', 'common.close': 'Close',
'workspaceTree.title': 'Deals', 'workspaceTree.title': 'Workspaces',
'workspaceTree.new': 'New Deal', 'workspaceTree.new': 'New workspace',
'workspaceTree.nameRequired': 'Name is required', 'workspaceTree.nameRequired': 'Name is required',
'workspaceTree.chooseTemplate': 'Choose a Deal template', 'workspaceTree.chooseTemplate': 'Choose a workspace template',
'workspaceTree.saveRename': 'Save rename', 'workspaceTree.saveRename': 'Save rename',
'workspaceTree.rename': 'Rename Deal', 'workspaceTree.rename': 'Rename workspace',
'workspaceTree.trash': 'Move Deal to trash', 'workspaceTree.trash': 'Trash workspace',
'workspaceTree.create': 'Create Deal', 'workspaceTree.create': 'Create workspace',
'workspaceTree.namePlaceholder': 'Deal name', 'workspaceTree.namePlaceholder': 'Workspace name',
'workspaceTree.template': 'Template', 'workspaceTree.template': 'Template',
'workspaceTree.creating': 'Creating...', 'workspaceTree.creating': 'Creating...',
'workspaceTree.templateAvailable': 'Available',
'workspaceTree.templateLimited': 'Available with limitations',
'workspaceTree.templateMissingPlugin': 'Plugin is not installed',
'workspaceTree.templatePluginDisabled': 'Plugin is disabled',
'workspaceTree.templateCapabilityUnavailable': 'A required capability is unavailable',
'workspaceTree.templateIncompatible': 'Plugin is incompatible with this version of Verstak',
'workspaceTree.templateLoadFailed': 'Plugin failed to load',
'workspaceTree.templateNotReady': 'Plugin is not ready',
'workspaceTree.templateToolTabs': 'Creates: {tabs}',
'workspaceTree.templateToolNoTabs': 'No Deal tab provided',
'workspaceTree.templateWillBeIncomplete': 'This Deal will be created without the unavailable components.',
'workspaceTree.templateIncompleteCreated': '«{name}» was created with unavailable components.',
'workspaceTree.templatesError': 'Could not load Deal templates. Please try again.',
'workspaceTree.loadError': 'Could not load Deals. Please try again.',
'workspaceTree.selectError': 'Could not select this Deal. Please try again.',
'workspaceTree.createError': 'Could not create the Deal. Please try again.',
'workspaceTree.renameError': 'Could not rename the Deal. Please try again.',
'workspaceTree.trashError': 'Could not move the Deal to trash. Please try again.',
'pluginManager.loadError': 'Could not load plugins. Please try again.',
'pluginManager.reloadError': 'Could not reload plugins. Please try again.',
'pluginManager.enableError': 'Could not enable the plugin. Please try again.',
'pluginManager.disableError': 'Could not disable the plugin. Please try again.',
'pluginManager.settingsLoadError': 'Could not load plugin settings. Please try again.',
'pluginManager.settingsUnavailable': 'Plugin settings are unavailable.',
'command.openOverview': 'Open Overview', 'command.openOverview': 'Open Overview',
'command.openFiles': 'Open Files', 'command.openFiles': 'Open Files',
'command.openActivity': 'Open Activity', 'command.openActivity': 'Open Activity',
'command.openBrowserInbox': 'Open Browser', 'command.openBrowserInbox': 'Open Browser Inbox',
'command.createMarkdown': 'Create Markdown File', 'command.createMarkdown': 'Create Markdown File',
'command.createText': 'Create Text File', 'command.createText': 'Create Text File',
'command.syncNow': 'Sync Now', 'command.syncNow': 'Sync Now',
@ -158,7 +134,6 @@ export default {
'command.openPluginManager': 'Open Plugin Manager', 'command.openPluginManager': 'Open Plugin Manager',
'command.handled': '{title} handled', 'command.handled': '{title} handled',
'command.result': '{title} {status}', 'command.result': '{title} {status}',
'command.failed': 'Could not run {title}. Please try again.',
'command.statusHandled': 'handled', 'command.statusHandled': 'handled',
'command.palette': 'Command Palette', 'command.palette': 'Command Palette',
'command.run': 'Run command', 'command.run': 'Run command',
@ -167,26 +142,26 @@ export default {
'search.placeholder': 'Search', 'search.placeholder': 'Search',
'search.global': 'Global search', 'search.global': 'Global search',
'search.noResults': 'No results', 'search.noResults': 'No results',
'search.type.workspace': 'Deal', 'search.type.workspace': 'Workspace',
'search.type.tool': 'Tool', 'search.type.tool': 'Tool',
'search.type.folder': 'Folder', 'search.type.folder': 'Folder',
'search.type.file': 'File', 'search.type.file': 'File',
'search.type.journal': 'Journal', 'search.type.journal': 'Journal',
'search.type.browserInbox': 'Browser', 'search.type.browserInbox': 'Browser Inbox',
'search.type.activity': 'Activity', 'search.type.activity': 'Activity',
'workbench.noProvider': 'No provider', 'workbench.noProvider': 'No provider',
'workbench.noViewer': 'No viewer/editor available', 'workbench.noViewer': 'No viewer/editor available',
'workbench.noResource': 'No resource opened', 'workbench.noResource': 'No resource opened',
'bundle.noFrontend': 'Plugin has no frontend bundle', 'bundle.noFrontend': 'Plugin has no frontend bundle',
'bundle.notFound': 'Plugin not found', 'bundle.notFound': 'Plugin not found',
'bundle.loadFailed': 'Could not load the plugin interface. Please try again.', 'bundle.loadFailed': 'Failed to load bundle: {error}',
'bundle.emptyContent': 'empty content', 'bundle.emptyContent': 'empty content',
'bundle.executionError': 'Could not start the plugin interface. Please try again.', 'bundle.executionError': 'Bundle execution error: {error}',
'bundle.registrationMissing': 'Bundle loaded but no VerstakPluginRegister call detected', 'bundle.registrationMissing': 'Bundle loaded but no VerstakPluginRegister call detected',
'bundle.componentMissing': 'The requested plugin interface is unavailable.', 'bundle.componentMissing': 'Component "{component}" not found in bundle. Available: {available}',
'bundle.mountError': 'Could not open the plugin interface. Please try again.', 'bundle.mountError': 'Component mount error: {error}',
'bundle.mountUnavailable': 'Mount container not available', 'bundle.mountUnavailable': 'Mount container not available',
'bundle.unexpectedError': 'Could not open the plugin interface. Please try again.', 'bundle.unexpectedError': 'Unexpected error: {error}',
'bundle.unknownError': 'Unknown error', 'bundle.unknownError': 'Unknown error',
'bundle.frontendEntry': 'Frontend entry', 'bundle.frontendEntry': 'Frontend entry',
'bundle.availableComponents': 'Available components', 'bundle.availableComponents': 'Available components',
@ -222,57 +197,18 @@ export default {
'overview.count.journal.many': '{count} journal entries', 'overview.count.journal.many': '{count} journal entries',
'overview.count.pending.one': '{count} pending item', 'overview.count.pending.one': '{count} pending item',
'overview.count.pending.many': '{count} pending items', 'overview.count.pending.many': '{count} pending items',
'overview.loadingContext': 'Loading Deal context...', 'overview.loadingContext': 'Loading workspace context...',
'overview.lastActive': 'Last active {time}', 'overview.lastActive': 'Last active {time}',
'overview.noRecentActivity': 'No recent Deal activity', 'overview.noRecentActivity': 'No recent workspace activity',
'overview.refresh': 'Refresh', 'overview.refresh': 'Refresh',
'overview.summary': 'Deal overview summary', 'overview.summary': 'Workspace overview summary',
'overview.continue': 'Continue working', 'overview.continue': 'Continue working',
'overview.continueHint': 'Pick up the next useful item in this Deal.', 'overview.continueHint': 'Pick up the next useful item in this workspace.',
'overview.loadingSignals': 'Loading Deal signals...', 'overview.loadingSignals': 'Loading workspace signals...',
'overview.noResume': 'No clear resume point yet', 'overview.noResume': 'No clear resume point yet',
'overview.noResumeHint': 'Recent notes, files, captures, and journal entries will appear here.', 'overview.noResumeHint': 'Recent notes, files, captures, and journal entries will appear here.',
'overview.recentChanges': 'Recent changes', 'overview.recentChanges': 'Recent changes',
'overview.recentChangesHint': 'Latest meaningful activity in this Deal.', 'overview.recentChangesHint': 'Latest meaningful activity in this workspace.',
'overview.event.workspaceOpened': 'Opened Deal',
'overview.event.activity': 'Deal activity',
'overview.event.noteEdited': 'Edited note "{name}"',
'overview.event.noteOpened': 'Opened note "{name}"',
'overview.event.noteCreated': 'Created note "{name}"',
'overview.event.fileOpened': 'Opened file "{name}"',
'overview.event.fileChanged': 'Changed file "{name}"',
'overview.event.fileCreated': 'Created file "{name}"',
'overview.event.fileRemoved': 'Removed file "{name}"',
'overview.event.capturePage': 'Captured page "{name}"',
'overview.event.captureSelection': 'Captured selection "{name}"',
'overview.event.captureLink': 'Captured link "{name}"',
'overview.event.captureFile': 'Captured file "{name}"',
'overview.event.captureConverted': 'Converted capture "{name}"',
'overview.event.capture': 'Captured {kind} "{title}"',
'overview.event.journalAdded': 'Added journal entry "{name}"',
'overview.event.workSessionDetected': 'Work session detected',
'overview.event.todo': 'Todo "{title}"',
'overview.event.reviewCapture': 'Review capture "{title}"',
'overview.event.continueJournal': 'Continue journal entry "{title}"',
'overview.event.possibleJournalEntry': 'Possible journal entry',
'overview.captureKind.page': 'page',
'overview.captureKind.selection': 'selection',
'overview.captureKind.link': 'link',
'overview.captureKind.file': 'file',
'overview.captureKind.item': 'item',
'overview.todo.reminderDue': 'Reminder due',
'overview.todo.overdue': 'Overdue',
'overview.todo.dueSoon': 'Due soon',
'overview.todo.due': 'Due {date}',
'overview.candidateMeta': 'Deal: {deal} · {minutes} min · {activities} activities',
'overview.minutes': '{count} min',
'overview.browserCapture': 'Browser capture',
'overview.untitledTodo': 'Untitled todo',
'overview.untitledCapture': 'Untitled capture',
'overview.untitledJournal': 'Journal entry',
'overview.unknownDeal': 'Unknown Deal',
'overview.item': 'item',
'overview.overviewNote': 'Deal overview note',
'overview.recentFilter': 'Recent changes filter', 'overview.recentFilter': 'Recent changes filter',
'overview.loadingRecent': 'Loading recent changes...', 'overview.loadingRecent': 'Loading recent changes...',
'overview.noChanges': 'No meaningful changes for this filter yet.', 'overview.noChanges': 'No meaningful changes for this filter yet.',

View File

@ -16,13 +16,13 @@ export default {
'sidebar.error.load': 'Не удалось загрузить боковую панель', 'sidebar.error.load': 'Не удалось загрузить боковую панель',
'sidebar.error.ui': 'Ошибка интерфейса плагина', 'sidebar.error.ui': 'Ошибка интерфейса плагина',
'workspace.overview': 'Обзор', 'workspace.overview': 'Обзор',
'workspace.search': 'Поиск в Деле', 'workspace.search': 'Поиск в рабочем пространстве',
'workspace.tools': 'Инструменты Дела', 'workspace.tools': 'Инструменты рабочего пространства',
'workspace.tool': 'Инструмент Дела', 'workspace.tool': 'Инструмент рабочего пространства',
'workspace.emptyTools': 'Нет доступных инструментов', 'workspace.emptyTools': 'Нет доступных инструментов',
'workspace.emptyToolsHint': 'Включите плагины с инструментами Дела или откройте Менеджер плагинов в настройках.', 'workspace.emptyToolsHint': 'Включите плагины с инструментами рабочего пространства или откройте Менеджер плагинов в настройках.',
'workspace.select': 'Выберите Дело', 'workspace.select': 'Выберите рабочее пространство',
'workspace.selectHint': 'Используйте кнопку + в разделе Дел, чтобы добавить первый проект.', 'workspace.selectHint': 'Используйте кнопку + в разделе рабочих пространств, чтобы добавить первый проект.',
'common.details': 'Подробнее', 'common.details': 'Подробнее',
'common.plugin': 'Плагин', 'common.plugin': 'Плагин',
'common.component': 'Компонент', 'common.component': 'Компонент',
@ -74,7 +74,7 @@ export default {
'pluginCard.count.sidebar': 'элементов боковой панели: {count}', 'pluginCard.count.sidebar': 'элементов боковой панели: {count}',
'pluginCard.count.statusbar': 'элементов строки состояния: {count}', 'pluginCard.count.statusbar': 'элементов строки состояния: {count}',
'pluginCard.count.openProviders': 'обработчиков открытия: {count}', 'pluginCard.count.openProviders': 'обработчиков открытия: {count}',
'pluginCard.count.workspace': 'инструментов Дела: {count}', 'pluginCard.count.workspace': 'инструментов рабочего пространства: {count}',
'pluginCard.degraded': 'Плагин работает, но некоторые необязательные capabilities недоступны.', 'pluginCard.degraded': 'Плагин работает, но некоторые необязательные capabilities недоступны.',
'pluginCard.name': 'Название', 'pluginCard.name': 'Название',
'pluginCard.contributions': 'Элементы интерфейса', 'pluginCard.contributions': 'Элементы интерфейса',
@ -96,12 +96,12 @@ export default {
'common.loading': 'Загрузка...', 'common.loading': 'Загрузка...',
'common.browse': 'Обзор...', 'common.browse': 'Обзор...',
'vaultSelection.chooseNew': 'Выберите или введите папку для нового хранилища.', 'vaultSelection.chooseNew': 'Выберите или введите папку для нового хранилища.',
'vaultSelection.createError': 'Не удалось создать хранилище. Повторите попытку.', 'vaultSelection.createError': 'Не удалось создать хранилище: {error}',
'vaultSelection.openError': 'Не удалось открыть хранилище. Повторите попытку.', 'vaultSelection.openError': 'Не удалось открыть хранилище: {error}',
'vaultSelection.chooseExisting': 'Выберите или введите существующее хранилище.', 'vaultSelection.chooseExisting': 'Выберите или введите существующее хранилище.',
'vaultSelection.subtitle': 'Выберите хранилище, чтобы начать работу', 'vaultSelection.subtitle': 'Выберите хранилище, чтобы начать работу',
'vaultSelection.createTitle': 'Создать новое хранилище', 'vaultSelection.createTitle': 'Создать новое хранилище',
'vaultSelection.createHint': 'Создайте локальную папку хранилища для Дел и проектов.', 'vaultSelection.createHint': 'Создайте локальную папку хранилища для рабочих пространств и проектов.',
'vaultSelection.pathPlaceholder': 'Выберите или введите путь...', 'vaultSelection.pathPlaceholder': 'Выберите или введите путь...',
'vaultSelection.creating': 'Создание...', 'vaultSelection.creating': 'Создание...',
'vaultSelection.create': 'Создать хранилище', 'vaultSelection.create': 'Создать хранилище',
@ -112,45 +112,21 @@ export default {
'vaultSelection.recent': 'Недавние хранилища', 'vaultSelection.recent': 'Недавние хранилища',
'common.cancel': 'Отмена', 'common.cancel': 'Отмена',
'common.close': 'Закрыть', 'common.close': 'Закрыть',
'workspaceTree.title': 'Дела', 'workspaceTree.title': 'Рабочие пространства',
'workspaceTree.new': 'Новое Дело', 'workspaceTree.new': 'Новое рабочее пространство',
'workspaceTree.nameRequired': 'Введите название', 'workspaceTree.nameRequired': 'Введите название',
'workspaceTree.chooseTemplate': 'Выберите шаблон Дела', 'workspaceTree.chooseTemplate': 'Выберите шаблон рабочего пространства',
'workspaceTree.saveRename': 'Сохранить новое название', 'workspaceTree.saveRename': 'Сохранить новое название',
'workspaceTree.rename': 'Переименовать Дело', 'workspaceTree.rename': 'Переименовать рабочее пространство',
'workspaceTree.trash': 'Переместить Дело в корзину', 'workspaceTree.trash': 'Переместить рабочее пространство в корзину',
'workspaceTree.create': 'Создать Дело', 'workspaceTree.create': 'Создать рабочее пространство',
'workspaceTree.namePlaceholder': 'Название Дела', 'workspaceTree.namePlaceholder': 'Название рабочего пространства',
'workspaceTree.template': 'Шаблон', 'workspaceTree.template': 'Шаблон',
'workspaceTree.creating': 'Создание...', 'workspaceTree.creating': 'Создание...',
'workspaceTree.templateAvailable': 'Доступен',
'workspaceTree.templateLimited': 'Доступен с ограничениями',
'workspaceTree.templateMissingPlugin': 'Плагин не установлен',
'workspaceTree.templatePluginDisabled': 'Плагин выключен',
'workspaceTree.templateCapabilityUnavailable': 'Не хватает обязательной возможности',
'workspaceTree.templateIncompatible': 'Плагин несовместим с этой версией Верстака',
'workspaceTree.templateLoadFailed': 'Плагин не удалось загрузить',
'workspaceTree.templateNotReady': 'Плагин ещё не готов к работе',
'workspaceTree.templateToolTabs': 'Создаёт вкладки: {tabs}',
'workspaceTree.templateToolNoTabs': 'Не предоставляет вкладку Дела',
'workspaceTree.templateWillBeIncomplete': 'Дело будет создано без недоступных компонентов.',
'workspaceTree.templateIncompleteCreated': 'Дело «{name}» создано с недоступными компонентами.',
'workspaceTree.templatesError': 'Не удалось загрузить шаблоны Дел. Повторите попытку.',
'workspaceTree.loadError': 'Не удалось загрузить Дела. Повторите попытку.',
'workspaceTree.selectError': 'Не удалось выбрать это Дело. Повторите попытку.',
'workspaceTree.createError': 'Не удалось создать Дело. Повторите попытку.',
'workspaceTree.renameError': 'Не удалось переименовать Дело. Повторите попытку.',
'workspaceTree.trashError': 'Не удалось переместить Дело в корзину. Повторите попытку.',
'pluginManager.loadError': 'Не удалось загрузить плагины. Повторите попытку.',
'pluginManager.reloadError': 'Не удалось перезагрузить плагины. Повторите попытку.',
'pluginManager.enableError': 'Не удалось включить плагин. Повторите попытку.',
'pluginManager.disableError': 'Не удалось выключить плагин. Повторите попытку.',
'pluginManager.settingsLoadError': 'Не удалось загрузить настройки плагина. Повторите попытку.',
'pluginManager.settingsUnavailable': 'Настройки плагина недоступны.',
'command.openOverview': 'Открыть обзор', 'command.openOverview': 'Открыть обзор',
'command.openFiles': 'Открыть файлы', 'command.openFiles': 'Открыть файлы',
'command.openActivity': 'Открыть активность', 'command.openActivity': 'Открыть активность',
'command.openBrowserInbox': 'Открыть Браузер', 'command.openBrowserInbox': 'Открыть входящие из браузера',
'command.createMarkdown': 'Создать файл Markdown', 'command.createMarkdown': 'Создать файл Markdown',
'command.createText': 'Создать текстовый файл', 'command.createText': 'Создать текстовый файл',
'command.syncNow': 'Синхронизировать сейчас', 'command.syncNow': 'Синхронизировать сейчас',
@ -158,7 +134,6 @@ export default {
'command.openPluginManager': 'Открыть менеджер плагинов', 'command.openPluginManager': 'Открыть менеджер плагинов',
'command.handled': 'Выполнено: {title}', 'command.handled': 'Выполнено: {title}',
'command.result': '{title}: {status}', 'command.result': '{title}: {status}',
'command.failed': 'Не удалось выполнить команду «{title}». Повторите попытку.',
'command.statusHandled': 'выполнено', 'command.statusHandled': 'выполнено',
'command.palette': 'Палитра команд', 'command.palette': 'Палитра команд',
'command.run': 'Выполнить команду', 'command.run': 'Выполнить команду',
@ -167,26 +142,26 @@ export default {
'search.placeholder': 'Поиск', 'search.placeholder': 'Поиск',
'search.global': 'Глобальный поиск', 'search.global': 'Глобальный поиск',
'search.noResults': 'Ничего не найдено', 'search.noResults': 'Ничего не найдено',
'search.type.workspace': 'Дело', 'search.type.workspace': 'Рабочее пространство',
'search.type.tool': 'Инструмент', 'search.type.tool': 'Инструмент',
'search.type.folder': 'Папка', 'search.type.folder': 'Папка',
'search.type.file': 'Файл', 'search.type.file': 'Файл',
'search.type.journal': 'Журнал', 'search.type.journal': 'Журнал',
'search.type.browserInbox': 'Браузер', 'search.type.browserInbox': 'Входящие из браузера',
'search.type.activity': 'Активность', 'search.type.activity': 'Активность',
'workbench.noProvider': 'Нет обработчика', 'workbench.noProvider': 'Нет обработчика',
'workbench.noViewer': 'Нет доступного средства просмотра или редактора', 'workbench.noViewer': 'Нет доступного средства просмотра или редактора',
'workbench.noResource': 'Ресурс не открыт', 'workbench.noResource': 'Ресурс не открыт',
'bundle.noFrontend': 'У плагина нет frontend bundle', 'bundle.noFrontend': 'У плагина нет frontend bundle',
'bundle.notFound': 'Плагин не найден', 'bundle.notFound': 'Плагин не найден',
'bundle.loadFailed': 'Не удалось загрузить интерфейс плагина. Повторите попытку.', 'bundle.loadFailed': 'Не удалось загрузить bundle: {error}',
'bundle.emptyContent': 'пустое содержимое', 'bundle.emptyContent': 'пустое содержимое',
'bundle.executionError': 'Не удалось запустить интерфейс плагина. Повторите попытку.', 'bundle.executionError': 'Ошибка выполнения bundle: {error}',
'bundle.registrationMissing': 'Bundle загружен, но вызов VerstakPluginRegister не обнаружен', 'bundle.registrationMissing': 'Bundle загружен, но вызов VerstakPluginRegister не обнаружен',
'bundle.componentMissing': 'Запрошенный интерфейс плагина недоступен.', 'bundle.componentMissing': 'Компонент «{component}» не найден в bundle. Доступны: {available}',
'bundle.mountError': 'Не удалось открыть интерфейс плагина. Повторите попытку.', 'bundle.mountError': 'Ошибка подключения компонента: {error}',
'bundle.mountUnavailable': 'Контейнер для подключения недоступен', 'bundle.mountUnavailable': 'Контейнер для подключения недоступен',
'bundle.unexpectedError': 'Не удалось открыть интерфейс плагина. Повторите попытку.', 'bundle.unexpectedError': 'Непредвиденная ошибка: {error}',
'bundle.unknownError': 'Неизвестная ошибка', 'bundle.unknownError': 'Неизвестная ошибка',
'bundle.frontendEntry': 'Точка входа frontend', 'bundle.frontendEntry': 'Точка входа frontend',
'bundle.availableComponents': 'Доступные компоненты', 'bundle.availableComponents': 'Доступные компоненты',
@ -222,57 +197,18 @@ export default {
'overview.count.journal.many': 'Записей журнала: {count}', 'overview.count.journal.many': 'Записей журнала: {count}',
'overview.count.pending.one': 'Ожидают: {count}', 'overview.count.pending.one': 'Ожидают: {count}',
'overview.count.pending.many': 'Ожидают: {count}', 'overview.count.pending.many': 'Ожидают: {count}',
'overview.loadingContext': 'Загрузка данных Дела...', 'overview.loadingContext': 'Загрузка данных рабочего пространства...',
'overview.lastActive': 'Последняя активность: {time}', 'overview.lastActive': 'Последняя активность: {time}',
'overview.noRecentActivity': 'В Деле пока нет недавней активности', 'overview.noRecentActivity': 'В рабочем пространстве пока нет недавней активности',
'overview.refresh': 'Обновить', 'overview.refresh': 'Обновить',
'overview.summary': 'Сводка Дела', 'overview.summary': 'Сводка рабочего пространства',
'overview.continue': 'Продолжить работу', 'overview.continue': 'Продолжить работу',
'overview.continueHint': 'Вернитесь к следующему полезному делу в этом Деле.', 'overview.continueHint': 'Вернитесь к следующему полезному делу в этом рабочем пространстве.',
'overview.loadingSignals': 'Загрузка данных Дела...', 'overview.loadingSignals': 'Загрузка рабочих данных...',
'overview.noResume': 'Пока неясно, с чего продолжить', 'overview.noResume': 'Пока неясно, с чего продолжить',
'overview.noResumeHint': 'Здесь появятся недавние заметки, файлы, сохранённое и записи журнала.', 'overview.noResumeHint': 'Здесь появятся недавние заметки, файлы, сохранённое и записи журнала.',
'overview.recentChanges': 'Недавние изменения', 'overview.recentChanges': 'Недавние изменения',
'overview.recentChangesHint': 'Последняя важная активность в этом Деле.', 'overview.recentChangesHint': 'Последняя важная активность в этом рабочем пространстве.',
'overview.event.workspaceOpened': 'Открыто Дело',
'overview.event.activity': 'Активность Дела',
'overview.event.noteEdited': 'Изменена заметка «{name}»',
'overview.event.noteOpened': 'Открыта заметка «{name}»',
'overview.event.noteCreated': 'Создана заметка «{name}»',
'overview.event.fileOpened': 'Открыт файл «{name}»',
'overview.event.fileChanged': 'Изменён файл «{name}»',
'overview.event.fileCreated': 'Создан файл «{name}»',
'overview.event.fileRemoved': 'Удалён файл «{name}»',
'overview.event.capturePage': 'Сохранена страница «{name}»',
'overview.event.captureSelection': 'Сохранено выделение «{name}»',
'overview.event.captureLink': 'Сохранена ссылка «{name}»',
'overview.event.captureFile': 'Сохранён файл «{name}»',
'overview.event.captureConverted': 'Сохранённое преобразовано: «{name}»',
'overview.event.capture': 'Сохранено: {kind} «{title}»',
'overview.event.journalAdded': 'Добавлена запись в журнал «{name}»',
'overview.event.workSessionDetected': 'Обнаружена рабочая сессия',
'overview.event.todo': 'Задача «{title}»',
'overview.event.reviewCapture': 'Разобрать сохранённое «{title}»',
'overview.event.continueJournal': 'Продолжить запись журнала «{title}»',
'overview.event.possibleJournalEntry': 'Возможная запись в журнал',
'overview.captureKind.page': 'страница',
'overview.captureKind.selection': 'выделение',
'overview.captureKind.link': 'ссылка',
'overview.captureKind.file': 'файл',
'overview.captureKind.item': 'элемент',
'overview.todo.reminderDue': 'Пора напомнить',
'overview.todo.overdue': 'Просрочено',
'overview.todo.dueSoon': 'Скоро срок',
'overview.todo.due': 'Срок: {date}',
'overview.candidateMeta': 'Дело: {deal} · {minutes} мин. · активностей: {activities}',
'overview.minutes': '{count} мин.',
'overview.browserCapture': 'Сохранённое из браузера',
'overview.untitledTodo': 'Задача без названия',
'overview.untitledCapture': 'Сохранённое без названия',
'overview.untitledJournal': 'Запись журнала',
'overview.unknownDeal': 'Неизвестное Дело',
'overview.item': 'элемент',
'overview.overviewNote': 'Обзорная заметка Дела',
'overview.recentFilter': 'Фильтр недавних изменений', 'overview.recentFilter': 'Фильтр недавних изменений',
'overview.loadingRecent': 'Загрузка недавних изменений...', 'overview.loadingRecent': 'Загрузка недавних изменений...',
'overview.noChanges': 'Для этого фильтра пока нет важных изменений.', 'overview.noChanges': 'Для этого фильтра пока нет важных изменений.',

View File

@ -127,11 +127,9 @@ export var debug = {
log: log, log: log,
logf: logf, logf: logf,
isEnabled: function () { return ENABLED; }, isEnabled: function () { return ENABLED; },
enable: function (options) { enable: function () {
ENABLED = true; ENABLED = true;
if (!options || options.persist !== false) {
try { localStorage.setItem('verstak-debug', 'true'); } catch (e) {} try { localStorage.setItem('verstak-debug', 'true'); } catch (e) {}
}
}, },
disable: function () { disable: function () {
ENABLED = false; ENABLED = false;

View File

@ -83,11 +83,6 @@
return { value: result, error: '' }; return { value: result, error: '' };
} }
function reportError(key, fallback, details) {
console.warn('[PluginBundleHost] ' + key + ':', details);
return tr(key, undefined, fallback);
}
async function loadAndMount(pId, compId, nextPropsKey) { async function loadAndMount(pId, compId, nextPropsKey) {
// If same plugin+component and already mounted, skip // If same plugin+component and already mounted, skip
if (currentPluginId === pId && currentComponent === compId && currentPropsKey === nextPropsKey && loadState === 'loaded') { if (currentPluginId === pId && currentComponent === compId && currentPropsKey === nextPropsKey && loadState === 'loaded') {
@ -130,7 +125,7 @@
const content = assetResult.value; const content = assetResult.value;
if (assetResult.error || !content) { if (assetResult.error || !content) {
loadState = 'error'; loadState = 'error';
errorText = reportError('bundle.loadFailed', 'Could not load the plugin interface. Please try again.', assetResult.error || tr('bundle.emptyContent')); errorText = tr('bundle.loadFailed', { error: assetResult.error || tr('bundle.emptyContent') });
return; return;
} }
@ -141,7 +136,8 @@
fn(); fn();
} catch (e) { } catch (e) {
loadState = 'error'; loadState = 'error';
errorText = reportError('bundle.executionError', 'Could not start the plugin interface. Please try again.', e); errorText = tr('bundle.executionError', { error: e.message });
console.error('[PluginBundleHost] bundle exec error:', e);
return; return;
} }
@ -158,7 +154,10 @@
const comp = components[compId]; const comp = components[compId];
if (!comp || !comp.mount) { if (!comp || !comp.mount) {
loadState = 'error'; loadState = 'error';
errorText = tr('bundle.componentMissing', undefined, 'The requested plugin interface is unavailable.'); errorText = tr('bundle.componentMissing', {
component: compId,
available: Object.keys(components).join(', ') || tr('common.none'),
});
return; return;
} }
@ -178,7 +177,8 @@
errorText = ''; errorText = '';
} catch (e) { } catch (e) {
loadState = 'error'; loadState = 'error';
errorText = reportError('bundle.mountError', 'Could not open the plugin interface. Please try again.', e); errorText = tr('bundle.mountError', { error: e.message });
console.error('[PluginBundleHost] mount error:', e);
} }
} else { } else {
loadState = 'error'; loadState = 'error';
@ -186,7 +186,8 @@
} }
} catch (e) { } catch (e) {
loadState = 'error'; loadState = 'error';
errorText = reportError('bundle.unexpectedError', 'Could not open the plugin interface. Please try again.', e); errorText = tr('bundle.unexpectedError', { error: e.message || e });
console.error('[PluginBundleHost] error:', e);
} }
} }
@ -207,18 +208,17 @@
<div class="host-state error"> <div class="host-state error">
<Icon name="warning" size={24} class="error-icon" /> <Icon name="warning" size={24} class="error-icon" />
<p class="error-title">{tr('pluginView.error')}</p> <p class="error-title">{tr('pluginView.error')}</p>
<p class="error-message">{errorText || tr('bundle.unknownError')}</p> <div class="error-details">
<details class="error-details">
<summary>{tr('common.details')}</summary>
<p><strong>{tr('common.plugin')}:</strong> {currentPluginId || tr('common.unknown')}</p> <p><strong>{tr('common.plugin')}:</strong> {currentPluginId || tr('common.unknown')}</p>
<p><strong>{tr('common.component')}:</strong> {currentComponent || 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} {#if pluginInfo}
<p class="error-meta">{tr('bundle.frontendEntry')}: {pluginInfo.entry || tr('common.none')}</p> <p class="error-meta">{tr('bundle.frontendEntry')}: {pluginInfo.entry || tr('common.none')}</p>
{/if} {/if}
{#if getComponentList().length > 0} {#if getComponentList().length > 0}
<p class="error-meta">{tr('bundle.availableComponents')}: {getComponentList().join(', ')}</p> <p class="error-meta">{tr('bundle.availableComponents')}: {getComponentList().join(', ')}</p>
{/if} {/if}
</details> </div>
</div> </div>
{:else} {:else}
@ -303,21 +303,11 @@
max-width: 400px; max-width: 400px;
text-align: left; text-align: left;
background: #16213e; background: #16213e;
padding: 0.5rem 0.75rem; padding: 1rem;
border-radius: 6px; border-radius: 6px;
border: 1px solid #0f3460; border: 1px solid #0f3460;
} }
.error-details[open] {
padding: 0.75rem 1rem;
}
.error-details summary {
cursor: pointer;
color: #e0e0f0;
font-weight: 600;
}
.error-details p { .error-details p {
margin: 0.3rem 0; margin: 0.3rem 0;
} }
@ -328,8 +318,12 @@
.error-message { .error-message {
color: #e94560; color: #e94560;
max-width: 400px; font-family: monospace;
margin: 0 0 0.75rem; font-size: 0.8rem;
margin-top: 0.5rem !important;
padding: 0.5rem;
background: rgba(233, 69, 96, 0.1);
border-radius: 4px;
} }
.error-meta { .error-meta {

View File

@ -220,24 +220,6 @@ export function createPluginAPI(pluginId) {
} }
}, },
notifications: {
replace: function(items) {
assertActive('notifications.replace');
if (!Array.isArray(items)) {
throw new Error('notifications.replace requires an array');
}
return callBackendErrorString(pluginId, 'notifications.replace', function() {
return App.ReplacePluginNotifications(pluginId, items);
});
},
clear: function() {
assertActive('notifications.clear');
return callBackendErrorString(pluginId, 'notifications.clear', function() {
return App.ClearPluginNotifications(pluginId);
});
}
},
settings: { settings: {
read: async function(key) { read: async function(key) {
assertActive('settings.read'); assertActive('settings.read');

View File

@ -53,12 +53,6 @@
}, 4000); }, 4000);
} }
function reportError(key, fallback, details) {
debug.log('[PluginManager] ' + key + ':', String(details));
WriteFrontendLog('PluginManager', key + ': ' + String(details)).catch(() => {});
return tr(key, undefined, fallback);
}
function notifyPluginsChanged() { function notifyPluginsChanged() {
window.dispatchEvent(new CustomEvent('verstak:plugins-changed')); window.dispatchEvent(new CustomEvent('verstak:plugins-changed'));
} }
@ -95,14 +89,14 @@
ReadPluginSettings(pluginId).then(result => { ReadPluginSettings(pluginId).then(result => {
const unpacked = unpackBackendResult(result); const unpacked = unpackBackendResult(result);
if (unpacked.error) { if (unpacked.error) {
settingsError = reportError('pluginManager.settingsLoadError', 'Could not load plugin settings. Please try again.', unpacked.error); settingsError = unpacked.error;
settingsData = {}; settingsData = {};
return; return;
} }
settingsData = unpacked.value || {}; settingsData = unpacked.value || {};
}).catch(() => { settingsData = {}; }); }).catch(() => { settingsData = {}; });
} else { } else {
settingsError = tr('pluginManager.settingsUnavailable', undefined, 'Plugin settings are unavailable.'); settingsError = `Settings panel not found for plugin "${pluginId}". Check that the plugin is enabled and has settingsPanels in its manifest.`;
} }
} }
@ -133,7 +127,7 @@
} catch (e) { } catch (e) {
debug.log('[PluginManager] loadAll: GetPlugins ERROR:', String(e)); debug.log('[PluginManager] loadAll: GetPlugins ERROR:', String(e));
WriteFrontendLog('PluginManager', 'loadAll: GetPlugins ERROR: ' + String(e)); WriteFrontendLog('PluginManager', 'loadAll: GetPlugins ERROR: ' + String(e));
error = reportError('pluginManager.loadError', 'Could not load plugins. Please try again.', e); error = 'GetPlugins: ' + String(e);
loading = false; loading = false;
return; return;
} }
@ -197,7 +191,7 @@
resultMsg = `Reloaded ${count} plugin(s). ${summary}`; resultMsg = `Reloaded ${count} plugin(s). ${summary}`;
} catch (e) { } catch (e) {
debug.log('[PluginManager] reload: ReloadPlugins ERROR:', String(e)); debug.log('[PluginManager] reload: ReloadPlugins ERROR:', String(e));
error = reportError('pluginManager.reloadError', 'Could not reload plugins. Please try again.', e); error = 'Reload: ' + String(e);
reloading = false; reloading = false;
return; return;
} }
@ -217,7 +211,7 @@
if (err) { if (err) {
debug.log('[PluginManager] enablePlugin: ERROR:', err); debug.log('[PluginManager] enablePlugin: ERROR:', err);
actionFeedback = { ...actionFeedback, [pluginId]: null }; actionFeedback = { ...actionFeedback, [pluginId]: null };
error = reportError('pluginManager.enableError', 'Could not enable the plugin. Please try again.', err); error = 'Enable: ' + err;
return; return;
} }
debug.log('[PluginManager] enablePlugin: success, reloading...'); debug.log('[PluginManager] enablePlugin: success, reloading...');
@ -238,7 +232,7 @@
if (err) { if (err) {
debug.log('[PluginManager] disablePlugin: ERROR:', err); debug.log('[PluginManager] disablePlugin: ERROR:', err);
actionFeedback = { ...actionFeedback, [pluginId]: null }; actionFeedback = { ...actionFeedback, [pluginId]: null };
error = reportError('pluginManager.disableError', 'Could not disable the plugin. Please try again.', err); error = 'Disable: ' + err;
return; return;
} }
debug.log('[PluginManager] disablePlugin: success, reloading...'); debug.log('[PluginManager] disablePlugin: success, reloading...');

View File

@ -3,7 +3,6 @@
import * as App from '../../../wailsjs/go/api/App'; import * as App from '../../../wailsjs/go/api/App';
import { executePluginCommand } from '../plugin-host/VerstakPluginAPI.js'; import { executePluginCommand } from '../plugin-host/VerstakPluginAPI.js';
import { i18n } from '../i18n/index.js'; import { i18n } from '../i18n/index.js';
import { debug } from '../log/debug.js';
let open = false; let open = false;
let query = ''; let query = '';
@ -195,12 +194,7 @@
closePalette(); closePalette();
setStatus('success', tr('command.result', { title: command.title || command.id, status: result.status || tr('command.statusHandled') })); setStatus('success', tr('command.result', { title: command.title || command.id, status: result.status || tr('command.statusHandled') }));
} catch (err) { } catch (err) {
const details = err?.message || String(err); setStatus('error', `${command.title || command.id}: ${err?.message || String(err)}`);
debug.log('[CommandPalette] command failed:', details);
App.WriteFrontendLog('CommandPalette', `command failed: ${details}`).catch(() => {});
setStatus('error', tr('command.failed', {
title: command.title || command.id,
}, `Could not run ${command.title || command.id}. Please try again.`));
} }
} }

View File

@ -8,8 +8,6 @@
import { i18n } from '../i18n/index.js'; import { i18n } from '../i18n/index.js';
export let showGlobalSearch = true; export let showGlobalSearch = true;
export let activeView = null;
export let activeViewPluginId = '';
function flog(msg) { function flog(msg) {
App.WriteFrontendLog('Sidebar', msg); App.WriteFrontendLog('Sidebar', msg);
@ -28,7 +26,6 @@
})(locale); })(locale);
$: vaultOpen = vaultStatus.status === 'open'; $: vaultOpen = vaultStatus.status === 'open';
$: activeSidebarKey = activeView ? `${activeViewPluginId}:${activeView}` : '';
async function loadSidebar() { async function loadSidebar() {
debug.log('[Sidebar] onMount: START'); debug.log('[Sidebar] onMount: START');
@ -110,8 +107,6 @@
{#each sidebarItems as item} {#each sidebarItems as item}
<button <button
class="nav-item plugin-item vt-list-row" class="nav-item plugin-item vt-list-row"
class:is-active={activeSidebarKey === `${item.pluginId || ''}:${item.view || item.id}`}
aria-current={activeSidebarKey === `${item.pluginId || ''}:${item.view || item.id}` ? 'page' : undefined}
on:click={() => handleSidebarItem(item)} on:click={() => handleSidebarItem(item)}
type="button" type="button"
> >
@ -215,16 +210,6 @@
color: var(--vt-color-text-primary); color: var(--vt-color-text-primary);
} }
.nav-item.is-active {
background: var(--vt-color-surface-selected);
color: var(--vt-color-accent);
}
.nav-item.is-active :global(.nav-icon.icon-plugin) {
color: currentColor;
opacity: 1;
}
:global(.nav-icon) { :global(.nav-icon) {
width: 0.9rem; width: 0.9rem;
height: 0.9rem; height: 0.9rem;

View File

@ -30,7 +30,6 @@
let keyResources = []; let keyResources = [];
let totalNotes = 0; let totalNotes = 0;
let loadedWorkspaceRoot = ''; let loadedWorkspaceRoot = '';
let loadedToolKey = '';
let toolProbe = 0; let toolProbe = 0;
let locale = i18n.getLocale(); let locale = i18n.getLocale();
let unsubscribeLocale = null; let unsubscribeLocale = null;
@ -39,25 +38,17 @@
void activeLocale; void activeLocale;
return i18n.t(key, params, fallback); return i18n.t(key, params, fallback);
})(locale); })(locale);
$: FILTERS = ['all', 'notes', 'files', 'captures', 'journal'].map((key) => ({
key,
label: tr(`overview.filter.${key}`),
}));
function countText(key, count, params = {}) { function countText(key, count, params = {}) {
return tr(`${key}.${count === 1 ? 'one' : 'many'}`, { count, ...params }); return tr(`${key}.${count === 1 ? 'one' : 'many'}`, { count, ...params });
} }
$: hasNotes = hasTool('notes', availableTools); $: hasNotes = hasTool('notes', availableTools);
$: hasFiles = hasTool('files', availableTools);
$: hasBrowserInbox = hasTool('browser-inbox', availableTools);
$: hasActivity = hasTool('activity', availableTools);
$: hasJournal = hasTool('journal', availableTools);
$: hasTodos = hasTool('todo', availableTools); $: hasTodos = hasTool('todo', availableTools);
$: overviewToolKey = [hasNotes, hasFiles, hasBrowserInbox, hasActivity, hasJournal, hasTodos].join('|'); $: recentChanges = buildRecentChanges(activityEvents, captures, journalEntries);
$: FILTERS = ['all']
.concat(hasNotes ? ['notes'] : [])
.concat(hasFiles ? ['files'] : [])
.concat(hasBrowserInbox ? ['captures'] : [])
.concat(hasJournal ? ['journal'] : [])
.map((key) => ({ key, label: tr(`overview.filter.${key}`) }));
$: if (!FILTERS.some(filter => filter.key === activeFilter)) activeFilter = 'all';
$: recentChanges = filterAvailableItems(buildRecentChanges(activityEvents, captures, journalEntries), overviewToolKey);
$: filteredRecentChanges = activeFilter === 'all' $: filteredRecentChanges = activeFilter === 'all'
? recentChanges ? recentChanges
: recentChanges.filter(item => item.category === activeFilter); : recentChanges.filter(item => item.category === activeFilter);
@ -67,19 +58,18 @@
$: linkedCandidateIds = new Set(journalEntries.map(item => String(item?.sourceCandidateId || '')).filter(Boolean)); $: linkedCandidateIds = new Set(journalEntries.map(item => String(item?.sourceCandidateId || '')).filter(Boolean));
$: pendingWorkSessionCandidates = workSessionCandidates.filter(item => !linkedCandidateIds.has(String(item?.candidateId || ''))); $: pendingWorkSessionCandidates = workSessionCandidates.filter(item => !linkedCandidateIds.has(String(item?.candidateId || '')));
$: urgentTodos = hasTodos ? todos.filter(item => todoAttentionState(item)) : []; $: urgentTodos = hasTodos ? todos.filter(item => todoAttentionState(item)) : [];
$: needsAttention = filterAvailableItems(buildNeedsAttention(unprocessedCaptures, pendingWorkSessionCandidates, urgentTodos), overviewToolKey); $: needsAttention = buildNeedsAttention(unprocessedCaptures, pendingWorkSessionCandidates, urgentTodos);
$: continueItems = filterAvailableItems(buildContinueItems(activityEvents, unprocessedCaptures, journalEntries, urgentTodos), overviewToolKey); $: continueItems = buildContinueItems(activityEvents, unprocessedCaptures, journalEntries, urgentTodos);
$: hasAttentionTools = hasBrowserInbox || hasTodos || (hasActivity && hasJournal); $: attentionActionKind = needsAttention[0]?.actionKind || 'browser-inbox';
$: attentionActionKind = needsAttention[0]?.actionKind || fallbackAttentionAction();
$: lastActive = lastActiveDate([...recentChanges, ...continueItems], captures, journalEntries, todos); $: lastActive = lastActiveDate([...recentChanges, ...continueItems], captures, journalEntries, todos);
$: summaryItems = [ $: summaryItems = [
hasNotes ? { key: 'notes', label: tr('overview.notes'), count: totalNotes, detail: countText('overview.count.totalRecent', noteRecentChanges, { total: totalNotes, recent: noteRecentChanges }), actionKind: 'notes', actionLabel: tr('overview.openNotes') } : null, { key: 'notes', label: tr('overview.notes'), count: totalNotes, detail: countText('overview.count.totalRecent', noteRecentChanges, { total: totalNotes, recent: noteRecentChanges }), actionKind: 'notes', actionLabel: tr('overview.openNotes') },
hasFiles ? { key: 'files', label: tr('overview.files'), count: fileRecentChanges, detail: countText('overview.count.recentChanges', fileRecentChanges), actionKind: 'files', actionLabel: tr('overview.openFiles') } : null, { key: 'files', label: tr('overview.files'), count: fileRecentChanges, detail: countText('overview.count.recentChanges', fileRecentChanges), actionKind: 'files', actionLabel: tr('overview.openFiles') },
hasBrowserInbox ? { key: 'captures', label: tr('overview.captures'), count: unprocessedCaptures.length, detail: countText('overview.count.captures', unprocessedCaptures.length), actionKind: 'browser-inbox', actionLabel: tr('overview.reviewInbox') } : null, { key: 'captures', label: tr('overview.captures'), count: unprocessedCaptures.length, detail: countText('overview.count.captures', unprocessedCaptures.length), actionKind: 'browser-inbox', actionLabel: tr('overview.reviewInbox') },
hasActivity ? { key: 'activity', label: tr('overview.activity'), count: activityEvents.length, detail: countText('overview.count.events', activityEvents.length), actionKind: 'activity', actionLabel: tr('overview.viewActivity') } : null, { key: 'activity', label: tr('overview.activity'), count: activityEvents.length, detail: countText('overview.count.events', activityEvents.length), actionKind: 'activity', actionLabel: tr('overview.viewActivity') },
hasJournal ? { key: 'journal', label: tr('overview.journal'), count: journalEntries.length, detail: countText('overview.count.journal', journalEntries.length), actionKind: 'journal', actionLabel: tr('overview.openJournal') } : null, { key: 'journal', label: tr('overview.journal'), count: journalEntries.length, detail: countText('overview.count.journal', journalEntries.length), actionKind: 'journal', actionLabel: tr('overview.openJournal') },
hasAttentionTools ? { key: 'attention', label: tr('overview.attention'), count: needsAttention.length, detail: countText('overview.count.pending', needsAttention.length), actionKind: attentionActionKind, actionLabel: tr('overview.reviewPending') } : null, { key: 'attention', label: tr('overview.attention'), count: needsAttention.length, detail: countText('overview.count.pending', needsAttention.length), actionKind: attentionActionKind, actionLabel: tr('overview.reviewPending') },
].filter(Boolean); ];
onMount(() => { onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => locale = nextLocale); unsubscribeLocale = i18n.subscribe((nextLocale) => locale = nextLocale);
@ -88,7 +78,7 @@
onDestroy(() => unsubscribeLocale?.()); onDestroy(() => unsubscribeLocale?.());
$: if (workspaceRootPath && (workspaceRootPath !== loadedWorkspaceRoot || overviewToolKey !== loadedToolKey)) { $: if (workspaceRootPath && workspaceRootPath !== loadedWorkspaceRoot) {
loadOverview(); loadOverview();
} }
@ -99,29 +89,11 @@
const label = `${tool?.title || ''} ${tool?.id || ''} ${tool?.pluginId || ''}`.toLowerCase(); const label = `${tool?.title || ''} ${tool?.id || ''} ${tool?.pluginId || ''}`.toLowerCase();
return label.includes(name); return label.includes(name);
}); });
return fromProps; if (fromProps) return true;
} if (typeof document === 'undefined') return false;
return Array.from(document.querySelectorAll('.workspace-tabs [role="tab"]')).some(tab => {
function actionIsAvailable(kind) { return String(tab.textContent || '').trim().toLowerCase().includes(name);
if (kind === 'notes') return hasNotes; });
if (kind === 'files') return hasFiles;
if (kind === 'browser-inbox') return hasBrowserInbox;
if (kind === 'activity') return hasActivity;
if (kind === 'journal') return hasJournal;
if (kind === 'todo') return hasTodos;
return false;
}
function filterAvailableItems(items, _toolKey) {
void _toolKey;
return (items || []).filter(item => actionIsAvailable(item?.actionKind));
}
function fallbackAttentionAction() {
if (hasBrowserInbox) return 'browser-inbox';
if (hasTodos) return 'todo';
if (hasActivity && hasJournal) return 'journal';
return '';
} }
function decodeTuple(response, fallback) { function decodeTuple(response, fallback) {
@ -195,7 +167,7 @@
} }
function todoTitle(item) { function todoTitle(item) {
return String(item?.title || item?.name || tr('overview.untitledTodo')).trim() || tr('overview.untitledTodo'); return String(item?.title || item?.name || 'Untitled todo').trim() || 'Untitled todo';
} }
function todoAttentionState(item) { function todoAttentionState(item) {
@ -203,9 +175,9 @@
const now = Date.now(); const now = Date.now();
const dueAt = todoDateMs(item?.dueAt); const dueAt = todoDateMs(item?.dueAt);
const reminderAt = todoDateMs(item?.reminderAt); const reminderAt = todoDateMs(item?.reminderAt);
if (reminderAt && reminderAt <= now) return tr('overview.todo.reminderDue'); if (reminderAt && reminderAt <= now) return 'Reminder due';
if (dueAt && dueAt < now) return tr('overview.todo.overdue'); if (dueAt && dueAt < now) return 'Overdue';
if (dueAt && dueAt <= now + 3 * 24 * 60 * 60 * 1000) return tr('overview.todo.dueSoon'); if (dueAt && dueAt <= now + 3 * 24 * 60 * 60 * 1000) return 'Due soon';
return ''; return '';
} }
@ -238,12 +210,17 @@
return fileName(path).replace(/\.(md|markdown|txt)$/i, '').replace(/_/g, ' ') || 'Untitled'; return fileName(path).replace(/\.(md|markdown|txt)$/i, '').replace(/_/g, ' ') || 'Untitled';
} }
function quoted(value) {
const clean = String(value || '').trim();
return clean ? `"${clean}"` : '';
}
function entityName(item) { function entityName(item) {
const payload = item?.payload && typeof item.payload === 'object' ? item.payload : {}; const payload = item?.payload && typeof item.payload === 'object' ? item.payload : {};
const path = payload.path || payload.notePath || item?.path || item?.relativePath || looksLikePath(item?.summary); const path = payload.path || payload.notePath || item?.path || item?.relativePath || looksLikePath(item?.summary);
const title = String(item?.title || payload.title || '').trim(); const title = String(item?.title || payload.title || '').trim();
if (path && (!title || /^(saved note|file opened|file changed|activity event)$/i.test(title))) return titleFromPath(path); if (path && (!title || /^(saved note|file opened|file changed|activity event)$/i.test(title))) return titleFromPath(path);
return title || titleFromPath(path) || String(item?.summary || item?.activityId || tr('overview.item')); return title || titleFromPath(path) || String(item?.summary || item?.activityId || 'item');
} }
function looksLikePath(value) { function looksLikePath(value) {
@ -253,11 +230,11 @@
} }
function captureTitle(capture) { function captureTitle(capture) {
return capture?.title || capture?.fileName || capture?.url || capture?.captureId || tr('overview.untitledCapture'); return capture?.title || capture?.fileName || capture?.url || capture?.captureId || 'Untitled capture';
} }
function journalTitle(entry) { function journalTitle(entry) {
return entry?.title || entry?.summary || entry?.date || entry?.entryId || tr('overview.untitledJournal'); return entry?.title || entry?.summary || entry?.date || entry?.entryId || 'Journal entry';
} }
function itemTimeLabel(item) { function itemTimeLabel(item) {
@ -307,31 +284,22 @@
function activityTitle(item) { function activityTitle(item) {
const type = String(item?.type || '').toLowerCase(); const type = String(item?.type || '').toLowerCase();
const name = entityName(item); const name = entityName(item);
if (type === 'note.saved' || type === 'note.edited') return tr('overview.event.noteEdited', { name }); if (type === 'note.saved' || type === 'note.edited') return `Edited note ${quoted(name)}`;
if (type === 'note.opened') return tr('overview.event.noteOpened', { name }); if (type === 'note.opened') return `Opened note ${quoted(name)}`;
if (type === 'note.created') return tr('overview.event.noteCreated', { name }); if (type === 'note.created') return `Created note ${quoted(name)}`;
if (type === 'file.opened') return tr('overview.event.fileOpened', { name }); if (type === 'file.opened') return `Opened file ${quoted(name)}`;
if (type === 'file.changed') return tr('overview.event.fileChanged', { name }); if (type === 'file.changed') return `Changed file ${quoted(name)}`;
if (type === 'file.created') return tr('overview.event.fileCreated', { name }); if (type === 'file.created') return `Created file ${quoted(name)}`;
if (type === 'file.deleted' || type === 'file.trashed') return tr('overview.event.fileRemoved', { name }); if (type === 'file.deleted' || type === 'file.trashed') return `Removed file ${quoted(name)}`;
if (type === 'browser.capture.page') return tr('overview.event.capturePage', { name }); if (type === 'browser.capture.page') return `Captured page ${quoted(name)}`;
if (type === 'browser.capture.selection') return tr('overview.event.captureSelection', { name }); if (type === 'browser.capture.selection') return `Captured selection ${quoted(name)}`;
if (type === 'browser.capture.link') return tr('overview.event.captureLink', { name }); if (type === 'browser.capture.link') return `Captured link ${quoted(name)}`;
if (type === 'browser.capture.file') return tr('overview.event.captureFile', { name }); if (type === 'browser.capture.file') return `Captured file ${quoted(name)}`;
if (type === 'browser.capture.converted') return tr('overview.event.captureConverted', { name }); if (type === 'browser.capture.converted') return `Converted capture ${quoted(name)}`;
if (type === 'journal.entry.added' || type === 'worklog.entry.added') return tr('overview.event.journalAdded', { name }); if (type === 'journal.entry.added' || type === 'worklog.entry.added') return `Added journal entry ${quoted(name)}`;
if (type === 'action.started') return tr('overview.event.workSessionDetected'); if (type === 'action.started') return 'Work session detected';
if (type === 'workspace.opened') return tr('overview.event.workspaceOpened'); if (type === 'workspace.opened') return 'Workspace opened';
return item?.title || item?.summary || tr('overview.event.activity'); return item?.title || item?.summary || 'Workspace activity';
}
function captureKindLabel(capture) {
const kind = String(capture?.kind || '').toLowerCase();
if (kind === 'page') return tr('overview.captureKind.page');
if (kind === 'selection') return tr('overview.captureKind.selection');
if (kind === 'link') return tr('overview.captureKind.link');
if (kind === 'file') return tr('overview.captureKind.file');
return tr('overview.captureKind.item');
} }
function actionForCategory(category) { function actionForCategory(category) {
@ -352,7 +320,7 @@
id: item.activityId || `${item.type}:${timeValue(item)}`, id: item.activityId || `${item.type}:${timeValue(item)}`,
category, category,
title: activityTitle(item), title: activityTitle(item),
meta: itemTimeLabel(item), meta: `${itemTimeLabel(item)}${item.sourcePluginId ? ' · ' + item.sourcePluginId.replace('verstak.', '') : ''}`,
time: timeValue(item), time: timeValue(item),
absolute: absoluteTime(timeValue(item)), absolute: absoluteTime(timeValue(item)),
actionKind: action.kind, actionKind: action.kind,
@ -362,8 +330,8 @@
const captureItems = captureRows.map(item => ({ const captureItems = captureRows.map(item => ({
id: item.captureId || `capture:${timeValue(item)}`, id: item.captureId || `capture:${timeValue(item)}`,
category: 'captures', category: 'captures',
title: tr('overview.event.capture', { kind: captureKindLabel(item), title: captureTitle(item) }), title: `Captured ${item.kind || 'item'} ${quoted(captureTitle(item))}`,
meta: `${itemTimeLabel(item)} · ${item.domain || item.url || tr('overview.browserCapture')}`, meta: `${itemTimeLabel(item)} · ${item.domain || item.url || 'Browser capture'}`,
time: timeValue(item), time: timeValue(item),
absolute: absoluteTime(timeValue(item)), absolute: absoluteTime(timeValue(item)),
actionKind: 'browser-inbox', actionKind: 'browser-inbox',
@ -372,8 +340,8 @@
const journalItems = journalRows.map(item => ({ const journalItems = journalRows.map(item => ({
id: item.entryId || `journal:${timeValue(item)}`, id: item.entryId || `journal:${timeValue(item)}`,
category: 'journal', category: 'journal',
title: tr('overview.event.journalAdded', { name: journalTitle(item) }), title: `Added journal entry ${quoted(journalTitle(item))}`,
meta: `${itemTimeLabel(item)}${item.minutes ? ' · ' + tr('overview.minutes', { count: item.minutes }) : ''}`, meta: `${itemTimeLabel(item)}${item.minutes ? ' · ' + item.minutes + ' min' : ''}`,
time: timeValue(item), time: timeValue(item),
absolute: absoluteTime(timeValue(item)), absolute: absoluteTime(timeValue(item)),
actionKind: 'journal', actionKind: 'journal',
@ -413,8 +381,8 @@
const todoCandidates = [...todoRows].sort((a, b) => todoDateMs(todoTimeValue(a)) - todoDateMs(todoTimeValue(b))).map(item => ({ const todoCandidates = [...todoRows].sort((a, b) => todoDateMs(todoTimeValue(a)) - todoDateMs(todoTimeValue(b))).map(item => ({
id: item.id || `todo:${todoTitle(item)}`, id: item.id || `todo:${todoTitle(item)}`,
category: 'todo', category: 'todo',
title: tr('overview.event.todo', { title: todoTitle(item) }), title: `Todo ${quoted(todoTitle(item))}`,
meta: `${todoAttentionState(item)}${item.dueAt ? ` · ${tr('overview.todo.due', { date: item.dueAt })}` : ''}`, meta: `${todoAttentionState(item)}${item.dueAt ? ` · Due ${item.dueAt}` : ''}`,
time: todoTimeValue(item), time: todoTimeValue(item),
absolute: absoluteTime(todoTimeValue(item)), absolute: absoluteTime(todoTimeValue(item)),
actionKind: 'todo', actionKind: 'todo',
@ -423,8 +391,8 @@
const captureCandidates = sortByTime(captureRows).map(item => ({ const captureCandidates = sortByTime(captureRows).map(item => ({
id: item.captureId || `capture:${timeValue(item)}`, id: item.captureId || `capture:${timeValue(item)}`,
category: 'captures', category: 'captures',
title: tr('overview.event.reviewCapture', { title: captureTitle(item) }), title: `Review capture ${quoted(captureTitle(item))}`,
meta: `${itemTimeLabel(item)} · ${item.domain || captureKindLabel(item) || tr('overview.browserCapture')}`, meta: `${itemTimeLabel(item)} · ${item.domain || item.kind || 'Browser capture'}`,
time: timeValue(item), time: timeValue(item),
absolute: absoluteTime(timeValue(item)), absolute: absoluteTime(timeValue(item)),
actionKind: 'browser-inbox', actionKind: 'browser-inbox',
@ -439,7 +407,7 @@
const journalCandidates = sortByTime(journalRows).map(item => ({ const journalCandidates = sortByTime(journalRows).map(item => ({
id: item.entryId || `journal:${timeValue(item)}`, id: item.entryId || `journal:${timeValue(item)}`,
category: 'journal', category: 'journal',
title: tr('overview.event.continueJournal', { title: journalTitle(item) }), title: `Continue journal entry ${quoted(journalTitle(item))}`,
meta: itemTimeLabel(item), meta: itemTimeLabel(item),
time: timeValue(item), time: timeValue(item),
absolute: absoluteTime(timeValue(item)), absolute: absoluteTime(timeValue(item)),
@ -453,18 +421,14 @@
const captureItems = sortByTime(captureRows).slice(0, 4).map(item => ({ const captureItems = sortByTime(captureRows).slice(0, 4).map(item => ({
id: item.captureId || `capture:${timeValue(item)}`, id: item.captureId || `capture:${timeValue(item)}`,
title: captureTitle(item), title: captureTitle(item),
meta: `${captureKindLabel(item)} · ${itemTimeLabel(item)}`, meta: `${item.kind || 'capture'} · ${itemTimeLabel(item)}`,
actionKind: 'browser-inbox', actionKind: 'browser-inbox',
actionLabel: tr('overview.reviewInbox'), actionLabel: tr('overview.reviewInbox'),
})); }));
const candidateItems = sortByTime(candidates).slice(0, 4).map(item => ({ const candidateItems = sortByTime(candidates).slice(0, 4).map(item => ({
id: item.candidateId || `work-session:${timeValue(item)}`, id: item.candidateId || `work-session:${timeValue(item)}`,
title: tr('overview.event.possibleJournalEntry'), title: 'Possible journal entry',
meta: tr('overview.candidateMeta', { meta: `Workspace: ${item.workspaceRootPath || workspaceRootPath || 'Unknown'} · ${item.estimatedMinutes || 0} min · ${item.activityCount || (item.activityIds || []).length || 0} activities`,
deal: item.workspaceRootPath || workspaceRootPath || tr('overview.unknownDeal'),
minutes: item.estimatedMinutes || 0,
activities: item.activityCount || (item.activityIds || []).length || 0,
}),
actionKind: 'journal', actionKind: 'journal',
actionLabel: tr('overview.reviewCandidate'), actionLabel: tr('overview.reviewCandidate'),
toolRequest: { type: 'work-session-candidate', candidate: item }, toolRequest: { type: 'work-session-candidate', candidate: item },
@ -472,7 +436,7 @@
const todoItems = [...todoRows].sort((a, b) => todoDateMs(todoTimeValue(a)) - todoDateMs(todoTimeValue(b))).slice(0, 4).map(item => ({ const todoItems = [...todoRows].sort((a, b) => todoDateMs(todoTimeValue(a)) - todoDateMs(todoTimeValue(b))).slice(0, 4).map(item => ({
id: item.id || `todo:${todoTitle(item)}`, id: item.id || `todo:${todoTitle(item)}`,
title: todoTitle(item), title: todoTitle(item),
meta: `${todoAttentionState(item)}${item.dueAt ? ` · ${tr('overview.todo.due', { date: item.dueAt })}` : ''}`, meta: `${todoAttentionState(item)}${item.dueAt ? ` · Due ${item.dueAt}` : ''}`,
actionKind: 'todo', actionKind: 'todo',
actionLabel: tr('overview.openTodos'), actionLabel: tr('overview.openTodos'),
})); }));
@ -513,12 +477,12 @@
} }
} }
async function loadWorkspaceResources(toolState) { async function loadWorkspaceResources() {
const workspace = String(workspaceRootPath || '').trim(); const workspace = String(workspaceRootPath || '').trim();
if (!workspace) return { keyResources: [], totalNotes: 0 }; if (!workspace) return { keyResources: [], totalNotes: 0 };
const [rootEntries, notesEntries] = await Promise.all([ const [rootEntries, notesEntries] = await Promise.all([
toolState.files ? listFiles('verstak.files', workspace) : Promise.resolve([]), listFiles('verstak.files', workspace),
toolState.notes ? listFiles('verstak.notes', `${workspace}/Notes`) : Promise.resolve([]), listFiles('verstak.notes', `${workspace}/Notes`),
]); ]);
const noteFiles = notesEntries.filter(item => item?.type === 'file'); const noteFiles = notesEntries.filter(item => item?.type === 'file');
const overview = [...notesEntries, ...rootEntries].find(item => /(^|\/)overview\.md$/i.test(String(item.relativePath || item.name || ''))); const overview = [...notesEntries, ...rootEntries].find(item => /(^|\/)overview\.md$/i.test(String(item.relativePath || item.name || '')));
@ -527,54 +491,44 @@
keyResources: overview ? [{ keyResources: overview ? [{
id: overview.relativePath || overview.name, id: overview.relativePath || overview.name,
title: overview.name || fileName(overview.relativePath) || 'Overview.md', title: overview.name || fileName(overview.relativePath) || 'Overview.md',
meta: overview.relativePath || tr('overview.overviewNote'), meta: overview.relativePath || 'Workspace overview note',
actionKind: toolState.notes ? 'notes' : 'files', actionKind: hasNotes ? 'notes' : 'files',
actionLabel: toolState.notes ? tr('overview.openNotes') : tr('overview.openFiles'), actionLabel: hasNotes ? tr('overview.openNotes') : tr('overview.openFiles'),
}] : [], }] : [],
}; };
} }
async function loadOverview() { async function loadOverview() {
const workspaceAtStart = String(workspaceRootPath || '').trim(); const workspaceAtStart = String(workspaceRootPath || '').trim();
const toolKeyAtStart = overviewToolKey;
const toolState = {
notes: hasNotes,
files: hasFiles,
browserInbox: hasBrowserInbox,
activity: hasActivity,
journal: hasJournal,
todos: hasTodos,
};
loadedWorkspaceRoot = workspaceAtStart; loadedWorkspaceRoot = workspaceAtStart;
loadedToolKey = toolKeyAtStart;
loading = true; loading = true;
const [browserSettings, activitySettings, activityRecords, journalSettings, todoSettings, resources] = await Promise.all([ const [browserSettings, activitySettings, activityRecords, journalSettings, todoSettings, resources] = await Promise.all([
toolState.browserInbox ? readPluginSettings('verstak.browser-inbox') : Promise.resolve({}), readPluginSettings('verstak.browser-inbox'),
toolState.activity ? readPluginSettings('verstak.activity') : Promise.resolve({}), readPluginSettings('verstak.activity'),
toolState.activity && App.ReadPluginDataNDJSON App.ReadPluginDataNDJSON
? App.ReadPluginDataNDJSON('verstak.activity', 'activity-events') ? App.ReadPluginDataNDJSON('verstak.activity', 'activity-events')
.then(value => decodeTuple(value, [])) .then(value => decodeTuple(value, []))
.catch(() => []) .catch(() => [])
: Promise.resolve([]), : Promise.resolve([]),
toolState.journal ? readPluginSettings('verstak.journal') : Promise.resolve({}), readPluginSettings('verstak.journal'),
toolState.todos ? readPluginSettings('verstak.todo') : Promise.resolve({}), readPluginSettings('verstak.todo'),
loadWorkspaceResources(toolState), loadWorkspaceResources(),
]); ]);
if (workspaceAtStart !== String(workspaceRootPath || '').trim() || toolKeyAtStart !== overviewToolKey) return; if (workspaceAtStart !== String(workspaceRootPath || '').trim()) return;
captures = toolState.browserInbox ? browserCaptureRowsForWorkspace(browserSettings) : []; captures = browserCaptureRowsForWorkspace(browserSettings);
activityEvents = toolState.activity ? normalizeRows(activityRecords).filter(item => { activityEvents = normalizeRows(activityRecords).filter(item => {
const tagged = String(item.workspaceRootPath || item.workspaceName || item.workspaceNodeId || '').trim(); const tagged = String(item.workspaceRootPath || item.workspaceName || item.workspaceNodeId || '').trim();
return !tagged || tagged === workspaceAtStart; return !tagged || tagged === workspaceAtStart;
}) : []; });
journalEntries = toolState.journal ? rowsFor(journalSettings, [ journalEntries = rowsFor(journalSettings, [
workspaceKey('worklog:workspace:'), workspaceKey('worklog:workspace:'),
'worklog', 'worklog',
]) : []; ]);
todos = toolState.todos ? todoRowsForWorkspace(todoSettings) : []; todos = todoRowsForWorkspace(todoSettings);
workSessionCandidates = toolState.activity && toolState.journal ? rowsFor(activitySettings, [ workSessionCandidates = rowsFor(activitySettings, [
workspaceKey('work-session-candidates:workspace:'), workspaceKey('work-session-candidates:workspace:'),
]) : []; ]);
keyResources = resources.keyResources; keyResources = resources.keyResources;
totalNotes = resources.totalNotes; totalNotes = resources.totalNotes;
loading = false; loading = false;
@ -703,7 +657,7 @@
</main> </main>
<aside class="overview-side"> <aside class="overview-side">
{#if hasAttentionTools && (needsAttention.length || !loading)} {#if needsAttention.length || !loading}
<section class="today-panel overview-panel" data-overview-section="attention"> <section class="today-panel overview-panel" data-overview-section="attention">
<div class="today-panel-head overview-panel-head"> <div class="today-panel-head overview-panel-head">
<div> <div>

View File

@ -18,12 +18,6 @@
void activeLocale; void activeLocale;
return i18n.t(key, params, fallback); return i18n.t(key, params, fallback);
})(locale); })(locale);
function reportError(key, fallback, details) {
console.warn('[VaultSelection] operation failed:', details);
return tr(key, undefined, fallback);
}
onDestroy(unsubscribeLocale); onDestroy(unsubscribeLocale);
onMount(async () => { onMount(async () => {
@ -60,13 +54,13 @@
try { try {
const createErr = await App.CreateVault(newVaultPath.trim()); const createErr = await App.CreateVault(newVaultPath.trim());
if (createErr) { if (createErr) {
error = reportError('vaultSelection.createError', 'Could not create the vault. Please try again.', createErr); error = tr('vaultSelection.createError', { error: createErr });
creating = false; creating = false;
return; return;
} }
const openErr = await App.OpenVault(newVaultPath.trim()); const openErr = await App.OpenVault(newVaultPath.trim());
if (openErr) { if (openErr) {
error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', openErr); error = tr('vaultSelection.openError', { error: openErr });
creating = false; creating = false;
return; return;
} }
@ -76,7 +70,7 @@
} }
window.dispatchEvent(new CustomEvent('verstak:vault-opened')); window.dispatchEvent(new CustomEvent('verstak:vault-opened'));
} catch (e) { } catch (e) {
error = reportError('vaultSelection.createError', 'Could not create the vault. Please try again.', e); error = String(e);
creating = false; creating = false;
} }
} }
@ -91,7 +85,7 @@
try { try {
const openErr = await App.OpenVault(openVaultPath.trim()); const openErr = await App.OpenVault(openVaultPath.trim());
if (openErr) { if (openErr) {
error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', openErr); error = tr('vaultSelection.openError', { error: openErr });
opening = false; opening = false;
return; return;
} }
@ -101,7 +95,7 @@
} }
window.dispatchEvent(new CustomEvent('verstak:vault-opened')); window.dispatchEvent(new CustomEvent('verstak:vault-opened'));
} catch (e) { } catch (e) {
error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', e); error = String(e);
opening = false; opening = false;
} }
} }
@ -112,7 +106,7 @@
try { try {
const openErr = await App.OpenVault(path); const openErr = await App.OpenVault(path);
if (openErr) { if (openErr) {
error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', openErr); error = tr('vaultSelection.openError', { error: openErr });
opening = false; opening = false;
return; return;
} }
@ -122,7 +116,7 @@
} }
window.dispatchEvent(new CustomEvent('verstak:vault-opened')); window.dispatchEvent(new CustomEvent('verstak:vault-opened'));
} catch (e) { } catch (e) {
error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', e); error = String(e);
opening = false; opening = false;
} }
} }

View File

@ -45,6 +45,7 @@
$: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || ''; $: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || '';
$: workspaceId = selectedWorkspace?.workspaceId || ''; $: workspaceId = selectedWorkspace?.workspaceId || '';
$: workspaceTitle = selectedWorkspace?.title || selectedWorkspace?.name || selectedWorkspace?.id || selectedWorkspaceName; $: workspaceTitle = selectedWorkspace?.title || selectedWorkspace?.name || selectedWorkspace?.id || selectedWorkspaceName;
$: workspaceType = selectedWorkspace?.type || 'workspace';
$: if (workspaceRootPath !== metadataWorkspaceRoot) { $: if (workspaceRootPath !== metadataWorkspaceRoot) {
metadataWorkspaceRoot = workspaceRootPath; metadataWorkspaceRoot = workspaceRootPath;
workspaceMetadata = null; workspaceMetadata = null;
@ -79,13 +80,11 @@
if (changed && selectedWorkspaceName) loadTools(); if (changed && selectedWorkspaceName) loadTools();
}); });
window.addEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool); window.addEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool);
window.addEventListener('verstak:plugins-changed', handlePluginsChanged);
}); });
onDestroy(() => { onDestroy(() => {
if (unsubscribeLocale) unsubscribeLocale(); if (unsubscribeLocale) unsubscribeLocale();
window.removeEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool); window.removeEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool);
window.removeEventListener('verstak:plugins-changed', handlePluginsChanged);
}); });
function toolKey(tool) { function toolKey(tool) {
@ -171,10 +170,6 @@
requestWorkspaceTool(event?.detail?.kind, event?.detail?.toolRequest || null); requestWorkspaceTool(event?.detail?.kind, event?.detail?.toolRequest || null);
} }
function handlePluginsChanged() {
if (selectedWorkspaceName) loadTools();
}
async function loadTools() { async function loadTools() {
try { try {
toolsLoaded = false; toolsLoaded = false;
@ -206,6 +201,7 @@
<div class="workspace-header vt-page-header"> <div class="workspace-header vt-page-header">
<div class="workspace-title-group"> <div class="workspace-title-group">
<span class="workspace-title vt-page-title">{workspaceTitle}</span> <span class="workspace-title vt-page-title">{workspaceTitle}</span>
<span class="workspace-type vt-badge accent">{workspaceType}</span>
</div> </div>
<div class="workspace-search" aria-label={tr('workspace.search')}> <div class="workspace-search" aria-label={tr('workspace.search')}>
<GlobalSearch /> <GlobalSearch />
@ -292,6 +288,14 @@
font-weight: 600; font-weight: 600;
} }
.workspace-type {
color: var(--vt-color-accent);
font-size: 0.75rem;
padding: 0.1rem 0.4rem;
border-radius: var(--vt-radius-sm);
background: var(--vt-color-accent-muted);
}
.workspace-search { .workspace-search {
width: min(27rem, 46vw); width: min(27rem, 46vw);
min-width: 16rem; min-width: 16rem;

View File

@ -18,11 +18,8 @@
let newWorkspaceName = ''; let newWorkspaceName = '';
let workspaceTemplates = []; let workspaceTemplates = [];
let templatePluginNames = {}; let templatePluginNames = {};
let templatePlugins = {};
let templateCapabilities = new Set();
let selectedTemplateId = 'default'; let selectedTemplateId = 'default';
let createError = ''; let createError = '';
let templateWarning = null;
let templatesLoading = false; let templatesLoading = false;
let creating = false; let creating = false;
let renamingId = ''; let renamingId = '';
@ -35,11 +32,6 @@
return i18n.t(key, params, fallback); return i18n.t(key, params, fallback);
})(locale); })(locale);
function reportError(key, fallback, details) {
console.warn('[WorkspaceTree] operation failed:', details);
return tr(key, undefined, fallback);
}
onMount(() => { onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => { unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale; const changed = locale !== nextLocale;
@ -69,71 +61,21 @@
} }
$: selectedTemplate = workspaceTemplates.find(template => template.id === selectedTemplateId) || workspaceTemplates[0] || null; $: selectedTemplate = workspaceTemplates.find(template => template.id === selectedTemplateId) || workspaceTemplates[0] || null;
$: selectedTemplateTools = (selectedTemplate?.workspaceTools || []).map((pluginId) => (
templateToolState(pluginId, templatePlugins, templateCapabilities, templatePluginNames, tr)
));
$: selectedTemplateIssues = selectedTemplateTools.filter(tool => tool.status !== 'available');
function toolLabel(pluginId, names = templatePluginNames) { function toolLabel(pluginId) {
return names[pluginId] || String(pluginId || '').replace(/^verstak\./, ''); return templatePluginNames[pluginId] || String(pluginId || '').replace(/^verstak\./, '');
}
function templateToolState(pluginId, plugins, capabilities, names, translate) {
const plugin = plugins[pluginId];
if (!plugin) {
return {
pluginId,
name: toolLabel(pluginId, names),
tabs: [],
status: 'unavailable',
reason: translate('workspaceTree.templateMissingPlugin'),
};
}
const manifest = plugin.manifest || {};
const tabs = Array.isArray(manifest.contributes?.workspaceItems)
? manifest.contributes.workspaceItems.map(item => item?.title || item?.id).filter(Boolean)
: [];
const pluginStatus = String(plugin.status || '').toLowerCase();
const missingCapability = Array.isArray(manifest.requires)
&& manifest.requires.some(capabilityId => !capabilities.has(capabilityId));
let status = 'available';
let reason = translate('workspaceTree.templateAvailable');
if (!plugin.enabled || pluginStatus === 'disabled') {
status = 'unavailable';
reason = translate('workspaceTree.templatePluginDisabled');
} else if (pluginStatus === 'missing-required-capability' || missingCapability) {
status = 'unavailable';
reason = translate('workspaceTree.templateCapabilityUnavailable');
} else if (pluginStatus === 'incompatible') {
status = 'unavailable';
reason = translate('workspaceTree.templateIncompatible');
} else if (pluginStatus === 'failed') {
status = 'unavailable';
reason = translate('workspaceTree.templateLoadFailed');
} else if (pluginStatus === 'degraded') {
status = 'limited';
reason = translate('workspaceTree.templateLimited');
} else if (pluginStatus !== 'loaded') {
status = 'unavailable';
reason = translate('workspaceTree.templateNotReady');
}
return { pluginId, name: manifest.name || toolLabel(pluginId, names), tabs, status, reason };
} }
async function loadWorkspaceTemplates() { async function loadWorkspaceTemplates() {
templatesLoading = true; templatesLoading = true;
try { try {
const [templates, plugins, capabilities] = await Promise.all([ const [templates, plugins] = await Promise.all([
App.ListWorkspaceTemplates ? App.ListWorkspaceTemplates() : [], App.ListWorkspaceTemplates ? App.ListWorkspaceTemplates() : [],
App.GetPlugins ? App.GetPlugins() : [], App.GetPlugins ? App.GetPlugins() : [],
App.GetCapabilities ? App.GetCapabilities() : [],
]); ]);
const [list, err] = resultOrError(templates, []); const [list, err] = resultOrError(templates, []);
if (err) { if (err) {
createError = reportError('workspaceTree.templatesError', 'Could not load Deal templates. Please try again.', err); createError = err;
workspaceTemplates = []; workspaceTemplates = [];
return; return;
} }
@ -141,25 +83,17 @@
await Promise.all((Array.isArray(plugins) ? plugins : []).map((plugin) => ( await Promise.all((Array.isArray(plugins) ? plugins : []).map((plugin) => (
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {}) i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
))); )));
const localizedPlugins = (Array.isArray(plugins) ? plugins : []).map((plugin) => i18n.localizePlugin(plugin)); templatePluginNames = (Array.isArray(plugins) ? plugins : []).map((plugin) => i18n.localizePlugin(plugin)).reduce((names, plugin) => {
templatePluginNames = localizedPlugins.reduce((names, plugin) => {
const id = plugin?.manifest?.id; const id = plugin?.manifest?.id;
const name = plugin?.manifest?.name; const name = plugin?.manifest?.name;
if (id && name) names[id] = name; if (id && name) names[id] = name;
return names; return names;
}, {}); }, {});
templatePlugins = localizedPlugins.reduce((result, plugin) => {
const id = plugin?.manifest?.id;
if (id) result[id] = plugin;
return result;
}, {});
const [capabilityList] = resultOrError(capabilities, []);
templateCapabilities = new Set((Array.isArray(capabilityList) ? capabilityList : []).map(capability => capability?.name).filter(Boolean));
if (!workspaceTemplates.some(template => template.id === selectedTemplateId)) { if (!workspaceTemplates.some(template => template.id === selectedTemplateId)) {
selectedTemplateId = workspaceTemplates[0]?.id || ''; selectedTemplateId = workspaceTemplates[0]?.id || '';
} }
} catch (error) { } catch (error) {
createError = reportError('workspaceTree.templatesError', 'Could not load Deal templates. Please try again.', error); createError = String(error);
workspaceTemplates = []; workspaceTemplates = [];
} finally { } finally {
templatesLoading = false; templatesLoading = false;
@ -193,7 +127,7 @@
try { try {
const [list, err] = resultOrError(await App.ListWorkspaces(), []); const [list, err] = resultOrError(await App.ListWorkspaces(), []);
if (err) { if (err) {
localError = reportError('workspaceTree.loadError', 'Could not load Deals. Please try again.', err); localError = err;
workspaces = []; workspaces = [];
} else { } else {
workspaces = list || []; workspaces = list || [];
@ -214,7 +148,7 @@
activeWorkspaceId.set(currentWorkspaceId); activeWorkspaceId.set(currentWorkspaceId);
} }
} catch (e) { } catch (e) {
localError = reportError('workspaceTree.loadError', 'Could not load Deals. Please try again.', e); localError = String(e);
} }
loading = false; loading = false;
} }
@ -223,7 +157,7 @@
const id = wsName(workspace); const id = wsName(workspace);
const err = await App.SetCurrentWorkspace(id); const err = await App.SetCurrentWorkspace(id);
if (err) { if (err) {
localError = reportError('workspaceTree.selectError', 'Could not select this Deal. Please try again.', err); localError = err;
return; return;
} }
currentWorkspaceId = id; currentWorkspaceId = id;
@ -243,16 +177,11 @@
createError = tr('workspaceTree.chooseTemplate'); createError = tr('workspaceTree.chooseTemplate');
return; return;
} }
const creationIssues = selectedTemplateIssues.map(tool => ({
pluginId: tool.pluginId,
name: tool.name,
reason: tool.reason,
}));
creating = true; creating = true;
createError = ''; createError = '';
const [, err] = resultOrError(await App.CreateWorkspace(name, selectedTemplate.id), null); const [, err] = resultOrError(await App.CreateWorkspace(name, selectedTemplate.id), null);
if (err) { if (err) {
createError = reportError('workspaceTree.createError', 'Could not create the Deal. Please try again.', err); createError = err;
creating = false; creating = false;
return; return;
} }
@ -262,14 +191,13 @@
await loadWorkspaces(); await loadWorkspaces();
const created = workspaces.find((ws) => wsName(ws) === name); const created = workspaces.find((ws) => wsName(ws) === name);
if (created) await selectWorkspace(created); if (created) await selectWorkspace(created);
templateWarning = creationIssues.length > 0 ? { workspaceName: name, issues: creationIssues } : null;
} }
async function openCreateDialog() { function openCreateDialog() {
showCreate = true; showCreate = true;
newWorkspaceName = ''; newWorkspaceName = '';
createError = ''; createError = '';
await loadWorkspaceTemplates(); if (!workspaceTemplates.length && !templatesLoading) loadWorkspaceTemplates();
} }
function closeCreateDialog() { function closeCreateDialog() {
@ -279,10 +207,6 @@
createError = ''; createError = '';
} }
function dismissTemplateWarning() {
templateWarning = null;
}
function startRename(workspace) { function startRename(workspace) {
renamingId = wsName(workspace); renamingId = wsName(workspace);
renameValue = renamingId; renameValue = renamingId;
@ -304,7 +228,7 @@
busyId = oldName; busyId = oldName;
const err = await App.RenameWorkspace(oldName, newName); const err = await App.RenameWorkspace(oldName, newName);
if (err) { if (err) {
localError = reportError('workspaceTree.renameError', 'Could not rename the Deal. Please try again.', err); localError = err;
busyId = ''; busyId = '';
return; return;
} }
@ -322,7 +246,7 @@
busyId = name; busyId = name;
const [, err] = resultOrError(await App.TrashWorkspace(name), null); const [, err] = resultOrError(await App.TrashWorkspace(name), null);
if (err) { if (err) {
localError = reportError('workspaceTree.trashError', 'Could not move the Deal to trash. Please try again.', err); localError = err;
busyId = ''; busyId = '';
return; return;
} }
@ -380,18 +304,6 @@
{/each} {/each}
</div> </div>
{#if templateWarning}
<section class="workspace-template-warning" data-workspace-template-warning role="alert">
<div>
<strong>{tr('workspaceTree.templateIncompleteCreated', { name: templateWarning.workspaceName })}</strong>
{#each templateWarning.issues as issue (issue.pluginId)}
<div class="workspace-template-warning-item">{issue.name}: {issue.reason}</div>
{/each}
</div>
<button class="wt-btn" type="button" on:click={dismissTemplateWarning} title={tr('common.close')}>{tr('common.close')}</button>
</section>
{/if}
{#if showCreate} {#if showCreate}
<div class="workspace-create-overlay" data-workspace-create-modal role="dialog" aria-modal="true" aria-label={tr('workspaceTree.create')}> <div class="workspace-create-overlay" data-workspace-create-modal role="dialog" aria-modal="true" aria-label={tr('workspaceTree.create')}>
<div class="workspace-create-modal"> <div class="workspace-create-modal">
@ -417,23 +329,10 @@
<div class="workspace-template-summary"> <div class="workspace-template-summary">
<p data-workspace-template-description>{selectedTemplate.description}</p> <p data-workspace-template-description>{selectedTemplate.description}</p>
<div class="workspace-template-tools" data-workspace-template-tools> <div class="workspace-template-tools" data-workspace-template-tools>
{#each selectedTemplateTools as tool (tool.pluginId)} {#each selectedTemplate.workspaceTools || [] as pluginId (pluginId)}
<div <span>{toolLabel(pluginId)}</span>
class="workspace-template-tool"
class:limited={tool.status === 'limited'}
class:unavailable={tool.status === 'unavailable'}
data-workspace-template-tool={tool.pluginId}
data-template-tool-status={tool.status}
>
<span class="workspace-template-tool-name">{tool.name}</span>
<span class="workspace-template-tool-tabs">{tool.tabs.length ? tr('workspaceTree.templateToolTabs', { tabs: tool.tabs.join(', ') }) : tr('workspaceTree.templateToolNoTabs')}</span>
<span class="workspace-template-tool-reason">{tool.reason}</span>
</div>
{/each} {/each}
</div> </div>
{#if selectedTemplateIssues.length > 0}
<p class="workspace-template-incomplete" data-workspace-template-incomplete>{tr('workspaceTree.templateWillBeIncomplete')}</p>
{/if}
</div> </div>
{/if} {/if}
{#if createError} {#if createError}
@ -479,25 +378,11 @@
.workspace-create-header h2 { margin: 0; font-size: 1rem; } .workspace-create-header h2 { margin: 0; font-size: 1rem; }
.workspace-create-field { display: grid; gap: 0.35rem; color: var(--vt-color-text-muted); font-size: 0.75rem; } .workspace-create-field { display: grid; gap: 0.35rem; color: var(--vt-color-text-muted); font-size: 0.75rem; }
.workspace-create-field input, .workspace-create-field select { width: 100%; min-height: 2rem; box-sizing: border-box; border: 1px solid var(--vt-color-border-strong); border-radius: var(--vt-radius-sm); background: #0f1424; color: var(--vt-color-text-primary); padding: 0.35rem 0.5rem; font: inherit; font-size: 0.84rem; } .workspace-create-field input, .workspace-create-field select { width: 100%; min-height: 2rem; box-sizing: border-box; border: 1px solid var(--vt-color-border-strong); border-radius: var(--vt-radius-sm); background: #0f1424; color: var(--vt-color-text-primary); padding: 0.35rem 0.5rem; font: inherit; font-size: 0.84rem; }
.workspace-create-field select { appearance: none; background-color: #0f1424; background-image: linear-gradient(45deg, transparent 50%, var(--vt-color-text-muted) 50%), linear-gradient(135deg, var(--vt-color-text-muted) 50%, transparent 50%); background-position: calc(100% - 14px) 50%, calc(100% - 9px) 50%; background-size: 5px 5px, 5px 5px; background-repeat: no-repeat; padding-right: 1.7rem; }
.workspace-create-field select option { background: #0f1424; color: var(--vt-color-text-primary); }
.workspace-create-field input:focus, .workspace-create-field select:focus { outline: none; border-color: var(--vt-color-accent); box-shadow: var(--vt-focus-ring); } .workspace-create-field input:focus, .workspace-create-field select:focus { outline: none; border-color: var(--vt-color-accent); box-shadow: var(--vt-focus-ring); }
.workspace-template-summary { display: grid; gap: 0.55rem; padding: 0.75rem; border: 1px solid var(--vt-color-border); border-radius: var(--vt-radius-md); background: var(--vt-color-surface-muted); } .workspace-template-summary { display: grid; gap: 0.55rem; padding: 0.75rem; border: 1px solid var(--vt-color-border); border-radius: var(--vt-radius-md); background: var(--vt-color-surface-muted); }
.workspace-template-summary p { margin: 0; color: var(--vt-color-text-secondary); font-size: 0.8rem; line-height: 1.45; } .workspace-template-summary p { margin: 0; color: var(--vt-color-text-secondary); font-size: 0.8rem; line-height: 1.45; }
.workspace-template-tools { display: grid; gap: 0.4rem; } .workspace-template-tools { display: flex; flex-wrap: wrap; gap: 0.35rem; }
.workspace-template-tool { display: grid; grid-template-columns: minmax(7rem, 1fr) minmax(0, 1.4fr) minmax(0, 1.2fr); gap: 0.5rem; align-items: center; padding: 0.45rem 0.55rem; border: 1px solid var(--vt-color-border-strong); border-radius: var(--vt-radius-sm); color: var(--vt-color-text-secondary); font-size: 0.72rem; } .workspace-template-tools span { min-height: 1.35rem; display: inline-flex; align-items: center; padding: 0 0.35rem; border: 1px solid var(--vt-color-border-strong); border-radius: var(--vt-radius-sm); color: var(--vt-color-text-secondary); font-size: 0.72rem; }
.workspace-template-tool-name { color: var(--vt-color-text-primary); font-weight: 600; }
.workspace-template-tool-tabs { color: var(--vt-color-text-muted); }
.workspace-template-tool-reason { color: var(--vt-color-accent); }
.workspace-template-tool.limited { border-color: rgba(220, 164, 57, 0.58); background: rgba(220, 164, 57, 0.08); }
.workspace-template-tool.limited .workspace-template-tool-reason { color: #e8bc63; }
.workspace-template-tool.unavailable { border-color: rgba(233, 69, 96, 0.58); background: var(--vt-color-danger-muted); }
.workspace-template-tool.unavailable .workspace-template-tool-reason { color: var(--vt-color-danger); }
.workspace-template-incomplete { color: var(--vt-color-danger) !important; }
.workspace-template-warning { display: flex; gap: 0.5rem; align-items: flex-start; justify-content: space-between; margin: 0.25rem 0.6rem 0.5rem; padding: 0.6rem; border: 1px solid rgba(233, 69, 96, 0.58); border-radius: var(--vt-radius-md); background: var(--vt-color-danger-muted); color: var(--vt-color-text-secondary); font-size: 0.73rem; line-height: 1.4; }
.workspace-template-warning strong { color: var(--vt-color-danger); }
.workspace-template-warning-item { margin-top: 0.2rem; }
@media (max-width: 700px) { .workspace-template-tool { grid-template-columns: 1fr; gap: 0.18rem; } }
.workspace-create-error { margin: 0; color: var(--vt-color-danger); font-size: 0.78rem; line-height: 1.4; } .workspace-create-error { margin: 0; color: var(--vt-color-danger); font-size: 0.78rem; line-height: 1.4; }
.workspace-create-actions { display: flex; gap: 0.4rem; justify-content: flex-end; } .workspace-create-actions { display: flex; gap: 0.4rem; justify-content: flex-end; }
.wt-btn-primary { background: var(--vt-color-accent); color: #101827; border: none; padding: 0.3rem 0.6rem; border-radius: var(--vt-radius-sm); cursor: pointer; font-size: 0.75rem; font-weight: 600; } .wt-btn-primary { background: var(--vt-color-accent); color: #101827; border: none; padding: 0.3rem 0.6rem; border-radius: var(--vt-radius-sm); cursor: pointer; font-size: 0.75rem; font-weight: 600; }

View File

@ -4,12 +4,6 @@
* Каждый метод возвращает Promise с данными, совместимыми с Wails-контрактом. * Каждый метод возвращает Promise с данными, совместимыми с Wails-контрактом.
* Состояние мутабельно тесты могут менять его между сценариями. * Состояние мутабельно тесты могут менять его между сценариями.
*/ */
import defaultEditorSource from '../../../../../verstak-official-plugins/plugins/default-editor/frontend/src/index.js?raw';
import secretsSource from '../../../../../verstak-official-plugins/plugins/secrets/frontend/src/index.js?raw';
import activitySource from '../../../../../verstak-official-plugins/plugins/activity/frontend/src/index.js?raw';
import todoSource from '../../../../../verstak-official-plugins/plugins/todo/frontend/src/index.js?raw';
import journalSource from '../../../../../verstak-official-plugins/plugins/journal/frontend/src/index.js?raw';
(function () { (function () {
if (window.__wailsMockReady) return; if (window.__wailsMockReady) return;
@ -225,7 +219,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
source: 'official', source: 'official',
icon: 'book-open', icon: 'book-open',
provides: ['worklog', 'journal', 'report.worklog'], provides: ['worklog', 'journal', 'report.worklog'],
permissions: ['events.publish', 'files.read', 'storage.namespace', 'ui.register'], permissions: ['storage.namespace', 'ui.register'],
frontend: { entry: 'frontend/dist/index.js' }, frontend: { entry: 'frontend/dist/index.js' },
contributes: { contributes: {
views: [{ id: 'verstak.journal.view', title: 'Journal', icon: 'book-open', component: 'JournalView' }], views: [{ id: 'verstak.journal.view', title: 'Journal', icon: 'book-open', component: 'JournalView' }],
@ -242,19 +236,19 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
manifest: { manifest: {
schemaVersion: 1, schemaVersion: 1,
id: 'verstak.browser-inbox', id: 'verstak.browser-inbox',
name: 'Browser', name: 'Browser Inbox',
version: '0.1.0', version: '0.1.0',
apiVersion: '0.1.0', apiVersion: '0.1.0',
description: 'Global browser materials with explicit Deal assignment.', description: 'Global browser capture queue with explicit workspace assignment.',
source: 'official', source: 'official',
icon: 'inbox', icon: 'inbox',
provides: ['browser.inbox'], provides: ['browser.inbox'],
permissions: ['events.subscribe', 'files.read', 'storage.namespace', 'ui.register'], permissions: ['events.subscribe', 'files.read', 'storage.namespace', 'ui.register'],
frontend: { entry: 'frontend/dist/index.js' }, frontend: { entry: 'frontend/dist/index.js' },
contributes: { contributes: {
views: [{ id: 'verstak.browser-inbox.view', title: 'Browser', icon: 'inbox', component: 'BrowserInboxView' }], views: [{ id: 'verstak.browser-inbox.view', title: 'Browser Inbox', icon: 'inbox', component: 'BrowserInboxView' }],
sidebarItems: [{ id: 'verstak.browser-inbox.sidebar', title: 'Browser', icon: 'inbox', view: 'verstak.browser-inbox.view', position: 30 }], sidebarItems: [{ id: 'verstak.browser-inbox.sidebar', title: 'Browser Inbox', icon: 'inbox', view: 'verstak.browser-inbox.view', position: 30 }],
workspaceItems: [{ id: 'verstak.browser-inbox.workspace', title: 'Browser', icon: 'inbox', component: 'BrowserInboxView' }] workspaceItems: [{ id: 'verstak.browser-inbox.workspace', title: 'Browser Inbox', icon: 'inbox', component: 'BrowserInboxView' }]
} }
}, },
rootPath: '/tmp/verstak-test/plugins/browser-inbox', rootPath: '/tmp/verstak-test/plugins/browser-inbox',
@ -297,7 +291,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
'verstak.sync': 'Синхронизация', 'verstak.sync': 'Синхронизация',
'verstak.activity': 'Активность', 'verstak.activity': 'Активность',
'verstak.journal': 'Журнал', 'verstak.journal': 'Журнал',
'verstak.browser-inbox': 'Браузер', 'verstak.browser-inbox': 'Входящие из браузера',
'verstak.search': 'Поиск', 'verstak.search': 'Поиск',
'verstak.trash': 'Корзина', 'verstak.trash': 'Корзина',
'verstak.todo': 'Задачи', 'verstak.todo': 'Задачи',
@ -365,9 +359,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
var pluginSettings = { var pluginSettings = {
'verstak.platform-test': { savedText: 'initial value' } 'verstak.platform-test': { savedText: 'initial value' }
}; };
var pluginNotifications = {};
var pluginData = {}; var pluginData = {};
var secretRecords = makeDefaultSecretRecords();
var vaultFiles = makeDefaultVaultFiles(); var vaultFiles = makeDefaultVaultFiles();
var externalOpens = []; var externalOpens = [];
var trashEntries = []; var trashEntries = [];
@ -412,27 +404,6 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
return JSON.parse(JSON.stringify(value)); return JSON.parse(JSON.stringify(value));
} }
function makeDefaultSecretRecords() {
return [
{
id: 'first.secret',
title: 'First secret',
username: 'first-user',
value: 'first-value',
scope: { kind: 'global' },
updatedAt: '2026-07-14T00:00:00Z'
},
{
id: 'target.secret',
title: 'Target secret',
username: 'target-user',
value: 'target-value',
scope: { kind: 'global' },
updatedAt: '2026-07-14T00:00:00Z'
}
];
}
function builtInWorkspaceTemplates() { function builtInWorkspaceTemplates() {
return [ return [
{ {
@ -574,8 +545,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
source: 'official', source: 'official',
icon: 'list-todo', icon: 'list-todo',
provides: ['todo.list', 'todo.workspace'], provides: ['todo.list', 'todo.workspace'],
requires: ['verstak/core/notifications/v1'], permissions: ['files.read', 'storage.namespace', 'ui.register'],
permissions: ['files.read', 'storage.namespace', 'ui.register', 'notifications.schedule'],
frontend: { entry: 'frontend/dist/index.js' }, frontend: { entry: 'frontend/dist/index.js' },
contributes: { contributes: {
views: [{ id: 'verstak.todo.view', title: 'Todos', icon: 'list-todo', component: 'TodoView' }], views: [{ id: 'verstak.todo.view', title: 'Todos', icon: 'list-todo', component: 'TodoView' }],
@ -602,18 +572,9 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
source: 'official', source: 'official',
icon: 'key-round', icon: 'key-round',
provides: ['secret-store', 'secrets.read-ui', 'secrets.write-ui'], provides: ['secret-store', 'secrets.read-ui', 'secrets.write-ui'],
permissions: ['files.read', 'secrets.read', 'secrets.write', 'ui.register'], permissions: ['secrets.read', 'secrets.write', 'ui.register'],
frontend: { entry: 'frontend/src/index.js' }, frontend: { entry: 'frontend/dist/index.js' },
contributes: { contributes: {
views: [{ id: 'verstak.secrets.view', title: 'Secrets', icon: 'key-round', component: 'SecretsView' }],
sidebarItems: [{ id: 'verstak.secrets.sidebar', title: 'Secrets', icon: 'key-round', view: 'verstak.secrets.view', position: 45 }],
openProviders: [{
id: 'verstak.secrets.secret',
title: 'Secrets',
priority: 100,
component: 'SecretsView',
supports: [{ kind: 'secret', modes: ['view'] }]
}],
workspaceItems: [{ id: 'verstak.secrets.workspace', title: 'Secrets', icon: 'key-round', component: 'SecretsView' }] workspaceItems: [{ id: 'verstak.secrets.workspace', title: 'Secrets', icon: 'key-round', component: 'SecretsView' }]
} }
}, },
@ -717,7 +678,6 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
caps.push({ name: 'verstak/core/files/v1', description: 'Files API', pluginId: 'verstak-desktop', status: 'stable' }); caps.push({ name: 'verstak/core/files/v1', description: 'Files API', pluginId: 'verstak-desktop', status: 'stable' });
caps.push({ name: 'verstak/core/workbench/v1', description: 'Workbench routing', pluginId: 'verstak-desktop', status: 'stable' }); caps.push({ name: 'verstak/core/workbench/v1', description: 'Workbench routing', pluginId: 'verstak-desktop', status: 'stable' });
caps.push({ name: 'verstak/core/sync/v1', description: 'Sync API', pluginId: 'verstak-desktop', status: 'stable' }); caps.push({ name: 'verstak/core/sync/v1', description: 'Sync API', pluginId: 'verstak-desktop', status: 'stable' });
caps.push({ name: 'verstak/core/notifications/v1', description: 'Native notifications', pluginId: 'verstak-desktop', status: 'stable' });
for (var id in pluginStates) { for (var id in pluginStates) {
var s = pluginStates[id]; var s = pluginStates[id];
if (s.status === 'loaded' && s.enabled && s.manifest && s.manifest.provides) { if (s.status === 'loaded' && s.enabled && s.manifest && s.manifest.provides) {
@ -2456,7 +2416,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
} }
function title(capture) { function title(capture) {
return capture.title || capture.fileName || capture.url || capture.captureId || 'Untitled material'; return capture.title || capture.fileName || capture.url || capture.captureId || 'Untitled capture';
} }
function renderBrowserInbox(containerEl, props, api) { function renderBrowserInbox(containerEl, props, api) {
@ -2473,7 +2433,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
containerEl.innerHTML = ''; containerEl.innerHTML = '';
var root = el('div', { className: 'browser-inbox-root', 'data-plugin-id': PLUGIN_ID }); var root = el('div', { className: 'browser-inbox-root', 'data-plugin-id': PLUGIN_ID });
var toolbar = el('div', { className: 'browser-inbox-toolbar' }); var toolbar = el('div', { className: 'browser-inbox-toolbar' });
var titleEl = el('span', { className: 'browser-inbox-title', textContent: rootPath ? 'Browser · ' + rootPath : 'Browser' }); var titleEl = el('span', { className: 'browser-inbox-title', textContent: rootPath ? 'Browser Inbox · ' + rootPath : 'Browser Inbox' });
var countEl = el('span', { className: 'browser-inbox-count' }); var countEl = el('span', { className: 'browser-inbox-count' });
var statusEl = el('span', { className: 'browser-inbox-status' }); var statusEl = el('span', { className: 'browser-inbox-status' });
var filtersEl = el('div', { className: 'browser-inbox-filters' }); var filtersEl = el('div', { className: 'browser-inbox-filters' });
@ -2557,7 +2517,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
function renderWorkspaceFilterOptions() { function renderWorkspaceFilterOptions() {
if (rootPath) return; if (rootPath) return;
workspaceFilterEl.innerHTML = ''; workspaceFilterEl.innerHTML = '';
workspaceFilterEl.appendChild(option('', 'All Deals')); workspaceFilterEl.appendChild(option('', 'All workspaces'));
workspaceRoots().forEach(function (workspace) { workspaceRoots().forEach(function (workspace) {
workspaceFilterEl.appendChild(option(workspace, workspace)); workspaceFilterEl.appendChild(option(workspace, workspace));
}); });
@ -2630,7 +2590,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
}) : captures).map(function (capture) { return capture.captureId; }); }) : captures).map(function (capture) { return capture.captureId; });
captures = captures.filter(function (capture) { return ids.indexOf(capture.captureId) === -1; }); captures = captures.filter(function (capture) { return ids.indexOf(capture.captureId) === -1; });
selectedId = ''; selectedId = '';
statusText = rootPath ? 'Deal materials cleared' : 'Inbox cleared'; statusText = rootPath ? 'Workspace captures cleared' : 'Inbox cleared';
return persist(); return persist();
} }
@ -2672,7 +2632,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
listEl.appendChild(el('div', { listEl.appendChild(el('div', {
className: 'browser-inbox-empty', className: 'browser-inbox-empty',
textContent: captures.length === 0 textContent: captures.length === 0
? 'No browser materials yet. Send a page, selection, or link from the extension.' ? 'No browser captures yet. Keep this view open, then send a page, selection, link, or file from the browser extension.'
: 'No captures match the current filters.' : 'No captures match the current filters.'
})); }));
return; return;
@ -2717,7 +2677,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
el('div', { className: 'browser-inbox-meta-value', textContent: capture.domain || '-' }), el('div', { className: 'browser-inbox-meta-value', textContent: capture.domain || '-' }),
el('div', { className: 'browser-inbox-meta-label', textContent: 'Browser' }), el('div', { className: 'browser-inbox-meta-label', textContent: 'Browser' }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.browserName || capture.source || '-' }), el('div', { className: 'browser-inbox-meta-value', textContent: capture.browserName || capture.source || '-' }),
el('div', { className: 'browser-inbox-meta-label', textContent: 'Deal' }), el('div', { className: 'browser-inbox-meta-label', textContent: 'Workspace' }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.workspaceRootPath || 'Unassigned' }), el('div', { className: 'browser-inbox-meta-value', textContent: capture.workspaceRootPath || 'Unassigned' }),
el('div', { className: 'browser-inbox-meta-label', textContent: 'Status' }), el('div', { className: 'browser-inbox-meta-label', textContent: 'Status' }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.processed ? 'Processed' : 'Unprocessed' }) el('div', { className: 'browser-inbox-meta-value', textContent: capture.processed ? 'Processed' : 'Unprocessed' })
@ -3368,43 +3328,6 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
GetPluginLocalization: function (pluginId, locale) { GetPluginLocalization: function (pluginId, locale) {
return Promise.resolve([mockPluginCatalog(pluginId, locale), '']); return Promise.resolve([mockPluginCatalog(pluginId, locale), '']);
}, },
PluginSecretsStatus: function () {
return Promise.resolve([{ initialized: true, unlocked: true }, '']);
},
PluginSecretsUnlock: function () {
return Promise.resolve('');
},
PluginSecretsList: function () {
return Promise.resolve([secretRecords.map(function (record) {
var listed = cloneJson(record);
delete listed.value;
return listed;
}), '']);
},
PluginSecretsRead: function (_pluginId, secretID) {
var record = secretRecords.find(function (item) { return item.id === secretID; });
if (!record) return Promise.resolve([{}, 'not-found: secret ' + secretID]);
return Promise.resolve([cloneJson(record), '']);
},
PluginSecretsWrite: function (_pluginId, nextRecord) {
var record = Object.assign({}, nextRecord || {});
if (!record.id) return Promise.resolve([{}, 'secret id is required']);
record.scope = record.scope || { kind: 'global' };
record.updatedAt = new Date().toISOString();
var index = secretRecords.findIndex(function (item) { return item.id === record.id; });
if (index === -1) secretRecords.push(record);
else secretRecords[index] = record;
return Promise.resolve([cloneJson(record), '']);
},
PluginSecretsDelete: function (_pluginId, secretID) {
secretRecords = secretRecords.filter(function (record) { return record.id !== secretID; });
return Promise.resolve('');
},
PluginSecretsCopyLink: function (_pluginId, secretID) {
var record = secretRecords.find(function (item) { return item.id === secretID; });
if (!record) return Promise.resolve(['', 'not-found: secret ' + secretID]);
return Promise.resolve(['[' + (record.title || record.id) + '](verstak-secret://' + encodeURIComponent(record.id) + ')', '']);
},
ReadPluginSettings: function (pluginId) { ReadPluginSettings: function (pluginId) {
return Promise.resolve([Object.assign({}, pluginSettings[pluginId] || {}), '']); return Promise.resolve([Object.assign({}, pluginSettings[pluginId] || {}), '']);
}, },
@ -3412,22 +3335,8 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
pluginSettings[pluginId] = Object.assign({}, settings || {}); pluginSettings[pluginId] = Object.assign({}, settings || {});
return Promise.resolve(''); return Promise.resolve('');
}, },
ReadPluginSetting: function (pluginId, key) { ReadPluginSetting: function () { return Promise.resolve(null); },
return Promise.resolve([pluginSettings[pluginId] && pluginSettings[pluginId][key], '']); WritePluginSetting: function () { return Promise.resolve(null); },
},
WritePluginSetting: function (pluginId, key, value) {
pluginSettings[pluginId] = pluginSettings[pluginId] || {};
pluginSettings[pluginId][key] = value;
return Promise.resolve('');
},
ReplacePluginNotifications: function (pluginId, items) {
pluginNotifications[pluginId] = Array.isArray(items) ? items.slice() : [];
return Promise.resolve('');
},
ClearPluginNotifications: function (pluginId) {
delete pluginNotifications[pluginId];
return Promise.resolve('');
},
ReadPluginDataJSON: function (pluginId, name) { ReadPluginDataJSON: function (pluginId, name) {
var data = (pluginData[pluginId] && pluginData[pluginId][name]) || {}; var data = (pluginData[pluginId] && pluginData[pluginId][name]) || {};
return Promise.resolve([Object.assign({}, data), '']); return Promise.resolve([Object.assign({}, data), '']);
@ -3546,8 +3455,8 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
if (pluginId === 'verstak.platform-test' && assetPath === 'frontend/dist/index.js') { if (pluginId === 'verstak.platform-test' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(platformTestBundle()); return Promise.resolve(platformTestBundle());
} }
if (pluginId === 'verstak.default-editor') { if (pluginId === 'verstak.default-editor' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(defaultEditorSource); return Promise.resolve(defaultEditorBundle());
} }
if (pluginId === 'verstak.files' && assetPath === 'frontend/dist/index.js') { if (pluginId === 'verstak.files' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(filesPluginBundle()); return Promise.resolve(filesPluginBundle());
@ -3558,20 +3467,20 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
if (pluginId === 'verstak.notes' && assetPath === 'frontend/dist/index.js') { if (pluginId === 'verstak.notes' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(simplePluginBundle('verstak.notes', 'NotesView', 'notes-root', 'Notes')); return Promise.resolve(simplePluginBundle('verstak.notes', 'NotesView', 'notes-root', 'Notes'));
} }
if (pluginId === 'verstak.activity') { if (pluginId === 'verstak.activity' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(activitySource); return Promise.resolve(activityBundle());
} }
if (pluginId === 'verstak.journal' && assetPath === 'frontend/dist/index.js') { if (pluginId === 'verstak.journal' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(journalSource); return Promise.resolve(journalBundle());
} }
if (pluginId === 'verstak.browser-inbox' && assetPath === 'frontend/dist/index.js') { if (pluginId === 'verstak.browser-inbox' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(browserInboxBundle()); return Promise.resolve(browserInboxBundle());
} }
if (pluginId === 'verstak.todo' && assetPath === 'frontend/dist/index.js') { if (pluginId === 'verstak.todo' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(todoSource); return Promise.resolve(todoBundle());
} }
if (pluginId === 'verstak.secrets') { if (pluginId === 'verstak.secrets' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(secretsSource); return Promise.resolve(simplePluginBundle('verstak.secrets', 'SecretsView', 'secrets-root', 'Secrets'));
} }
if (pluginId === 'verstak.search' && assetPath === 'frontend/dist/index.js') { if (pluginId === 'verstak.search' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(searchPluginBundle()); return Promise.resolve(searchPluginBundle());
@ -4179,7 +4088,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
source: 'official', source: 'official',
icon: 'book-open', icon: 'book-open',
provides: ['worklog', 'journal', 'report.worklog'], provides: ['worklog', 'journal', 'report.worklog'],
permissions: ['events.publish', 'files.read', 'storage.namespace', 'ui.register'], permissions: ['storage.namespace', 'ui.register'],
frontend: { entry: 'frontend/dist/index.js' }, frontend: { entry: 'frontend/dist/index.js' },
contributes: { contributes: {
views: [{ id: 'verstak.journal.view', title: 'Journal', icon: 'book-open', component: 'JournalView' }], views: [{ id: 'verstak.journal.view', title: 'Journal', icon: 'book-open', component: 'JournalView' }],
@ -4196,19 +4105,19 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
manifest: { manifest: {
schemaVersion: 1, schemaVersion: 1,
id: 'verstak.browser-inbox', id: 'verstak.browser-inbox',
name: 'Browser', name: 'Browser Inbox',
version: '0.1.0', version: '0.1.0',
apiVersion: '0.1.0', apiVersion: '0.1.0',
description: 'Global browser materials with explicit Deal assignment.', description: 'Global browser capture queue with explicit workspace assignment.',
source: 'official', source: 'official',
icon: 'inbox', icon: 'inbox',
provides: ['browser.inbox'], provides: ['browser.inbox'],
permissions: ['events.subscribe', 'files.read', 'storage.namespace', 'ui.register'], permissions: ['events.subscribe', 'files.read', 'storage.namespace', 'ui.register'],
frontend: { entry: 'frontend/dist/index.js' }, frontend: { entry: 'frontend/dist/index.js' },
contributes: { contributes: {
views: [{ id: 'verstak.browser-inbox.view', title: 'Browser', icon: 'inbox', component: 'BrowserInboxView' }], views: [{ id: 'verstak.browser-inbox.view', title: 'Browser Inbox', icon: 'inbox', component: 'BrowserInboxView' }],
sidebarItems: [{ id: 'verstak.browser-inbox.sidebar', title: 'Browser', icon: 'inbox', view: 'verstak.browser-inbox.view', position: 30 }], sidebarItems: [{ id: 'verstak.browser-inbox.sidebar', title: 'Browser Inbox', icon: 'inbox', view: 'verstak.browser-inbox.view', position: 30 }],
workspaceItems: [{ id: 'verstak.browser-inbox.workspace', title: 'Browser', icon: 'inbox', component: 'BrowserInboxView' }] workspaceItems: [{ id: 'verstak.browser-inbox.workspace', title: 'Browser Inbox', icon: 'inbox', component: 'BrowserInboxView' }]
} }
}, },
rootPath: '/tmp/verstak-test/plugins/browser-inbox', rootPath: '/tmp/verstak-test/plugins/browser-inbox',
@ -4255,8 +4164,6 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
workbenchPreferences = {}; workbenchPreferences = {};
openedResources = []; openedResources = [];
pluginSettings = { 'verstak.platform-test': { savedText: 'initial value' } }; pluginSettings = { 'verstak.platform-test': { savedText: 'initial value' } };
pluginNotifications = {};
secretRecords = makeDefaultSecretRecords();
vaultFiles = makeDefaultVaultFiles(); vaultFiles = makeDefaultVaultFiles();
externalOpens = []; externalOpens = [];
trashEntries = []; trashEntries = [];

View File

@ -1,32 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import vm from 'node:vm';
const source = fs.readFileSync(path.resolve('frontend/src/lib/log/debug.js'), 'utf8');
const values = new Map();
const localStorage = {
getItem(key) { return values.has(key) ? values.get(key) : null; },
setItem(key, value) { values.set(key, String(value)); },
removeItem(key) { values.delete(key); },
};
const context = vm.createContext({
console: { log() {} },
localStorage,
window: { location: { search: '' } },
});
const module = new vm.SourceTextModule(source, { context, identifier: 'debug.js' });
await module.link(() => { throw new Error('debug.js must not import modules'); });
await module.evaluate();
const { debug } = module.namespace;
debug.enable({ persist: false });
assert.equal(debug.isEnabled(), true, 'session debug should enable diagnostics');
assert.equal(
localStorage.getItem('verstak-debug'),
null,
'a --debug session must not make diagnostics persist into ordinary launches',
);
console.log('debug mode smoke passed');

View File

@ -1,36 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const files = [
'src/lib/shell/CommandPalette.svelte',
'src/lib/plugin-manager/PluginManager.svelte',
'src/lib/shell/VaultSelection.svelte',
'src/lib/shell/WorkspaceTree.svelte',
'src/lib/plugin-host/PluginBundleHost.svelte',
];
const patterns = [
/(?:^|\n)\s*(?:error|localError|createError)\s*=\s*(?:err|String\()/,
/(?:^|\n)\s*error\s*=\s*['"].*['"]\s*\+\s*(?:err|String\()/,
/tr\([^\n]*\{\s*error\s*:/,
];
const violations = [];
for (const relativePath of files) {
const source = fs.readFileSync(path.join(root, relativePath), 'utf8');
for (const pattern of patterns) {
if (pattern.test(source)) violations.push(relativePath);
}
}
if (violations.length) {
throw new Error(`Technical backend errors reach desktop UI in: ${[...new Set(violations)].join(', ')}`);
}
const bundleHost = fs.readFileSync(path.join(root, 'src/lib/plugin-host/PluginBundleHost.svelte'), 'utf8');
if (!/<p class="error-message">\{errorText \|\| tr\('bundle\.unknownError'\)\}<\/p>\s*<details class="error-details">/.test(bundleHost)) {
throw new Error('Plugin bundle errors must keep the user-facing message outside expandable technical details');
}
console.log('desktop UI does not interpolate raw backend errors');

View File

@ -3,7 +3,6 @@ import path from 'node:path';
import { pathToFileURL } from 'node:url'; import { pathToFileURL } from 'node:url';
const pluginData = {}; const pluginData = {};
const scheduledNotifications = [];
globalThis.window = { globalThis.window = {
__VERSTAK_PLUGIN_REGISTRY__: {}, __VERSTAK_PLUGIN_REGISTRY__: {},
@ -39,14 +38,6 @@ globalThis.window = {
pluginData[pluginId][name] = Object.assign({}, data || {}); pluginData[pluginId][name] = Object.assign({}, data || {});
return Promise.resolve(''); return Promise.resolve('');
}, },
ReplacePluginNotifications: (pluginId, items) => {
scheduledNotifications.push({ pluginId, items });
return Promise.resolve(items[0]?.id === 'rejected' ? 'notification permission denied' : '');
},
ClearPluginNotifications: (pluginId) => {
scheduledNotifications.push({ pluginId, clear: true });
return Promise.resolve('');
},
}, },
}, },
}, },
@ -125,28 +116,6 @@ if (stored.version !== 1 || stored.workspaceRootPath !== 'Project') {
throw new Error(`unexpected storage data: ${JSON.stringify(stored)}`); throw new Error(`unexpected storage data: ${JSON.stringify(stored)}`);
} }
if (!api.notifications || typeof api.notifications.replace !== 'function' || typeof api.notifications.clear !== 'function') {
throw new Error('api.notifications replace/clear contract is missing');
}
await api.notifications.replace([{ id: 'reminder-1', dueAt: '2026-07-14T10:00:00Z', title: 'Reminder' }]);
await api.notifications.clear();
if (scheduledNotifications.length !== 2
|| scheduledNotifications[0].pluginId !== 'verstak.files'
|| scheduledNotifications[0].items[0].id !== 'reminder-1'
|| scheduledNotifications[1].pluginId !== 'verstak.files'
|| !scheduledNotifications[1].clear) {
throw new Error(`unexpected notification calls: ${JSON.stringify(scheduledNotifications)}`);
}
let rejected = false;
try {
await api.notifications.replace([{ id: 'rejected', dueAt: '2026-07-14T10:00:00Z', title: 'Reminder' }]);
} catch (err) {
rejected = String(err.message || err).includes('[plugin:verstak.files] notifications.replace failed: notification permission denied');
}
if (!rejected) {
throw new Error('notification backend errors must be plugin-scoped rejections');
}
api.dispose(); api.dispose();
if (localeListeners.size !== 0) { if (localeListeners.size !== 0) {
throw new Error('api.i18n locale subscription was not disposed'); throw new Error('api.i18n locale subscription was not disposed');

View File

@ -1,9 +0,0 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
const source = await readFile(new URL('../src/lib/shell/WorkspaceTree.svelte', import.meta.url), 'utf8');
assert.match(source, /\.workspace-create-field select\s*\{[^}]*appearance:\s*none/, 'workspace template select must hide the native arrow');
assert.match(source, /\.workspace-create-field select option\s*\{[^}]*background/, 'workspace template options must use the application surface');
console.log('workspace select style contract passed');

View File

@ -1,28 +0,0 @@
import en from '../src/lib/i18n/catalogs/en.js';
import ru from '../src/lib/i18n/catalogs/ru.js';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const forbidden = [
['English catalog', /\bworkspace(?:s)?\b/i],
['Russian catalog', /рабоч(?:ее|ие|ем|его|их)\s+пространств(?:о|а|е)?/i],
];
for (const [name, pattern] of forbidden) {
const violations = Object.entries(name === 'English catalog' ? en : ru)
.filter(([, value]) => pattern.test(value));
if (violations.length) {
throw new Error(`${name} still exposes workspace terminology: ${violations.map(([key]) => key).join(', ')}`);
}
}
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const overview = fs.readFileSync(path.join(root, 'src/lib/shell/TodaySurface.svelte'), 'utf8');
for (const phrase of ['Workspace opened', 'Workspace activity', 'Workspace overview note']) {
if (overview.includes(phrase)) {
throw new Error(`Overview exposes workspace terminology: ${phrase}`);
}
}
console.log('desktop user terminology uses Deal/Дело');

View File

@ -1,13 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
const bindingsPath = path.resolve('frontend/wailsjs/go/api/App.js');
const bindings = fs.readFileSync(bindingsPath, 'utf8');
for (const method of ['ReplacePluginNotifications', 'ClearPluginNotifications']) {
if (!bindings.includes(`export function ${method}(`)) {
throw new Error(`Wails binding does not export ${method}`);
}
}
console.log('Wails notification bindings are present');

View File

@ -1,4 +1,3 @@
import { resolve } from 'node:path';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte'; import { svelte } from '@sveltejs/vite-plugin-svelte';
@ -14,9 +13,6 @@ export default defineConfig(({ mode }) => {
server: { server: {
port: isTest ? 5174 : 5173, port: isTest ? 5174 : 5173,
strictPort: true, strictPort: true,
fs: {
allow: [resolve(__dirname, '..', '..')],
},
}, },
optimizeDeps: { optimizeDeps: {
include: [ include: [

View File

@ -1,19 +1,15 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT // This file is automatically generated. DO NOT EDIT
import {workspace} from '../models'; import {workspace} from '../models';
import {context} from '../models';
import {workbench} from '../models'; import {workbench} from '../models';
import {capability} from '../models'; import {capability} from '../models';
import {api} from '../models'; import {api} from '../models';
import {permissions} from '../models'; import {permissions} from '../models';
import {plugin} from '../models'; import {plugin} from '../models';
import {files} from '../models'; import {files} from '../models';
import {notifications} from '../models';
export function ArchiveWorkspaceNode(arg1:string):Promise<string>; export function ArchiveWorkspaceNode(arg1:string):Promise<string>;
export function ClearPluginNotifications(arg1:string):Promise<string>;
export function CloseVault():Promise<void>; export function CloseVault():Promise<void>;
export function CreateVault(arg1:string):Promise<void>; export function CreateVault(arg1:string):Promise<void>;
@ -28,8 +24,6 @@ export function DeleteVaultTrash(arg1:string,arg2:string):Promise<string>;
export function DisablePlugin(arg1:string):Promise<string>; export function DisablePlugin(arg1:string):Promise<string>;
export function DomReady(arg1:context.Context):Promise<void>;
export function EditWorkbenchResource(arg1:string,arg2:Record<string, any>):Promise<workbench.OpenResourceResult|string>; export function EditWorkbenchResource(arg1:string,arg2:Record<string, any>):Promise<workbench.OpenResourceResult|string>;
export function EnablePlugin(arg1:string):Promise<string>; export function EnablePlugin(arg1:string):Promise<string>;
@ -78,22 +72,22 @@ export function ListVaultFiles(arg1:string,arg2:string):Promise<Array<files.File
export function ListVaultTrash(arg1:string):Promise<Array<files.TrashEntry>|string>; export function ListVaultTrash(arg1:string):Promise<Array<files.TrashEntry>|string>;
export function ListWorkspaces():Promise<Array<workspace.Workspace>|string>;
export function ListWorkspaceIdentities():Promise<Array<workspace.WorkspaceIdentity>|string>; export function ListWorkspaceIdentities():Promise<Array<workspace.WorkspaceIdentity>|string>;
export function ListWorkspaceTemplates():Promise<Array<workspace.WorkspaceTemplate>|string>; export function ListWorkspaceTemplates():Promise<Array<workspace.WorkspaceTemplate>|string>;
export function ListWorkspaces():Promise<Array<workspace.Workspace>|string>;
export function MoveVaultPath(arg1:string,arg2:string,arg3:string,arg4:files.MoveOptions):Promise<string>; export function MoveVaultPath(arg1:string,arg2:string,arg3:string,arg4:files.MoveOptions):Promise<string>;
export function MoveWorkspaceNode(arg1:string,arg2:string):Promise<string>; export function MoveWorkspaceNode(arg1:string,arg2:string):Promise<string>;
export function OpenExternalURL(arg1:string,arg2:string):Promise<string>;
export function OpenVault(arg1:string):Promise<void>; export function OpenVault(arg1:string):Promise<void>;
export function OpenVaultPathExternal(arg1:string,arg2:string):Promise<string>; export function OpenVaultPathExternal(arg1:string,arg2:string):Promise<string>;
export function OpenExternalURL(arg1:string,arg2:string):Promise<string>;
export function OpenWorkbenchResource(arg1:string,arg2:Record<string, any>):Promise<workbench.OpenResourceResult|string>; export function OpenWorkbenchResource(arg1:string,arg2:Record<string, any>):Promise<workbench.OpenResourceResult|string>;
export function PluginBrowserReceiverPairing(arg1:string):Promise<Record<string, string>|string>; export function PluginBrowserReceiverPairing(arg1:string):Promise<Record<string, string>|string>;
@ -130,10 +124,6 @@ export function PluginSyncTestConnection(arg1:string,arg2:string,arg3:string,arg
export function PublishPluginEvent(arg1:string,arg2:string,arg3:Record<string, any>):Promise<string>; export function PublishPluginEvent(arg1:string,arg2:string,arg3:Record<string, any>):Promise<string>;
export function PurgeWorkspaceTrash(arg1:string):Promise<string>;
export function Quit():Promise<void>;
export function ReadPluginDataJSON(arg1:string,arg2:string):Promise<Record<string, any>>; export function ReadPluginDataJSON(arg1:string,arg2:string):Promise<Record<string, any>>;
export function ReadPluginDataNDJSON(arg1:string,arg2:string):Promise<Array<Record<string, any>>>; export function ReadPluginDataNDJSON(arg1:string,arg2:string):Promise<Array<Record<string, any>>>;
@ -152,11 +142,9 @@ export function ReloadPlugins():Promise<number|string>;
export function RenameWorkspace(arg1:string,arg2:string):Promise<string>; export function RenameWorkspace(arg1:string,arg2:string):Promise<string>;
export function RenameWorkspaceNode(arg1:string,arg2:string):Promise<string>;
export function RepairWorkspaceIdentity(arg1:string,arg2:string):Promise<string>; export function RepairWorkspaceIdentity(arg1:string,arg2:string):Promise<string>;
export function ReplacePluginNotifications(arg1:string,arg2:Array<notifications.Request>):Promise<string>; export function RenameWorkspaceNode(arg1:string,arg2:string):Promise<string>;
export function RestoreVaultTrash(arg1:string,arg2:string,arg3:files.RestoreOptions):Promise<string|string>; export function RestoreVaultTrash(arg1:string,arg2:string,arg3:files.RestoreOptions):Promise<string|string>;
@ -164,6 +152,8 @@ export function RestoreWorkspaceTrash(arg1:string,arg2:string):Promise<workspace
export function SelectDirectory():Promise<string>; export function SelectDirectory():Promise<string>;
export function PurgeWorkspaceTrash(arg1:string):Promise<string>;
export function SelectVaultForOpen():Promise<string>; export function SelectVaultForOpen():Promise<string>;
export function SetCurrentVault(arg1:string):Promise<string>; export function SetCurrentVault(arg1:string):Promise<string>;
@ -172,14 +162,8 @@ export function SetCurrentWorkspace(arg1:string):Promise<string>;
export function SetCurrentWorkspaceNode(arg1:string):Promise<string>; export function SetCurrentWorkspaceNode(arg1:string):Promise<string>;
export function SetNotificationService(arg1:api.notificationService):Promise<void>;
export function ShowVaultPathInFolder(arg1:string,arg2:string):Promise<string>; export function ShowVaultPathInFolder(arg1:string,arg2:string):Promise<string>;
export function ShowWindow():Promise<void>;
export function Shutdown(arg1:context.Context):Promise<void>;
export function SubscribePluginEvent(arg1:string,arg2:string):Promise<string>; export function SubscribePluginEvent(arg1:string,arg2:string):Promise<string>;
export function TrashVaultPath(arg1:string,arg2:string):Promise<files.TrashResult|string>; export function TrashVaultPath(arg1:string,arg2:string):Promise<files.TrashResult|string>;

View File

@ -6,10 +6,6 @@ export function ArchiveWorkspaceNode(arg1) {
return window['go']['api']['App']['ArchiveWorkspaceNode'](arg1); return window['go']['api']['App']['ArchiveWorkspaceNode'](arg1);
} }
export function ClearPluginNotifications(arg1) {
return window['go']['api']['App']['ClearPluginNotifications'](arg1);
}
export function CloseVault() { export function CloseVault() {
return window['go']['api']['App']['CloseVault'](); return window['go']['api']['App']['CloseVault']();
} }
@ -38,10 +34,6 @@ export function DisablePlugin(arg1) {
return window['go']['api']['App']['DisablePlugin'](arg1); return window['go']['api']['App']['DisablePlugin'](arg1);
} }
export function DomReady(arg1) {
return window['go']['api']['App']['DomReady'](arg1);
}
export function EditWorkbenchResource(arg1, arg2) { export function EditWorkbenchResource(arg1, arg2) {
return window['go']['api']['App']['EditWorkbenchResource'](arg1, arg2); return window['go']['api']['App']['EditWorkbenchResource'](arg1, arg2);
} }
@ -138,6 +130,10 @@ export function ListVaultTrash(arg1) {
return window['go']['api']['App']['ListVaultTrash'](arg1); return window['go']['api']['App']['ListVaultTrash'](arg1);
} }
export function ListWorkspaces() {
return window['go']['api']['App']['ListWorkspaces']();
}
export function ListWorkspaceIdentities() { export function ListWorkspaceIdentities() {
return window['go']['api']['App']['ListWorkspaceIdentities'](); return window['go']['api']['App']['ListWorkspaceIdentities']();
} }
@ -146,10 +142,6 @@ export function ListWorkspaceTemplates() {
return window['go']['api']['App']['ListWorkspaceTemplates'](); return window['go']['api']['App']['ListWorkspaceTemplates']();
} }
export function ListWorkspaces() {
return window['go']['api']['App']['ListWorkspaces']();
}
export function MoveVaultPath(arg1, arg2, arg3, arg4) { export function MoveVaultPath(arg1, arg2, arg3, arg4) {
return window['go']['api']['App']['MoveVaultPath'](arg1, arg2, arg3, arg4); return window['go']['api']['App']['MoveVaultPath'](arg1, arg2, arg3, arg4);
} }
@ -158,10 +150,6 @@ export function MoveWorkspaceNode(arg1, arg2) {
return window['go']['api']['App']['MoveWorkspaceNode'](arg1, arg2); return window['go']['api']['App']['MoveWorkspaceNode'](arg1, arg2);
} }
export function OpenExternalURL(arg1, arg2) {
return window['go']['api']['App']['OpenExternalURL'](arg1, arg2);
}
export function OpenVault(arg1) { export function OpenVault(arg1) {
return window['go']['api']['App']['OpenVault'](arg1); return window['go']['api']['App']['OpenVault'](arg1);
} }
@ -170,6 +158,10 @@ export function OpenVaultPathExternal(arg1, arg2) {
return window['go']['api']['App']['OpenVaultPathExternal'](arg1, arg2); return window['go']['api']['App']['OpenVaultPathExternal'](arg1, arg2);
} }
export function OpenExternalURL(arg1, arg2) {
return window['go']['api']['App']['OpenExternalURL'](arg1, arg2);
}
export function OpenWorkbenchResource(arg1, arg2) { export function OpenWorkbenchResource(arg1, arg2) {
return window['go']['api']['App']['OpenWorkbenchResource'](arg1, arg2); return window['go']['api']['App']['OpenWorkbenchResource'](arg1, arg2);
} }
@ -242,14 +234,6 @@ export function PublishPluginEvent(arg1, arg2, arg3) {
return window['go']['api']['App']['PublishPluginEvent'](arg1, arg2, arg3); return window['go']['api']['App']['PublishPluginEvent'](arg1, arg2, arg3);
} }
export function PurgeWorkspaceTrash(arg1) {
return window['go']['api']['App']['PurgeWorkspaceTrash'](arg1);
}
export function Quit() {
return window['go']['api']['App']['Quit']();
}
export function ReadPluginDataJSON(arg1, arg2) { export function ReadPluginDataJSON(arg1, arg2) {
return window['go']['api']['App']['ReadPluginDataJSON'](arg1, arg2); return window['go']['api']['App']['ReadPluginDataJSON'](arg1, arg2);
} }
@ -286,16 +270,12 @@ export function RenameWorkspace(arg1, arg2) {
return window['go']['api']['App']['RenameWorkspace'](arg1, arg2); return window['go']['api']['App']['RenameWorkspace'](arg1, arg2);
} }
export function RenameWorkspaceNode(arg1, arg2) {
return window['go']['api']['App']['RenameWorkspaceNode'](arg1, arg2);
}
export function RepairWorkspaceIdentity(arg1, arg2) { export function RepairWorkspaceIdentity(arg1, arg2) {
return window['go']['api']['App']['RepairWorkspaceIdentity'](arg1, arg2); return window['go']['api']['App']['RepairWorkspaceIdentity'](arg1, arg2);
} }
export function ReplacePluginNotifications(arg1, arg2) { export function RenameWorkspaceNode(arg1, arg2) {
return window['go']['api']['App']['ReplacePluginNotifications'](arg1, arg2); return window['go']['api']['App']['RenameWorkspaceNode'](arg1, arg2);
} }
export function RestoreVaultTrash(arg1, arg2, arg3) { export function RestoreVaultTrash(arg1, arg2, arg3) {
@ -310,6 +290,10 @@ export function SelectDirectory() {
return window['go']['api']['App']['SelectDirectory'](); return window['go']['api']['App']['SelectDirectory']();
} }
export function PurgeWorkspaceTrash(arg1) {
return window['go']['api']['App']['PurgeWorkspaceTrash'](arg1);
}
export function SelectVaultForOpen() { export function SelectVaultForOpen() {
return window['go']['api']['App']['SelectVaultForOpen'](); return window['go']['api']['App']['SelectVaultForOpen']();
} }
@ -326,22 +310,10 @@ export function SetCurrentWorkspaceNode(arg1) {
return window['go']['api']['App']['SetCurrentWorkspaceNode'](arg1); return window['go']['api']['App']['SetCurrentWorkspaceNode'](arg1);
} }
export function SetNotificationService(arg1) {
return window['go']['api']['App']['SetNotificationService'](arg1);
}
export function ShowVaultPathInFolder(arg1, arg2) { export function ShowVaultPathInFolder(arg1, arg2) {
return window['go']['api']['App']['ShowVaultPathInFolder'](arg1, arg2); return window['go']['api']['App']['ShowVaultPathInFolder'](arg1, arg2);
} }
export function ShowWindow() {
return window['go']['api']['App']['ShowWindow']();
}
export function Shutdown(arg1) {
return window['go']['api']['App']['Shutdown'](arg1);
}
export function SubscribePluginEvent(arg1, arg2) { export function SubscribePluginEvent(arg1, arg2) {
return window['go']['api']['App']['SubscribePluginEvent'](arg1, arg2); return window['go']['api']['App']['SubscribePluginEvent'](arg1, arg2);
} }

View File

@ -538,29 +538,6 @@ export namespace files {
} }
export namespace notifications {
export class Request {
id: string;
dueAt: string;
title: string;
body?: string;
static createFrom(source: any = {}) {
return new Request(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.dueAt = source["dueAt"];
this.title = source["title"];
this.body = source["body"];
}
}
}
export namespace permissions { export namespace permissions {
export class Entry { export class Entry {
@ -1253,6 +1230,27 @@ export namespace workbench {
export namespace workspace { export namespace workspace {
export class WorkspaceTemplate {
id: string;
name: string;
description: string;
version: number;
workspaceTools: string[];
static createFrom(source: any = {}) {
return new WorkspaceTemplate(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.description = source["description"];
this.version = source["version"];
this.workspaceTools = source["workspaceTools"];
}
}
export class TemplateSnapshot { export class TemplateSnapshot {
templateId: string; templateId: string;
templateName: string; templateName: string;
@ -1366,6 +1364,7 @@ export namespace workspace {
this.rootPath = source["rootPath"]; this.rootPath = source["rootPath"];
} }
} }
export class WorkspaceIdentity { export class WorkspaceIdentity {
workspaceId: string; workspaceId: string;
rootPath: string; rootPath: string;
@ -1382,25 +1381,5 @@ export namespace workspace {
this.state = source["state"]; this.state = source["state"];
} }
} }
export class WorkspaceTemplate {
id: string;
name: string;
description: string;
version: number;
workspaceTools: string[];
static createFrom(source: any = {}) {
return new WorkspaceTemplate(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.description = source["description"];
this.version = source["version"];
this.workspaceTools = source["workspaceTools"];
}
}
} }

9
go.mod
View File

@ -3,7 +3,6 @@ module github.com/verstak/verstak-desktop
go 1.24.4 go 1.24.4
require ( require (
github.com/getlantern/systray v1.2.2
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/wailsapp/wails/v2 v2.12.0 github.com/wailsapp/wails/v2 v2.12.0
golang.org/x/net v0.35.0 golang.org/x/net v0.35.0
@ -12,14 +11,7 @@ require (
require ( require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/bep/debounce v1.2.1 // indirect github.com/bep/debounce v1.2.1 // indirect
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect
github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-stack/stack v1.8.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
@ -31,7 +23,6 @@ require (
github.com/leaanthony/u v1.1.1 // indirect github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect

26
go.sum
View File

@ -2,27 +2,10 @@ git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE=
github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@ -45,8 +28,6 @@ github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
@ -55,8 +36,6 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@ -68,9 +47,6 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
@ -91,7 +67,6 @@ golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -104,6 +79,5 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -24,7 +24,6 @@ import (
"github.com/verstak/verstak-desktop/internal/core/externalopen" "github.com/verstak/verstak-desktop/internal/core/externalopen"
corefiles "github.com/verstak/verstak-desktop/internal/core/files" corefiles "github.com/verstak/verstak-desktop/internal/core/files"
"github.com/verstak/verstak-desktop/internal/core/filewatcher" "github.com/verstak/verstak-desktop/internal/core/filewatcher"
"github.com/verstak/verstak-desktop/internal/core/notifications"
"github.com/verstak/verstak-desktop/internal/core/permissions" "github.com/verstak/verstak-desktop/internal/core/permissions"
"github.com/verstak/verstak-desktop/internal/core/plugin" "github.com/verstak/verstak-desktop/internal/core/plugin"
"github.com/verstak/verstak-desktop/internal/core/pluginstate" "github.com/verstak/verstak-desktop/internal/core/pluginstate"
@ -39,19 +38,6 @@ import (
var newSyncClient = syncsvc.NewClient var newSyncClient = syncsvc.NewClient
var emitFrontendEvent = runtime.EventsEmit var emitFrontendEvent = runtime.EventsEmit
var initializeNativeNotifications = runtime.InitializeNotifications
var cleanupNativeNotifications = runtime.CleanupNotifications
var sendNativeNotification = runtime.SendNotification
var hideNativeWindow = runtime.WindowHide
var showNativeWindow = runtime.WindowShow
var quitNativeApplication = runtime.Quit
type notificationService interface {
Replace(pluginID string, requests []notifications.Request) error
Clear(pluginID string) error
Start(ctx context.Context)
Stop()
}
const pluginEventRuntimeName = "verstak:plugin-event" const pluginEventRuntimeName = "verstak:plugin-event"
const activityPluginID = "verstak.activity" const activityPluginID = "verstak.activity"
@ -94,20 +80,10 @@ type App struct {
browserReceiver *browserreceiver.Receiver browserReceiver *browserreceiver.Receiver
secretsSession *coresecrets.VaultSession secretsSession *coresecrets.VaultSession
fileWatcher *filewatcher.Service fileWatcher *filewatcher.Service
notifications notificationService
debug bool debug bool
activityEvents map[string]bool activityEvents map[string]bool
browserInboxEvents map[string]bool browserInboxEvents map[string]bool
browserInboxEnabled atomic.Bool browserInboxEnabled atomic.Bool
allowQuit atomic.Bool
}
// SetNotificationService attaches the core-owned plugin notification scheduler.
func (a *App) SetNotificationService(service notificationService) {
if a == nil {
return
}
a.notifications = service
} }
type externalOpenService interface { type externalOpenService interface {
@ -201,71 +177,6 @@ func (a *App) Startup(ctx context.Context) {
log.Printf("[api] App.Startup: initialized with %d plugins", len(a.plugins)) log.Printf("[api] App.Startup: initialized with %d plugins", len(a.plugins))
} }
// DomReady initializes the native notification runtime before starting schedules.
func (a *App) DomReady(ctx context.Context) {
if a.notifications == nil {
return
}
if err := initializeNativeNotifications(ctx); err != nil {
log.Printf("[api] native notifications unavailable: %v", err)
return
}
a.notifications.Start(ctx)
}
// Shutdown stops scheduled delivery before releasing native notification resources.
func (a *App) Shutdown(ctx context.Context) {
if a.notifications != nil {
a.notifications.Stop()
}
cleanupNativeNotifications(ctx)
}
// BeforeClose hides the primary window until the user explicitly quits from the tray.
func (a *App) BeforeClose(ctx context.Context) bool {
if a.allowQuit.Load() {
return false
}
hideNativeWindow(ctx)
return true
}
// ShowWindow brings the primary window back from the tray.
func (a *App) ShowWindow() {
if a == nil || a.ctx == nil {
return
}
showNativeWindow(a.ctx)
}
// Quit allows the close event and ends the application process.
func (a *App) Quit() {
if a == nil {
return
}
a.allowQuit.Store(true)
if a.ctx != nil {
quitNativeApplication(a.ctx)
}
}
// NativeNotificationSender delivers scheduler items through the Wails runtime.
type NativeNotificationSender struct{}
// NewNativeNotificationSender creates the adapter used by the core scheduler.
func NewNativeNotificationSender() notifications.Sender {
return NativeNotificationSender{}
}
// Send shows a native system notification for a scheduled plugin reminder.
func (NativeNotificationSender) Send(ctx context.Context, item notifications.Item) error {
return sendNativeNotification(ctx, runtime.NotificationOptions{
ID: "verstak:" + item.PluginID + ":" + item.ID,
Title: item.Title,
Body: item.Body,
})
}
func (a *App) ensureBrowserInboxSubscriptions() { func (a *App) ensureBrowserInboxSubscriptions() {
if a.eventBus == nil || a.storage == nil { if a.eventBus == nil || a.storage == nil {
a.browserInboxEnabled.Store(false) a.browserInboxEnabled.Store(false)
@ -1064,23 +975,30 @@ func (a *App) ReloadPlugins() (int, string) {
// Unregister all non-core capabilities // Unregister all non-core capabilities
a.capRegistry.UnregisterAll() a.capRegistry.UnregisterAll()
// Re-register the same core capabilities as initial startup. Keeping this // Re-register core capabilities
// list in the capability package prevents a plugin reload from dropping a coreCaps := []string{
// required capability such as native notifications. "verstak/core/plugin-manager/v1",
if err := a.capRegistry.Register(capability.CorePluginID, capability.CorePlatformCapabilities()); err != nil { "verstak/core/capability-registry/v1",
"verstak/core/contribution-registry/v1",
"verstak/core/permissions/v1",
"verstak/core/events/v1",
"verstak/core/files/v1",
"verstak/core/workbench/v1",
}
if err := a.capRegistry.Register("verstak-desktop", coreCaps); err != nil {
log.Printf("[api] ReloadPlugins: failed to re-register core capabilities: %v", err) log.Printf("[api] ReloadPlugins: failed to re-register core capabilities: %v", err)
} }
// Re-register vault capability if vault is open // Re-register vault capability if vault is open
if a.vault != nil && a.vault.GetVaultStatus() == vault.StatusOpen { if a.vault != nil && a.vault.GetVaultStatus() == vault.StatusOpen {
if err := a.capRegistry.Register(capability.CorePluginID, []string{"verstak/core/vault/v1"}); err != nil { if err := a.capRegistry.Register("verstak-desktop", []string{"verstak/core/vault/v1"}); err != nil {
log.Printf("[api] ReloadPlugins: failed to re-register vault capability: %v", err) log.Printf("[api] ReloadPlugins: failed to re-register vault capability: %v", err)
} }
} }
// Re-register workspace capability if workspace is initialized // Re-register workspace capability if workspace is initialized
if a.workspace != nil && a.workspace.IsInitialized() { if a.workspace != nil && a.workspace.IsInitialized() {
if err := a.capRegistry.Register(capability.CorePluginID, []string{"verstak/core/workspace/v1"}); err != nil { if err := a.capRegistry.Register("verstak-desktop", []string{"verstak/core/workspace/v1"}); err != nil {
log.Printf("[api] ReloadPlugins: failed to re-register workspace capability: %v", err) log.Printf("[api] ReloadPlugins: failed to re-register workspace capability: %v", err)
} }
} }
@ -1348,44 +1266,6 @@ func (a *App) WritePluginDataJSON(pluginID, name string, data map[string]interfa
return "" return ""
} }
// ReplacePluginNotifications replaces one plugin's desired native notification
// schedules. Plugins must declare both the capability and permission.
func (a *App) ReplacePluginNotifications(pluginID string, requests []notifications.Request) string {
if _, err := a.requirePluginAccess(pluginID, "notifications.schedule"); err != nil {
return err.Error()
}
if _, err := a.requirePluginCapabilityAccess(pluginID, "verstak/core/notifications/v1"); err != nil {
return err.Error()
}
if a.notifications == nil {
return "notification scheduler not initialized"
}
if err := a.notifications.Replace(pluginID, requests); err != nil {
log.Printf("[api] ReplacePluginNotifications(%s): %v", pluginID, err)
return err.Error()
}
return ""
}
// ClearPluginNotifications removes every native notification schedule owned by
// one plugin.
func (a *App) ClearPluginNotifications(pluginID string) string {
if _, err := a.requirePluginAccess(pluginID, "notifications.schedule"); err != nil {
return err.Error()
}
if _, err := a.requirePluginCapabilityAccess(pluginID, "verstak/core/notifications/v1"); err != nil {
return err.Error()
}
if a.notifications == nil {
return "notification scheduler not initialized"
}
if err := a.notifications.Clear(pluginID); err != nil {
log.Printf("[api] ClearPluginNotifications(%s): %v", pluginID, err)
return err.Error()
}
return ""
}
// ListVaultFiles lists a vault-relative directory for a plugin with files.read. // ListVaultFiles lists a vault-relative directory for a plugin with files.read.
func (a *App) ListVaultFiles(pluginID, relativeDir string) ([]corefiles.FileEntry, string) { func (a *App) ListVaultFiles(pluginID, relativeDir string) ([]corefiles.FileEntry, string) {
if _, err := a.requirePluginAccess(pluginID, "files.read"); err != nil { if _, err := a.requirePluginAccess(pluginID, "files.read"); err != nil {

View File

@ -14,15 +14,12 @@ import (
"testing" "testing"
"time" "time"
"github.com/wailsapp/wails/v2/pkg/runtime"
"github.com/verstak/verstak-desktop/internal/core/appsettings" "github.com/verstak/verstak-desktop/internal/core/appsettings"
"github.com/verstak/verstak-desktop/internal/core/browserreceiver" "github.com/verstak/verstak-desktop/internal/core/browserreceiver"
"github.com/verstak/verstak-desktop/internal/core/capability" "github.com/verstak/verstak-desktop/internal/core/capability"
"github.com/verstak/verstak-desktop/internal/core/contribution" "github.com/verstak/verstak-desktop/internal/core/contribution"
"github.com/verstak/verstak-desktop/internal/core/events" "github.com/verstak/verstak-desktop/internal/core/events"
corefiles "github.com/verstak/verstak-desktop/internal/core/files" corefiles "github.com/verstak/verstak-desktop/internal/core/files"
"github.com/verstak/verstak-desktop/internal/core/notifications"
"github.com/verstak/verstak-desktop/internal/core/plugin" "github.com/verstak/verstak-desktop/internal/core/plugin"
"github.com/verstak/verstak-desktop/internal/core/storage" "github.com/verstak/verstak-desktop/internal/core/storage"
syncsvc "github.com/verstak/verstak-desktop/internal/core/sync" syncsvc "github.com/verstak/verstak-desktop/internal/core/sync"
@ -30,44 +27,6 @@ import (
"github.com/verstak/verstak-desktop/internal/core/workspace" "github.com/verstak/verstak-desktop/internal/core/workspace"
) )
type fakeNotificationScheduler struct {
replaceCalls int
clearCalls int
startCalls int
stopCalls int
pluginID string
requests []notifications.Request
onStart func()
onStop func()
}
func (s *fakeNotificationScheduler) Replace(pluginID string, requests []notifications.Request) error {
s.replaceCalls++
s.pluginID = pluginID
s.requests = append([]notifications.Request(nil), requests...)
return nil
}
func (s *fakeNotificationScheduler) Clear(pluginID string) error {
s.clearCalls++
s.pluginID = pluginID
return nil
}
func (s *fakeNotificationScheduler) Start(context.Context) {
s.startCalls++
if s.onStart != nil {
s.onStart()
}
}
func (s *fakeNotificationScheduler) Stop() {
s.stopCalls++
if s.onStop != nil {
s.onStop()
}
}
func newLocalHTTPTestServer(t *testing.T, handler http.Handler) *httptest.Server { func newLocalHTTPTestServer(t *testing.T, handler http.Handler) *httptest.Server {
t.Helper() t.Helper()
@ -118,18 +77,6 @@ func newTestApp(tmpRoot string) *App {
} }
} }
func newNotificationsTestApp(manifest plugin.Manifest) (*App, *fakeNotificationScheduler) {
scheduler := &fakeNotificationScheduler{}
return &App{
plugins: []plugin.Plugin{{
Manifest: manifest,
Status: plugin.StatusLoaded,
Enabled: true,
}},
notifications: scheduler,
}, scheduler
}
func newFilesTestApp(t *testing.T, perms []string) (*App, string) { func newFilesTestApp(t *testing.T, perms []string) (*App, string) {
t.Helper() t.Helper()
v := vault.NewVault(nil) v := vault.NewVault(nil)
@ -187,187 +134,6 @@ func newSyncFilesTestApp(t *testing.T, perms []string, deviceID string) (*App, s
return app, root return app, root
} }
func TestReplacePluginNotificationsRequiresCapabilityAndPermission(t *testing.T) {
requests := []notifications.Request{{ID: "reminder", DueAt: "2026-07-14T10:00:00Z", Title: "Reminder"}}
for _, manifest := range []plugin.Manifest{
{ID: "notifications.test", Permissions: []string{"notifications.schedule"}},
{ID: "notifications.test", Requires: []string{"verstak/core/notifications/v1"}},
} {
app, scheduler := newNotificationsTestApp(manifest)
if got := app.ReplacePluginNotifications(manifest.ID, requests); got == "" {
t.Fatalf("manifest %#v unexpectedly scheduled notifications", manifest)
}
if scheduler.replaceCalls != 0 {
t.Fatalf("rejected request called scheduler %d times", scheduler.replaceCalls)
}
}
}
func TestReplaceAndClearPluginNotificationsStayWithinPluginNamespace(t *testing.T) {
manifest := plugin.Manifest{
ID: "notifications.test",
Permissions: []string{"notifications.schedule"},
Requires: []string{"verstak/core/notifications/v1"},
}
app, scheduler := newNotificationsTestApp(manifest)
requests := []notifications.Request{{ID: "reminder", DueAt: "2026-07-14T10:00:00Z", Title: "Reminder"}}
if got := app.ReplacePluginNotifications(manifest.ID, requests); got != "" {
t.Fatalf("ReplacePluginNotifications error = %q", got)
}
if scheduler.replaceCalls != 1 || scheduler.pluginID != manifest.ID || len(scheduler.requests) != 1 {
t.Fatalf("scheduler state = %#v", scheduler)
}
if got := app.ClearPluginNotifications(manifest.ID); got != "" {
t.Fatalf("ClearPluginNotifications error = %q", got)
}
if scheduler.clearCalls != 1 || scheduler.pluginID != manifest.ID {
t.Fatalf("clear state = %#v", scheduler)
}
}
func TestReloadPluginsRestoresNotificationCoreCapability(t *testing.T) {
app := &App{
capRegistry: capability.NewRegistry(),
contribRegistry: contribution.NewRegistry(),
}
app.ReloadPlugins()
if !app.capRegistry.Has("verstak/core/notifications/v1") {
t.Fatal("ReloadPlugins did not restore verstak/core/notifications/v1")
}
}
func TestDomReadyInitializesNotificationsBeforeScheduler(t *testing.T) {
oldInitialize := initializeNativeNotifications
defer func() { initializeNativeNotifications = oldInitialize }()
order := []string{}
initializeNativeNotifications = func(context.Context) error {
order = append(order, "initialize")
return nil
}
app, scheduler := newNotificationsTestApp(plugin.Manifest{ID: "notifications.test"})
scheduler.onStart = func() { order = append(order, "start") }
app.DomReady(context.Background())
if got, want := strings.Join(order, ","), "initialize,start"; got != want {
t.Fatalf("notification startup order = %q, want %q", got, want)
}
if scheduler.startCalls != 1 {
t.Fatalf("scheduler start calls = %d, want 1", scheduler.startCalls)
}
}
func TestDomReadyDoesNotStartSchedulerWhenNotificationInitializationFails(t *testing.T) {
oldInitialize := initializeNativeNotifications
defer func() { initializeNativeNotifications = oldInitialize }()
initializeNativeNotifications = func(context.Context) error { return fmt.Errorf("unavailable") }
app, scheduler := newNotificationsTestApp(plugin.Manifest{ID: "notifications.test"})
app.DomReady(context.Background())
if scheduler.startCalls != 0 {
t.Fatalf("scheduler start calls = %d, want 0 when native notifications are unavailable", scheduler.startCalls)
}
}
func TestShutdownStopsSchedulerBeforeCleaningUpNotifications(t *testing.T) {
oldCleanup := cleanupNativeNotifications
defer func() { cleanupNativeNotifications = oldCleanup }()
order := []string{}
cleanupNativeNotifications = func(context.Context) { order = append(order, "cleanup") }
app, scheduler := newNotificationsTestApp(plugin.Manifest{ID: "notifications.test"})
scheduler.onStop = func() { order = append(order, "stop") }
app.Shutdown(context.Background())
if got, want := strings.Join(order, ","), "stop,cleanup"; got != want {
t.Fatalf("notification shutdown order = %q, want %q", got, want)
}
if scheduler.stopCalls != 1 {
t.Fatalf("scheduler stop calls = %d, want 1", scheduler.stopCalls)
}
}
func TestNativeNotificationSenderUsesStablePluginScopedID(t *testing.T) {
oldSend := sendNativeNotification
defer func() { sendNativeNotification = oldSend }()
var gotTitle, gotBody, gotID string
sendNativeNotification = func(_ context.Context, options runtime.NotificationOptions) error {
gotID = options.ID
gotTitle = options.Title
gotBody = options.Body
return nil
}
err := NewNativeNotificationSender().Send(context.Background(), notifications.Item{
PluginID: "verstak.todo",
ID: "task-42",
Title: "Todo reminder",
Body: "Prepare alpha release",
})
if err != nil {
t.Fatalf("Send() error = %v", err)
}
if gotID != "verstak:verstak.todo:task-42" || gotTitle != "Todo reminder" || gotBody != "Prepare alpha release" {
t.Fatalf("native notification = id=%q title=%q body=%q", gotID, gotTitle, gotBody)
}
}
func TestBeforeCloseHidesWindowUntilUserChoosesQuit(t *testing.T) {
oldHide := hideNativeWindow
defer func() { hideNativeWindow = oldHide }()
hideCalls := 0
hideNativeWindow = func(context.Context) { hideCalls++ }
app := &App{}
if prevent := app.BeforeClose(context.Background()); !prevent {
t.Fatal("BeforeClose() = false, want true while tray mode is active")
}
if hideCalls != 1 {
t.Fatalf("hide calls = %d, want 1", hideCalls)
}
}
func TestTrayQuitAllowsWindowCloseAndQuitsApplication(t *testing.T) {
oldQuit := quitNativeApplication
defer func() { quitNativeApplication = oldQuit }()
quitCalls := 0
quitNativeApplication = func(context.Context) { quitCalls++ }
app := &App{ctx: context.Background()}
app.Quit()
if quitCalls != 1 {
t.Fatalf("quit calls = %d, want 1", quitCalls)
}
if prevent := app.BeforeClose(context.Background()); prevent {
t.Fatal("BeforeClose() = true after Quit(), want false")
}
}
func TestShowWindowUsesWailsContext(t *testing.T) {
oldShow := showNativeWindow
defer func() { showNativeWindow = oldShow }()
showCalls := 0
showNativeWindow = func(context.Context) { showCalls++ }
app := &App{ctx: context.Background()}
app.ShowWindow()
if showCalls != 1 {
t.Fatalf("show calls = %d, want 1", showCalls)
}
}
// TestGetPluginFrontendInfo_KnownPluginWithFrontend verifies that // TestGetPluginFrontendInfo_KnownPluginWithFrontend verifies that
// GetPluginFrontendInfo returns correct metadata for a plugin with a frontend. // GetPluginFrontendInfo returns correct metadata for a plugin with a frontend.
func TestGetPluginFrontendInfo_KnownPluginWithFrontend(t *testing.T) { func TestGetPluginFrontendInfo_KnownPluginWithFrontend(t *testing.T) {

View File

@ -1,20 +0,0 @@
package capability
const CorePluginID = "verstak-desktop"
var platformCapabilities = []string{
"verstak/core/plugin-manager/v1",
"verstak/core/capability-registry/v1",
"verstak/core/contribution-registry/v1",
"verstak/core/permissions/v1",
"verstak/core/events/v1",
"verstak/core/files/v1",
"verstak/core/workbench/v1",
"verstak/core/notifications/v1",
}
// CorePlatformCapabilities returns a copy of the capabilities registered by
// the desktop before dynamic plugins are resolved.
func CorePlatformCapabilities() []string {
return append([]string(nil), platformCapabilities...)
}

View File

@ -1,254 +0,0 @@
// Package notifications persists plugin-owned reminder schedules and delivers
// due entries through a desktop-native sender.
package notifications
import (
"context"
"fmt"
"sort"
"strings"
"sync"
"time"
)
const (
maxRequestsPerPlugin = 500
maxIDLength = 256
maxTitleLength = 512
maxBodyLength = 4096
)
// VaultPath provides a stable operation while a vault is open.
type VaultPath interface {
WithOpenPath(func(string) error) error
}
// Request is a desired scheduled notification in a plugin's own namespace.
type Request struct {
ID string `json:"id"`
DueAt string `json:"dueAt"`
Title string `json:"title"`
Body string `json:"body,omitempty"`
}
// Item is the persisted form of a plugin notification schedule.
type Item struct {
PluginID string `json:"pluginId"`
ID string `json:"id"`
DueAt string `json:"dueAt"`
Title string `json:"title"`
Body string `json:"body,omitempty"`
SentForDueAt string `json:"sentForDueAt,omitempty"`
}
// Sender delivers one native desktop notification.
type Sender interface {
Send(context.Context, Item) error
}
// Manager owns canonical schedules for every authorized plugin.
type Manager struct {
mu sync.Mutex
vault VaultPath
sender Sender
now func() time.Time
items []Item
loaded bool
stop chan struct{}
}
// New creates a notification manager. The supplied clock is used by tests; a
// nil clock uses the current time.
func New(vault VaultPath, sender Sender, now func() time.Time) *Manager {
if now == nil {
now = time.Now
}
return &Manager{vault: vault, sender: sender, now: now}
}
// Replace atomically replaces one plugin's desired schedules.
func (m *Manager) Replace(pluginID string, requests []Request) error {
if err := validateRequests(pluginID, requests); err != nil {
return err
}
m.mu.Lock()
defer m.mu.Unlock()
if err := m.loadLocked(); err != nil {
return err
}
previous := make(map[string]Item)
kept := make([]Item, 0, len(m.items)+len(requests))
for _, item := range m.items {
if item.PluginID == pluginID {
previous[item.ID] = item
continue
}
kept = append(kept, item)
}
for _, request := range requests {
item := Item{
PluginID: pluginID,
ID: request.ID,
DueAt: request.DueAt,
Title: request.Title,
Body: request.Body,
}
if old, ok := previous[request.ID]; ok && old.DueAt == request.DueAt {
item.SentForDueAt = old.SentForDueAt
}
kept = append(kept, item)
}
sortItems(kept)
if err := m.writeLocked(kept); err != nil {
return err
}
m.items = kept
return nil
}
// Clear removes every schedule owned by a plugin.
func (m *Manager) Clear(pluginID string) error {
return m.Replace(pluginID, nil)
}
// Items returns a snapshot of the current schedule state.
func (m *Manager) Items() []Item {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.loadLocked(); err != nil {
return nil
}
return append([]Item(nil), m.items...)
}
// Path reports the canonical schedule path while the vault is open.
func (m *Manager) Path() string {
if m == nil || m.vault == nil {
return ""
}
var path string
if err := m.vault.WithOpenPath(func(vaultPath string) error {
path = schedulePath(vaultPath)
return nil
}); err != nil {
return ""
}
return path
}
// Start immediately checks due schedules, then repeats every 30 seconds.
func (m *Manager) Start(ctx context.Context) {
if m == nil {
return
}
m.mu.Lock()
if m.stop != nil {
m.mu.Unlock()
return
}
stop := make(chan struct{})
m.stop = stop
m.mu.Unlock()
go func() {
m.Tick(ctx)
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
m.Tick(ctx)
case <-stop:
return
case <-ctx.Done():
return
}
}
}()
}
// Stop stops a running scheduler. It is safe to call repeatedly.
func (m *Manager) Stop() {
if m == nil {
return
}
m.mu.Lock()
defer m.mu.Unlock()
if m.stop == nil {
return
}
close(m.stop)
m.stop = nil
}
// Tick delivers each overdue schedule once. A sender failure leaves the item
// pending for a later tick.
func (m *Manager) Tick(ctx context.Context) {
if m == nil || m.sender == nil {
return
}
m.mu.Lock()
defer m.mu.Unlock()
if err := m.loadLocked(); err != nil {
return
}
now := m.now().UTC()
for index, item := range m.items {
dueAt, err := time.Parse(time.RFC3339, item.DueAt)
if err != nil || dueAt.After(now) || item.SentForDueAt == item.DueAt {
continue
}
if err := m.sender.Send(ctx, item); err != nil {
continue
}
updated := append([]Item(nil), m.items...)
updated[index].SentForDueAt = item.DueAt
if err := m.writeLocked(updated); err != nil {
continue
}
m.items = updated
}
}
func validateRequests(pluginID string, requests []Request) error {
if strings.TrimSpace(pluginID) == "" || strings.ContainsAny(pluginID, "/\\") {
return fmt.Errorf("invalid plugin ID %q", pluginID)
}
if len(requests) > maxRequestsPerPlugin {
return fmt.Errorf("plugin %q has more than %d notification schedules", pluginID, maxRequestsPerPlugin)
}
seen := make(map[string]bool, len(requests))
for _, request := range requests {
if request.ID == "" || len(request.ID) > maxIDLength {
return fmt.Errorf("notification ID is empty or too long")
}
if seen[request.ID] {
return fmt.Errorf("duplicate notification ID %q", request.ID)
}
seen[request.ID] = true
if !strings.HasSuffix(request.DueAt, "Z") {
return fmt.Errorf("notification %q dueAt must be UTC RFC3339", request.ID)
}
if _, err := time.Parse(time.RFC3339, request.DueAt); err != nil {
return fmt.Errorf("notification %q has invalid dueAt: %w", request.ID, err)
}
if strings.TrimSpace(request.Title) == "" || len(request.Title) > maxTitleLength {
return fmt.Errorf("notification %q title is empty or too long", request.ID)
}
if len(request.Body) > maxBodyLength {
return fmt.Errorf("notification %q body is too long", request.ID)
}
}
return nil
}
func sortItems(items []Item) {
sort.Slice(items, func(left, right int) bool {
if items[left].PluginID != items[right].PluginID {
return items[left].PluginID < items[right].PluginID
}
return items[left].ID < items[right].ID
})
}

View File

@ -1,131 +0,0 @@
package notifications
import (
"context"
"errors"
"path/filepath"
"testing"
"time"
)
type testVault struct {
path string
}
func (v testVault) WithOpenPath(operation func(string) error) error {
return operation(v.path)
}
type fakeSender struct {
err error
items []Item
}
func (s *fakeSender) Send(_ context.Context, item Item) error {
s.items = append(s.items, item)
return s.err
}
func testManager(t *testing.T, sender *fakeSender, now time.Time) *Manager {
t.Helper()
return New(testVault{path: t.TempDir()}, sender, func() time.Time { return now })
}
func request(id, dueAt string) Request {
return Request{ID: id, DueAt: dueAt, Title: "Reminder " + id, Body: "Todo " + id}
}
func TestReplaceRemovesStalePluginSchedules(t *testing.T) {
manager := testManager(t, &fakeSender{}, time.Date(2026, 7, 14, 9, 0, 0, 0, time.UTC))
if err := manager.Replace("verstak.todo", []Request{
request("first", "2026-07-14T10:00:00Z"),
request("second", "2026-07-14T11:00:00Z"),
}); err != nil {
t.Fatalf("initial replace: %v", err)
}
if err := manager.Replace("verstak.todo", []Request{request("second", "2026-07-14T11:00:00Z")}); err != nil {
t.Fatalf("stale-item replace: %v", err)
}
items := manager.Items()
if len(items) != 1 || items[0].PluginID != "verstak.todo" || items[0].ID != "second" {
t.Fatalf("items = %#v, want only second Todo reminder", items)
}
}
func TestReplaceReschedulesDeliveredItem(t *testing.T) {
now := time.Date(2026, 7, 14, 12, 0, 0, 0, time.UTC)
sender := &fakeSender{}
manager := testManager(t, sender, now)
if err := manager.Replace("verstak.todo", []Request{request("todo-1", "2026-07-14T11:00:00Z")}); err != nil {
t.Fatalf("initial replace: %v", err)
}
manager.Tick(context.Background())
if len(sender.items) != 1 || manager.Items()[0].SentForDueAt == "" {
t.Fatalf("first delivery = %#v, state = %#v", sender.items, manager.Items())
}
if err := manager.Replace("verstak.todo", []Request{request("todo-1", "2026-07-14T13:00:00Z")}); err != nil {
t.Fatalf("reschedule: %v", err)
}
if got := manager.Items()[0].SentForDueAt; got != "" {
t.Fatalf("rescheduled item kept old acknowledgment %q", got)
}
}
func TestTickRetriesFailedSendAndAcknowledgesOneDelivery(t *testing.T) {
now := time.Date(2026, 7, 14, 12, 0, 0, 0, time.UTC)
sender := &fakeSender{err: errors.New("notification unavailable")}
manager := testManager(t, sender, now)
if err := manager.Replace("verstak.todo", []Request{request("todo-1", "2026-07-14T11:00:00Z")}); err != nil {
t.Fatalf("replace: %v", err)
}
manager.Tick(context.Background())
if len(sender.items) != 1 || manager.Items()[0].SentForDueAt != "" {
t.Fatalf("failed send was acknowledged: sent=%d items=%#v", len(sender.items), manager.Items())
}
sender.err = nil
manager.Tick(context.Background())
manager.Tick(context.Background())
if len(sender.items) != 2 {
t.Fatalf("send attempts = %d, want 2", len(sender.items))
}
if got := manager.Items()[0].SentForDueAt; got != "2026-07-14T11:00:00Z" {
t.Fatalf("sent acknowledgment = %q", got)
}
}
func TestPersistentSchedulesReloadAndDeliverOverdueOnlyOnce(t *testing.T) {
path := t.TempDir()
now := time.Date(2026, 7, 14, 12, 0, 0, 0, time.UTC)
first := New(testVault{path: path}, &fakeSender{}, func() time.Time { return now })
if err := first.Replace("verstak.todo", []Request{request("todo-1", "2026-07-14T11:00:00Z")}); err != nil {
t.Fatalf("replace: %v", err)
}
if got, want := filepath.Join(path, ".verstak", "notifications", "schedules.json"), first.Path(); got != want {
t.Fatalf("schedule path = %q, want %q", got, want)
}
sender := &fakeSender{}
restarted := New(testVault{path: path}, sender, func() time.Time { return now })
restarted.Tick(context.Background())
restarted.Tick(context.Background())
if len(sender.items) != 1 || sender.items[0].ID != "todo-1" {
t.Fatalf("overdue delivery = %#v", sender.items)
}
}
func TestReplaceRejectsInvalidRequests(t *testing.T) {
manager := testManager(t, &fakeSender{}, time.Now().UTC())
for _, requests := range [][]Request{
{{ID: "", DueAt: "2026-07-14T10:00:00Z", Title: "title"}},
{{ID: "same", DueAt: "2026-07-14T10:00:00Z", Title: "title"}, {ID: "same", DueAt: "2026-07-14T11:00:00Z", Title: "title"}},
{{ID: "bad-date", DueAt: "not-a-date", Title: "title"}},
} {
if err := manager.Replace("verstak.todo", requests); err == nil {
t.Fatalf("Replace(%#v) succeeded", requests)
}
}
}

View File

@ -1,80 +0,0 @@
package notifications
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)
type storedSchedules struct {
Items []Item `json:"items"`
}
func schedulePath(vaultPath string) string {
return filepath.Join(vaultPath, ".verstak", "notifications", "schedules.json")
}
func (m *Manager) loadLocked() error {
if m.loaded {
return nil
}
if m.vault == nil {
return fmt.Errorf("notification vault is not initialized")
}
var loaded []Item
if err := m.vault.WithOpenPath(func(vaultPath string) error {
path := schedulePath(vaultPath)
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
loaded = []Item{}
return nil
}
if err != nil {
return fmt.Errorf("read notification schedules: %w", err)
}
var stored storedSchedules
if err := json.Unmarshal(data, &stored); err != nil {
return fmt.Errorf("decode notification schedules: %w", err)
}
for _, item := range stored.Items {
if err := validateRequests(item.PluginID, []Request{{ID: item.ID, DueAt: item.DueAt, Title: item.Title, Body: item.Body}}); err != nil {
return fmt.Errorf("invalid stored notification schedule: %w", err)
}
loaded = append(loaded, item)
}
sortItems(loaded)
return nil
}); err != nil {
return err
}
m.items = loaded
m.loaded = true
return nil
}
func (m *Manager) writeLocked(items []Item) error {
if m.vault == nil {
return fmt.Errorf("notification vault is not initialized")
}
return m.vault.WithOpenPath(func(vaultPath string) error {
data, err := json.MarshalIndent(storedSchedules{Items: items}, "", " ")
if err != nil {
return fmt.Errorf("encode notification schedules: %w", err)
}
path := schedulePath(vaultPath)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create notification schedule directory: %w", err)
}
temporary := filepath.Join(filepath.Dir(path), fmt.Sprintf(".schedules.%d.tmp", time.Now().UnixNano()))
if err := os.WriteFile(temporary, data, 0o644); err != nil {
return fmt.Errorf("write notification schedules: %w", err)
}
if err := os.Rename(temporary, path); err != nil {
_ = os.Remove(temporary)
return fmt.Errorf("replace notification schedules: %w", err)
}
return nil
})
}

View File

@ -39,7 +39,6 @@ func (r *Registry) registerDefaults() {
{Name: "files.openExternal", Description: "Open vault files and folders in external OS applications", Dangerous: true}, {Name: "files.openExternal", Description: "Open vault files and folders in external OS applications", Dangerous: true},
{Name: "storage.namespace", Description: "Read/write plugin's own storage namespace", Dangerous: false}, {Name: "storage.namespace", Description: "Read/write plugin's own storage namespace", Dangerous: false},
{Name: "storage.migrations", Description: "Run database migrations in plugin namespace", Dangerous: false}, {Name: "storage.migrations", Description: "Run database migrations in plugin namespace", Dangerous: false},
{Name: "notifications.schedule", Description: "Schedule native notifications in the plugin's own namespace", Dangerous: false},
{Name: "events.publish", Description: "Publish events to the event bus", Dangerous: false}, {Name: "events.publish", Description: "Publish events to the event bus", Dangerous: false},
{Name: "events.subscribe", Description: "Subscribe to events on the event bus", Dangerous: false}, {Name: "events.subscribe", Description: "Subscribe to events on the event bus", Dangerous: false},
{Name: "ui.register", Description: "Register UI components and contributions", Dangerous: false}, {Name: "ui.register", Description: "Register UI components and contributions", Dangerous: false},

View File

@ -3,16 +3,25 @@ package plugin
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"testing" "testing"
"github.com/verstak/verstak-desktop/internal/core/capability" "github.com/verstak/verstak-desktop/internal/core/capability"
) )
// registerCoreCapabilities registers the desktop's core capabilities on a registry. // coreCapabilities lists the 5 core capabilities that the platform registers
// before any plugins are loaded.
var coreCapabilities = []string{
"verstak/core/plugin-manager/v1",
"verstak/core/capability-registry/v1",
"verstak/core/contribution-registry/v1",
"verstak/core/permissions/v1",
"verstak/core/events/v1",
}
// registerCoreCapabilities registers the 5 core capabilities on a registry.
func registerCoreCapabilities(t *testing.T, reg *capability.Registry) { func registerCoreCapabilities(t *testing.T, reg *capability.Registry) {
t.Helper() t.Helper()
if err := reg.Register(capability.CorePluginID, capability.CorePlatformCapabilities()); err != nil { if err := reg.Register("verstak-core", coreCapabilities); err != nil {
t.Fatalf("failed to register core capabilities: %v", err) t.Fatalf("failed to register core capabilities: %v", err)
} }
} }
@ -67,43 +76,6 @@ func TestLifecycle_CoreCapabilitiesRegisteredBeforePlugins(t *testing.T) {
} }
} }
func TestBundledOfficialPluginRequirementsResolve(t *testing.T) {
plugins, errs := DiscoverPlugins([]string{bundledPluginDir(t)})
if len(errs) > 0 {
t.Fatalf("discover bundled plugins: %v", errs)
}
reg := capability.NewRegistry()
registerCoreCapabilities(t, reg)
ResolveLifecycle(plugins, reg, nil)
foundTodo := false
for _, loaded := range plugins {
if loaded.Manifest.ID == "verstak.todo" {
foundTodo = true
}
if loaded.Status == StatusMissingRequiredCapability {
t.Fatalf("bundled plugin %q is missing required capabilities: %s", loaded.Manifest.ID, loaded.Error)
}
}
if !foundTodo {
t.Fatal("bundled Todo manifest was not discovered")
}
}
func bundledPluginDir(t *testing.T) string {
t.Helper()
if configured := os.Getenv("VERSTAK_RELEASE_PLUGIN_DIR"); configured != "" {
return configured
}
_, sourceFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("could not resolve lifecycle test path")
}
return filepath.Join(filepath.Dir(sourceFile), "../../../plugins")
}
// TestLifecycle_MissingRequiredCapability verifies that when the required // TestLifecycle_MissingRequiredCapability verifies that when the required
// capability is NOT registered, CheckRequired reports it as missing and the // capability is NOT registered, CheckRequired reports it as missing and the
// plugin status should be set to StatusMissingRequiredCapability. // plugin status should be set to StatusMissingRequiredCapability.

View File

@ -1,74 +0,0 @@
// Package tray owns the native tray menu wiring for the desktop shell.
package tray
import "sync"
// MenuItem exposes click events from a native tray menu item.
type MenuItem interface {
Clicked() <-chan struct{}
}
// Backend is the platform tray implementation.
type Backend interface {
Register(onReady func(), onExit func())
SetIcon(icon []byte)
SetTooltip(tooltip string)
AddMenuItem(title, tooltip string) MenuItem
Quit()
}
// Actions are executed from the native tray menu.
type Actions struct {
Show func()
Quit func()
}
// Controller initializes one native tray and routes its menu actions.
type Controller struct {
backend Backend
icon []byte
start sync.Once
}
// New creates a tray controller for one application process.
func New(backend Backend, icon []byte) *Controller {
return &Controller{
backend: backend,
icon: append([]byte(nil), icon...),
}
}
// Start registers the native tray without taking over the Wails event loop.
func (c *Controller) Start(actions Actions) {
if c == nil || c.backend == nil {
return
}
c.start.Do(func() {
c.backend.Register(func() {
c.backend.SetIcon(c.icon)
c.backend.SetTooltip("Verstak")
show := c.backend.AddMenuItem("Show Verstak", "Show the Verstak window")
quit := c.backend.AddMenuItem("Quit", "Quit Verstak")
if actions.Show != nil && show != nil {
go routeClicks(show.Clicked(), actions.Show)
}
if actions.Quit != nil && quit != nil {
go routeClicks(quit.Clicked(), actions.Quit)
}
}, nil)
})
}
// Stop releases the native tray after Wails has begun application shutdown.
func (c *Controller) Stop() {
if c == nil || c.backend == nil {
return
}
c.backend.Quit()
}
func routeClicks(clicked <-chan struct{}, action func()) {
for range clicked {
action()
}
}

View File

@ -1,91 +0,0 @@
package tray
import (
"testing"
"time"
)
type fakeMenuItem struct {
clicked chan struct{}
}
func (i *fakeMenuItem) Clicked() <-chan struct{} {
return i.clicked
}
type fakeBackend struct {
icon []byte
tooltip string
items map[string]*fakeMenuItem
quitCalls int
registering bool
}
func (b *fakeBackend) Register(onReady func(), _ func()) {
b.registering = true
onReady()
}
func (b *fakeBackend) SetIcon(icon []byte) {
b.icon = append([]byte(nil), icon...)
}
func (b *fakeBackend) SetTooltip(tooltip string) {
b.tooltip = tooltip
}
func (b *fakeBackend) AddMenuItem(title, _ string) MenuItem {
item := &fakeMenuItem{clicked: make(chan struct{}, 1)}
b.items[title] = item
return item
}
func (b *fakeBackend) Quit() {
b.quitCalls++
}
func waitFor(t *testing.T, signal <-chan struct{}) {
t.Helper()
select {
case <-signal:
case <-time.After(time.Second):
t.Fatal("timed out waiting for tray action")
}
}
func TestControllerInitializesTrayAndRoutesMenuActions(t *testing.T) {
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
showCalls := make(chan struct{}, 1)
quitCalls := make(chan struct{}, 1)
controller := New(backend, []byte{1, 2, 3})
controller.Start(Actions{
Show: func() { showCalls <- struct{}{} },
Quit: func() { quitCalls <- struct{}{} },
})
if !backend.registering || string(backend.icon) != string([]byte{1, 2, 3}) || backend.tooltip != "Verstak" {
t.Fatalf("tray initialization = registering:%t icon:%v tooltip:%q", backend.registering, backend.icon, backend.tooltip)
}
showItem := backend.items["Show Verstak"]
quitItem := backend.items["Quit"]
if showItem == nil || quitItem == nil {
t.Fatalf("tray menu = %#v, want Show Verstak and Quit", backend.items)
}
showItem.clicked <- struct{}{}
waitFor(t, showCalls)
quitItem.clicked <- struct{}{}
waitFor(t, quitCalls)
}
func TestControllerStopsNativeTrayBackend(t *testing.T) {
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
controller := New(backend, []byte{1})
controller.Stop()
if backend.quitCalls != 1 {
t.Fatalf("backend quit calls = %d, want 1", backend.quitCalls)
}
}

View File

@ -1,14 +0,0 @@
package tray
import "encoding/base64"
const iconPNGBase64 = "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAACT1BMVEUAAACOjo7///8RERHY2NghISEDAwOjo6MODg7GxsYQEBDIyMgPDw/Hx8cKCgq9vb12dnaHh4fFxcV1dXWjo6OoqKinp6eNjY25ubnr6+v29vb29vb29vbx8fHV1dXm5ub9/f3z8/OUlJQAAADu7u74+Pi5ubkAAADu7u74+Pi6urq6urrs7Oz39/ezs7PZ2dn5+fn8/Pzq6uobGxtdXV3U1NTl5eXl5eXl5eXm5ubd3d2urq7////+/v7//v7//v/s7OzLy8vLy8ru7u708/POzc3Kysrd3d39/f3Z2dnU1NT7+/u0tLQpKSkoKCcnJycsLCy+vr63t7crKyskJCRzc3NQUFAlJSUoKChvb28gICAdHR0cHBzDwsJ6enoaGhkZGRltbW3MzMwqKioeHh6pqam+vb0iIiJGRkYbGxseHR0aGhpubm6cnJw6Ojrg4ODExMQmJiYeHh36+vppaWhxcXH8/PwvLy/a2tmKiorl5eU+Pj6zs7PQ0NA1NTXR0dFSUlIbGhq/v79BQUHn5+fW1tYuLi48PDyysrItLS02NjaNjY17e3v///7b29syMjJ/f38hISEqKSlqampaWlq9vb3f398cGxs9PT1KSko3NzeioqJRUVFJSUnk5OQaGRljY2OGhobp6ek/Pz+dnZ3IyMgoKSjHx8ZDQ0PHx8fx8fHw8PBVVVX39/cdHh2RkZHz8/NPT0+Ojo7Dw8PPz8/29vZUVFTCwsL19fX4+PhYWFgWFhYXFxfAwMCqqqqFhYWHhoaEhITe3t6MjIzi4uIwLXXJAAAAPHRSTlMAAAAAAAAAAAAAAAAAAAAAAAAABRIWFQoUis3R0K82b/22DQGZ2x0Cm9wfHo/UGULi+IIEAzZudHNzUA0lLC8OAAAAAWJLR0QCZgt8ZAAAAAd0SU1FB+oHDRIyN3EuEOkAAAHzSURBVDjLvdPXXxNBEAdwPHvvFewde8WyG3NGT41KlFxMwoyKguLeCCZRUCzRgIXYUexEjR17713/MPfCB+Vy8ZXfw+3tzvdlbueyslolbfr26z8gQwYqbVP1doMGD8nOsSU7Z+gwpb2sd1CGj2CZM3JURwk6jR4j37l5wDlvejrkssjBxo7rLEGX8RPk1qmqi1UXW7JU05bx5Ss0VXOvZBNzu/4Fq1bne9asZQVej+7jrnX+gB6UYNI/UFAIqK9nGzZC0Sa2uRihZAuzgK2lAB7VtU0YtJ2VIWD5DisI+QG87lAYIhTcuYsiVFFpBVo+4O49VQKAKvbuE1C6XzbWEhwoBowePBSDEvJV+0HUHE4DR44iHquN43G9yHdCROgkP2UFp8+gqDkLdcFzdL6ewH9Bfi8LuBjFS5ev0NVrAdEQRZFwpoPr5QiB8I2b1WHw1oFIMkcauHVbGAB4565sF+De/dS9tATOB8IwMO5uNEGs4aENPEpIQI+fhHR4CvjMrFvB8xcxA8RL9kqHCMZf28Gbt2RAoJG9k4Dq39vBh4+f6HPCxb4UElFZqm4FlV+/ff9Ry9nPqmTy128L6GYCR/OwNY9eE8jtLkEPZTL/z9BOmdrTHHtl2nSeMTNmzuplgt6z58zNm2dL3vwFC/u0yo/7B9C02RGfGBOeAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDI2LTA3LTEzVDE0OjIyOjMzKzAwOjAwJHU36gAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyNi0wNy0xM1QxNDoyMjozMyswMDowMFUoj1YAAAAASUVORK5CYII="
// DefaultIcon returns the compact source-controlled Verstak tray icon.
func DefaultIcon() []byte {
icon, err := base64.StdEncoding.DecodeString(iconPNGBase64)
if err != nil {
return nil
}
return icon
}

View File

@ -1,41 +0,0 @@
package tray
import "github.com/getlantern/systray"
type systrayBackend struct{}
type systrayMenuItem struct {
item *systray.MenuItem
}
// NewNativeBackend creates the cross-platform native tray backend.
func NewNativeBackend() Backend {
return systrayBackend{}
}
func (systrayBackend) Register(onReady func(), onExit func()) {
systray.Register(onReady, onExit)
}
func (systrayBackend) SetIcon(icon []byte) {
systray.SetIcon(icon)
}
func (systrayBackend) SetTooltip(tooltip string) {
systray.SetTooltip(tooltip)
}
func (systrayBackend) AddMenuItem(title, tooltip string) MenuItem {
return systrayMenuItem{item: systray.AddMenuItem(title, tooltip)}
}
func (systrayBackend) Quit() {
systray.Quit()
}
func (item systrayMenuItem) Clicked() <-chan struct{} {
if item.item == nil {
return nil
}
return item.item.ClickedCh
}

33
main.go
View File

@ -1,11 +1,9 @@
package main package main
import ( import (
"context"
"embed" "embed"
"log" "log"
"os" "os"
"time"
"github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options"
@ -18,7 +16,6 @@ import (
"github.com/verstak/verstak-desktop/internal/core/contribution" "github.com/verstak/verstak-desktop/internal/core/contribution"
"github.com/verstak/verstak-desktop/internal/core/events" "github.com/verstak/verstak-desktop/internal/core/events"
corefiles "github.com/verstak/verstak-desktop/internal/core/files" corefiles "github.com/verstak/verstak-desktop/internal/core/files"
"github.com/verstak/verstak-desktop/internal/core/notifications"
"github.com/verstak/verstak-desktop/internal/core/permissions" "github.com/verstak/verstak-desktop/internal/core/permissions"
"github.com/verstak/verstak-desktop/internal/core/plugin" "github.com/verstak/verstak-desktop/internal/core/plugin"
"github.com/verstak/verstak-desktop/internal/core/pluginstate" "github.com/verstak/verstak-desktop/internal/core/pluginstate"
@ -27,7 +24,6 @@ import (
"github.com/verstak/verstak-desktop/internal/core/vault" "github.com/verstak/verstak-desktop/internal/core/vault"
"github.com/verstak/verstak-desktop/internal/core/workspace" "github.com/verstak/verstak-desktop/internal/core/workspace"
"github.com/verstak/verstak-desktop/internal/shell/debug" "github.com/verstak/verstak-desktop/internal/shell/debug"
"github.com/verstak/verstak-desktop/internal/shell/tray"
) )
//go:embed frontend/dist //go:embed frontend/dist
@ -86,8 +82,16 @@ func main() {
} }
// ─── Register Core Capabilities ───────────────────────── // ─── Register Core Capabilities ─────────────────────────
corePluginID := capability.CorePluginID corePluginID := "verstak-desktop"
coreCaps := capability.CorePlatformCapabilities() coreCaps := []string{
"verstak/core/plugin-manager/v1",
"verstak/core/capability-registry/v1",
"verstak/core/contribution-registry/v1",
"verstak/core/permissions/v1",
"verstak/core/events/v1",
"verstak/core/files/v1",
"verstak/core/workbench/v1",
}
if err := capRegistry.Register(corePluginID, coreCaps); err != nil { if err := capRegistry.Register(corePluginID, coreCaps); err != nil {
log.Fatalf("[main] failed to register core capabilities: %v", err) log.Fatalf("[main] failed to register core capabilities: %v", err)
} }
@ -228,8 +232,6 @@ func main() {
}) })
} }
app = api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, appSettingsMgr, pluginStateMgr, workspaceMgr, syncService, browserReceiver, debugEnabled) app = api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, appSettingsMgr, pluginStateMgr, workspaceMgr, syncService, browserReceiver, debugEnabled)
app.SetNotificationService(notifications.New(vaultService, api.NewNativeNotificationSender(), time.Now))
trayController := tray.New(tray.NewNativeBackend(), tray.DefaultIcon())
if browserReceiver != nil { if browserReceiver != nil {
browserReceiverServer, err := browserreceiver.Start(browserreceiver.DefaultAddr, browserReceiver) browserReceiverServer, err := browserreceiver.Start(browserreceiver.DefaultAddr, browserReceiver)
if err != nil { if err != nil {
@ -249,21 +251,6 @@ func main() {
MinHeight: 600, MinHeight: 600,
WindowStartState: options.Normal, WindowStartState: options.Normal,
OnStartup: app.Startup, OnStartup: app.Startup,
OnDomReady: func(ctx context.Context) {
app.DomReady(ctx)
trayController.Start(tray.Actions{Show: app.ShowWindow, Quit: app.Quit})
},
OnShutdown: func(ctx context.Context) {
trayController.Stop()
app.Shutdown(ctx)
},
OnBeforeClose: app.BeforeClose,
SingleInstanceLock: &options.SingleInstanceLock{
UniqueId: "605fba28-7cbf-4f14-9d1b-a4da0c1723f8",
OnSecondInstanceLaunch: func(options.SecondInstanceData) {
app.ShowWindow()
},
},
AssetServer: &assetserver.Options{ AssetServer: &assetserver.Options{
Assets: assets, Assets: assets,
}, },

View File

@ -3,7 +3,7 @@ Version: @VERSION@
Section: utils Section: utils
Priority: optional Priority: optional
Architecture: amd64 Architecture: amd64
Depends: libwebkit2gtk-4.1-0, libgtk-3-0t64 | libgtk-3-0, libayatana-appindicator3-1 Depends: libwebkit2gtk-4.1-0, libgtk-3-0t64 | libgtk-3-0
Maintainer: Verstak contributors <dev@verstak.app> Maintainer: Verstak contributors <dev@verstak.app>
Homepage: https://github.com/mirivlad/verstak Homepage: https://github.com/mirivlad/verstak
Description: Local-first workspace Description: Local-first workspace

View File

@ -1,13 +0,0 @@
#!/bin/sh
set -eu
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database -q /usr/share/applications || true
fi
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
gtk-update-icon-cache -q -t -f /usr/share/icons/hicolor || true
fi
exit 0

View File

@ -10,4 +10,4 @@ export GST_PLUGIN_SYSTEM_PATH_1_0="$HERE/usr/lib/gstreamer-1.0"
export WEBKIT_EXEC_PATH="$HERE/usr/lib/webkit2gtk-4.1" export WEBKIT_EXEC_PATH="$HERE/usr/lib/webkit2gtk-4.1"
cd "$HERE/usr/bin" cd "$HERE/usr/bin"
exec "$HERE/usr/bin/verstak" "$@" exec "$HERE/usr/bin/verstak-desktop" "$@"

View File

@ -1,6 +0,0 @@
#!/usr/bin/env sh
set -eu
HERE="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
exec "$HERE/verstak-desktop" "$@"

View File

@ -2,10 +2,8 @@
Type=Application Type=Application
Name=Verstak Name=Verstak
Comment=Local-first workspace Comment=Local-first workspace
Exec=verstak %U Exec=verstak-desktop %U
Icon=verstak Icon=verstak
Terminal=false Terminal=false
Categories=Office; Categories=Office;Utility;
StartupNotify=true StartupNotify=true
X-Verstak-Desktop-Entry=true
X-AppImage-Name=Verstak

View File

@ -13,7 +13,6 @@ fi
echo "=== verstak desktop Linux amd64 bundle ===" echo "=== verstak desktop Linux amd64 bundle ==="
(cd "$OFFICIAL_PLUGINS" && ./scripts/build.sh) (cd "$OFFICIAL_PLUGINS" && ./scripts/build.sh)
"$ROOT/scripts/install-dev-plugins.sh" "$ROOT/scripts/install-dev-plugins.sh"
(cd "$ROOT" && go test ./internal/core/plugin -run TestBundledOfficialPluginRequirementsResolve -count=1)
"$ROOT/scripts/build.sh" "$ROOT/scripts/build.sh"
BINARY="$ROOT/build/bin/verstak-desktop" BINARY="$ROOT/build/bin/verstak-desktop"

View File

@ -52,7 +52,6 @@ if [[ ! -d "$WINDOWS_PLUGIN_DIST" ]]; then
echo "Windows plugin packages were not produced: $WINDOWS_PLUGIN_DIST" >&2 echo "Windows plugin packages were not produced: $WINDOWS_PLUGIN_DIST" >&2
exit 1 exit 1
fi fi
VERSTAK_RELEASE_PLUGIN_DIR="$WINDOWS_PLUGIN_DIST" go test ./internal/core/plugin -run TestBundledOfficialPluginRequirementsResolve -count=1
# Wails' -compiler option selects a Go binary, not a C compiler. Cross-CGO # Wails' -compiler option selects a Go binary, not a C compiler. Cross-CGO
# therefore has to be supplied through the standard Go environment instead. # therefore has to be supplied through the standard Go environment instead.

View File

@ -26,15 +26,6 @@ if ! command -v npm &>/dev/null; then
fi fi
echo " ✅ npm $(npm --version)" echo " ✅ npm $(npm --version)"
if [[ "$(go env GOOS)" == "linux" ]]; then
if ! command -v pkg-config &>/dev/null || ! pkg-config --exists ayatana-appindicator3-0.1; then
echo " ❌ native tray development files are missing"
echo " Debian/Ubuntu: sudo apt install libayatana-appindicator3-dev"
exit 1
fi
echo " ✅ ayatana-appindicator3 $(pkg-config --modversion ayatana-appindicator3-0.1)"
fi
# ── Frontend (build first — Go //go:embed needs frontend/dist/) ── # ── Frontend (build first — Go //go:embed needs frontend/dist/) ──
echo "" echo ""
echo "[frontend]" echo "[frontend]"

View File

@ -45,7 +45,6 @@ mkdir -p "$APPDIR/usr/bin" "$APPDIR/usr/lib" "$APPDIR/usr/share/applications" \
"$APPDIR/usr/share/icons/hicolor/scalable/apps" "$APPDIR/usr/share/icons/hicolor/scalable/apps"
install -m 755 "$ROOT/packaging/linux/AppRun" "$APPDIR/AppRun" install -m 755 "$ROOT/packaging/linux/AppRun" "$APPDIR/AppRun"
install -m 755 "$BUNDLE/verstak-desktop" "$APPDIR/usr/bin/verstak-desktop" install -m 755 "$BUNDLE/verstak-desktop" "$APPDIR/usr/bin/verstak-desktop"
install -m 755 "$ROOT/packaging/linux/verstak" "$APPDIR/usr/bin/verstak"
install -m 644 "$ROOT/packaging/linux/verstak.desktop" "$APPDIR/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.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/verstak.svg"
@ -106,11 +105,6 @@ while IFS= read -r candidate; do
done < "$APPDIR/.elf-queue" done < "$APPDIR/.elf-queue"
rm -f "$APPDIR/.elf-queue" rm -f "$APPDIR/.elf-queue"
if ! compgen -G "$APPDIR/usr/lib/libayatana-appindicator3.so.*" >/dev/null; then
echo "native tray runtime library was not bundled" >&2
exit 1
fi
if command -v glib-compile-schemas >/dev/null; then if command -v glib-compile-schemas >/dev/null; then
glib-compile-schemas "$APPDIR/usr/share/glib-2.0/schemas" glib-compile-schemas "$APPDIR/usr/share/glib-2.0/schemas"
fi fi

View File

@ -28,7 +28,6 @@ mkdir -p "$STAGING/DEBIAN" "$STAGING/opt/verstak" "$STAGING/usr/bin" \
"$STAGING/usr/share/doc/verstak" "$STAGING/usr/share/doc/verstak"
sed "s/@VERSION@/$PACKAGE_VERSION/g" "$ROOT/packaging/deb/control" > "$STAGING/DEBIAN/control" sed "s/@VERSION@/$PACKAGE_VERSION/g" "$ROOT/packaging/deb/control" > "$STAGING/DEBIAN/control"
install -m 755 "$ROOT/packaging/deb/postinst" "$STAGING/DEBIAN/postinst"
install -m 755 "$ROOT/packaging/deb/verstak" "$STAGING/usr/bin/verstak" install -m 755 "$ROOT/packaging/deb/verstak" "$STAGING/usr/bin/verstak"
install -m 755 "$BUNDLE/verstak-desktop" "$STAGING/opt/verstak/verstak-desktop" 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 "$BUNDLE/README.md" "$BUNDLE/LICENSE" "$STAGING/usr/share/doc/verstak/"

View File

@ -15,22 +15,9 @@ done
grep -Fq 'dpkg-deb' "$ROOT/scripts/package-deb.sh" grep -Fq 'dpkg-deb' "$ROOT/scripts/package-deb.sh"
grep -Fq -- '--build' "$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 'packaging/deb/control' "$ROOT/scripts/package-deb.sh"
grep -Fq 'packaging/deb/postinst' "$ROOT/scripts/package-deb.sh"
test -x "$ROOT/packaging/deb/postinst"
sh -n "$ROOT/packaging/deb/postinst"
grep -Fxq 'Exec=verstak %U' "$ROOT/packaging/linux/verstak.desktop"
grep -Fxq 'X-Verstak-Desktop-Entry=true' "$ROOT/packaging/linux/verstak.desktop"
grep -Fxq 'Categories=Office;' "$ROOT/packaging/linux/verstak.desktop"
grep -Fq 'update-desktop-database' "$ROOT/packaging/deb/postinst"
grep -Fq 'gtk-update-icon-cache' "$ROOT/packaging/deb/postinst"
grep -Fq 'packaging/linux/verstak' "$ROOT/scripts/package-appimage.sh"
grep -Fq 'usr/bin/verstak' "$ROOT/packaging/linux/AppRun"
grep -Fq 'libwebkit2gtk-4.1-0' "$ROOT/packaging/deb/control" grep -Fq 'libwebkit2gtk-4.1-0' "$ROOT/packaging/deb/control"
grep -Fq 'libayatana-appindicator3-1' "$ROOT/packaging/deb/control"
grep -Fq 'appimagetool' "$ROOT/scripts/package-appimage.sh" grep -Fq 'appimagetool' "$ROOT/scripts/package-appimage.sh"
grep -Fq 'WebKitWebProcess' "$ROOT/scripts/package-appimage.sh" grep -Fq 'WebKitWebProcess' "$ROOT/scripts/package-appimage.sh"
grep -Fq 'libayatana-appindicator3.so.' "$ROOT/scripts/package-appimage.sh"
grep -Fq 'ayatana-appindicator3-0.1' "$ROOT/scripts/build.sh"
if grep -Fq 'FixedVersionRuntime' "$ROOT/scripts/package-windows-portable.sh"; then if grep -Fq 'FixedVersionRuntime' "$ROOT/scripts/package-windows-portable.sh"; then
echo "Windows portable archive must not bundle Fixed Version WebView2" >&2 echo "Windows portable archive must not bundle Fixed Version WebView2" >&2
exit 1 exit 1
@ -49,9 +36,6 @@ grep -Fq 'WebView2 Runtime' "$ROOT/README.md"
grep -Fq 'package-deb.sh' "$ROOT/scripts/release.sh" grep -Fq 'package-deb.sh' "$ROOT/scripts/release.sh"
grep -Fq 'package-appimage.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 'package-windows-portable.sh' "$ROOT/scripts/release.sh"
git -C "$ROOT" check-ignore -q verstak-desktop-res.syso
grep -Fq 'chmod -R a+rX' "$ROOT/scripts/build-linux-bundle.sh" grep -Fq 'chmod -R a+rX' "$ROOT/scripts/build-linux-bundle.sh"
grep -Fq 'TestBundledOfficialPluginRequirementsResolve' "$ROOT/scripts/build-linux-bundle.sh"
grep -Fq 'TestBundledOfficialPluginRequirementsResolve' "$ROOT/scripts/build-windows.sh"
echo "desktop package script contracts passed" echo "desktop package script contracts passed"

View File

@ -39,18 +39,6 @@ OUTPUT=$(cd "$ROOT" && go test -count=1 -v ./... 2>&1) || GO_TEST_STATUS=$?
echo "$OUTPUT" | grep -E '(FAIL|PASS|---)' || true echo "$OUTPUT" | grep -E '(FAIL|PASS|---)' || true
report "go test" "$GO_TEST_STATUS" report "go test" "$GO_TEST_STATUS"
WAILS_BINDINGS_STATUS=0
(cd "$ROOT" && node frontend/tests/wails-bindings-test.mjs) || WAILS_BINDINGS_STATUS=$?
report "Wails notification bindings" "$WAILS_BINDINGS_STATUS"
DEBUG_MODE_STATUS=0
(cd "$ROOT" && node --experimental-vm-modules frontend/tests/debug-mode-test.mjs) || DEBUG_MODE_STATUS=$?
report "session-only debug mode" "$DEBUG_MODE_STATUS"
SELECT_STYLE_STATUS=0
(cd "$ROOT" && node frontend/tests/select-styles-test.mjs) || SELECT_STYLE_STATUS=$?
report "workspace select styles" "$SELECT_STYLE_STATUS"
# ── Frontend tests ── # ── Frontend tests ──
echo "[frontend]" echo "[frontend]"
if ensure_npm_deps "$ROOT/frontend"; then if ensure_npm_deps "$ROOT/frontend"; then