Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb4bb2a0c3 | ||
|
|
76bc312bb9 | ||
|
|
d353b5b335 | ||
|
|
3d78e0deaf | ||
|
|
41aeefc182 | ||
|
|
f9b20d51ff | ||
|
|
8be107013d | ||
|
|
ddcd799f16 | ||
|
|
7b7b892332 | ||
|
|
be3755e50e | ||
|
|
dbfc4e8d0a | ||
|
|
cd44ee3866 | ||
|
|
a0262b97b3 |
@@ -7,7 +7,7 @@
|
|||||||
### Keep the context of your work together — locally.
|
### Keep the context of your work together — locally.
|
||||||
|
|
||||||
Files, notes, links, browser captures, activity and work history
|
Files, notes, links, browser captures, activity and work history
|
||||||
in one extensible local-first workspace.
|
in one extensible local-first workbench.
|
||||||
|
|
||||||
**English** · [Русский](README.ru.md)
|
**English** · [Русский](README.ru.md)
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ in one extensible local-first workspace.
|
|||||||
|
|
||||||
Verstak is a local-first workbench that keeps the context around your work in one place.
|
Verstak is a local-first workbench that keeps the context around your work in one place.
|
||||||
|
|
||||||
A **workspace** can represent almost anything:
|
A **Deal** can represent almost anything:
|
||||||
|
|
||||||
* a software project;
|
* a software project;
|
||||||
* a client or customer;
|
* a client or customer;
|
||||||
@@ -47,29 +47,29 @@ No account, cloud service or sync server is required for local use.
|
|||||||
|
|
||||||
## Verstak in 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. |
|
| Overview: return to a Deal and see its recent work, captures and items that need attention. | Files: manage ordinary folders and documents inside a Deal. |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
| Notes: keep Markdown notes next to the workspace they describe. | Journal: record a completed session and retain its context. |
|
| Notes: keep Markdown notes next to the Deal they describe. | Journal: record a completed session and retain its context. |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
## Main features
|
## Main features
|
||||||
|
|
||||||
| Feature | Description |
|
| Feature | Description |
|
||||||
| ------------------------ | -------------------------------------------------------------------------------------------- |
|
| ------------------------ | -------------------------------------------------------------------------------------------- |
|
||||||
| **Workspaces** | Organize files, notes and activity around durable projects, clients and other areas of work. |
|
| **Deals** | Organize files, notes and activity around durable projects, clients and other areas of work. |
|
||||||
| **Files** | Browse and manage ordinary files stored inside your vault. |
|
| **Files** | Browse and manage ordinary files stored inside your vault. |
|
||||||
| **Notes** | Create Markdown notes, overview pages and links between related information. |
|
| **Notes** | Create Markdown notes, overview pages and links between related information. |
|
||||||
| **Overview** | Quickly return to recent work and see what may need attention. |
|
| **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. |
|
| **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. |
|
| **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. |
|
| **Todo** | Keep optional task lists inside individual Deals and across the whole vault. |
|
||||||
| **Search** | Search across notes, files and supported plugin data. |
|
| **Search** | Search across notes, files and supported plugin data. |
|
||||||
| **Trash** | Restore deleted items or remove them permanently from one central location. |
|
| **Trash** | Restore deleted items or remove them permanently from one central location. |
|
||||||
| **Secrets** | Keep credentials and access information connected to the relevant workspace. |
|
| **Secrets** | Keep credentials and access information connected to the relevant Deal. |
|
||||||
| **Templates** | Create repeatable workspace structures for common types of work. |
|
| **Templates** | Create repeatable Deal structures for common types of work. |
|
||||||
| **Plugins** | Add, replace or disable tools without turning Verstak into one rigid monolithic application. |
|
| **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. |
|
| **Optional sync** | Synchronize vaults between devices using a self-hosted Verstak Sync Server. |
|
||||||
|
|
||||||
@@ -151,9 +151,9 @@ Documents/
|
|||||||
|
|
||||||
Your files and notes remain ordinary files that can be accessed without Verstak.
|
Your files and notes remain ordinary files that can be accessed without Verstak.
|
||||||
|
|
||||||
### 2. Create your first workspace
|
### 2. Create your first Deal
|
||||||
|
|
||||||
Inside the vault, create a workspace for a project, client, server, device or another area of work.
|
Inside the vault, create a Deal for a project, client, server, device or another area of work.
|
||||||
|
|
||||||
For example:
|
For example:
|
||||||
|
|
||||||
@@ -167,7 +167,7 @@ Training course
|
|||||||
|
|
||||||
### 3. Add context
|
### 3. Add context
|
||||||
|
|
||||||
Open the workspace and add the information needed to return to it later:
|
Open the Deal and add the information needed to return to it later:
|
||||||
|
|
||||||
* notes and decisions;
|
* notes and decisions;
|
||||||
* documents and source files;
|
* documents and source files;
|
||||||
@@ -245,15 +245,15 @@ for distribution-specific dependencies.
|
|||||||
The desktop, SDK and official plugin repositories must be cloned as sibling directories:
|
The desktop, SDK and official plugin repositories must be cloned as sibling directories:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
verstak-workspace/
|
verstak-source/
|
||||||
├── verstak/
|
├── verstak/
|
||||||
├── verstak-sdk/
|
├── verstak-sdk/
|
||||||
└── verstak-official-plugins/
|
└── verstak-official-plugins/
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir verstak-workspace
|
mkdir verstak-source
|
||||||
cd verstak-workspace
|
cd verstak-source
|
||||||
|
|
||||||
git clone https://github.com/mirivlad/verstak.git
|
git clone https://github.com/mirivlad/verstak.git
|
||||||
git clone https://github.com/mirivlad/verstak-sdk.git
|
git clone https://github.com/mirivlad/verstak-sdk.git
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
### Рабочий контекст остаётся рядом — и хранится локально.
|
### Рабочий контекст остаётся рядом — и хранится локально.
|
||||||
|
|
||||||
Файлы, заметки, ссылки, материалы из браузера, активность и история работы
|
Файлы, заметки, ссылки, материалы из браузера, активность и история работы
|
||||||
в одном расширяемом рабочем пространстве.
|
в одной расширяемой локальной среде.
|
||||||
|
|
||||||
[English](README.md) · **Русский**
|
[English](README.md) · **Русский**
|
||||||
|
|
||||||
@@ -49,11 +49,11 @@
|
|||||||
|
|
||||||
| Обзор: возвращает к делу и показывает недавнюю работу, входящие и то, что требует внимания. | Файлы: обычные папки и документы прямо внутри дела. |
|
| Обзор: возвращает к делу и показывает недавнюю работу, входящие и то, что требует внимания. | Файлы: обычные папки и документы прямо внутри дела. |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
| Заметки: Markdown-заметки остаются рядом с делом, которое они описывают. | Журнал: фиксирует завершённую работу и её контекст. |
|
| Заметки: Markdown-заметки остаются рядом с делом, которое они описывают. | Журнал: фиксирует завершённую работу и её контекст. |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
## Основные возможности
|
## Основные возможности
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ APPIMAGE_EXTRACT_AND_RUN=1 ./verstak-linux-x86_64-*.AppImage
|
|||||||
|
|
||||||
## Фоновая работа и трей
|
## Фоновая работа и трей
|
||||||
|
|
||||||
Закрытие главного окна не завершает Верстак: приложение остаётся в системном трее. Выберите **Show Verstak**, чтобы вернуть окно, или **Quit**, чтобы полностью завершить приложение. Благодаря этому напоминания Todo продолжают работать, пока окно скрыто.
|
Закрытие главного окна не завершает Верстак: приложение остаётся в системном трее. Выберите **«Показать Верстак»**, чтобы вернуть окно, или **«Выйти»**, чтобы полностью завершить приложение. Благодаря этому напоминания Todo продолжают работать, пока окно скрыто.
|
||||||
|
|
||||||
### Проверка скачанного файла
|
### Проверка скачанного файла
|
||||||
|
|
||||||
@@ -244,15 +244,15 @@ sha256sum -c SHA256SUMS --ignore-missing
|
|||||||
Desktop, SDK и официальные плагины должны находиться в соседних каталогах:
|
Desktop, SDK и официальные плагины должны находиться в соседних каталогах:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
verstak-workspace/
|
verstak-source/
|
||||||
├── verstak/
|
├── verstak/
|
||||||
├── verstak-sdk/
|
├── verstak-sdk/
|
||||||
└── verstak-official-plugins/
|
└── verstak-official-plugins/
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir verstak-workspace
|
mkdir verstak-source
|
||||||
cd verstak-workspace
|
cd verstak-source
|
||||||
|
|
||||||
git clone https://github.com/mirivlad/verstak.git
|
git clone https://github.com/mirivlad/verstak.git
|
||||||
git clone https://github.com/mirivlad/verstak-sdk.git
|
git clone https://github.com/mirivlad/verstak-sdk.git
|
||||||
|
|||||||
@@ -464,7 +464,7 @@ contributions summary.
|
|||||||
Conflicts are rejected unless `options.overwrite` is true.
|
Conflicts are rejected unless `options.overwrite` is true.
|
||||||
- `files.deleteTrash(trashId)` — permanently removes a file/folder already in
|
- `files.deleteTrash(trashId)` — permanently removes a file/folder already in
|
||||||
internal trash. This operation cannot be undone.
|
internal trash. This operation cannot be undone.
|
||||||
- UI policy: the workspace `Files` plugin displays only live files and folders.
|
- UI policy: the Deal `Files` plugin displays only live files and folders.
|
||||||
The global official `verstak.trash` plugin owns listing, restoring, filtering,
|
The global official `verstak.trash` plugin owns listing, restoring, filtering,
|
||||||
and permanently deleting deleted items across the vault.
|
and permanently deleting deleted items across the vault.
|
||||||
- `files.openExternal(relativePath)` — opens a vault-relative file/folder in
|
- `files.openExternal(relativePath)` — opens a vault-relative file/folder in
|
||||||
@@ -635,7 +635,7 @@ bundled runtime. Это реальный runtime contract для cooperative bun
|
|||||||
|
|
||||||
1. Unregister contributions всех plugins предыдущего discovery.
|
1. Unregister contributions всех plugins предыдущего discovery.
|
||||||
2. Unregister all non-core capabilities.
|
2. Unregister all non-core capabilities.
|
||||||
3. Re-register core capabilities + vault + workspace (если открыт).
|
3. Re-register core capabilities + vault + Deal manager (если открыт).
|
||||||
4. Re-scan discovery directories и повторить capability resolution.
|
4. Re-scan discovery directories и повторить capability resolution.
|
||||||
5. Register contributions для loaded/degraded plugins (disabled/failed — не регистрируются).
|
5. Register contributions для loaded/degraded plugins (disabled/failed — не регистрируются).
|
||||||
6. Update plugins list.
|
6. Update plugins list.
|
||||||
@@ -838,51 +838,52 @@ Vault plugin state хранится **внутри vault** в `.verstak/plugins.
|
|||||||
- `./scripts/smoke-platform.sh` — ✅ (enable/disable/plugins.json)
|
- `./scripts/smoke-platform.sh` — ✅ (enable/disable/plugins.json)
|
||||||
- `./scripts/build.sh` — ✅
|
- `./scripts/build.sh` — ✅
|
||||||
|
|
||||||
## Workspace Core Capability
|
## Дело: core capability
|
||||||
|
|
||||||
Workspace — это физическая папка верхнего уровня внутри vault root. Filesystem
|
Дело — это физическая папка верхнего уровня внутри vault root. Filesystem
|
||||||
является source of truth для списка workspaces.
|
является source of truth для списка Дел.
|
||||||
|
|
||||||
Пример:
|
Пример:
|
||||||
|
|
||||||
```
|
```
|
||||||
<vault>/
|
<vault>/
|
||||||
Workspace/
|
Deal/
|
||||||
Notes/
|
Notes/
|
||||||
Project/
|
Project/
|
||||||
ClientA/
|
ClientA/
|
||||||
.verstak/
|
.verstak/
|
||||||
```
|
```
|
||||||
|
|
||||||
Нет единого `<vault>/Workspace/` контейнера для всех workspaces. Папка
|
Нет единого `<vault>/Deal/` контейнера для всех Дел. Папка `Deal/` может быть
|
||||||
`Workspace/` может быть обычным workspace, но `Project/` и `ClientA/` являются
|
обычным Делом, но `Project/` и `ClientA/` являются такими же Делами на том же
|
||||||
такими же workspace на том же уровне.
|
уровне.
|
||||||
|
|
||||||
### Хранение
|
### Хранение
|
||||||
|
|
||||||
Workspace existence/list хранится только в filesystem:
|
Существование и список Дел хранятся только в filesystem:
|
||||||
|
|
||||||
- `ListWorkspaces()` читает top-level directories из vault root.
|
- `ListWorkspaces()` читает top-level directories из vault root.
|
||||||
- `.verstak`, reserved/internal directories, top-level files и symlinks не
|
- `.verstak`, reserved/internal directories, top-level files и symlinks не
|
||||||
считаются workspaces.
|
считаются Делами.
|
||||||
- `.verstak/workspace*.json` не является source of truth для списка workspaces.
|
- `.verstak/workspace*.json` не является source of truth для списка Дел.
|
||||||
- Нет persisted workspace path mapping и нет virtual workspace tree, которое
|
- Нет virtual workspace tree, которое
|
||||||
мапится на произвольные папки.
|
мапится на произвольные папки.
|
||||||
|
|
||||||
`.verstak` может хранить только metadata, которая не заменяет filesystem:
|
`.verstak` может хранить только metadata, которая не заменяет filesystem:
|
||||||
|
|
||||||
- UI state: selected workspace, expanded folders, sort/pin state, preferences.
|
- UI state: выбранное Дело, expanded folders, sort/pin state, preferences.
|
||||||
- Semantic snapshot: applied template snapshot, enabled feature areas, exact
|
- Semantic snapshot: applied template snapshot, enabled feature areas, exact
|
||||||
`workspaceTools`, and folder conventions.
|
`workspaceTools`, and folder conventions.
|
||||||
|
|
||||||
Template snapshot копируется в metadata при создании workspace. Workspace
|
При создании Дела runtime генерирует UUID и сохраняет его в
|
||||||
identity при этом остаётся именем top-level folder; `metadata.workspaceName`
|
`.verstak/workspace.json`. `workspaceId` — постоянная личность Дела;
|
||||||
является presentation/snapshot field, not canonical identity. Если сохранённое
|
`workspaceRootPath` и `workspaceName` — изменяемый адрес и presentation fields.
|
||||||
значение расходится с именем папки, runtime возвращает canonical `workspaceName`
|
Переименование обновляет путь, но не ID. Новая папка с прежним именем получает
|
||||||
равным имени папки без filesystem side effects.
|
другой ID и не может перехватить старые привязки.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"workspaceId": "1eb7cc69-52e8-4a18-ae2a-50d5229c5b60",
|
||||||
"workspaceName": "Project",
|
"workspaceName": "Project",
|
||||||
"createdFromTemplate": {
|
"createdFromTemplate": {
|
||||||
"templateId": "project",
|
"templateId": "project",
|
||||||
@@ -921,25 +922,25 @@ identity при этом остаётся именем top-level folder; `metada
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Если original template удалён или изменён позже, существующий workspace
|
Если original template удалён или изменён позже, существующее Дело
|
||||||
открывается по сохранённому snapshot и не мутирует автоматически. Template
|
открывается по сохранённому snapshot и не мутирует автоматически. Template
|
||||||
update/migration может быть только явной future feature. Если metadata
|
update/migration может быть только явной future feature. Если metadata
|
||||||
отсутствует, workspace открывается как generic workspace минимум с `files: true`.
|
отсутствует, Дело открывается как generic Deal минимум с `files: true`.
|
||||||
|
|
||||||
### API
|
### API
|
||||||
|
|
||||||
- `ListWorkspaces()` — список top-level physical folders.
|
- `ListWorkspaces()` — список top-level папок Дел с их `workspaceId`.
|
||||||
- `ListWorkspaceTemplates()` — selectable built-in templates с presentation
|
- `ListWorkspaceTemplates()` — selectable built-in templates с presentation
|
||||||
metadata и `workspaceTools`.
|
metadata и `workspaceTools`.
|
||||||
- `CreateWorkspace(name, templateId?)` — создать `<vault>/<name>/`, применить
|
- `CreateWorkspace(name, templateId?)` — создать `<vault>/<name>/`, применить
|
||||||
template один раз, сохранить snapshot metadata.
|
template один раз, сохранить snapshot metadata.
|
||||||
- `RenameWorkspace(oldName, newName)` — физически переименовать top-level folder
|
- `RenameWorkspace(oldName, newName)` — физически переименовать top-level папку
|
||||||
и обновить metadata key/name.
|
Дела и обновить metadata name, не меняя `workspaceId`.
|
||||||
- `TrashWorkspace(name)` — перенести весь top-level workspace folder в internal
|
- `TrashWorkspace(name)` — перенести весь top-level folder Дела в internal
|
||||||
trash policy.
|
trash policy.
|
||||||
- `GetWorkspaceMetadata(name)` — прочитать metadata или вернуть generic fallback.
|
- `GetWorkspaceMetadata(name)` — прочитать metadata или вернуть generic fallback.
|
||||||
- `UpdateWorkspaceMetadata(name, patch)` — обновить metadata без влияния на
|
- `UpdateWorkspaceMetadata(name, patch)` — обновить metadata без влияния на
|
||||||
существование workspace.
|
существование Дела.
|
||||||
|
|
||||||
Deprecated compatibility APIs:
|
Deprecated compatibility APIs:
|
||||||
|
|
||||||
@@ -949,7 +950,7 @@ Deprecated compatibility APIs:
|
|||||||
- `ArchiveWorkspaceNode(...)` — wrapper over `TrashWorkspace`.
|
- `ArchiveWorkspaceNode(...)` — wrapper over `TrashWorkspace`.
|
||||||
- `MoveWorkspaceNode(...)` — unsupported; old nested/mapped moves are rejected.
|
- `MoveWorkspaceNode(...)` — unsupported; old nested/mapped moves are rejected.
|
||||||
- `GetCurrentWorkspaceNode()` / `SetCurrentWorkspaceNode(...)` — wrappers over
|
- `GetCurrentWorkspaceNode()` / `SetCurrentWorkspaceNode(...)` — wrappers over
|
||||||
selected top-level workspace UI state.
|
selected top-level Deal UI state.
|
||||||
|
|
||||||
Эти методы существуют только для постепенного frontend/Wails cleanup. Они не
|
Эти методы существуют только для постепенного frontend/Wails cleanup. Они не
|
||||||
должны создавать или сохранять nested workspace tree и не должны восстанавливать
|
должны создавать или сохранять nested workspace tree и не должны восстанавливать
|
||||||
@@ -957,32 +958,33 @@ Deprecated compatibility APIs:
|
|||||||
|
|
||||||
### Capability
|
### Capability
|
||||||
|
|
||||||
`verstak/core/workspace/v1` — регистрируется только когда vault открыт и workspace инициализирован.
|
`verstak/core/workspace/v1` — техническое имя capability; оно регистрируется
|
||||||
|
только когда vault открыт и Deal manager инициализирован.
|
||||||
|
|
||||||
### Правила
|
### Правила
|
||||||
|
|
||||||
- Workspace name — один safe folder name, не path.
|
- Имя Дела — один safe folder name, не path.
|
||||||
- Reject: empty, slash, backslash, absolute-looking paths, `..`, null byte,
|
- Reject: empty, slash, backslash, absolute-looking paths, `..`, null byte,
|
||||||
`.verstak`, reserved/internal names, symlink workspaces, conflicts.
|
`.verstak`, reserved/internal names, symlink Дел, conflicts.
|
||||||
- WorkspaceItems получают `workspaceRootPath`, равный имени top-level папки
|
- WorkspaceItems получают техническое поле `workspaceRootPath`, равное имени top-level папки
|
||||||
(`Project`, `ClientA`, etc). Files plugin показывает именно эту папку.
|
(`Project`, `ClientA`, etc). Files plugin показывает именно эту папку.
|
||||||
- Files API остаётся raw vault-relative API: `Project/Notes/example.md`,
|
- Files API остаётся raw vault-relative API: `Project/Notes/example.md`,
|
||||||
`Project/docs/file.md`, `Test/readme.md`.
|
`Project/docs/file.md`, `Test/readme.md`.
|
||||||
- Notes are ordinary Markdown files under `<workspace>/Notes/`; нет
|
- Notes are ordinary Markdown files under `<Deal>/Notes/`; нет
|
||||||
`.verstak/notes`, UUID note storage или второго source of truth для note
|
`.verstak/notes`, UUID note storage или второго source of truth для note
|
||||||
content.
|
content.
|
||||||
|
|
||||||
### Lifecycle Events
|
### Lifecycle Events
|
||||||
|
|
||||||
Runtime publishes workspace lifecycle events after successful operations:
|
Runtime publishes lifecycle events Дел после успешных операций:
|
||||||
|
|
||||||
- `workspace.created` — payload includes `operation: "create"`,
|
- `workspace.created` — payload includes `operation: "create"`, `workspaceId`,
|
||||||
`workspaceRootPath`, `workspaceName`, and optional `templateId`.
|
`workspaceRootPath`, `workspaceName`, and optional `templateId`.
|
||||||
- `workspace.renamed` — payload includes `operation: "rename"`, new
|
- `workspace.renamed` — payload includes `operation: "rename"`, `workspaceId`,
|
||||||
`workspaceRootPath` / `workspaceName`, and previous workspace fields.
|
new `workspaceRootPath` / `workspaceName`, and previous path fields.
|
||||||
- `workspace.trashed` — payload includes `operation: "trash"`,
|
- `workspace.trashed` — payload includes `operation: "trash"`, `workspaceId`,
|
||||||
`workspaceRootPath`, `workspaceName`, `trashId`, `trashPath`, and `deletedAt`.
|
`workspaceRootPath`, `workspaceName`, `trashId`, `trashPath`, and `deletedAt`.
|
||||||
- `workspace.selected` — payload includes `operation: "select"`,
|
- `workspace.selected` — payload includes `operation: "select"`, `workspaceId`,
|
||||||
`workspaceRootPath`, and `workspaceName`.
|
`workspaceRootPath`, and `workspaceName`.
|
||||||
|
|
||||||
Official Activity subscribes to these events and stores them through the normal
|
Official Activity subscribes to these events and stores them through the normal
|
||||||
@@ -990,11 +992,11 @@ activity provider path.
|
|||||||
|
|
||||||
### UI
|
### UI
|
||||||
|
|
||||||
Workspace list в sidebar:
|
Список Дел в sidebar:
|
||||||
- Flat list of top-level workspace folders.
|
- Flat list of top-level папок Дел.
|
||||||
- Create workspace, rename workspace, trash workspace.
|
- Создать Дело, переименовать Дело, переместить Дело в корзину.
|
||||||
- Selection is stored as selected workspace name.
|
- Selection is stored as selected Deal name.
|
||||||
- No expand/collapse workspace tree and no case/folder node creation in core.
|
- No expand/collapse tree and no case/folder node creation in core.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# Workspace Templates
|
# Deal Templates
|
||||||
|
|
||||||
Workspace templates choose which dynamic plugin workspace tabs are visible when a
|
Deal templates choose which dynamic plugin Deal tabs are visible when a new
|
||||||
new top-level workspace is created. They do not enable or disable plugins for the
|
top-level Deal is created. They do not enable or disable plugins for the
|
||||||
whole vault and do not affect global sidebar tools.
|
whole vault and do not affect global sidebar tools.
|
||||||
|
|
||||||
## Built-in templates
|
## Built-in templates
|
||||||
|
|
||||||
| Template | Workspace tabs |
|
| Template | Deal tabs |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| General | Notes, Files, Journal, Activity, Browser Inbox |
|
| General | Notes, Files, Journal, Activity, Browser Inbox |
|
||||||
| Project | Notes, Files, Todos, Journal, Activity, Browser Inbox |
|
| Project | Notes, Files, Todos, Journal, Activity, Browser Inbox |
|
||||||
@@ -14,27 +14,27 @@ whole vault and do not affect global sidebar tools.
|
|||||||
| Admin | Notes, Files, Secrets, Todos, Journal |
|
| Admin | Notes, Files, Secrets, Todos, Journal |
|
||||||
| Minimal | Notes, Files |
|
| Minimal | Notes, Files |
|
||||||
|
|
||||||
The create-workspace modal displays the selected template description and its
|
The create-Deal modal displays the selected template description and its
|
||||||
included plugin tabs before the folder is created.
|
included plugin tabs before the folder is created.
|
||||||
|
|
||||||
## Metadata and compatibility
|
## Metadata and compatibility
|
||||||
|
|
||||||
Creation stores a template snapshot in `.verstak/workspaces/` metadata. The
|
Creation stores a template snapshot in `.verstak/workspaces/` metadata. The
|
||||||
snapshot contains the template id, name, version, applied time, and an ordered
|
snapshot contains the template id, name, version, applied time, and an ordered
|
||||||
`workspaceTools` list of plugin IDs. Existing workspace metadata without
|
`workspaceTools` list of plugin IDs. Existing Deal metadata without
|
||||||
`workspaceTools` remains compatible: its workspace continues to show all globally
|
`workspaceTools` remains compatible: its Deal continues to show all globally
|
||||||
enabled workspace plugins rather than unexpectedly hiding tabs.
|
enabled Deal plugins rather than unexpectedly hiding tabs.
|
||||||
|
|
||||||
Templates are applied once. Editing the built-in catalog or creating another
|
Templates are applied once. Editing the built-in catalog or creating another
|
||||||
workspace with a different template never changes an existing workspace snapshot.
|
Deal with a different template never changes an existing Deal snapshot.
|
||||||
There is no template editor or post-creation template switcher yet.
|
There is no template editor or post-creation template switcher yet.
|
||||||
|
|
||||||
## Global tools and unavailable plugins
|
## Global tools and unavailable plugins
|
||||||
|
|
||||||
Template visibility applies only to `workspaceItems` in the selected workspace.
|
Template visibility applies only to `workspaceItems` in the selected Deal.
|
||||||
Global views and sidebar items, such as global Todos, Browser Inbox, and Trash,
|
Global views and sidebar items, such as global Todos, Browser Inbox, and Trash,
|
||||||
remain available according to the normal plugin enablement state.
|
remain available according to the normal plugin enablement state.
|
||||||
|
|
||||||
The workspace host intersects the snapshot with dynamically discovered, globally
|
The Deal host intersects the snapshot with dynamically discovered, globally
|
||||||
enabled plugin contributions. If a template references a plugin that is missing or
|
enabled plugin contributions. If a template references a plugin that is missing or
|
||||||
disabled, that tab is simply absent; the other template tabs remain usable.
|
disabled, that tab is simply absent; the other template tabs remain usable.
|
||||||
|
|||||||
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 116 KiB |
@@ -106,7 +106,7 @@ test.describe('Activity workflow', () => {
|
|||||||
await expect(candidateSection).toContainText('Possible journal entries');
|
await expect(candidateSection).toContainText('Possible journal entries');
|
||||||
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('Deal: Project');
|
||||||
await expect(candidate).toContainText('Estimated duration: 10 min');
|
await expect(candidate).toContainText('Estimated duration: 10 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');
|
||||||
@@ -142,7 +142,7 @@ test.describe('Activity workflow', () => {
|
|||||||
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true');
|
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true');
|
||||||
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('Deal: Project');
|
||||||
await expect(journal.locator('[data-journal-candidate]')).toContainText('Estimated duration: 10 min');
|
await expect(journal.locator('[data-journal-candidate]')).toContainText('Estimated duration: 10 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('');
|
||||||
|
|||||||
@@ -175,10 +175,10 @@ test.describe('F: Default Editor Plugin', () => {
|
|||||||
const workbench = page.locator('.workbench-host');
|
const workbench = page.locator('.workbench-host');
|
||||||
await expect(workbench).toBeVisible({ timeout: 10000 });
|
await expect(workbench).toBeVisible({ timeout: 10000 });
|
||||||
const title = workbench.locator('.workbench-title');
|
const title = workbench.locator('.workbench-title');
|
||||||
await expect(title).toHaveText('Docs/readme.md');
|
await expect(title).toHaveText('readme.md');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('open .md with notes context routes to highest-priority provider', async ({ page }) => {
|
test('opening a note uses its title rather than its technical path', async ({ page }) => {
|
||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
|
const [result, err] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
|
||||||
kind: 'vault-file',
|
kind: 'vault-file',
|
||||||
@@ -193,7 +193,7 @@ test.describe('F: Default Editor Plugin', () => {
|
|||||||
const workbench = page.locator('.workbench-host');
|
const workbench = page.locator('.workbench-host');
|
||||||
await expect(workbench).toBeVisible({ timeout: 10000 });
|
await expect(workbench).toBeVisible({ timeout: 10000 });
|
||||||
const title = workbench.locator('.workbench-title');
|
const title = workbench.locator('.workbench-title');
|
||||||
await expect(title).toHaveText('Notes/Overview.md');
|
await expect(title).toHaveText('Overview');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('default-editor plugin is listed as loaded in plugin manager', async ({ page }) => {
|
test('default-editor plugin is listed as loaded in plugin manager', async ({ page }) => {
|
||||||
|
|||||||
@@ -513,7 +513,7 @@ test.describe('G: Files Plugin', () => {
|
|||||||
|
|
||||||
const workbench = page.locator('.workbench-host');
|
const workbench = page.locator('.workbench-host');
|
||||||
await expect(workbench).toBeVisible({ timeout: 10000 });
|
await expect(workbench).toBeVisible({ timeout: 10000 });
|
||||||
await expect(workbench.locator('.workbench-title')).toHaveText('Docs/readme.md');
|
await expect(workbench.locator('.workbench-title')).toHaveText('readme.md');
|
||||||
|
|
||||||
const preview = page.locator('.de-preview');
|
const preview = page.locator('.de-preview');
|
||||||
await expect(preview).toBeVisible({ timeout: 10000 });
|
await expect(preview).toBeVisible({ timeout: 10000 });
|
||||||
@@ -535,7 +535,7 @@ test.describe('G: Files Plugin', () => {
|
|||||||
|
|
||||||
const workbench = page.locator('.workbench-host');
|
const workbench = page.locator('.workbench-host');
|
||||||
await expect(workbench).toBeVisible({ timeout: 10000 });
|
await expect(workbench).toBeVisible({ timeout: 10000 });
|
||||||
await expect(workbench.locator('.workbench-title')).toHaveText('Notes/Overview.md');
|
await expect(workbench.locator('.workbench-title')).toHaveText('Overview');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('files plugin card shows openProviders in contributions', async ({ page }) => {
|
test('files plugin card shows openProviders in contributions', async ({ page }) => {
|
||||||
|
|||||||
@@ -25,9 +25,23 @@ test.describe('Desktop localization', () => {
|
|||||||
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Тест платформы' })).toBeVisible();
|
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Тест платформы' })).toBeVisible();
|
||||||
await expect(page.locator('[data-status-item-id="verstak.platform-test.status"]')).toContainText('Все тесты пройдены');
|
await expect(page.locator('[data-status-item-id="verstak.platform-test.status"]')).toContainText('Все тесты пройдены');
|
||||||
|
|
||||||
|
const project = page.locator('.wt-node').filter({ hasText: 'Project' });
|
||||||
|
await project.locator('button[title="Переименовать Дело"]').click();
|
||||||
|
await expect(page.locator('button[title="Сохранить новое название"]')).toHaveText('Сохранить');
|
||||||
|
await page.locator('.wt-rename').press('Escape');
|
||||||
|
|
||||||
await page.locator('[data-settings-menu-button]').click();
|
await page.locator('[data-settings-menu-button]').click();
|
||||||
await expect(page.locator('[data-settings-action="plugin-manager"]')).toContainText('Менеджер плагинов');
|
await expect(page.locator('[data-settings-action="plugin-manager"]')).toContainText('Менеджер плагинов');
|
||||||
await expect(page.locator('[data-settings-language="ru"]')).toHaveAttribute('aria-checked', 'true');
|
await expect(page.locator('[data-settings-language="ru"]')).toHaveAttribute('aria-checked', 'true');
|
||||||
|
await page.locator('[data-settings-action="plugin-manager"]').click();
|
||||||
|
const pluginManager = page.locator('.plugin-manager');
|
||||||
|
await expect(pluginManager).toContainText('Зарегистрировано возможностей:');
|
||||||
|
await expect(pluginManager.locator('.registry-section')).toContainText('Реестр возможностей');
|
||||||
|
await expect(pluginManager.locator('.registry-section')).toContainText('Возможность');
|
||||||
|
await expect(pluginManager.locator('.plugin-filters')).toContainText('Фильтры');
|
||||||
|
await expect(pluginManager.locator('[data-plugin-filter-results]')).toContainText('Показано плагинов:');
|
||||||
|
await expect(pluginManager.locator('[data-plugin-filter="status"]')).toContainText('Выключенные');
|
||||||
|
await expect(pluginManager).not.toContainText('Workspace-scoped');
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await waitForAppReady(page);
|
await waitForAppReady(page);
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ test.describe('D: Plugin API bridge', () => {
|
|||||||
|
|
||||||
const workbench = page.locator('.workbench-host');
|
const workbench = page.locator('.workbench-host');
|
||||||
await expect(workbench).toBeVisible({ timeout: 10000 });
|
await expect(workbench).toBeVisible({ timeout: 10000 });
|
||||||
await expect(workbench.locator('.workbench-title')).toHaveText('Notes/Overview.md');
|
await expect(workbench.locator('.workbench-title')).toHaveText('Overview');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('workbench routes markdown files to default-editor provider', async ({ page }) => {
|
test('workbench routes markdown files to default-editor provider', async ({ page }) => {
|
||||||
@@ -76,7 +76,7 @@ test.describe('D: Plugin API bridge', () => {
|
|||||||
const workbench = page.locator('.workbench-host');
|
const workbench = page.locator('.workbench-host');
|
||||||
await expect(workbench).toBeVisible({ timeout: 10000 });
|
await expect(workbench).toBeVisible({ timeout: 10000 });
|
||||||
const title = workbench.locator('.workbench-title');
|
const title = workbench.locator('.workbench-title');
|
||||||
await expect(title).toHaveText('Docs/readme.md');
|
await expect(title).toHaveText('readme.md');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('workbench shows no-provider fallback when no provider matches', async ({ page }) => {
|
test('workbench shows no-provider fallback when no provider matches', async ({ page }) => {
|
||||||
@@ -91,8 +91,10 @@ test.describe('D: Plugin API bridge', () => {
|
|||||||
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
|
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(page.locator('[data-workbench-status="no-provider"]')).toBeVisible();
|
const fallback = page.locator('[data-workbench-status="no-provider"]');
|
||||||
await expect(page.locator('[data-workbench-status="no-provider"]')).toContainText('No viewer/editor available');
|
await expect(fallback).toBeVisible();
|
||||||
|
await expect(fallback).toContainText('No viewer/editor available');
|
||||||
|
await expect(fallback).not.toContainText('generic-text');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sync plugin API routes through mocked Wails bridge', async ({ page }) => {
|
test('sync plugin API routes through mocked Wails bridge', async ({ page }) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
|
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager, setPluginStatus } from './helpers.js';
|
||||||
|
|
||||||
test.describe('E: Plugin Manager layout', () => {
|
test.describe('E: Plugin Manager layout', () => {
|
||||||
let consoleCollector;
|
let consoleCollector;
|
||||||
@@ -88,6 +88,56 @@ test.describe('E: Plugin Manager layout', () => {
|
|||||||
await expect(risk).toContainText('elevated permissions');
|
await expect(risk).toContainText('elevated permissions');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('plugin manager filters by state, declared permissions, capabilities, settings and source', async ({ page }) => {
|
||||||
|
await setPluginStatus(page, 'verstak.todo', 'disabled', false);
|
||||||
|
await page.evaluate(() => window.__wailsMock.addSyntheticPlugins(1, 'third-party'));
|
||||||
|
await openPluginManager(page);
|
||||||
|
|
||||||
|
const results = page.locator('[data-plugin-filter-results]');
|
||||||
|
await expect(results).toContainText('Showing');
|
||||||
|
const filterSelectStyle = await page.locator('[data-plugin-filter="status"]').evaluate((node) => {
|
||||||
|
const style = getComputedStyle(node);
|
||||||
|
return { appearance: style.appearance, backgroundImage: style.backgroundImage };
|
||||||
|
});
|
||||||
|
expect(filterSelectStyle.appearance).toBe('none');
|
||||||
|
expect(filterSelectStyle.backgroundImage).not.toBe('none');
|
||||||
|
|
||||||
|
await page.locator('[data-plugin-filter="status"]').selectOption('disabled');
|
||||||
|
await expect(page.locator('.plugin-card')).toHaveCount(1);
|
||||||
|
await expect(page.locator('.plugin-card')).toContainText('Todos');
|
||||||
|
await page.locator('button.reload-btn').click();
|
||||||
|
await expect(page.locator('[data-plugin-filter="status"]')).toHaveValue('disabled');
|
||||||
|
await expect(page.locator('.plugin-card')).toHaveCount(1);
|
||||||
|
|
||||||
|
await page.locator('[data-plugin-filter-reset]').click();
|
||||||
|
await page.locator('[data-plugin-filter-permission="notifications.schedule"]').check();
|
||||||
|
await expect(page.locator('.plugin-card')).toHaveCount(1);
|
||||||
|
await expect(page.locator('.plugin-card')).toContainText('Todos');
|
||||||
|
await page.locator('[data-plugin-filter-permission="secrets.read"]').check();
|
||||||
|
await expect(page.locator('.plugin-card')).toHaveCount(2);
|
||||||
|
await page.locator('[data-plugin-filter-permission="secrets.read"]').uncheck();
|
||||||
|
|
||||||
|
await page.locator('[data-plugin-filter-reset]').click();
|
||||||
|
await page.locator('[data-plugin-filter-capability="verstak/core/notifications/v1"]').check();
|
||||||
|
await expect(page.locator('.plugin-card')).toHaveCount(1);
|
||||||
|
await expect(page.locator('.plugin-card')).toContainText('Todos');
|
||||||
|
await page.locator('[data-plugin-filter-capability="secret-store"]').check();
|
||||||
|
await expect(page.locator('.plugin-card')).toHaveCount(2);
|
||||||
|
await page.locator('[data-plugin-filter-capability="secret-store"]').uncheck();
|
||||||
|
|
||||||
|
await page.locator('[data-plugin-filter-reset]').click();
|
||||||
|
await page.locator('[data-plugin-filter="settings"]').selectOption('with');
|
||||||
|
await expect(page.locator('.plugin-card').filter({ hasText: 'Platform Test' })).toBeVisible();
|
||||||
|
await expect(page.locator('.plugin-card').filter({ hasText: 'Default Editor' })).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.locator('[data-plugin-filter="source"]').selectOption('third-party');
|
||||||
|
await expect(page.locator('.plugin-card')).toHaveCount(0);
|
||||||
|
await expect(page.locator('[data-plugin-filter-empty]')).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator('[data-plugin-filter-reset]').click();
|
||||||
|
await expect(page.locator('.plugin-card')).toHaveCount(13);
|
||||||
|
});
|
||||||
|
|
||||||
test('workspace selection keeps exactly one active node', async ({ page }) => {
|
test('workspace selection keeps exactly one active node', async ({ page }) => {
|
||||||
const selected = page.locator('.wt-node.selected .wt-label');
|
const selected = page.locator('.wt-node.selected .wt-label');
|
||||||
await expect(selected).toHaveCount(1);
|
await expect(selected).toHaveCount(1);
|
||||||
@@ -118,15 +168,15 @@ test.describe('E: Plugin Manager layout', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('workspace sidebar creates renames and trashes top-level workspaces', async ({ page }) => {
|
test('workspace sidebar creates renames and trashes top-level workspaces', async ({ page }) => {
|
||||||
await page.locator('button[title="New workspace"]').click();
|
await page.locator('button[title="New Deal"]').click();
|
||||||
const modal = page.locator('[data-workspace-create-modal]');
|
const modal = page.locator('[data-workspace-create-modal]');
|
||||||
await modal.locator('[data-workspace-name]').fill('ClientA');
|
await modal.locator('[data-workspace-name]').fill('ClientA');
|
||||||
await modal.getByRole('button', { name: 'Create workspace' }).click();
|
await modal.getByRole('button', { name: 'Create Deal' }).click();
|
||||||
|
|
||||||
await expect(page.locator('.wt-label').filter({ hasText: 'ClientA' })).toBeVisible();
|
await expect(page.locator('.wt-label').filter({ hasText: 'ClientA' })).toBeVisible();
|
||||||
|
|
||||||
const client = page.locator('.wt-node').filter({ hasText: 'ClientA' });
|
const client = page.locator('.wt-node').filter({ hasText: 'ClientA' });
|
||||||
await client.locator('button[title="Rename workspace"]').click();
|
await client.locator('button[title="Rename Deal"]').click();
|
||||||
await page.locator('.wt-rename').fill('ClientB');
|
await page.locator('.wt-rename').fill('ClientB');
|
||||||
await page.locator('button[title="Save rename"]').click();
|
await page.locator('button[title="Save rename"]').click();
|
||||||
|
|
||||||
@@ -134,7 +184,7 @@ test.describe('E: Plugin Manager layout', () => {
|
|||||||
await expect(page.locator('.wt-label').filter({ hasText: 'ClientA' })).toHaveCount(0);
|
await expect(page.locator('.wt-label').filter({ hasText: 'ClientA' })).toHaveCount(0);
|
||||||
|
|
||||||
const renamed = page.locator('.wt-node').filter({ hasText: 'ClientB' });
|
const renamed = page.locator('.wt-node').filter({ hasText: 'ClientB' });
|
||||||
await renamed.locator('button[title="Trash workspace"]').click();
|
await renamed.locator('button[title="Move Deal to trash"]').click();
|
||||||
|
|
||||||
await expect(page.locator('.wt-label').filter({ hasText: 'ClientB' })).toHaveCount(0);
|
await expect(page.locator('.wt-label').filter({ hasText: 'ClientB' })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -248,10 +248,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('Workspace: Project · 10 min · 2 activities');
|
await expect(attention).toContainText('Deal: 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('Workspace: Project');
|
await expect(page.locator('.journal-root [data-journal-candidate]')).toContainText('Deal: 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();
|
||||||
|
|
||||||
@@ -295,4 +295,28 @@ 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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export default {
|
|||||||
'pluginView.select': 'Select a plugin view from the sidebar',
|
'pluginView.select': 'Select a plugin view from the sidebar',
|
||||||
'pluginView.selectHint': 'Plugin views will appear here',
|
'pluginView.selectHint': 'Plugin views will appear here',
|
||||||
'common.retry': '⟳ Retry',
|
'common.retry': '⟳ Retry',
|
||||||
|
'common.save': 'Save',
|
||||||
'common.source': 'Source',
|
'common.source': 'Source',
|
||||||
'common.status': 'Status',
|
'common.status': 'Status',
|
||||||
'common.capability': 'Capability',
|
'common.capability': 'Capability',
|
||||||
@@ -54,6 +55,24 @@ export default {
|
|||||||
'pluginManager.health': 'Plugin Health',
|
'pluginManager.health': 'Plugin Health',
|
||||||
'pluginManager.permissionRisk': 'Permission Risk',
|
'pluginManager.permissionRisk': 'Permission Risk',
|
||||||
'pluginManager.elevatedPermissions': '{count} plugins request elevated permissions',
|
'pluginManager.elevatedPermissions': '{count} plugins request elevated permissions',
|
||||||
|
'pluginManager.filters': 'Filters',
|
||||||
|
'pluginManager.filterResults': 'Showing {visible} of {total} plugins',
|
||||||
|
'pluginManager.filterReset': 'Reset filters',
|
||||||
|
'pluginManager.filterState': 'State',
|
||||||
|
'pluginManager.filterAll': 'All',
|
||||||
|
'pluginManager.filterEnabled': 'Enabled',
|
||||||
|
'pluginManager.filterDisabled': 'Disabled',
|
||||||
|
'pluginManager.filterFailed': 'Problem',
|
||||||
|
'pluginManager.filterDegraded': 'Degraded',
|
||||||
|
'pluginManager.filterPermissions': 'Permissions',
|
||||||
|
'pluginManager.filterCapabilities': 'Capabilities',
|
||||||
|
'pluginManager.filterSettings': 'Settings',
|
||||||
|
'pluginManager.filterWithSettings': 'With settings',
|
||||||
|
'pluginManager.filterWithoutSettings': 'Without settings',
|
||||||
|
'pluginManager.filterSource': 'Source',
|
||||||
|
'pluginManager.filterOfficial': 'Official',
|
||||||
|
'pluginManager.filterThirdParty': 'Third-party or local',
|
||||||
|
'pluginManager.filterEmpty': 'No plugins match the selected filters.',
|
||||||
'pluginManager.none': 'No plugins found',
|
'pluginManager.none': 'No plugins found',
|
||||||
'pluginManager.scannedDirs': 'Plugin directories scanned:',
|
'pluginManager.scannedDirs': 'Plugin directories scanned:',
|
||||||
'pluginManager.userPlugins': 'user plugins',
|
'pluginManager.userPlugins': 'user plugins',
|
||||||
@@ -236,6 +255,42 @@ export default {
|
|||||||
'overview.recentChangesHint': 'Latest meaningful activity in this Deal.',
|
'overview.recentChangesHint': 'Latest meaningful activity in this Deal.',
|
||||||
'overview.event.workspaceOpened': 'Opened Deal',
|
'overview.event.workspaceOpened': 'Opened Deal',
|
||||||
'overview.event.activity': 'Deal activity',
|
'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.overviewNote': 'Deal overview note',
|
||||||
'overview.recentFilter': 'Recent changes filter',
|
'overview.recentFilter': 'Recent changes filter',
|
||||||
'overview.loadingRecent': 'Loading recent changes...',
|
'overview.loadingRecent': 'Loading recent changes...',
|
||||||
|
|||||||
@@ -35,9 +35,10 @@ export default {
|
|||||||
'pluginView.select': 'Выберите представление плагина на боковой панели',
|
'pluginView.select': 'Выберите представление плагина на боковой панели',
|
||||||
'pluginView.selectHint': 'Представления плагинов появятся здесь',
|
'pluginView.selectHint': 'Представления плагинов появятся здесь',
|
||||||
'common.retry': '⟳ Повторить',
|
'common.retry': '⟳ Повторить',
|
||||||
|
'common.save': 'Сохранить',
|
||||||
'common.source': 'Источник',
|
'common.source': 'Источник',
|
||||||
'common.status': 'Состояние',
|
'common.status': 'Состояние',
|
||||||
'common.capability': 'Capability',
|
'common.capability': 'Возможность',
|
||||||
'common.provider': 'Поставщик',
|
'common.provider': 'Поставщик',
|
||||||
'status.loaded': 'загружено',
|
'status.loaded': 'загружено',
|
||||||
'status.degraded': 'ограничено',
|
'status.degraded': 'ограничено',
|
||||||
@@ -48,12 +49,30 @@ export default {
|
|||||||
'pluginManager.reload': '⟳ Обновить',
|
'pluginManager.reload': '⟳ Обновить',
|
||||||
'pluginManager.scanning': 'Сканирование каталогов плагинов...',
|
'pluginManager.scanning': 'Сканирование каталогов плагинов...',
|
||||||
'pluginManager.summary.plugins': 'Обнаружено плагинов: {count}',
|
'pluginManager.summary.plugins': 'Обнаружено плагинов: {count}',
|
||||||
'pluginManager.summary.capabilities': 'Зарегистрировано capabilities: {count}',
|
'pluginManager.summary.capabilities': 'Зарегистрировано возможностей: {count}',
|
||||||
'pluginManager.summary.permissions': 'Известно разрешений: {count}',
|
'pluginManager.summary.permissions': 'Известно разрешений: {count}',
|
||||||
'pluginManager.scanSummary': 'Сводка проверки плагинов',
|
'pluginManager.scanSummary': 'Сводка проверки плагинов',
|
||||||
'pluginManager.health': 'Состояние плагинов',
|
'pluginManager.health': 'Состояние плагинов',
|
||||||
'pluginManager.permissionRisk': 'Риск разрешений',
|
'pluginManager.permissionRisk': 'Риск разрешений',
|
||||||
'pluginManager.elevatedPermissions': 'Плагинов с повышенными разрешениями: {count}',
|
'pluginManager.elevatedPermissions': 'Плагинов с повышенными разрешениями: {count}',
|
||||||
|
'pluginManager.filters': 'Фильтры',
|
||||||
|
'pluginManager.filterResults': 'Показано плагинов: {visible} из {total}',
|
||||||
|
'pluginManager.filterReset': 'Сбросить фильтры',
|
||||||
|
'pluginManager.filterState': 'Состояние',
|
||||||
|
'pluginManager.filterAll': 'Все',
|
||||||
|
'pluginManager.filterEnabled': 'Включённые',
|
||||||
|
'pluginManager.filterDisabled': 'Выключенные',
|
||||||
|
'pluginManager.filterFailed': 'С проблемами',
|
||||||
|
'pluginManager.filterDegraded': 'Ограниченные',
|
||||||
|
'pluginManager.filterPermissions': 'Разрешения',
|
||||||
|
'pluginManager.filterCapabilities': 'Возможности',
|
||||||
|
'pluginManager.filterSettings': 'Настройки',
|
||||||
|
'pluginManager.filterWithSettings': 'С настройками',
|
||||||
|
'pluginManager.filterWithoutSettings': 'Без настроек',
|
||||||
|
'pluginManager.filterSource': 'Источник',
|
||||||
|
'pluginManager.filterOfficial': 'Официальные',
|
||||||
|
'pluginManager.filterThirdParty': 'Сторонние или локальные',
|
||||||
|
'pluginManager.filterEmpty': 'Нет плагинов, соответствующих выбранным фильтрам.',
|
||||||
'pluginManager.none': 'Плагины не найдены',
|
'pluginManager.none': 'Плагины не найдены',
|
||||||
'pluginManager.scannedDirs': 'Проверенные каталоги плагинов:',
|
'pluginManager.scannedDirs': 'Проверенные каталоги плагинов:',
|
||||||
'pluginManager.userPlugins': 'пользовательские плагины',
|
'pluginManager.userPlugins': 'пользовательские плагины',
|
||||||
@@ -62,7 +81,7 @@ export default {
|
|||||||
'pluginManager.missingTitle': 'Отсутствующие установленные плагины',
|
'pluginManager.missingTitle': 'Отсутствующие установленные плагины',
|
||||||
'pluginManager.missingHint': 'Эти плагины требуются хранилищу, но их пакеты не установлены на этом устройстве.',
|
'pluginManager.missingHint': 'Эти плагины требуются хранилищу, но их пакеты не установлены на этом устройстве.',
|
||||||
'pluginManager.missingPackage': 'Плагин указан среди желаемых плагинов хранилища, но его пакет не установлен.',
|
'pluginManager.missingPackage': 'Плагин указан среди желаемых плагинов хранилища, но его пакет не установлен.',
|
||||||
'pluginManager.capabilityRegistry': 'Реестр capabilities ({count})',
|
'pluginManager.capabilityRegistry': 'Реестр возможностей ({count})',
|
||||||
'pluginManager.settingsError': 'Ошибка настроек',
|
'pluginManager.settingsError': 'Ошибка настроек',
|
||||||
'pluginManager.pluginSettings': 'Настройки плагина',
|
'pluginManager.pluginSettings': 'Настройки плагина',
|
||||||
'pluginManager.settingsBundleUnavailable': 'Frontend bundle панели настроек недоступен',
|
'pluginManager.settingsBundleUnavailable': 'Frontend bundle панели настроек недоступен',
|
||||||
@@ -75,7 +94,7 @@ export default {
|
|||||||
'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': 'Плагин работает, но некоторые необязательные возможности недоступны.',
|
||||||
'pluginCard.name': 'Название',
|
'pluginCard.name': 'Название',
|
||||||
'pluginCard.contributions': 'Элементы интерфейса',
|
'pluginCard.contributions': 'Элементы интерфейса',
|
||||||
'pluginCard.technicalDetails': 'Технические сведения',
|
'pluginCard.technicalDetails': 'Технические сведения',
|
||||||
@@ -83,9 +102,9 @@ export default {
|
|||||||
'pluginCard.root': 'Корневой каталог',
|
'pluginCard.root': 'Корневой каталог',
|
||||||
'pluginCard.provides': 'Предоставляет',
|
'pluginCard.provides': 'Предоставляет',
|
||||||
'pluginCard.requires': 'Требует',
|
'pluginCard.requires': 'Требует',
|
||||||
'pluginCard.missingRequired': 'Отсутствуют обязательные capabilities',
|
'pluginCard.missingRequired': 'Отсутствуют обязательные возможности',
|
||||||
'pluginCard.optionalRequires': 'Необязательные зависимости',
|
'pluginCard.optionalRequires': 'Необязательные зависимости',
|
||||||
'pluginCard.optionalUnavailable': 'Необязательные capabilities недоступны — плагин работает в ограниченном режиме',
|
'pluginCard.optionalUnavailable': 'Необязательные возможности недоступны — плагин работает в ограниченном режиме',
|
||||||
'pluginCard.permissions': 'Разрешения',
|
'pluginCard.permissions': 'Разрешения',
|
||||||
'pluginCard.enabling': '⟳ Включение...',
|
'pluginCard.enabling': '⟳ Включение...',
|
||||||
'pluginCard.enable': '▶ Включить',
|
'pluginCard.enable': '▶ Включить',
|
||||||
@@ -236,6 +255,42 @@ export default {
|
|||||||
'overview.recentChangesHint': 'Последняя важная активность в этом Деле.',
|
'overview.recentChangesHint': 'Последняя важная активность в этом Деле.',
|
||||||
'overview.event.workspaceOpened': 'Открыто Дело',
|
'overview.event.workspaceOpened': 'Открыто Дело',
|
||||||
'overview.event.activity': 'Активность Дела',
|
'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.overviewNote': 'Обзорная заметка Дела',
|
||||||
'overview.recentFilter': 'Фильтр недавних изменений',
|
'overview.recentFilter': 'Фильтр недавних изменений',
|
||||||
'overview.loadingRecent': 'Загрузка недавних изменений...',
|
'overview.loadingRecent': 'Загрузка недавних изменений...',
|
||||||
|
|||||||
@@ -33,6 +33,11 @@
|
|||||||
let reloading = false;
|
let reloading = false;
|
||||||
let toastMessage = '';
|
let toastMessage = '';
|
||||||
let toastType = 'success'; // 'success' | 'error' | 'info'
|
let toastType = 'success'; // 'success' | 'error' | 'info'
|
||||||
|
let statusFilter = 'all';
|
||||||
|
let permissionFilters = [];
|
||||||
|
let capabilityFilters = [];
|
||||||
|
let settingsFilter = 'all';
|
||||||
|
let sourceFilter = 'all';
|
||||||
|
|
||||||
export let activeSettingsPluginId = '';
|
export let activeSettingsPluginId = '';
|
||||||
export let activeSettingsPanelId = '';
|
export let activeSettingsPanelId = '';
|
||||||
@@ -256,6 +261,11 @@
|
|||||||
$: totalPerms = permissions.length;
|
$: totalPerms = permissions.length;
|
||||||
$: statusSummary = computeStatusSummary(plugins);
|
$: statusSummary = computeStatusSummary(plugins);
|
||||||
$: elevatedPermissionPluginCount = computeElevatedPermissionPluginCount(plugins, permissions);
|
$: elevatedPermissionPluginCount = computeElevatedPermissionPluginCount(plugins, permissions);
|
||||||
|
$: filterPermissions = collectManifestValues(plugins, 'permissions');
|
||||||
|
$: filterCapabilities = collectCapabilities(plugins);
|
||||||
|
$: hasReliableSourceMetadata = plugins.some((plugin) => sourceGroup(plugin) !== '');
|
||||||
|
$: visiblePlugins = filterPlugins(plugins, statusFilter, permissionFilters, capabilityFilters, settingsFilter, sourceFilter, contributions);
|
||||||
|
$: filtersActive = statusFilter !== 'all' || permissionFilters.length > 0 || capabilityFilters.length > 0 || settingsFilter !== 'all' || sourceFilter !== 'all';
|
||||||
|
|
||||||
function computeStatusSummary(pluginRows) {
|
function computeStatusSummary(pluginRows) {
|
||||||
return pluginRows.reduce((summary, plugin) => {
|
return pluginRows.reduce((summary, plugin) => {
|
||||||
@@ -274,6 +284,69 @@
|
|||||||
return (pluginRows || []).filter(plugin => (plugin?.manifest?.permissions || []).some(permission => dangerous.has(permission))).length;
|
return (pluginRows || []).filter(plugin => (plugin?.manifest?.permissions || []).some(permission => dangerous.has(permission))).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collectManifestValues(pluginRows, key) {
|
||||||
|
return Array.from(new Set((pluginRows || []).flatMap((plugin) => plugin?.manifest?.[key] || [])))
|
||||||
|
.sort((left, right) => left.localeCompare(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectCapabilities(pluginRows) {
|
||||||
|
return Array.from(new Set((pluginRows || []).flatMap((plugin) => [
|
||||||
|
...(plugin?.manifest?.provides || []),
|
||||||
|
...(plugin?.manifest?.requires || []),
|
||||||
|
...(plugin?.manifest?.optionalRequires || []),
|
||||||
|
]))).sort((left, right) => left.localeCompare(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluginStatusGroup(plugin) {
|
||||||
|
const status = plugin?.status || '';
|
||||||
|
if (status === 'failed' || status === 'incompatible' || status === 'missing-required-capability') return 'failed';
|
||||||
|
if (status === 'degraded') return 'degraded';
|
||||||
|
if (status === 'disabled' || plugin?.enabled === false) return 'disabled';
|
||||||
|
return 'enabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceGroup(plugin) {
|
||||||
|
const source = plugin?.manifest?.source;
|
||||||
|
if (source === 'official') return 'official';
|
||||||
|
if (source === 'local' || source === 'third-party') return 'third-party';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSettings(plugin, activeContributions) {
|
||||||
|
const pluginId = plugin?.manifest?.id;
|
||||||
|
return Boolean(pluginId && (activeContributions.settingsPanels || []).some((panel) => panel.pluginId === pluginId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function includesAny(values, expected) {
|
||||||
|
return expected.length === 0 || expected.some((value) => values.includes(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterPlugins(pluginRows, activeStatusFilter, activePermissionFilters, activeCapabilityFilters, activeSettingsFilter, activeSourceFilter, activeContributions) {
|
||||||
|
return pluginRows.filter((plugin) => matchesFilters(plugin, activeStatusFilter, activePermissionFilters, activeCapabilityFilters, activeSettingsFilter, activeSourceFilter, activeContributions));
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesFilters(plugin, activeStatusFilter, activePermissionFilters, activeCapabilityFilters, activeSettingsFilter, activeSourceFilter, activeContributions) {
|
||||||
|
if (activeStatusFilter !== 'all' && pluginStatusGroup(plugin) !== activeStatusFilter) return false;
|
||||||
|
if (!includesAny(plugin?.manifest?.permissions || [], activePermissionFilters)) return false;
|
||||||
|
const declaredCapabilities = [
|
||||||
|
...(plugin?.manifest?.provides || []),
|
||||||
|
...(plugin?.manifest?.requires || []),
|
||||||
|
...(plugin?.manifest?.optionalRequires || []),
|
||||||
|
];
|
||||||
|
if (!includesAny(declaredCapabilities, activeCapabilityFilters)) return false;
|
||||||
|
if (activeSettingsFilter === 'with' && !hasSettings(plugin, activeContributions)) return false;
|
||||||
|
if (activeSettingsFilter === 'without' && hasSettings(plugin, activeContributions)) return false;
|
||||||
|
return activeSourceFilter === 'all' || sourceGroup(plugin) === activeSourceFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
statusFilter = 'all';
|
||||||
|
permissionFilters = [];
|
||||||
|
capabilityFilters = [];
|
||||||
|
settingsFilter = 'all';
|
||||||
|
sourceFilter = 'all';
|
||||||
|
}
|
||||||
|
|
||||||
function closeSettings() {
|
function closeSettings() {
|
||||||
settingsPanel = null;
|
settingsPanel = null;
|
||||||
settingsPluginId = '';
|
settingsPluginId = '';
|
||||||
@@ -345,6 +418,70 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section class="plugin-filters" aria-label={tr('pluginManager.filters')}>
|
||||||
|
<div class="filter-header">
|
||||||
|
<div>
|
||||||
|
<h3>{tr('pluginManager.filters')}</h3>
|
||||||
|
<p data-plugin-filter-results>{tr('pluginManager.filterResults', { visible: visiblePlugins.length, total: totalPlugins })}</p>
|
||||||
|
</div>
|
||||||
|
<button class="filter-reset" data-plugin-filter-reset type="button" on:click={resetFilters} disabled={!filtersActive}>{tr('pluginManager.filterReset')}</button>
|
||||||
|
</div>
|
||||||
|
<div class="filter-grid">
|
||||||
|
<label class="filter-select-label">
|
||||||
|
<span>{tr('pluginManager.filterState')}</span>
|
||||||
|
<select class="filter-select" data-plugin-filter="status" bind:value={statusFilter}>
|
||||||
|
<option value="all">{tr('pluginManager.filterAll')}</option>
|
||||||
|
<option value="enabled">{tr('pluginManager.filterEnabled')}</option>
|
||||||
|
<option value="disabled">{tr('pluginManager.filterDisabled')}</option>
|
||||||
|
<option value="failed">{tr('pluginManager.filterFailed')}</option>
|
||||||
|
<option value="degraded">{tr('pluginManager.filterDegraded')}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="filter-select-label">
|
||||||
|
<span>{tr('pluginManager.filterSettings')}</span>
|
||||||
|
<select class="filter-select" data-plugin-filter="settings" bind:value={settingsFilter}>
|
||||||
|
<option value="all">{tr('pluginManager.filterAll')}</option>
|
||||||
|
<option value="with">{tr('pluginManager.filterWithSettings')}</option>
|
||||||
|
<option value="without">{tr('pluginManager.filterWithoutSettings')}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{#if hasReliableSourceMetadata}
|
||||||
|
<label class="filter-select-label">
|
||||||
|
<span>{tr('pluginManager.filterSource')}</span>
|
||||||
|
<select class="filter-select" data-plugin-filter="source" bind:value={sourceFilter}>
|
||||||
|
<option value="all">{tr('pluginManager.filterAll')}</option>
|
||||||
|
<option value="official">{tr('pluginManager.filterOfficial')}</option>
|
||||||
|
<option value="third-party">{tr('pluginManager.filterThirdParty')}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if filterPermissions.length > 0}
|
||||||
|
<fieldset class="filter-options">
|
||||||
|
<legend>{tr('pluginManager.filterPermissions')}</legend>
|
||||||
|
<div class="filter-option-list">
|
||||||
|
{#each filterPermissions as permission}
|
||||||
|
<label><input data-plugin-filter-permission={permission} type="checkbox" bind:group={permissionFilters} value={permission} /> <code>{permission}</code></label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if filterCapabilities.length > 0}
|
||||||
|
<fieldset class="filter-options">
|
||||||
|
<legend>{tr('pluginManager.filterCapabilities')}</legend>
|
||||||
|
<div class="filter-option-list">
|
||||||
|
{#each filterCapabilities as capability}
|
||||||
|
<label><input data-plugin-filter-capability={capability} type="checkbox" bind:group={capabilityFilters} value={capability} /> <code>{capability}</code></label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
{#if plugins.length === 0 && missingInstalled.length === 0}
|
{#if plugins.length === 0 && missingInstalled.length === 0}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<div class="empty-icon">
|
<div class="empty-icon">
|
||||||
@@ -360,9 +497,13 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<p class="hint">{tr('pluginManager.installHint')}</p>
|
<p class="hint">{tr('pluginManager.installHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{:else if visiblePlugins.length === 0}
|
||||||
|
<div class="empty filter-empty" data-plugin-filter-empty>
|
||||||
|
<p>{tr('pluginManager.filterEmpty')}</p>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="plugin-list">
|
<div class="plugin-list">
|
||||||
{#each plugins as p}
|
{#each visiblePlugins as p}
|
||||||
<PluginCard {p} {capabilities} {permissions} {contributions} {vaultOpen} {actionFeedback} settingsPanels={(contributions.settingsPanels || []).filter(sp => sp.pluginId === p.manifest?.id)} onEnable={enablePlugin} onDisable={disablePlugin} />
|
<PluginCard {p} {capabilities} {permissions} {contributions} {vaultOpen} {actionFeedback} settingsPanels={(contributions.settingsPanels || []).filter(sp => sp.pluginId === p.manifest?.id)} onEnable={enablePlugin} onDisable={disablePlugin} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -577,6 +718,105 @@
|
|||||||
color: #f4f7fb;
|
color: #f4f7fb;
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
}
|
}
|
||||||
|
.plugin-filters {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #0f3460;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #121a2c;
|
||||||
|
}
|
||||||
|
.filter-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.filter-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #e0e0f0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.filter-header p {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
color: #a0a0b8;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.filter-reset {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0.35rem 0.65rem;
|
||||||
|
border: 1px solid #533483;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #16213e;
|
||||||
|
color: #e0e0e0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.filter-reset:hover:not(:disabled) { background: #0f3460; }
|
||||||
|
.filter-reset:disabled { cursor: not-allowed; opacity: 0.5; }
|
||||||
|
.filter-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
.filter-select-label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.28rem;
|
||||||
|
min-width: 10rem;
|
||||||
|
color: #a0a0b8;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.filter-select {
|
||||||
|
min-height: 2rem;
|
||||||
|
padding: 0.3rem 1.9rem 0.3rem 0.55rem;
|
||||||
|
border: 1px solid #0f3460;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #16213e;
|
||||||
|
background-image: linear-gradient(45deg, transparent 50%, #a0a0b8 50%), linear-gradient(135deg, #a0a0b8 50%, transparent 50%);
|
||||||
|
background-position: calc(100% - 14px) 50%, calc(100% - 9px) 50%;
|
||||||
|
background-size: 5px 5px, 5px 5px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
color: #e0e0e0;
|
||||||
|
font: inherit;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
.filter-select option { background: #16213e; color: #e0e0e0; }
|
||||||
|
.filter-select:focus { outline: 2px solid #533483; outline-offset: 1px; }
|
||||||
|
.filter-options {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
padding: 0.55rem 0.65rem 0.65rem;
|
||||||
|
border: 1px solid rgba(15, 52, 96, 0.85);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.filter-options legend {
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
color: #a0a0b8;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.filter-option-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem 0.75rem;
|
||||||
|
}
|
||||||
|
.filter-option-list label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.28rem;
|
||||||
|
min-width: 0;
|
||||||
|
color: #c6c6d8;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.filter-option-list input { accent-color: #6c4fa3; }
|
||||||
|
.filter-option-list code {
|
||||||
|
max-width: min(31rem, 72vw);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.filter-empty { margin-bottom: 1.5rem; }
|
||||||
.empty {
|
.empty {
|
||||||
padding: 2rem; text-align: center; color: #a0a0b8;
|
padding: 2rem; text-align: center; color: #a0a0b8;
|
||||||
background: #16213e; border-radius: 8px; border: 1px dashed #0f3460;
|
background: #16213e; border-radius: 8px; border: 1px dashed #0f3460;
|
||||||
@@ -585,7 +825,6 @@
|
|||||||
.hint { font-size: 0.85rem; margin-top: 0.5rem; opacity: 0.7; }
|
.hint { font-size: 0.85rem; margin-top: 0.5rem; opacity: 0.7; }
|
||||||
.hint-list { list-style: none; padding: 0; margin: 0.5rem 0; font-size: 0.8rem; opacity: 0.7; }
|
.hint-list { list-style: none; padding: 0; margin: 0.5rem 0; font-size: 0.8rem; opacity: 0.7; }
|
||||||
.hint-list li { margin: 0.25rem 0; }
|
.hint-list li { margin: 0.25rem 0; }
|
||||||
.hint code { background: #0f3460; padding: 0.1rem 0.3rem; border-radius: 3px; }
|
|
||||||
.plugin-list { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1.5rem; min-width: 0; }
|
.plugin-list { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1.5rem; min-width: 0; }
|
||||||
|
|
||||||
.missing-section { margin-bottom: 1.5rem; }
|
.missing-section { margin-bottom: 1.5rem; }
|
||||||
@@ -652,6 +891,19 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-header {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-reset {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select-label {
|
||||||
|
flex: 1 1 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
width: min(880px, calc(100vw - 2rem));
|
width: min(880px, calc(100vw - 2rem));
|
||||||
height: min(680px, calc(100vh - 2rem));
|
height: min(680px, calc(100vh - 2rem));
|
||||||
|
|||||||
@@ -195,7 +195,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function todoTitle(item) {
|
function todoTitle(item) {
|
||||||
return String(item?.title || item?.name || 'Untitled todo').trim() || 'Untitled todo';
|
return String(item?.title || item?.name || tr('overview.untitledTodo')).trim() || tr('overview.untitledTodo');
|
||||||
}
|
}
|
||||||
|
|
||||||
function todoAttentionState(item) {
|
function todoAttentionState(item) {
|
||||||
@@ -203,9 +203,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 'Reminder due';
|
if (reminderAt && reminderAt <= now) return tr('overview.todo.reminderDue');
|
||||||
if (dueAt && dueAt < now) return 'Overdue';
|
if (dueAt && dueAt < now) return tr('overview.todo.overdue');
|
||||||
if (dueAt && dueAt <= now + 3 * 24 * 60 * 60 * 1000) return 'Due soon';
|
if (dueAt && dueAt <= now + 3 * 24 * 60 * 60 * 1000) return tr('overview.todo.dueSoon');
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,17 +238,12 @@
|
|||||||
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 || 'item');
|
return title || titleFromPath(path) || String(item?.summary || item?.activityId || tr('overview.item'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function looksLikePath(value) {
|
function looksLikePath(value) {
|
||||||
@@ -258,11 +253,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function captureTitle(capture) {
|
function captureTitle(capture) {
|
||||||
return capture?.title || capture?.fileName || capture?.url || capture?.captureId || 'Untitled capture';
|
return capture?.title || capture?.fileName || capture?.url || capture?.captureId || tr('overview.untitledCapture');
|
||||||
}
|
}
|
||||||
|
|
||||||
function journalTitle(entry) {
|
function journalTitle(entry) {
|
||||||
return entry?.title || entry?.summary || entry?.date || entry?.entryId || 'Journal entry';
|
return entry?.title || entry?.summary || entry?.date || entry?.entryId || tr('overview.untitledJournal');
|
||||||
}
|
}
|
||||||
|
|
||||||
function itemTimeLabel(item) {
|
function itemTimeLabel(item) {
|
||||||
@@ -312,24 +307,33 @@
|
|||||||
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 `Edited note ${quoted(name)}`;
|
if (type === 'note.saved' || type === 'note.edited') return tr('overview.event.noteEdited', { name });
|
||||||
if (type === 'note.opened') return `Opened note ${quoted(name)}`;
|
if (type === 'note.opened') return tr('overview.event.noteOpened', { name });
|
||||||
if (type === 'note.created') return `Created note ${quoted(name)}`;
|
if (type === 'note.created') return tr('overview.event.noteCreated', { name });
|
||||||
if (type === 'file.opened') return `Opened file ${quoted(name)}`;
|
if (type === 'file.opened') return tr('overview.event.fileOpened', { name });
|
||||||
if (type === 'file.changed') return `Changed file ${quoted(name)}`;
|
if (type === 'file.changed') return tr('overview.event.fileChanged', { name });
|
||||||
if (type === 'file.created') return `Created file ${quoted(name)}`;
|
if (type === 'file.created') return tr('overview.event.fileCreated', { name });
|
||||||
if (type === 'file.deleted' || type === 'file.trashed') return `Removed file ${quoted(name)}`;
|
if (type === 'file.deleted' || type === 'file.trashed') return tr('overview.event.fileRemoved', { name });
|
||||||
if (type === 'browser.capture.page') return `Captured page ${quoted(name)}`;
|
if (type === 'browser.capture.page') return tr('overview.event.capturePage', { name });
|
||||||
if (type === 'browser.capture.selection') return `Captured selection ${quoted(name)}`;
|
if (type === 'browser.capture.selection') return tr('overview.event.captureSelection', { name });
|
||||||
if (type === 'browser.capture.link') return `Captured link ${quoted(name)}`;
|
if (type === 'browser.capture.link') return tr('overview.event.captureLink', { name });
|
||||||
if (type === 'browser.capture.file') return `Captured file ${quoted(name)}`;
|
if (type === 'browser.capture.file') return tr('overview.event.captureFile', { name });
|
||||||
if (type === 'browser.capture.converted') return `Converted capture ${quoted(name)}`;
|
if (type === 'browser.capture.converted') return tr('overview.event.captureConverted', { name });
|
||||||
if (type === 'journal.entry.added' || type === 'worklog.entry.added') return `Added journal entry ${quoted(name)}`;
|
if (type === 'journal.entry.added' || type === 'worklog.entry.added') return tr('overview.event.journalAdded', { name });
|
||||||
if (type === 'action.started') return 'Work session detected';
|
if (type === 'action.started') return tr('overview.event.workSessionDetected');
|
||||||
if (type === 'workspace.opened') return tr('overview.event.workspaceOpened');
|
if (type === 'workspace.opened') return tr('overview.event.workspaceOpened');
|
||||||
return item?.title || item?.summary || tr('overview.event.activity');
|
return item?.title || item?.summary || tr('overview.event.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) {
|
||||||
if (category === 'notes') return { kind: 'notes', label: tr('overview.openNotes') };
|
if (category === 'notes') return { kind: 'notes', label: tr('overview.openNotes') };
|
||||||
if (category === 'files') return { kind: 'files', label: tr('overview.openFiles') };
|
if (category === 'files') return { kind: 'files', label: tr('overview.openFiles') };
|
||||||
@@ -348,7 +352,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)}${item.sourcePluginId ? ' · ' + item.sourcePluginId.replace('verstak.', '') : ''}`,
|
meta: itemTimeLabel(item),
|
||||||
time: timeValue(item),
|
time: timeValue(item),
|
||||||
absolute: absoluteTime(timeValue(item)),
|
absolute: absoluteTime(timeValue(item)),
|
||||||
actionKind: action.kind,
|
actionKind: action.kind,
|
||||||
@@ -358,8 +362,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: `Captured ${item.kind || 'item'} ${quoted(captureTitle(item))}`,
|
title: tr('overview.event.capture', { kind: captureKindLabel(item), title: captureTitle(item) }),
|
||||||
meta: `${itemTimeLabel(item)} · ${item.domain || item.url || 'Browser capture'}`,
|
meta: `${itemTimeLabel(item)} · ${item.domain || item.url || tr('overview.browserCapture')}`,
|
||||||
time: timeValue(item),
|
time: timeValue(item),
|
||||||
absolute: absoluteTime(timeValue(item)),
|
absolute: absoluteTime(timeValue(item)),
|
||||||
actionKind: 'browser-inbox',
|
actionKind: 'browser-inbox',
|
||||||
@@ -368,8 +372,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: `Added journal entry ${quoted(journalTitle(item))}`,
|
title: tr('overview.event.journalAdded', { name: journalTitle(item) }),
|
||||||
meta: `${itemTimeLabel(item)}${item.minutes ? ' · ' + item.minutes + ' min' : ''}`,
|
meta: `${itemTimeLabel(item)}${item.minutes ? ' · ' + tr('overview.minutes', { count: item.minutes }) : ''}`,
|
||||||
time: timeValue(item),
|
time: timeValue(item),
|
||||||
absolute: absoluteTime(timeValue(item)),
|
absolute: absoluteTime(timeValue(item)),
|
||||||
actionKind: 'journal',
|
actionKind: 'journal',
|
||||||
@@ -409,8 +413,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: `Todo ${quoted(todoTitle(item))}`,
|
title: tr('overview.event.todo', { title: todoTitle(item) }),
|
||||||
meta: `${todoAttentionState(item)}${item.dueAt ? ` · Due ${item.dueAt}` : ''}`,
|
meta: `${todoAttentionState(item)}${item.dueAt ? ` · ${tr('overview.todo.due', { date: item.dueAt })}` : ''}`,
|
||||||
time: todoTimeValue(item),
|
time: todoTimeValue(item),
|
||||||
absolute: absoluteTime(todoTimeValue(item)),
|
absolute: absoluteTime(todoTimeValue(item)),
|
||||||
actionKind: 'todo',
|
actionKind: 'todo',
|
||||||
@@ -419,8 +423,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: `Review capture ${quoted(captureTitle(item))}`,
|
title: tr('overview.event.reviewCapture', { title: captureTitle(item) }),
|
||||||
meta: `${itemTimeLabel(item)} · ${item.domain || item.kind || 'Browser capture'}`,
|
meta: `${itemTimeLabel(item)} · ${item.domain || captureKindLabel(item) || tr('overview.browserCapture')}`,
|
||||||
time: timeValue(item),
|
time: timeValue(item),
|
||||||
absolute: absoluteTime(timeValue(item)),
|
absolute: absoluteTime(timeValue(item)),
|
||||||
actionKind: 'browser-inbox',
|
actionKind: 'browser-inbox',
|
||||||
@@ -435,7 +439,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: `Continue journal entry ${quoted(journalTitle(item))}`,
|
title: tr('overview.event.continueJournal', { title: journalTitle(item) }),
|
||||||
meta: itemTimeLabel(item),
|
meta: itemTimeLabel(item),
|
||||||
time: timeValue(item),
|
time: timeValue(item),
|
||||||
absolute: absoluteTime(timeValue(item)),
|
absolute: absoluteTime(timeValue(item)),
|
||||||
@@ -449,14 +453,18 @@
|
|||||||
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: `${item.kind || 'capture'} · ${itemTimeLabel(item)}`,
|
meta: `${captureKindLabel(item)} · ${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: 'Possible journal entry',
|
title: tr('overview.event.possibleJournalEntry'),
|
||||||
meta: `Workspace: ${item.workspaceRootPath || workspaceRootPath || 'Unknown'} · ${item.estimatedMinutes || 0} min · ${item.activityCount || (item.activityIds || []).length || 0} activities`,
|
meta: tr('overview.candidateMeta', {
|
||||||
|
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 },
|
||||||
@@ -464,7 +472,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 ? ` · Due ${item.dueAt}` : ''}`,
|
meta: `${todoAttentionState(item)}${item.dueAt ? ` · ${tr('overview.todo.due', { date: item.dueAt })}` : ''}`,
|
||||||
actionKind: 'todo',
|
actionKind: 'todo',
|
||||||
actionLabel: tr('overview.openTodos'),
|
actionLabel: tr('overview.openTodos'),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
$: requestContext = openedResource?.request?.context?.notesMode || openedResource?.request?.context?.isInsideNotesFolder
|
$: requestContext = openedResource?.request?.context?.notesMode || openedResource?.request?.context?.isInsideNotesFolder
|
||||||
? 'notes-markdown'
|
? 'notes-markdown'
|
||||||
: ((openedResource?.request?.extension === '.md' || openedResource?.request?.extension === '.markdown') ? 'generic-markdown' : 'generic-text');
|
: ((openedResource?.request?.extension === '.md' || openedResource?.request?.extension === '.markdown') ? 'generic-markdown' : 'generic-text');
|
||||||
|
$: resourceTitle = resourceDisplayTitle(resourcePath, requestContext);
|
||||||
$: componentProps = openedResource || {};
|
$: componentProps = openedResource || {};
|
||||||
$: mountKey = [
|
$: mountKey = [
|
||||||
providerPluginId,
|
providerPluginId,
|
||||||
@@ -31,6 +32,11 @@
|
|||||||
requestContext,
|
requestContext,
|
||||||
].join(':');
|
].join(':');
|
||||||
|
|
||||||
|
function resourceDisplayTitle(path, context) {
|
||||||
|
const fileName = String(path || '').split('/').filter(Boolean).pop() || String(path || '');
|
||||||
|
return context === 'notes-markdown' ? fileName.replace(/\.(md|markdown)$/i, '') : fileName;
|
||||||
|
}
|
||||||
|
|
||||||
function closeWorkbench() {
|
function closeWorkbench() {
|
||||||
window.dispatchEvent(new CustomEvent('verstak:close-workbench', { cancelable: true }));
|
window.dispatchEvent(new CustomEvent('verstak:close-workbench', { cancelable: true }));
|
||||||
}
|
}
|
||||||
@@ -41,7 +47,7 @@
|
|||||||
<div class="workbench-host vt-page">
|
<div class="workbench-host vt-page">
|
||||||
{#if openedResource?.status === 'no-provider'}
|
{#if openedResource?.status === 'no-provider'}
|
||||||
<div class="workbench-header vt-page-header">
|
<div class="workbench-header vt-page-header">
|
||||||
<span class="workbench-title vt-page-title">{resourcePath}</span>
|
<span class="workbench-title vt-page-title">{resourceTitle}</span>
|
||||||
<span class="workbench-provider vt-badge">{tr('workbench.noProvider')}</span>
|
<span class="workbench-provider vt-badge">{tr('workbench.noProvider')}</span>
|
||||||
<button class="close-btn btn-ghost btn-icon" type="button" title={tr('common.close')} aria-label={tr('common.close')} on:click={closeWorkbench}>
|
<button class="close-btn btn-ghost btn-icon" type="button" title={tr('common.close')} aria-label={tr('common.close')} on:click={closeWorkbench}>
|
||||||
<Icon name="x" size={18} />
|
<Icon name="x" size={18} />
|
||||||
@@ -49,11 +55,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="workbench-empty no-provider vt-empty-state" data-workbench-status="no-provider">
|
<div class="workbench-empty no-provider vt-empty-state" data-workbench-status="no-provider">
|
||||||
<p class="vt-empty-title">{tr('workbench.noViewer')}</p>
|
<p class="vt-empty-title">{tr('workbench.noViewer')}</p>
|
||||||
<p class="workbench-meta">{requestMode} · {requestContext}</p>
|
{#if debug.isEnabled()}
|
||||||
|
<p class="workbench-meta">{requestMode} · {requestContext}</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if openedResource}
|
{:else if openedResource}
|
||||||
<div class="workbench-header vt-page-header">
|
<div class="workbench-header vt-page-header">
|
||||||
<span class="workbench-title vt-page-title">{resourcePath}</span>
|
<span class="workbench-title vt-page-title">{resourceTitle}</span>
|
||||||
{#if debug.isEnabled() && providerId}
|
{#if debug.isEnabled() && providerId}
|
||||||
<span class="workbench-provider vt-badge accent" data-debug-provider>{providerId}</span>
|
<span class="workbench-provider vt-badge accent" data-debug-provider>{providerId}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -364,7 +364,7 @@
|
|||||||
if (e.key === 'Escape') cancelRename();
|
if (e.key === 'Escape') cancelRename();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button class="wt-btn wt-btn-small wt-always" on:click={() => commitRename(workspace)} title={tr('workspaceTree.saveRename')} type="button" disabled={busyId === id}>OK</button>
|
<button class="wt-btn wt-btn-small wt-always" on:click={() => commitRename(workspace)} title={tr('workspaceTree.saveRename')} type="button" disabled={busyId === id}>{tr('common.save')}</button>
|
||||||
<button class="wt-btn wt-btn-small wt-always" on:click={cancelRename} title={tr('common.cancel')} type="button" disabled={busyId === id}>{tr('common.cancel')}</button>
|
<button class="wt-btn wt-btn-small wt-always" on:click={cancelRename} title={tr('common.cancel')} type="button" disabled={busyId === id}>{tr('common.cancel')}</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="wt-label" on:click={() => selectWorkspace(workspace)} type="button">{id}</button>
|
<button class="wt-label" on:click={() => selectWorkspace(workspace)} type="button">{id}</button>
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
|||||||
name: 'Notes',
|
name: 'Notes',
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
apiVersion: '0.1.0',
|
apiVersion: '0.1.0',
|
||||||
description: 'Workspace-scoped notes manager.',
|
description: 'Deal-scoped notes manager.',
|
||||||
source: 'official',
|
source: 'official',
|
||||||
icon: 'edit',
|
icon: 'edit',
|
||||||
provides: ['verstak/notes/v1'],
|
provides: ['verstak/notes/v1'],
|
||||||
@@ -197,7 +197,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
|||||||
name: 'Activity',
|
name: 'Activity',
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
apiVersion: '0.1.0',
|
apiVersion: '0.1.0',
|
||||||
description: 'Workspace-scoped activity log for public plugin events.',
|
description: 'Deal-scoped activity log for public plugin events.',
|
||||||
source: 'official',
|
source: 'official',
|
||||||
icon: 'activity',
|
icon: 'activity',
|
||||||
provides: ['activity.log', 'activity.provider', 'activity.reconstruction'],
|
provides: ['activity.log', 'activity.provider', 'activity.reconstruction'],
|
||||||
@@ -221,7 +221,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
|||||||
name: 'Journal',
|
name: 'Journal',
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
apiVersion: '0.1.0',
|
apiVersion: '0.1.0',
|
||||||
description: 'Workspace-scoped journal with user-authored entries and optional Activity links.',
|
description: 'Deal-scoped journal with user-authored entries and optional Activity links.',
|
||||||
source: 'official',
|
source: 'official',
|
||||||
icon: 'book-open',
|
icon: 'book-open',
|
||||||
provides: ['worklog', 'journal', 'report.worklog'],
|
provides: ['worklog', 'journal', 'report.worklog'],
|
||||||
@@ -271,7 +271,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
|||||||
name: 'Search',
|
name: 'Search',
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
apiVersion: '0.1.0',
|
apiVersion: '0.1.0',
|
||||||
description: 'Workspace-scoped vault text search provider.',
|
description: 'Deal-scoped vault text search provider.',
|
||||||
source: 'official',
|
source: 'official',
|
||||||
icon: 'search',
|
icon: 'search',
|
||||||
provides: ['verstak/search/v1', 'search.provider'],
|
provides: ['verstak/search/v1', 'search.provider'],
|
||||||
@@ -438,7 +438,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
|||||||
{
|
{
|
||||||
id: 'default',
|
id: 'default',
|
||||||
name: 'General',
|
name: 'General',
|
||||||
description: 'Everyday workspace with notes, files, journal, activity, and browser captures.',
|
description: 'Everyday Deal with notes, files, journal, activity, and browser captures.',
|
||||||
version: 2,
|
version: 2,
|
||||||
workspaceTools: ['verstak.notes', 'verstak.files', 'verstak.journal', 'verstak.activity', 'verstak.browser-inbox'],
|
workspaceTools: ['verstak.notes', 'verstak.files', 'verstak.journal', 'verstak.activity', 'verstak.browser-inbox'],
|
||||||
folders: ['Notes', 'Files'],
|
folders: ['Notes', 'Files'],
|
||||||
@@ -456,7 +456,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
|||||||
{
|
{
|
||||||
id: 'writing',
|
id: 'writing',
|
||||||
name: 'Writing',
|
name: 'Writing',
|
||||||
description: 'Focused notes, files, and journal workspace for documentation and writing.',
|
description: 'Focused notes, files, and journal Deal for documentation and writing.',
|
||||||
version: 1,
|
version: 1,
|
||||||
workspaceTools: ['verstak.notes', 'verstak.files', 'verstak.journal'],
|
workspaceTools: ['verstak.notes', 'verstak.files', 'verstak.journal'],
|
||||||
folders: ['Notes', 'Files'],
|
folders: ['Notes', 'Files'],
|
||||||
@@ -465,7 +465,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
|||||||
{
|
{
|
||||||
id: 'admin',
|
id: 'admin',
|
||||||
name: 'Admin',
|
name: 'Admin',
|
||||||
description: 'Infrastructure workspace with secrets, todos, and journal.',
|
description: 'Infrastructure Deal with secrets, todos, and journal.',
|
||||||
version: 1,
|
version: 1,
|
||||||
workspaceTools: ['verstak.notes', 'verstak.files', 'verstak.secrets', 'verstak.todo', 'verstak.journal'],
|
workspaceTools: ['verstak.notes', 'verstak.files', 'verstak.secrets', 'verstak.todo', 'verstak.journal'],
|
||||||
folders: ['Notes', 'Files', 'Secrets'],
|
folders: ['Notes', 'Files', 'Secrets'],
|
||||||
@@ -474,7 +474,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
|||||||
{
|
{
|
||||||
id: 'minimal',
|
id: 'minimal',
|
||||||
name: 'Minimal',
|
name: 'Minimal',
|
||||||
description: 'Only notes and files for a lightweight workspace.',
|
description: 'Only notes and files for a lightweight Deal.',
|
||||||
version: 1,
|
version: 1,
|
||||||
workspaceTools: ['verstak.notes', 'verstak.files'],
|
workspaceTools: ['verstak.notes', 'verstak.files'],
|
||||||
folders: ['Notes', 'Files'],
|
folders: ['Notes', 'Files'],
|
||||||
@@ -570,7 +570,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
|||||||
name: 'Todos',
|
name: 'Todos',
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
apiVersion: '0.1.0',
|
apiVersion: '0.1.0',
|
||||||
description: 'Global and workspace todo tracking with due and reminder metadata.',
|
description: 'Global and Deal-scoped todo tracking with due and reminder metadata.',
|
||||||
source: 'official',
|
source: 'official',
|
||||||
icon: 'list-todo',
|
icon: 'list-todo',
|
||||||
provides: ['todo.list', 'todo.workspace'],
|
provides: ['todo.list', 'todo.workspace'],
|
||||||
@@ -598,7 +598,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
|||||||
name: 'Secrets',
|
name: 'Secrets',
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
apiVersion: '0.1.0',
|
apiVersion: '0.1.0',
|
||||||
description: 'Encrypted global and workspace-scoped secret manager.',
|
description: 'Encrypted global and Deal-scoped secret manager.',
|
||||||
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'],
|
||||||
@@ -4104,7 +4104,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
|||||||
name: 'Notes',
|
name: 'Notes',
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
apiVersion: '0.1.0',
|
apiVersion: '0.1.0',
|
||||||
description: 'Workspace-scoped notes manager.',
|
description: 'Deal-scoped notes manager.',
|
||||||
source: 'official',
|
source: 'official',
|
||||||
icon: 'edit',
|
icon: 'edit',
|
||||||
provides: ['verstak/notes/v1'],
|
provides: ['verstak/notes/v1'],
|
||||||
@@ -4151,7 +4151,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
|||||||
name: 'Activity',
|
name: 'Activity',
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
apiVersion: '0.1.0',
|
apiVersion: '0.1.0',
|
||||||
description: 'Workspace-scoped activity log for public plugin events.',
|
description: 'Deal-scoped activity log for public plugin events.',
|
||||||
source: 'official',
|
source: 'official',
|
||||||
icon: 'activity',
|
icon: 'activity',
|
||||||
provides: ['activity.log', 'activity.provider', 'activity.reconstruction'],
|
provides: ['activity.log', 'activity.provider', 'activity.reconstruction'],
|
||||||
@@ -4175,7 +4175,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
|||||||
name: 'Journal',
|
name: 'Journal',
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
apiVersion: '0.1.0',
|
apiVersion: '0.1.0',
|
||||||
description: 'Workspace-scoped journal with user-authored entries and optional Activity links.',
|
description: 'Deal-scoped journal with user-authored entries and optional Activity links.',
|
||||||
source: 'official',
|
source: 'official',
|
||||||
icon: 'book-open',
|
icon: 'book-open',
|
||||||
provides: ['worklog', 'journal', 'report.worklog'],
|
provides: ['worklog', 'journal', 'report.worklog'],
|
||||||
@@ -4225,7 +4225,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
|||||||
name: 'Search',
|
name: 'Search',
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
apiVersion: '0.1.0',
|
apiVersion: '0.1.0',
|
||||||
description: 'Workspace-scoped vault text search provider.',
|
description: 'Deal-scoped vault text search provider.',
|
||||||
source: 'official',
|
source: 'official',
|
||||||
icon: 'search',
|
icon: 'search',
|
||||||
provides: ['verstak/search/v1', 'search.provider'],
|
provides: ['verstak/search/v1', 'search.provider'],
|
||||||
@@ -4276,8 +4276,9 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
|||||||
getPluginState: function (pluginId) {
|
getPluginState: function (pluginId) {
|
||||||
return pluginStates[pluginId] ? Object.assign({}, pluginStates[pluginId]) : null;
|
return pluginStates[pluginId] ? Object.assign({}, pluginStates[pluginId]) : null;
|
||||||
},
|
},
|
||||||
addSyntheticPlugins: function (count) {
|
addSyntheticPlugins: function (count, source) {
|
||||||
var total = Number(count || 0);
|
var total = Number(count || 0);
|
||||||
|
var pluginSource = source === 'official' || source === 'local' || source === 'third-party' ? source : 'third-party';
|
||||||
for (var i = 1; i <= total; i++) {
|
for (var i = 1; i <= total; i++) {
|
||||||
var id = 'verstak.synthetic-layout-' + String(i).padStart(2, '0');
|
var id = 'verstak.synthetic-layout-' + String(i).padStart(2, '0');
|
||||||
pluginStates[id] = {
|
pluginStates[id] = {
|
||||||
@@ -4290,7 +4291,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
|||||||
version: '0.0.' + i,
|
version: '0.0.' + i,
|
||||||
apiVersion: '0.1.0',
|
apiVersion: '0.1.0',
|
||||||
description: 'Synthetic plugin used by frontend layout tests.',
|
description: 'Synthetic plugin used by frontend layout tests.',
|
||||||
source: 'test',
|
source: pluginSource,
|
||||||
provides: ['verstak/synthetic-layout-' + i + '/v1'],
|
provides: ['verstak/synthetic-layout-' + i + '/v1'],
|
||||||
requires: [],
|
requires: [],
|
||||||
optionalRequires: [],
|
optionalRequires: [],
|
||||||
@@ -4310,7 +4311,7 @@ import journalSource from '../../../../../verstak-official-plugins/plugins/journ
|
|||||||
vaultPluginState.enabledPlugins.push(id);
|
vaultPluginState.enabledPlugins.push(id);
|
||||||
}
|
}
|
||||||
if (!vaultPluginState.desiredPlugins.some(function (p) { return p.id === id; })) {
|
if (!vaultPluginState.desiredPlugins.some(function (p) { return p.id === id; })) {
|
||||||
vaultPluginState.desiredPlugins.push({ id: id, version: '0.0.' + i, source: 'test' });
|
vaultPluginState.desiredPlugins.push({ id: id, version: '0.0.' + i, source: pluginSource });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -78,9 +78,10 @@ type WindowState struct {
|
|||||||
|
|
||||||
// Manager provides thread-safe access to app settings.
|
// Manager provides thread-safe access to app settings.
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
config *Config
|
config *Config
|
||||||
configPath string
|
configPath string
|
||||||
|
onLanguageChanged func(string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultConfigPath returns the default path for app settings.
|
// DefaultConfigPath returns the default path for app settings.
|
||||||
@@ -230,13 +231,32 @@ func (m *Manager) UpdateLanguage(language string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
|
||||||
if m.config == nil {
|
if m.config == nil {
|
||||||
m.config = defaultConfig()
|
m.config = defaultConfig()
|
||||||
}
|
}
|
||||||
m.config.Language = language
|
m.config.Language = language
|
||||||
m.config.LastOpenedAt = time.Now().UTC().Format(time.RFC3339)
|
m.config.LastOpenedAt = time.Now().UTC().Format(time.RFC3339)
|
||||||
return m.saveLocked()
|
err := m.saveLocked()
|
||||||
|
handler := m.onLanguageChanged
|
||||||
|
m.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if handler != nil {
|
||||||
|
handler(language)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLanguageChangedHandler registers the shell callback invoked after a
|
||||||
|
// language preference has been durably updated.
|
||||||
|
func (m *Manager) SetLanguageChangedHandler(handler func(string)) {
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.onLanguageChanged = handler
|
||||||
|
m.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSync replaces sync settings without changing unrelated app settings.
|
// UpdateSync replaces sync settings without changing unrelated app settings.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package appsettings
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -74,6 +75,34 @@ func TestUpdateLanguagePersistsAndPreservesUnrelatedSettings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateLanguageNotifiesTheDesktopShellAfterPersisting(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
manager := NewManager(path)
|
||||||
|
if err := manager.Load(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var received []string
|
||||||
|
manager.SetLanguageChangedHandler(func(language string) {
|
||||||
|
received = append(received, language)
|
||||||
|
})
|
||||||
|
if err := manager.UpdateLanguage(LanguageRussian); err != nil {
|
||||||
|
t.Fatalf("UpdateLanguage: %v", err)
|
||||||
|
}
|
||||||
|
if got := manager.Get().Language; got != LanguageRussian {
|
||||||
|
t.Fatalf("persisted language = %q, want %q", got, LanguageRussian)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(received, []string{LanguageRussian}) {
|
||||||
|
t.Fatalf("language notifications = %#v, want [%q]", received, LanguageRussian)
|
||||||
|
}
|
||||||
|
if err := manager.UpdateLanguage("de"); err == nil {
|
||||||
|
t.Fatal("UpdateLanguage(de) succeeded, want validation failure")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(received, []string{LanguageRussian}) {
|
||||||
|
t.Fatalf("language notification fired after rejected update: %#v", received)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoad_CorruptConfig(t *testing.T) {
|
func TestLoad_CorruptConfig(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "config.json")
|
path := filepath.Join(dir, "config.json")
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
// Package tray owns the native tray menu wiring for the desktop shell.
|
// Package tray owns the native tray menu wiring for the desktop shell.
|
||||||
package tray
|
package tray
|
||||||
|
|
||||||
import "sync"
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
// MenuItem exposes click events from a native tray menu item.
|
// MenuItem exposes click events from a native tray menu item.
|
||||||
type MenuItem interface {
|
type MenuItem interface {
|
||||||
Clicked() <-chan struct{}
|
Clicked() <-chan struct{}
|
||||||
|
SetTitle(title string)
|
||||||
|
SetTooltip(tooltip string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backend is the platform tray implementation.
|
// Backend is the platform tray implementation.
|
||||||
@@ -23,11 +28,63 @@ type Actions struct {
|
|||||||
Quit func()
|
Quit func()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Labels contains the user-visible text for one tray menu locale.
|
||||||
|
type Labels struct {
|
||||||
|
ShowTitle string
|
||||||
|
ShowTooltip string
|
||||||
|
QuitTitle string
|
||||||
|
QuitTooltip string
|
||||||
|
}
|
||||||
|
|
||||||
|
func englishLabels() Labels {
|
||||||
|
return Labels{
|
||||||
|
ShowTitle: "Show Verstak",
|
||||||
|
ShowTooltip: "Show the Verstak window",
|
||||||
|
QuitTitle: "Quit",
|
||||||
|
QuitTooltip: "Quit Verstak",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func russianLabels() Labels {
|
||||||
|
return Labels{
|
||||||
|
ShowTitle: "Показать Верстак",
|
||||||
|
ShowTooltip: "Показать окно Верстака",
|
||||||
|
QuitTitle: "Выйти",
|
||||||
|
QuitTooltip: "Завершить Верстак",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LabelsForPreference resolves the saved language preference for native tray UI.
|
||||||
|
// System locale values are intentionally passed in by the shell so this package
|
||||||
|
// stays independent of process environment and is deterministic in tests.
|
||||||
|
func LabelsForPreference(preference string, systemLocales ...string) Labels {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(preference), "ru") {
|
||||||
|
return russianLabels()
|
||||||
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(preference), "system") {
|
||||||
|
for _, locale := range systemLocales {
|
||||||
|
locale = strings.ToLower(strings.TrimSpace(locale))
|
||||||
|
if locale == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(locale, "ru") {
|
||||||
|
return russianLabels()
|
||||||
|
}
|
||||||
|
return englishLabels()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return englishLabels()
|
||||||
|
}
|
||||||
|
|
||||||
// Controller initializes one native tray and routes its menu actions.
|
// Controller initializes one native tray and routes its menu actions.
|
||||||
type Controller struct {
|
type Controller struct {
|
||||||
backend Backend
|
backend Backend
|
||||||
icon []byte
|
icon []byte
|
||||||
start sync.Once
|
start sync.Once
|
||||||
|
mu sync.RWMutex
|
||||||
|
labels Labels
|
||||||
|
show MenuItem
|
||||||
|
quit MenuItem
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a tray controller for one application process.
|
// New creates a tray controller for one application process.
|
||||||
@@ -35,9 +92,22 @@ func New(backend Backend, icon []byte) *Controller {
|
|||||||
return &Controller{
|
return &Controller{
|
||||||
backend: backend,
|
backend: backend,
|
||||||
icon: append([]byte(nil), icon...),
|
icon: append([]byte(nil), icon...),
|
||||||
|
labels: englishLabels(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetLabels updates the current and future native tray menu labels.
|
||||||
|
func (c *Controller) SetLabels(labels Labels) {
|
||||||
|
if c == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.labels = labels
|
||||||
|
show, quit := c.show, c.quit
|
||||||
|
c.mu.Unlock()
|
||||||
|
applyLabels(show, quit, labels)
|
||||||
|
}
|
||||||
|
|
||||||
// Start registers the native tray without taking over the Wails event loop.
|
// Start registers the native tray without taking over the Wails event loop.
|
||||||
func (c *Controller) Start(actions Actions) {
|
func (c *Controller) Start(actions Actions) {
|
||||||
if c == nil || c.backend == nil {
|
if c == nil || c.backend == nil {
|
||||||
@@ -47,8 +117,16 @@ func (c *Controller) Start(actions Actions) {
|
|||||||
c.backend.Register(func() {
|
c.backend.Register(func() {
|
||||||
c.backend.SetIcon(c.icon)
|
c.backend.SetIcon(c.icon)
|
||||||
c.backend.SetTooltip("Verstak")
|
c.backend.SetTooltip("Verstak")
|
||||||
show := c.backend.AddMenuItem("Show Verstak", "Show the Verstak window")
|
c.mu.RLock()
|
||||||
quit := c.backend.AddMenuItem("Quit", "Quit Verstak")
|
labels := c.labels
|
||||||
|
c.mu.RUnlock()
|
||||||
|
show := c.backend.AddMenuItem(labels.ShowTitle, labels.ShowTooltip)
|
||||||
|
quit := c.backend.AddMenuItem(labels.QuitTitle, labels.QuitTooltip)
|
||||||
|
c.mu.Lock()
|
||||||
|
c.show, c.quit = show, quit
|
||||||
|
labels = c.labels
|
||||||
|
c.mu.Unlock()
|
||||||
|
applyLabels(show, quit, labels)
|
||||||
if actions.Show != nil && show != nil {
|
if actions.Show != nil && show != nil {
|
||||||
go routeClicks(show.Clicked(), actions.Show)
|
go routeClicks(show.Clicked(), actions.Show)
|
||||||
}
|
}
|
||||||
@@ -59,6 +137,17 @@ func (c *Controller) Start(actions Actions) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyLabels(show, quit MenuItem, labels Labels) {
|
||||||
|
if show != nil {
|
||||||
|
show.SetTitle(labels.ShowTitle)
|
||||||
|
show.SetTooltip(labels.ShowTooltip)
|
||||||
|
}
|
||||||
|
if quit != nil {
|
||||||
|
quit.SetTitle(labels.QuitTitle)
|
||||||
|
quit.SetTooltip(labels.QuitTooltip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Stop releases the native tray after Wails has begun application shutdown.
|
// Stop releases the native tray after Wails has begun application shutdown.
|
||||||
func (c *Controller) Stop() {
|
func (c *Controller) Stop() {
|
||||||
if c == nil || c.backend == nil {
|
if c == nil || c.backend == nil {
|
||||||
|
|||||||
@@ -7,12 +7,22 @@ import (
|
|||||||
|
|
||||||
type fakeMenuItem struct {
|
type fakeMenuItem struct {
|
||||||
clicked chan struct{}
|
clicked chan struct{}
|
||||||
|
title string
|
||||||
|
tooltip string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *fakeMenuItem) Clicked() <-chan struct{} {
|
func (i *fakeMenuItem) Clicked() <-chan struct{} {
|
||||||
return i.clicked
|
return i.clicked
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (i *fakeMenuItem) SetTitle(title string) {
|
||||||
|
i.title = title
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *fakeMenuItem) SetTooltip(tooltip string) {
|
||||||
|
i.tooltip = tooltip
|
||||||
|
}
|
||||||
|
|
||||||
type fakeBackend struct {
|
type fakeBackend struct {
|
||||||
icon []byte
|
icon []byte
|
||||||
tooltip string
|
tooltip string
|
||||||
@@ -35,7 +45,7 @@ func (b *fakeBackend) SetTooltip(tooltip string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *fakeBackend) AddMenuItem(title, _ string) MenuItem {
|
func (b *fakeBackend) AddMenuItem(title, _ string) MenuItem {
|
||||||
item := &fakeMenuItem{clicked: make(chan struct{}, 1)}
|
item := &fakeMenuItem{clicked: make(chan struct{}, 1), title: title}
|
||||||
b.items[title] = item
|
b.items[title] = item
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
@@ -89,3 +99,38 @@ func TestControllerStopsNativeTrayBackend(t *testing.T) {
|
|||||||
t.Fatalf("backend quit calls = %d, want 1", backend.quitCalls)
|
t.Fatalf("backend quit calls = %d, want 1", backend.quitCalls)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestControllerUsesAndUpdatesLocalizedLabels(t *testing.T) {
|
||||||
|
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
|
||||||
|
controller := New(backend, []byte{1})
|
||||||
|
controller.SetLabels(LabelsForPreference("ru"))
|
||||||
|
controller.Start(Actions{})
|
||||||
|
|
||||||
|
show := backend.items["Показать Верстак"]
|
||||||
|
quit := backend.items["Выйти"]
|
||||||
|
if show == nil || quit == nil {
|
||||||
|
t.Fatalf("Russian tray menu = %#v", backend.items)
|
||||||
|
}
|
||||||
|
if show.tooltip != "Показать окно Верстака" || quit.tooltip != "Завершить Верстак" {
|
||||||
|
t.Fatalf("Russian tray tooltips = show:%q quit:%q", show.tooltip, quit.tooltip)
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.SetLabels(LabelsForPreference("en"))
|
||||||
|
if show.title != "Show Verstak" || quit.title != "Quit" {
|
||||||
|
t.Fatalf("English tray menu after update = show:%q quit:%q", show.title, quit.title)
|
||||||
|
}
|
||||||
|
if show.tooltip != "Show the Verstak window" || quit.tooltip != "Quit Verstak" {
|
||||||
|
t.Fatalf("English tray tooltips after update = show:%q quit:%q", show.tooltip, quit.tooltip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLabelsForSystemRussianLocale(t *testing.T) {
|
||||||
|
labels := LabelsForPreference("system", "", "ru_RU.UTF-8", "en_US.UTF-8")
|
||||||
|
if labels.ShowTitle != "Показать Верстак" || labels.QuitTitle != "Выйти" {
|
||||||
|
t.Fatalf("system Russian labels = %#v", labels)
|
||||||
|
}
|
||||||
|
labels = LabelsForPreference("system", "C.UTF-8", "", "ru_RU.UTF-8")
|
||||||
|
if labels.ShowTitle != "Show Verstak" || labels.QuitTitle != "Quit" {
|
||||||
|
t.Fatalf("higher-priority system locale must win, got %#v", labels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,3 +39,15 @@ func (item systrayMenuItem) Clicked() <-chan struct{} {
|
|||||||
}
|
}
|
||||||
return item.item.ClickedCh
|
return item.item.ClickedCh
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (item systrayMenuItem) SetTitle(title string) {
|
||||||
|
if item.item != nil {
|
||||||
|
item.item.SetTitle(title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (item systrayMenuItem) SetTooltip(tooltip string) {
|
||||||
|
if item.item != nil {
|
||||||
|
item.item.SetTooltip(tooltip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -230,6 +230,13 @@ 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))
|
app.SetNotificationService(notifications.New(vaultService, api.NewNativeNotificationSender(), time.Now))
|
||||||
trayController := tray.New(tray.NewNativeBackend(), tray.DefaultIcon())
|
trayController := tray.New(tray.NewNativeBackend(), tray.DefaultIcon())
|
||||||
|
trayLabels := func(language string) tray.Labels {
|
||||||
|
return tray.LabelsForPreference(language, os.Getenv("LC_ALL"), os.Getenv("LC_MESSAGES"), os.Getenv("LANG"))
|
||||||
|
}
|
||||||
|
trayController.SetLabels(trayLabels(cfg.Language))
|
||||||
|
appSettingsMgr.SetLanguageChangedHandler(func(language string) {
|
||||||
|
trayController.SetLabels(trayLabels(language))
|
||||||
|
})
|
||||||
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 {
|
||||||
|
|||||||