45 Commits
Author SHA1 Message Date
mirivlad 03f5299209 Плагин verstak.workspace-folders: иконки, цвета, drag-and-drop
- Заменяет дерево Дел через workspaceTree contribution
- IconPicker: 1703 иконки Lucide с typeahead-поиском
- ColorPicker: 12 предустановленных цветов
- Drag-and-drop перемещение Дел между папками
- Создание и редактирование папок с иконкой/цветом
- Создание Дела с выбором родительской папки
- Локализация: EN + RU, release notes v0.1.0-alpha.5
2026-07-18 18:54:42 +08:00
mirivlad 1a201bed6d Документация: удалён черновик PLAN_FILES_IMPROVEMENTS, добавлен README.ru.md с шапкой 2026-07-18 18:06:44 +08:00
mirivlad 1bcad23b58 Localize sync server error codes 2026-07-17 05:09:59 +08:00
mirivlad 126d90cd68 Expose remote vault pairing in sync plugin 2026-07-17 04:11:12 +08:00
mirivlad 9904d4a962 docs: add historical release notes 2026-07-15 07:50:17 +08:00
mirivlad 515dec6a7b docs: require human release notes 2026-07-15 07:42:02 +08:00
mirivlad c9c26faf56 fix: localize file type labels 2026-07-15 03:13:48 +08:00
mirivlad 484d599a9f docs: use Deal terminology in official plugins 2026-07-14 23:50:59 +08:00
mirivlad 5ddc63bb8d docs: describe search as Deal scoped 2026-07-14 23:20:08 +08:00
mirivlad f456287c2e fix: use Deal terminology in trash 2026-07-14 23:19:11 +08:00
mirivlad c8b8045da1 fix: use Deal terminology in todos 2026-07-14 23:17:08 +08:00
mirivlad 3a1920d104 fix: localize journal candidate review 2026-07-14 23:12:29 +08:00
mirivlad 713b95b5a8 fix: localize activity event labels 2026-07-14 23:08:00 +08:00
mirivlad b449e9c616 fix: simplify markdown editor chrome 2026-07-14 22:56:40 +08:00
mirivlad 90ad8e8467 fix: protect secrets values and deletion 2026-07-14 22:53:37 +08:00
mirivlad aa7956dec4 fix: localize files context menu 2026-07-14 22:49:59 +08:00
mirivlad 44b39ca051 fix: use modal forms for files 2026-07-14 22:46:14 +08:00
mirivlad c83c15bced fix: use modal forms for notes 2026-07-14 22:41:17 +08:00
mirivlad 99172944a1 fix: localize Browser materials workflow 2026-07-14 22:28:53 +08:00
mirivlad a1b3c31d0d fix: localize plugin operation errors 2026-07-14 22:00:24 +08:00
mirivlad 1f03226378 fix: localize Secrets operation errors 2026-07-14 20:56:25 +08:00
mirivlad bee365ad22 fix: support typed Todo reminder times 2026-07-14 20:50:14 +08:00
mirivlad dfc53f1a5f feat: add global secrets view 2026-07-14 20:41:40 +08:00
mirivlad dd75335727 fix: add entries from global journal 2026-07-14 19:43:21 +08:00
mirivlad f23fb6e993 fix: make Todo reminder time selectable 2026-07-14 19:38:00 +08:00
mirivlad 0cd2c2a6ab test: cover activity deletion confirmation 2026-07-14 19:38:00 +08:00
mirivlad 304620952f fix: confirm activity deletion 2026-07-14 19:19:06 +08:00
mirivlad 112fb4cbec fix: apply markdown blocks to full lines 2026-07-14 19:13:56 +08:00
mirivlad 78a9dd31a3 fix: open requested secrets from workbench 2026-07-14 19:13:56 +08:00
mirivlad eb0f468463 fix: make Todo reminders and selects usable 2026-07-14 08:00:33 +08:00
mirivlad 7687f410c4 feat: schedule Todo native reminders 2026-07-14 02:47:05 +08:00
mirivlad 7e34a73835 build: publish portable plugin releases 2026-07-14 01:58:30 +08:00
mirivlad 47c1db9979 build: add portable plugin archives 2026-07-13 22:29:46 +08:00
mirivlad 22c842d5e3 build: add Windows plugin packages 2026-07-13 21:46:40 +08:00
mirivlad 1a15d57033 build: publish plugin releases to GitHub 2026-07-13 05:01:42 +08:00
mirivlad 483b6ba46e build: ignore generated plugin releases 2026-07-12 21:34:57 +08:00
mirivlad 226464f082 docs: prepare official plugins alpha release 2026-07-12 21:22:24 +08:00
mirivlad e93584c37c feat: stabilize activity sessions for journal review 2026-07-12 20:49:13 +08:00
mirivlad 50c4fc5e68 feat: open browser links and resolve filename collisions 2026-07-12 20:39:09 +08:00
mirivlad b45a56b09d feat: add browser inbox archive flow 2026-07-12 17:43:17 +08:00
mirivlad 0db9f896ce feat: read activity from append-only storage 2026-07-12 17:29:13 +08:00
mirivlad 69a1938ff3 fix: make browser inbox mutations durable 2026-07-11 23:30:43 +08:00
mirivlad fe97c5ddcc fix: fully localize platform diagnostics 2026-07-11 12:56:40 +08:00
mirivlad 1bc5ecfd16 fix: keep plugin localization live and complete 2026-07-11 12:48:15 +08:00
mirivlad 3dd0ec9f1f feat: localize official plugins in Russian 2026-07-11 12:42:37 +08:00
93 changed files with 6227 additions and 1251 deletions
+2
View File
@@ -1,4 +1,6 @@
dist/
dist-windows/
release/
node_modules/
# compiled backend binaries (built from source)
plugins/*/backend/platform-test
+17
View File
@@ -0,0 +1,17 @@
Verstak Official Plugins
Copyright (C) 2026 Verstak contributors
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU Affero General Public License as published by the Free
Software Foundation, either version 3 of the License, or (at your option) any
later version.
This program is distributed in the hope that it will be useful, but WITHOUT
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
details.
The complete license text is available at:
https://www.gnu.org/licenses/agpl-3.0.html
SPDX-License-Identifier: AGPL-3.0-or-later
+85 -3
View File
@@ -1,5 +1,87 @@
# verstak-official-plugins
<div align="center">
Official Verstak plugins monorepo — files, trash, notes, markdown-editor, preview, activity, journal, todos, browser-inbox, search, secrets, templates + shared packages.
# Verstak Official Plugins
`Files` shows live workspace files and folders. Global `Trash` contains deleted items from every workspace; restoring never overwrites an existing item, and permanent deletion is confirmed before it runs.
### The first-party plugin set — Files, Notes, Editor, Activity, Journal and more.
**English** · [Русский](README.ru.md)
[![Release](https://img.shields.io/github/v/release/mirivlad/verstak-official-plugins?include_prereleases\&label=release)](https://github.com/mirivlad/verstak-official-plugins/releases)
![Status](https://img.shields.io/badge/status-alpha-orange)
[![License](https://img.shields.io/github/license/mirivlad/verstak-official-plugins)](LICENSE)
</div>
> **Alpha software.** Build this repository together with compatible
> `verstak-desktop` and `verstak-sdk` checkouts.
Verstak Official Plugins is the first-party plugin set for Verstak Desktop:
Files, Trash, Notes, Default Editor, File Preview, Browser Inbox, Activity,
Journal, Todo, Search, Secrets, Sync, and Templates. Plugin bundles are built
into independent directories that the desktop host loads from its `plugins/`
folder.
## Build
Requirements: Node.js with npm, Go and Python 3. Keep this repository next to
`verstak-sdk` so manifest validation can use the SDK schema.
```bash
npm --version
go version
./scripts/check.sh
./scripts/build.sh
```
The build creates `dist/<plugin-id>/` packages. To install them into a sibling
desktop checkout:
```bash
cd ../verstak-desktop
./scripts/install-dev-plugins.sh
```
## Portable plugin archives
Build target-specific archives on Linux:
```bash
sudo apt install gcc-mingw-w64-x86-64
./scripts/package-portable.sh v0.1.0-alpha.1
```
The command creates `release/verstak-official-plugins-linux-amd64-<version>.tar.gz`
and `release/verstak-official-plugins-windows-amd64-<version>.zip`, plus
`SHA256SUMS`. Each archive expands directly into the desktop application's
`plugins/` directory. Frontends and manifests are shared; native Go sidecars
are built for the target OS. This is a local package operation and does not
create a GitHub Release.
## Alpha behaviour
- Browser Inbox archives processed captures and can restore them; permanent
deletion is a separate action.
- Saving a browser link creates a collision-safe `.url` file. Opening a link is
handled by Verstak, so it does not depend on a Linux `.url` file association.
- Activity sessions can be scoped to a durable Deal identity or to an
explicit unassigned scope. Journal creation is always a user action.
- Todo is optional: Overview uses it only when the Todo capability is present.
## Publish a GitHub Release
```bash
./scripts/publish-github-release.sh v0.1.0-alpha.1
```
The publisher requires an authenticated [`gh`](https://cli.github.com/) CLI
and a clean, up-to-date `main`. It runs the local release command, creates and
pushes an annotated tag if necessary, then creates or updates the GitHub
Release with the Linux archive, Windows archive and `SHA256SUMS`. Alpha, beta
and release-candidate tags are published as prereleases; stable tags are
marked latest. Re-running it for a tag that points at the current commit
replaces the release assets.
## License
Copyright © 2026 Verstak contributors. Licensed under
[GNU AGPLv3 or later](LICENSE).
+91
View File
@@ -0,0 +1,91 @@
<div align="center">
# Официальные плагины Верстака
### Базовый набор плагинов — Файлы, Заметки, Редактор, Активность, Журнал и другие.
[English](README.md) · **Русский**
[![Релиз](https://img.shields.io/github/v/release/mirivlad/verstak-official-plugins?include_prereleases\&label=release)](https://github.com/mirivlad/verstak-official-plugins/releases)
![Статус](https://img.shields.io/badge/status-alpha-orange)
[![Лицензия](https://img.shields.io/github/license/mirivlad/verstak-official-plugins)](LICENSE)
</div>
> **Alpha-версия.** Собирайте этот репозиторий вместе с совместимыми
> версиями `verstak-desktop` и `verstak-sdk`.
Официальные плагины — базовый набор инструментов Верстака: Файлы, Корзина,
Заметки, Редактор, Предпросмотр, Входящие из браузера, Активность, Журнал,
Задачи, Поиск, Секреты, Синхронизация и Шаблоны. Плагины собираются в
независимые пакеты, которые desktop-приложение загружает из папки `plugins/`.
## Состав
| Плагин | Назначение |
|--------|------------|
| `verstak.files` | Файловый менеджер внутри дела |
| `verstak.trash` | Корзина: восстановление и удаление |
| `verstak.notes` | Заметки в формате Markdown |
| `verstak.default-editor` | Редактор текста и Markdown |
| `verstak.file-preview` | Предпросмотр изображений |
| `verstak.activity` | История активности и реконструкция работы |
| `verstak.journal` | Журнал работ |
| `verstak.browser-inbox` | Приём материалов из браузера |
| `verstak.search` | Поиск по файлам и заметкам |
| `verstak.secrets` | Защищённое хранилище доступов |
| `verstak.todo` | Списки задач |
| `verstak.sync` | Синхронизация между устройствами |
| `verstak.templates` | Шаблоны дел |
Все плагины используют один и тот же runtime, что и сторонние.
## Сборка
Требования: Node.js с npm, Go и Python 3. Репозиторий должен находиться рядом
с `verstak-sdk`, чтобы проверка manifest использовала SDK-схему.
```bash
npm --version
go version
./scripts/check.sh
./scripts/build.sh
```
Сборка создаёт пакеты в `dist/<plugin-id>/`. Чтобы установить их в соседний
desktop-репозиторий:
```bash
cd ../verstak-desktop
./scripts/install-dev-plugins.sh
```
## Портативные архивы плагинов
Сборка архивов под конкретную платформу (Linux):
```bash
sudo apt install gcc-mingw-w64-x86-64
./scripts/package-portable.sh v0.1.0-alpha.1
```
Команда создаёт `release/verstak-official-plugins-linux-amd64-<version>.tar.gz`
и `release/verstak-official-plugins-windows-amd64-<version>.zip`, а также
`SHA256SUMS`. Каждый архив распаковывается прямо в `plugins/` desktop-приложения.
Frontend и manifest общие; нативные Go sidecar собираются под целевую ОС.
Это локальная операция упаковки, она не создаёт GitHub Release.
## Публикация GitHub Release
```bash
./scripts/publish-github-release.sh v0.1.0-alpha.1
```
Требуется авторизованный [`gh`](https://cli.github.com/) CLI и чистый
актуальный `main`. Создаёт и отправляет аннотированный тег, затем создаёт
или обновляет GitHub Release с Linux-архивом, Windows-архивом и `SHA256SUMS`.
## Лицензия
Copyright © 2026 Verstak contributors. Распространяется на условиях
[GNU AGPLv3 или новее](LICENSE).
+12 -12
View File
@@ -4,22 +4,22 @@ Browser Inbox is one global queue of browser captures. New and changed records a
stored in `captures:global`; previous `captures` and `captures:workspace:*` keys
remain readable for migration compatibility.
Each capture contains `workspaceRootPath` and `processed`. An empty
`workspaceRootPath` means the capture is **Unassigned**. The global view can filter
all captures by assignment, workspace, processed state, and text search. A capture
Each capture contains a durable `workspaceId`, its current or historical
`workspaceRootPath`, and `processed`. An empty `workspaceRootPath` means the
capture is **Unassigned**. The global view can filter all captures by assignment,
Deal, processed state, and text search. A capture
can be assigned, reassigned, made unassigned, marked processed or unprocessed, and
deleted.
The local browser receiver adds the currently active workspace before publishing a
capture event. When it has no active workspace, the capture remains unassigned
The local browser receiver adds the currently active Deal before publishing a
capture event. When it has no active Deal, the capture remains unassigned
unless an explicit existing domain binding matches it. The frontend never assigns an
untagged capture merely because a workspace view happens to be open.
untagged capture merely because a Deal view happens to be open.
Workspace assignment currently uses the top-level vault folder path as the
identifier because the core workspace model has no separate immutable ID. In the
current model that path is also the displayed workspace name, so a workspace rename
requires a later reassignment of existing captures.
Deal assignment uses the immutable `workspaceId` stored in Deal metadata;
`workspaceRootPath` is an address and display value. A rename updates the latter,
while a newly created folder with the old name cannot take over existing captures.
Workspace Inbox and Overview show only captures whose `workspaceRootPath` exactly
matches the selected workspace. Unassigned captures remain visible only in the
Deal Inbox and Overview show only captures whose `workspaceId` matches the selected
Deal. Unassigned captures remain visible only in the
global Browser Inbox.
+14 -16
View File
@@ -2,12 +2,12 @@
Todos are stored by the official `verstak.todo` plugin under the canonical
`todos:global` settings key. A todo is global when `workspaceRootPath` is empty;
otherwise that field identifies the top-level workspace folder that owns it.
otherwise that field identifies the top-level Deal folder that owns it.
The global Todos view aggregates all records and supports workspace, status, text,
and due/reminder/updated sorting filters. The workspace Todos view shows only
records whose `workspaceRootPath` exactly matches the current workspace. This
keeps unassigned and other-workspace records out of a workspace tab and its
The global Todos view aggregates all records and supports Deal, status, text,
and due/reminder/updated sorting filters. The Deal Todos view shows only records
whose `workspaceRootPath` exactly matches the current Deal. This keeps unassigned
and other-Deal records out of a Deal tab and its
Overview signals.
## Stored fields
@@ -20,15 +20,15 @@ Each record has a stable `id`, `title`, optional `description`, optional
## Reminders
The plugin stores reminder metadata and renders clear indicators for overdue,
due-soon, and reminder-due todos. Verstak does not yet have a notification
scheduler, so a reminder does not create a native desktop notification or run in
the background.
The plugin stores reminder metadata, renders clear indicators for overdue,
due-soon, and reminder-due todos, and schedules native desktop notifications when
the Desktop notification capability is available. Without that capability, the
reminder remains visible in Todos but is not treated as an error.
## Completed Todo to Journal
From a workspace Todo tab, a completed todo exposes **Create Journal Entry**. It
opens the current workspace Journal with a normal, editable form. The form copies
From a Deal Todo tab, a completed todo exposes **Create Journal Entry**. It opens
the current Deal Journal with a normal, editable form. The form copies
only factual data from the todo: title, description, completion date, and zero
minutes. It never generates a summary, duration, or billable status.
@@ -40,8 +40,6 @@ own record.
## Visibility
The Plugin Manager can globally enable or disable the Todo plugin. The current
workspace host has no per-workspace or template-level contribution filter yet, so
a globally enabled Todo plugin contributes its tab to every workspace. Template
visibility will be handled with the workspace/template model rather than by a
Todo-specific exception.
The Plugin Manager can globally enable or disable the Todo plugin. A Deal template
controls whether its Todos tab is available; disabling the plugin hides the tab
without affecting stored todo records.
+388 -142
View File
@@ -8,12 +8,14 @@
var PLUGIN_ID = 'verstak.activity';
var MAX_EVENTS = 250;
var RAW_DATA_NAME = 'activity-events';
var MAX_CANDIDATES = 12;
var LEGACY_KEY = 'events';
var GLOBAL_KEY = 'events:global';
var WORKSPACE_PREFIX = 'events:workspace:';
var CANDIDATE_PREFIX = 'work-session-candidates:workspace:';
var DISMISSAL_PREFIX = 'work-session-dismissals:workspace:';
var SESSION_REGISTRY_KEY = 'activity-session-registry-v2';
var WORKLOG_COMMAND_ID = 'verstak.activity.suggestWorklog';
var MIN_SESSION_DURATION_MINUTES = 10;
var MIN_SESSION_ACTIVITY_COUNT = 2;
@@ -30,21 +32,24 @@
'browser.capture.selection',
'browser.capture.link',
'browser.capture.file',
'browser.capture.converted'
'browser.capture.converted',
'browser.activity.batch',
'activity.session.handled'
];
var EVENT_LABELS = {
'workspace.selected': 'Workspace selected',
'case.selected': 'Workspace selected',
'file.opened': 'File opened',
'file.changed': 'File changed',
'note.saved': 'Note edited',
'action.started': 'Work session detected',
'browser.capture.received': 'Browser capture received',
'browser.capture.page': 'Page captured',
'browser.capture.selection': 'Selection captured',
'browser.capture.link': 'Link captured',
'browser.capture.file': 'File captured',
'browser.capture.converted': 'Capture converted'
'workspace.selected': { key: 'ui.event.workspaceSelected', fallback: 'Deal selected' },
'case.selected': { key: 'ui.event.workspaceSelected', fallback: 'Deal selected' },
'file.opened': { key: 'ui.event.fileOpened', fallback: 'File opened' },
'file.changed': { key: 'ui.event.fileChanged', fallback: 'File changed' },
'note.saved': { key: 'ui.event.noteEdited', fallback: 'Note edited' },
'action.started': { key: 'ui.event.workSessionDetected', fallback: 'Work session detected' },
'browser.capture.received': { key: 'ui.event.browserCaptureReceived', fallback: 'Browser capture received' },
'browser.capture.page': { key: 'ui.event.pageCaptured', fallback: 'Page captured' },
'browser.capture.selection': { key: 'ui.event.selectionCaptured', fallback: 'Selection captured' },
'browser.capture.link': { key: 'ui.event.linkCaptured', fallback: 'Link captured' },
'browser.capture.file': { key: 'ui.event.fileCaptured', fallback: 'File captured' },
'browser.capture.converted': { key: 'ui.event.captureConverted', fallback: 'Capture converted' },
'browser.activity.domain': { key: 'ui.event.browserDomainActivity', fallback: 'Browser domain activity' }
};
var LOW_VALUE_EVENT_TYPES = {
'workspace.selected': true,
@@ -74,6 +79,7 @@
'.activity-btn.danger{border-color:rgba(233,69,96,.42);color:#ff9a9a}',
'.activity-status{font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3);white-space:nowrap}',
'.activity-status.error{display:inline-flex;border:1px solid rgba(233,69,96,.45);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-danger-muted,rgba(233,69,96,.14));color:#ffc6ce;padding:.18rem .4rem}',
'.activity-modal-host[hidden]{display:none}.activity-modal-overlay{position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;padding:1rem;background:rgba(0,0,0,.58)}.activity-modal{width:440px;max-width:96vw;display:grid;gap:.75rem;padding:1rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-lg,8px);background:var(--vt-color-surface,#15152c);box-shadow:0 18px 44px rgba(0,0,0,.38)}.activity-modal-title{font-size:.95rem;font-weight:650}.activity-modal-copy{color:var(--vt-color-text-secondary,#b7c0d4);font-size:.84rem;line-height:1.45}.activity-modal-actions{display:flex;justify-content:flex-end;gap:.5rem}.activity-btn.destructive{background:var(--vt-color-danger,#e94560);border-color:var(--vt-color-danger,#e94560);color:#fff}',
'.activity-candidates{border-bottom:1px solid rgba(32,43,70,.72);background:var(--vt-color-surface-muted,#111629);padding:.65rem .75rem;display:grid;gap:.5rem}',
'.activity-candidates-title{font-size:.76rem;font-weight:600;color:var(--vt-color-text-muted,#7f8aa3);text-transform:uppercase;letter-spacing:.04em}',
'.activity-candidate{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.65rem;align-items:start;padding:.65rem .75rem;border:1px solid rgba(78,204,163,.34);border-radius:var(--vt-radius-lg,8px);background:var(--vt-color-surface,#15152c)}',
@@ -146,14 +152,16 @@
function scopeFromProps(props) {
var workspaceRoot = workspaceFromProps(props);
var workspaceId = text(props && (props.workspaceId || (props.workspaceNode && props.workspaceNode.workspaceId))).trim();
if (!workspaceRoot) {
return { mode: 'global', key: GLOBAL_KEY, label: 'All workspaces', workspaceRoot: '' };
return { mode: 'global', key: GLOBAL_KEY, label: 'All Deals', workspaceRoot: '', workspaceId: '' };
}
return {
mode: 'workspace',
key: WORKSPACE_PREFIX + encodeKey(workspaceRoot),
label: workspaceRoot,
workspaceRoot: workspaceRoot
workspaceRoot: workspaceRoot,
workspaceId: workspaceId
};
}
@@ -175,6 +183,9 @@
receivedAt: text(item.receivedAt),
sourcePluginId: text(item.sourcePluginId || item.pluginId),
workspaceRootPath: cleanWorkspace(item.workspaceRootPath || workspaceFromPayload(item.payload || {})),
workspaceId: text(item.workspaceId || (item.sessionScope && item.sessionScope.workspaceId) || (item.payload && item.payload.workspaceId)),
sessionScope: item.sessionScope && typeof item.sessionScope === 'object' ? item.sessionScope : {},
durationSeconds: Math.max(0, Number(item.durationSeconds || (item.payload && item.payload.durationSeconds) || 0)),
_storageKey: storageKey || '',
payload: item.payload && typeof item.payload === 'object' ? item.payload : {}
};
@@ -192,6 +203,9 @@
receivedAt: item.receivedAt,
sourcePluginId: item.sourcePluginId,
workspaceRootPath: item.workspaceRootPath,
workspaceId: item.workspaceId,
sessionScope: item.sessionScope || {},
durationSeconds: item.durationSeconds || 0,
payload: item.payload || {}
};
});
@@ -225,13 +239,22 @@
return cleanWorkspace(activity && (activity.workspaceRootPath || workspaceFromPayload(activity.payload || {})));
}
function candidateScope(activity) {
var workspaceId = text(activity && (activity.workspaceId || (activity.sessionScope && activity.sessionScope.workspaceId) || (activity.payload && activity.payload.workspaceId))).trim();
if (workspaceId) return 'workspace:' + workspaceId;
var workspaceRoot = candidateWorkspace(activity);
if (workspaceRoot) return 'legacy-workspace:' + workspaceRoot;
return 'unassigned';
}
function toISOTime(time) {
var date = new Date(time);
return isNaN(date.getTime()) ? '' : date.toISOString();
}
function candidateId(workspaceRootPath, firstActivity, lastActivity) {
return 'work-session:' + encodeKey(workspaceRootPath) + ':' + encodeKey(firstActivity.activityId) + ':' + encodeKey(lastActivity.activityId);
function newSessionId() {
if (typeof crypto !== 'undefined' && crypto && typeof crypto.randomUUID === 'function') return crypto.randomUUID();
return 'session-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
}
function candidateActivity(activity) {
@@ -240,82 +263,176 @@
type: text(activity.type),
occurredAt: toISOTime(eventTimeMs(activity)),
sourcePluginId: text(activity.sourcePluginId),
workspaceRootPath: candidateWorkspace(activity)
workspaceRootPath: candidateWorkspace(activity),
workspaceId: text(activity.workspaceId)
};
}
function normalizeSessionRegistry(value) {
value = value && typeof value === 'object' ? value : {};
return {
sessions: value.sessions && typeof value.sessions === 'object' ? value.sessions : {},
eventSessionIds: value.eventSessionIds && typeof value.eventSessionIds === 'object' ? value.eventSessionIds : {}
};
}
function pruneSessionRegistry(registry, activityList) {
var present = {};
(activityList || []).forEach(function (activity) { present[text(activity && activity.activityId)] = true; });
Object.keys(registry.eventSessionIds).forEach(function (activityId) {
if (!present[activityId]) delete registry.eventSessionIds[activityId];
});
var referenced = {};
Object.keys(registry.eventSessionIds).forEach(function (activityId) {
referenced[registry.eventSessionIds[activityId]] = true;
});
Object.keys(registry.sessions).forEach(function (sessionId) {
if (!referenced[sessionId]) delete registry.sessions[sessionId];
});
return registry;
}
function sessionDurationMinutes(session) {
var events = session.activities.slice().sort(function (a, b) { return eventTimeMs(a) - eventTimeMs(b); });
var durationMs = 0;
for (var index = 0; index < events.length; index += 1) {
var explicit = Math.max(0, Number(events[index].durationSeconds || 0)) * 1000;
durationMs += explicit;
if (index === 0 || explicit > 0 || Number(events[index - 1].durationSeconds || 0) > 0) continue;
var gap = eventTimeMs(events[index]) - eventTimeMs(events[index - 1]);
if (gap > 0 && gap <= MAX_IDLE_GAP_MINUTES * 60 * 1000) durationMs += Math.min(gap, 10 * 60 * 1000);
}
return Math.min(MAX_SESSION_DURATION_MINUTES, Math.floor(durationMs / 60000));
}
function findCompatibleSession(sessions, scope, time) {
var nearest = null;
sessions.forEach(function (session) {
if (session.scope !== scope) return;
var before = session.firstTime - time;
var after = time - session.lastTime;
var distance = time < session.firstTime ? before : after;
if (distance < 0 || distance > MAX_IDLE_GAP_MINUTES * 60 * 1000) return;
if (Math.max(session.lastTime, time) - Math.min(session.firstTime, time) > MAX_SESSION_DURATION_MINUTES * 60 * 1000) return;
if (!nearest || distance < nearest.distance) nearest = { session: session, distance: distance };
});
return nearest && nearest.session;
}
function logicalSessions(activityList, registry) {
var sessionsById = {};
var sessions = [];
var lastScope = '';
var ordered = sortEvents(activityList || []).filter(function (activity) {
return isMeaningfulActivity(activity) && eventTimeMs(activity);
}).slice().sort(function (a, b) { return eventTimeMs(a) - eventTimeMs(b); });
ordered.forEach(function (activity) {
var scope = candidateScope(activity);
var activityId = text(activity.activityId);
var sessionId = text(registry.eventSessionIds[activityId]);
var session = sessionId && sessionsById[sessionId];
if (!session && sessionId && registry.sessions[sessionId]) {
var persisted = registry.sessions[sessionId];
session = {
sessionId: sessionId,
scope: text(persisted.scope || scope),
workspaceRootPath: cleanWorkspace(persisted.workspaceRootPath || candidateWorkspace(activity)),
workspaceId: text(persisted.workspaceId || activity.workspaceId),
anchor: text(persisted.anchor),
firstTime: eventTimeMs(activity),
lastTime: eventTimeMs(activity),
activities: []
};
sessionsById[sessionId] = session;
sessions.push(session);
}
if (!session && (!lastScope || lastScope === scope)) {
session = findCompatibleSession(sessions, scope, eventTimeMs(activity));
}
if (!session) {
sessionId = newSessionId();
session = {
sessionId: sessionId,
scope: scope,
workspaceRootPath: candidateWorkspace(activity),
workspaceId: text(activity.workspaceId),
anchor: new Date().toISOString(),
firstTime: eventTimeMs(activity),
lastTime: eventTimeMs(activity),
activities: []
};
sessionsById[sessionId] = session;
sessions.push(session);
registry.sessions[sessionId] = {
scope: session.scope,
workspaceRootPath: session.workspaceRootPath,
workspaceId: session.workspaceId,
anchor: session.anchor
};
}
registry.eventSessionIds[activityId] = session.sessionId;
session.activities.push(activity);
session.firstTime = Math.min(session.firstTime, eventTimeMs(activity));
session.lastTime = Math.max(session.lastTime, eventTimeMs(activity));
if (candidateWorkspace(activity)) session.workspaceRootPath = candidateWorkspace(activity);
if (activity.workspaceId) session.workspaceId = text(activity.workspaceId);
lastScope = scope;
});
return sessions;
}
function buildCandidate(session) {
var first = session.activities[0];
var last = session.activities[session.activities.length - 1];
var duration = Math.round((eventTimeMs(last) - eventTimeMs(first)) / 60000);
if (session.activities.length < MIN_SESSION_ACTIVITY_COUNT || duration < MIN_SESSION_DURATION_MINUTES) return null;
var activities = session.activities.slice().sort(function (a, b) { return eventTimeMs(a) - eventTimeMs(b); });
var first = activities[0];
var last = activities[activities.length - 1];
var duration = sessionDurationMinutes(session);
if (session.scope === 'unassigned' || !session.workspaceRootPath || activities.length < MIN_SESSION_ACTIVITY_COUNT || duration < MIN_SESSION_DURATION_MINUTES) return null;
return {
candidateId: candidateId(session.workspaceRootPath, first, last),
candidateId: 'work-session:' + encodeKey(session.sessionId) + ':' + encodeKey(last.activityId),
sessionId: session.sessionId,
handledThrough: toISOTime(eventTimeMs(last)),
workspaceRootPath: session.workspaceRootPath,
workspaceId: session.workspaceId,
startedAt: toISOTime(eventTimeMs(first)),
endedAt: toISOTime(eventTimeMs(last)),
estimatedMinutes: duration,
activityCount: session.activities.length,
activityIds: session.activities.map(function (activity) { return activity.activityId; }).filter(Boolean),
activities: session.activities.map(candidateActivity)
activityCount: activities.length,
activityIds: activities.map(function (activity) { return activity.activityId; }).filter(Boolean),
activities: activities.map(candidateActivity)
};
}
function buildWorkSessionCandidates(activityList, workspaceFilter) {
function buildWorkSessionCandidates(activityList, workspaceFilter, registry) {
var filter = cleanWorkspace(workspaceFilter);
var ordered = sortEvents(activityList || []).filter(function (activity) {
return isMeaningfulActivity(activity) && candidateWorkspace(activity) && eventTimeMs(activity);
}).slice().sort(function (a, b) {
return eventTimeMs(a) - eventTimeMs(b);
});
var sessions = [];
var current = null;
ordered.forEach(function (activity) {
var workspace = candidateWorkspace(activity);
var time = eventTimeMs(activity);
if (!current) {
current = { workspaceRootPath: workspace, activities: [activity] };
return;
}
var firstTime = eventTimeMs(current.activities[0]);
var lastTime = eventTimeMs(current.activities[current.activities.length - 1]);
var switchedWorkspace = current.workspaceRootPath !== workspace;
var idleGap = time - lastTime > MAX_IDLE_GAP_MINUTES * 60 * 1000;
var exceededMaximum = time - firstTime > MAX_SESSION_DURATION_MINUTES * 60 * 1000;
if (switchedWorkspace || idleGap || exceededMaximum) {
sessions.push(current);
current = { workspaceRootPath: workspace, activities: [activity] };
return;
}
current.activities.push(activity);
});
if (current) sessions.push(current);
return sessions.map(buildCandidate).filter(function (candidate) {
registry = normalizeSessionRegistry(registry);
return logicalSessions(activityList, registry).map(buildCandidate).filter(function (candidate) {
return candidate && (!filter || candidate.workspaceRootPath === filter);
}).sort(function (a, b) {
return b.endedAt.localeCompare(a.endedAt) || a.workspaceRootPath.localeCompare(b.workspaceRootPath);
}).slice(0, MAX_CANDIDATES);
}
function humanEventType(type) {
return EVENT_LABELS[text(type).toLowerCase()] || '';
function humanEventType(type, translate) {
var label = EVENT_LABELS[text(type).toLowerCase()];
return label ? translate(label.key, null, label.fallback) : '';
}
function humanEventTitle(activity) {
function humanEventTitle(activity, translate) {
var explicit = text(activity && activity.title).trim();
var type = text(activity && activity.type).trim();
if (explicit && explicit.toLowerCase() !== type.toLowerCase()) return explicit;
return humanEventType(type) || text(activity && (activity.summary || activity.activityId)).trim() || 'Activity event';
var label = humanEventType(type, translate);
if (explicit && explicit.toLowerCase() !== type.toLowerCase()) return label ? label + ' — ' + explicit : explicit;
return label || text(activity && (activity.summary || activity.activityId)).trim() || translate('ui.event.activity', null, 'Activity event');
}
function eventKind(activity) {
function eventKind(activity, translate) {
var type = text(activity && activity.type).toLowerCase();
if (type.indexOf('browser.capture') === 0) return 'Capture';
if (type.indexOf('file.') === 0) return 'File';
if (type.indexOf('note.') === 0) return 'Note';
if (type.indexOf('workspace') !== -1 || type.indexOf('case.') === 0) return 'Workspace';
if (type.indexOf('action.') === 0) return 'Work';
return 'Activity';
if (type.indexOf('browser.capture') === 0) return translate('ui.kind.capture', null, 'Capture');
if (type.indexOf('file.') === 0) return translate('ui.kind.file', null, 'File');
if (type.indexOf('note.') === 0) return translate('ui.kind.note', null, 'Note');
if (type.indexOf('workspace') !== -1 || type.indexOf('case.') === 0) return translate('ui.kind.deal', null, 'Deal');
if (type.indexOf('action.') === 0) return translate('ui.kind.work', null, 'Work');
return translate('ui.kind.activity', null, 'Activity');
}
function globalEventKeys(settings) {
@@ -347,6 +464,15 @@
return sortEvents(scopedEvents.concat(globalEvents, legacyEvents));
}
function eventsFromRecords(records, workspaceRoot) {
var normalized = normalizeStoredEvents(records, RAW_DATA_NAME);
var workspace = cleanWorkspace(workspaceRoot);
if (!workspace) return sortEvents(normalized);
return sortEvents(normalized.filter(function (item) {
return item.workspaceRootPath === workspace;
}));
}
function candidateStorageKey(workspaceRoot) {
return CANDIDATE_PREFIX + encodeKey(workspaceRoot);
}
@@ -367,26 +493,45 @@
function dismissedCandidatesFromSettings(settings) {
var dismissed = {};
Object.keys(settings || {}).forEach(function (key) {
if (key.indexOf(DISMISSAL_PREFIX) !== 0 || !Array.isArray(settings[key])) return;
if (key.indexOf(DISMISSAL_PREFIX) !== 0) return;
var workspace = decodeStoredWorkspace(key, DISMISSAL_PREFIX);
if (!workspace) return;
dismissed[workspace] = {};
settings[key].forEach(function (candidateId) {
candidateId = text(candidateId).trim();
if (candidateId) dismissed[workspace][candidateId] = true;
});
if (Array.isArray(settings[key])) {
settings[key].forEach(function (candidateId) {
candidateId = text(candidateId).trim();
if (candidateId) dismissed[workspace][candidateId] = true;
});
return;
}
if (settings[key] && typeof settings[key] === 'object') dismissed[workspace] = settings[key];
});
return dismissed;
}
function isCandidateDismissed(candidate, dismissedByWorkspace) {
return !!(candidate && dismissedByWorkspace && dismissedByWorkspace[candidate.workspaceRootPath] && dismissedByWorkspace[candidate.workspaceRootPath][candidate.candidateId]);
function candidateAfterWatermark(candidate, watermark) {
if (!watermark || watermark === true) return watermark === true ? null : candidate;
var handledTime = Date.parse(watermark.handledThrough || '');
if (!Number.isFinite(handledTime)) return candidate;
var additions = candidate.activities.filter(function (activity) { return eventTimeMs(activity) > handledTime; });
if (eventTimeMs({ occurredAt: candidate.endedAt }) <= handledTime || additions.length < MIN_SESSION_ACTIVITY_COUNT || eventTimeMs(additions[additions.length - 1]) - handledTime < MIN_SESSION_DURATION_MINUTES * 60 * 1000) return null;
var duration = sessionDurationMinutes({ activities: additions });
if (duration < MIN_SESSION_DURATION_MINUTES) return null;
return Object.assign({}, candidate, {
startedAt: toISOTime(eventTimeMs(additions[0])),
estimatedMinutes: duration,
activityCount: additions.length,
activityIds: additions.map(function (activity) { return activity.activityId; }),
activities: additions.map(candidateActivity)
});
}
function visibleCandidates(activityList, workspaceFilter, dismissedByWorkspace) {
return buildWorkSessionCandidates(activityList, workspaceFilter).filter(function (candidate) {
return !isCandidateDismissed(candidate, dismissedByWorkspace);
});
function visibleCandidates(activityList, workspaceFilter, sessionRegistry, dismissedByWorkspace, handledSessions) {
return buildWorkSessionCandidates(activityList, workspaceFilter, sessionRegistry).map(function (candidate) {
var dismissed = dismissedByWorkspace && dismissedByWorkspace[candidate.workspaceRootPath];
var watermark = handledSessions && handledSessions[candidate.sessionId];
return candidateAfterWatermark(candidate, watermark || (dismissed && (dismissed[candidate.sessionId] || dismissed[candidate.candidateId])));
}).filter(Boolean);
}
function formatDate(value) {
@@ -409,31 +554,34 @@
var candidateSourceEvents = [];
var candidates = [];
var dismissedByWorkspace = {};
var statusText = 'Loading activity...';
var handledSessions = {};
var sessionRegistry = normalizeSessionRegistry({});
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
var statusText = tr('ui.loading', null, 'Loading activity...');
var statusClass = '';
var disposed = false;
var unsubscribers = [];
function reportError(key, fallback, err) {
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
console.warn('[verstak.activity] ' + key, err);
}
statusText = tr(key, null, fallback);
statusClass = 'error';
}
var toolbar = el('div', { className: 'activity-toolbar' });
var titleEl = el('span', { className: 'activity-title', textContent: scope.mode === 'global' ? 'Activity' : 'Activity · ' + scope.label });
var titleEl = el('span', { className: 'activity-title', textContent: scope.mode === 'global' ? tr('ui.title', null, 'Activity') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Activity · ' + scope.label) });
var countEl = el('span', { className: 'activity-count' });
var statusEl = el('span', { className: 'activity-status' });
var clearBtn = el('button', {
className: 'activity-btn danger',
'data-activity-action': 'clear',
textContent: 'Clear',
onClick: function () {
if (scope.mode === 'global') {
clearGlobal().then(render);
return;
}
events = [];
candidateSourceEvents = candidateSourceEvents.filter(function (activity) {
return candidateWorkspace(activity) !== scope.workspaceRoot;
});
updateCandidates();
persist().then(render);
}
textContent: tr('ui.clear', null, 'Clear'),
onClick: showClearConfirmation
});
toolbar.appendChild(titleEl);
toolbar.appendChild(countEl);
@@ -446,12 +594,14 @@
'data-activity-section': 'work-session-candidates'
});
var listEl = el('div', { className: 'activity-list' });
var modalHost = el('div', { className: 'activity-modal-host', hidden: 'hidden' });
containerEl.appendChild(toolbar);
containerEl.appendChild(candidatesEl);
containerEl.appendChild(listEl);
containerEl.appendChild(modalHost);
function candidatesForWorkspace(workspaceRoot) {
return visibleCandidates(candidateSourceEvents, workspaceRoot, dismissedByWorkspace);
return visibleCandidates(candidateSourceEvents, workspaceRoot, sessionRegistry, dismissedByWorkspace, handledSessions);
}
function updateCandidates() {
@@ -480,7 +630,12 @@
function persistDismissals(workspaceRoot) {
if (!workspaceRoot || !api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
var dismissed = dismissedByWorkspace[workspaceRoot] || {};
return api.settings.write(dismissalStorageKey(workspaceRoot), Object.keys(dismissed));
return api.settings.write(dismissalStorageKey(workspaceRoot), dismissed);
}
function persistSessionRegistry() {
if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
return api.settings.write(SESSION_REGISTRY_KEY, sessionRegistry);
}
function persist() {
@@ -488,9 +643,8 @@
var toStore = scope.mode === 'global'
? events.filter(function (item) { return !item._storageKey || item._storageKey === GLOBAL_KEY; })
: events;
return api.settings.write(scope.key, storageEvents(toStore)).then(persistCandidateCaches).catch(function (err) {
statusText = 'Could not save activity: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
return api.settings.write(scope.key, storageEvents(toStore)).then(persistSessionRegistry).then(persistCandidateCaches).catch(function (err) {
reportError('ui.saveError', 'Could not save activity. Please try again.', err);
});
}
@@ -499,7 +653,12 @@
events = [];
return Promise.resolve();
}
return api.settings.read().then(function (settings) {
var clearRaw = api.storage && api.storage.data && typeof api.storage.data.writeNDJSON === 'function'
? api.storage.data.writeNDJSON(RAW_DATA_NAME, [])
: Promise.resolve();
return clearRaw.then(function () {
return api.settings.read();
}).then(function (settings) {
var keys = globalEventKeys(settings || {}).concat(Object.keys(settings || {}).filter(function (key) {
return key.indexOf(CANDIDATE_PREFIX) === 0 || key.indexOf(DISMISSAL_PREFIX) === 0;
}));
@@ -507,17 +666,82 @@
candidateSourceEvents = [];
candidates = [];
dismissedByWorkspace = {};
handledSessions = {};
sessionRegistry = normalizeSessionRegistry({});
return Promise.all(keys.filter(function (key, index, all) {
return all.indexOf(key) === index;
}).map(function (key) {
return api.settings.write(key, []);
}));
}).then(function () {
statusText = 'Activity cleared';
statusText = tr('ui.cleared', null, 'Activity cleared');
statusClass = '';
}).catch(function (err) {
statusText = 'Could not clear activity: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
reportError('ui.clearError', 'Could not clear activity. Please try again.', err);
});
}
function closeClearConfirmation() {
modalHost.innerHTML = '';
modalHost.setAttribute('hidden', 'hidden');
}
function clearCurrentScope() {
if (scope.mode === 'global') {
return clearGlobal().then(render);
}
events = [];
candidateSourceEvents = candidateSourceEvents.filter(function (activity) {
return candidateWorkspace(activity) !== scope.workspaceRoot;
});
updateCandidates();
return clearWorkspaceRaw(scope.workspaceRoot).then(persist).then(render);
}
function showClearConfirmation() {
var scopeMessage = scope.mode === 'global'
? tr('ui.clearGlobalWarning', null, 'This permanently deletes all recorded activity and journal suggestions in every case.')
: tr('ui.clearWorkspaceWarning', { workspace: scope.label }, 'This permanently deletes recorded activity for ' + scope.label + '. Activity in other cases remains.');
var confirmBtn = el('button', {
className: 'activity-btn danger destructive',
type: 'button',
'data-activity-clear-confirm': '',
textContent: tr('ui.confirmClear', null, 'Clear activity'),
onClick: function () {
confirmBtn.disabled = true;
clearCurrentScope().then(closeClearConfirmation);
}
});
modalHost.innerHTML = '';
if (typeof modalHost.removeAttribute === 'function') modalHost.removeAttribute('hidden');
else delete modalHost.attributes.hidden;
modalHost.appendChild(el('div', {
className: 'activity-modal-overlay',
onClick: function (event) {
if (event.target === event.currentTarget) closeClearConfirmation();
}
}, [
el('div', { className: 'activity-modal', role: 'dialog', 'aria-modal': 'true', 'data-activity-clear-confirmation': '' }, [
el('div', { className: 'activity-modal-title', textContent: tr('ui.clearConfirmTitle', null, 'Clear activity?') }),
el('div', { className: 'activity-modal-copy', textContent: scopeMessage }),
el('div', { className: 'activity-modal-actions' }, [
el('button', { className: 'activity-btn', type: 'button', 'data-activity-clear-cancel': '', textContent: tr('ui.cancel', null, 'Cancel'), onClick: closeClearConfirmation }),
confirmBtn
])
])
]));
}
function clearWorkspaceRaw(workspaceRoot) {
if (!api || !api.storage || !api.storage.data || typeof api.storage.data.readNDJSON !== 'function' || typeof api.storage.data.writeNDJSON !== 'function') {
return Promise.resolve();
}
return api.storage.data.readNDJSON(RAW_DATA_NAME).then(function (records) {
var workspace = cleanWorkspace(workspaceRoot);
var kept = (Array.isArray(records) ? records : []).filter(function (record) {
return cleanWorkspace(record && (record.workspaceRootPath || workspaceFromPayload(record.payload || {}))) !== workspace;
});
return api.storage.data.writeNDJSON(RAW_DATA_NAME, kept);
});
}
@@ -525,8 +749,8 @@
listEl.innerHTML = '';
if (events.length === 0) {
listEl.appendChild(el('div', { className: 'activity-empty' }, [
el('div', { className: 'activity-empty-title', textContent: 'No activity events yet' }),
el('div', { textContent: 'File changes, browser captures, and conversions will appear here.' })
el('div', { className: 'activity-empty-title', textContent: tr('ui.empty', null, 'No activity events yet') }),
el('div', { textContent: tr('ui.emptyHint', null, 'File changes, browser captures, and conversions will appear here.') })
]));
return;
}
@@ -538,14 +762,10 @@
el('div', { className: 'activity-time', textContent: formatDate(activity.occurredAt) || '-' }),
el('div', { className: 'activity-main' }, [
el('div', { className: 'activity-row-head' }, [
el('span', { className: 'activity-type', textContent: eventKind(activity) }),
el('span', { className: 'activity-title-text', textContent: humanEventTitle(activity) })
el('span', { className: 'activity-type', textContent: eventKind(activity, tr) }),
el('span', { className: 'activity-title-text', textContent: humanEventTitle(activity, tr) })
]),
activity.summary ? el('div', { className: 'activity-summary', textContent: activity.summary }) : null,
el('details', { className: 'activity-details' }, [
el('summary', {}, ['Details']),
el('div', { className: 'activity-source', textContent: 'Event: ' + activity.type + (activity.sourcePluginId ? ' · Source: ' + activity.sourcePluginId : '') })
])
activity.summary ? el('div', { className: 'activity-summary', textContent: activity.summary }) : null
])
]));
});
@@ -568,17 +788,20 @@
function dismissCandidate(candidate) {
if (!candidate || !candidate.workspaceRootPath || !candidate.candidateId) return;
dismissedByWorkspace[candidate.workspaceRootPath] = dismissedByWorkspace[candidate.workspaceRootPath] || {};
dismissedByWorkspace[candidate.workspaceRootPath][candidate.candidateId] = true;
dismissedByWorkspace[candidate.workspaceRootPath][candidate.sessionId || candidate.candidateId] = {
handledThrough: candidate.handledThrough || candidate.endedAt,
handledAt: new Date().toISOString(),
status: 'dismissed'
};
updateCandidates();
Promise.all([
persistDismissals(candidate.workspaceRootPath),
persistCandidateCache(candidate.workspaceRootPath)
]).then(function () {
statusText = 'Candidate dismissed';
statusText = tr('ui.dismissed', null, 'Candidate dismissed');
statusClass = '';
}).catch(function (err) {
statusText = 'Could not dismiss candidate: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
reportError('ui.dismissError', 'Could not dismiss the suggestion. Please try again.', err);
}).then(render);
}
@@ -590,37 +813,37 @@
}
if (typeof candidatesEl.removeAttribute === 'function') candidatesEl.removeAttribute('hidden');
else delete candidatesEl.attributes.hidden;
candidatesEl.appendChild(el('div', { className: 'activity-candidates-title', textContent: 'Possible journal entries' }));
candidatesEl.appendChild(el('div', { className: 'activity-candidates-title', textContent: tr('ui.candidates', null, 'Possible journal entries') }));
candidates.forEach(function (candidate) {
candidatesEl.appendChild(el('div', {
className: 'activity-candidate',
'data-work-session-candidate': candidate.candidateId
}, [
el('div', {}, [
el('div', { className: 'activity-candidate-title', textContent: 'Possible journal entry' }),
el('div', { className: 'activity-candidate-title', textContent: tr('ui.candidate', null, 'Possible journal entry') }),
el('div', { className: 'activity-candidate-facts' }, [
el('div', { textContent: 'Workspace: ' + candidate.workspaceRootPath }),
el('div', { textContent: 'Time: ' + candidateTimeRange(candidate) }),
el('div', { textContent: 'Estimated duration: ' + candidate.estimatedMinutes + ' min' }),
el('div', { textContent: 'Activities: ' + candidate.activityCount })
el('div', { textContent: tr('ui.candidateDeal', { deal: candidate.workspaceRootPath }, 'Deal: ' + candidate.workspaceRootPath) }),
el('div', { textContent: tr('ui.candidateTime', { time: candidateTimeRange(candidate) }, 'Time: ' + candidateTimeRange(candidate)) }),
el('div', { textContent: tr('ui.candidateDuration', { minutes: candidate.estimatedMinutes }, 'Estimated duration: ' + candidate.estimatedMinutes + ' min') }),
el('div', { textContent: tr('ui.candidateActivities', { count: candidate.activityCount }, 'Activities: ' + candidate.activityCount) })
]),
el('details', { className: 'activity-candidate-activities' }, [
el('summary', { textContent: 'Included activities (' + candidate.activityCount + ')' })
el('summary', { textContent: tr('ui.includedActivities', { count: candidate.activityCount }, 'Included activities (' + candidate.activityCount + ')') })
].concat(candidate.activities.map(function (activity) {
return el('div', { className: 'activity-candidate-activity', textContent: activity.occurredAt + ' · ' + activity.type + ' · ' + activity.activityId });
return el('div', { className: 'activity-candidate-activity', textContent: activity.occurredAt + ' · ' + humanEventTitle(activity, tr) });
})))
]),
el('div', { className: 'activity-candidate-actions' }, [
el('div', { className: 'activity-candidate-duration', textContent: candidate.estimatedMinutes + ' min' }),
el('button', { className: 'activity-btn', type: 'button', 'data-work-session-action': 'review', textContent: 'Review', onClick: function () { reviewCandidate(candidate); } }),
el('button', { className: 'activity-btn', type: 'button', 'data-work-session-action': 'dismiss', textContent: 'Dismiss', onClick: function () { dismissCandidate(candidate); } })
el('div', { className: 'activity-candidate-duration', textContent: tr('ui.minutes', { count: candidate.estimatedMinutes }, candidate.estimatedMinutes + ' min') }),
el('button', { className: 'activity-btn', type: 'button', 'data-work-session-action': 'review', textContent: tr('ui.review', null, 'Review'), onClick: function () { reviewCandidate(candidate); } }),
el('button', { className: 'activity-btn', type: 'button', 'data-work-session-action': 'dismiss', textContent: tr('ui.dismiss', null, 'Dismiss'), onClick: function () { dismissCandidate(candidate); } })
])
]));
});
}
function render() {
countEl.textContent = events.length + ' event' + (events.length === 1 ? '' : 's');
countEl.textContent = tr(events.length === 1 ? 'ui.eventCount.one' : 'ui.eventCount.many', { count: events.length }, events.length + ' event' + (events.length === 1 ? '' : 's'));
clearBtn.disabled = events.length === 0;
statusEl.textContent = statusText;
statusEl.className = 'activity-status' + (statusClass ? ' ' + statusClass : '');
@@ -630,27 +853,43 @@
function loadStored() {
if (!api || !api.settings || typeof api.settings.read !== 'function') return Promise.resolve();
return api.settings.read().then(function (settings) {
var readRaw = api.storage && api.storage.data && typeof api.storage.data.readNDJSON === 'function'
? api.storage.data.readNDJSON(RAW_DATA_NAME)
: Promise.resolve([]);
return Promise.all([api.settings.read(), readRaw]).then(function (results) {
var settings = results[0] || {};
var rawRecords = Array.isArray(results[1]) ? results[1] : [];
settings = settings || {};
candidateSourceEvents = eventsFromSettings(settings, '');
events = eventsFromSettings(settings, scope.mode === 'workspace' ? scope.workspaceRoot : '');
sessionRegistry = normalizeSessionRegistry(settings[SESSION_REGISTRY_KEY]);
handledSessions = settings["activity-session-handling-v2"] && typeof settings["activity-session-handling-v2"] === 'object'
? settings["activity-session-handling-v2"]
: {};
candidateSourceEvents = rawRecords.length ? eventsFromRecords(rawRecords, '') : eventsFromSettings(settings, '');
sessionRegistry = pruneSessionRegistry(sessionRegistry, candidateSourceEvents);
events = rawRecords.length
? eventsFromRecords(rawRecords, scope.mode === 'workspace' ? scope.workspaceRoot : '')
: eventsFromSettings(settings, scope.mode === 'workspace' ? scope.workspaceRoot : '');
dismissedByWorkspace = dismissedCandidatesFromSettings(settings);
updateCandidates();
return persistCandidateCaches();
return persistSessionRegistry().then(persistCandidateCaches);
}).catch(function (err) {
statusText = 'Could not load activity: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
reportError('ui.loadError', 'Could not load activity. Please try again.', err);
});
}
function listWorkSessionCandidates(args) {
var workspace = cleanWorkspace(args && args.workspaceRootPath);
if (!api || !api.settings || typeof api.settings.read !== 'function') {
return Promise.resolve({ candidates: visibleCandidates(candidateSourceEvents, workspace || (scope.mode === 'workspace' ? scope.workspaceRoot : ''), dismissedByWorkspace) });
return Promise.resolve({ candidates: visibleCandidates(candidateSourceEvents, workspace || (scope.mode === 'workspace' ? scope.workspaceRoot : ''), sessionRegistry, dismissedByWorkspace, handledSessions) });
}
return api.settings.read().then(function (settings) {
settings = settings || {};
return { candidates: visibleCandidates(eventsFromSettings(settings, ''), workspace, dismissedCandidatesFromSettings(settings)) };
var readRaw = api.storage && api.storage.data && typeof api.storage.data.readNDJSON === 'function'
? api.storage.data.readNDJSON(RAW_DATA_NAME)
: Promise.resolve([]);
return Promise.all([api.settings.read(), readRaw]).then(function (results) {
var settings = results[0] || {};
var rawRecords = Array.isArray(results[1]) ? results[1] : [];
var source = rawRecords.length ? eventsFromRecords(rawRecords, '') : eventsFromSettings(settings, '');
return { candidates: visibleCandidates(source, workspace, sessionRegistry, dismissedCandidatesFromSettings(settings), settings["activity-session-handling-v2"] || {}) };
}).catch(function () {
return { candidates: [] };
});
@@ -661,8 +900,7 @@
return api.commands.register(WORKLOG_COMMAND_ID, listWorkSessionCandidates).then(function (unregister) {
if (typeof unregister === 'function') unsubscribers.push(unregister);
}).catch(function (err) {
statusText = 'Activity commands unavailable: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
reportError('ui.commandsUnavailable', 'Activity actions are unavailable. Please try again.', err);
});
}
@@ -677,11 +915,12 @@
if (typeof unsubscribe === 'function') unsubscribers.push(unsubscribe);
});
})).then(function () {
statusText = scope.mode === 'global' ? 'Listening for all activity' : 'Listening for workspace activity';
statusText = scope.mode === 'global'
? tr('ui.listeningGlobal', null, 'Listening for all activity')
: tr('ui.listeningDeal', null, 'Listening for Deal activity');
statusClass = '';
}).catch(function (err) {
statusText = 'Activity subscriptions unavailable: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
reportError('ui.subscriptionsUnavailable', 'Activity updates are unavailable. Please try again.', err);
});
}
@@ -696,6 +935,13 @@
}).then(function () {
if (!disposed) render();
});
if (api && api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
api.i18n.onDidChangeLocale(function () {
titleEl.textContent = scope.mode === 'global' ? tr('ui.title', null, 'Activity') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Activity · ' + scope.label);
clearBtn.textContent = tr('ui.clear', null, 'Clear');
render();
});
}
containerEl.__activityUnmount = function () {
disposed = true;
+62
View File
@@ -0,0 +1,62 @@
{
"manifest.name": "Activity",
"manifest.description": "Deal-scoped activity log for public plugin events.",
"contributions.views.verstak.activity.view.title": "Activity",
"contributions.sidebarItems.verstak.activity.sidebar.title": "Activity",
"contributions.workspaceItems.verstak.activity.workspace.title": "Activity",
"contributions.commands.verstak.activity.suggestWorklog.title": "List Possible Journal Entries",
"ui.loading": "Loading activity...",
"ui.title": "Activity",
"ui.workspaceTitle": "Activity · {workspace}",
"ui.clear": "Clear",
"ui.clearConfirmTitle": "Clear activity?",
"ui.clearGlobalWarning": "This permanently deletes all recorded activity and journal suggestions in every Deal.",
"ui.clearWorkspaceWarning": "This permanently deletes recorded activity for {workspace}. Activity in other Deals remains.",
"ui.confirmClear": "Clear activity",
"ui.cancel": "Cancel",
"ui.saveError": "Could not save activity. Please try again.",
"ui.cleared": "Activity cleared",
"ui.clearError": "Could not clear activity. Please try again.",
"ui.dismissError": "Could not dismiss the suggestion. Please try again.",
"ui.loadError": "Could not load activity. Please try again.",
"ui.commandsUnavailable": "Activity actions are unavailable. Please try again.",
"ui.subscriptionsUnavailable": "Activity updates are unavailable. Please try again.",
"ui.empty": "No activity events yet",
"ui.emptyHint": "File changes, browser captures, and conversions will appear here.",
"ui.details": "Details",
"ui.eventSource": "Event: {event}{source}",
"ui.dismissed": "Candidate dismissed",
"ui.candidates": "Possible journal entries",
"ui.candidate": "Possible journal entry",
"ui.review": "Review",
"ui.dismiss": "Dismiss",
"ui.event.workspaceSelected": "Deal selected",
"ui.event.fileOpened": "File opened",
"ui.event.fileChanged": "File changed",
"ui.event.noteEdited": "Note edited",
"ui.event.workSessionDetected": "Work session detected",
"ui.event.browserCaptureReceived": "Browser capture received",
"ui.event.pageCaptured": "Page captured",
"ui.event.selectionCaptured": "Selection captured",
"ui.event.linkCaptured": "Link captured",
"ui.event.fileCaptured": "File captured",
"ui.event.captureConverted": "Capture converted",
"ui.event.browserDomainActivity": "Browser domain activity",
"ui.event.activity": "Activity event",
"ui.kind.capture": "Capture",
"ui.kind.file": "File",
"ui.kind.note": "Note",
"ui.kind.deal": "Deal",
"ui.kind.work": "Work",
"ui.kind.activity": "Activity",
"ui.candidateDeal": "Deal: {deal}",
"ui.candidateTime": "Time: {time}",
"ui.candidateDuration": "Estimated duration: {minutes} min",
"ui.candidateActivities": "Activities: {count}",
"ui.includedActivities": "Included activities ({count})",
"ui.minutes": "{count} min",
"ui.eventCount.one": "{count} event",
"ui.eventCount.many": "{count} events",
"ui.listeningGlobal": "Listening for all activity",
"ui.listeningDeal": "Listening for Deal activity"
}
+62
View File
@@ -0,0 +1,62 @@
{
"manifest.name": "Активность",
"manifest.description": "Журнал публичных событий плагинов в Деле.",
"contributions.views.verstak.activity.view.title": "Активность",
"contributions.sidebarItems.verstak.activity.sidebar.title": "Активность",
"contributions.workspaceItems.verstak.activity.workspace.title": "Активность",
"contributions.commands.verstak.activity.suggestWorklog.title": "Показать возможные записи журнала",
"ui.loading": "Загрузка активности...",
"ui.title": "Активность",
"ui.workspaceTitle": "Активность · {workspace}",
"ui.clear": "Очистить",
"ui.clearConfirmTitle": "Очистить активность?",
"ui.clearGlobalWarning": "Будут безвозвратно удалены вся записанная активность и предложения для журнала во всех Делах.",
"ui.clearWorkspaceWarning": "Будет безвозвратно удалена записанная активность Дела «{workspace}». Активность других Дел останется.",
"ui.confirmClear": "Очистить активность",
"ui.cancel": "Отмена",
"ui.saveError": "Не удалось сохранить активность. Повторите попытку.",
"ui.cleared": "Активность очищена",
"ui.clearError": "Не удалось очистить активность. Повторите попытку.",
"ui.dismissError": "Не удалось отклонить предложение. Повторите попытку.",
"ui.loadError": "Не удалось загрузить активность. Повторите попытку.",
"ui.commandsUnavailable": "Действия с активностью недоступны. Повторите попытку.",
"ui.subscriptionsUnavailable": "Обновления активности недоступны. Повторите попытку.",
"ui.empty": "Событий активности пока нет",
"ui.emptyHint": "Здесь появятся изменения файлов, материалы из браузера и преобразования.",
"ui.details": "Подробнее",
"ui.eventSource": "Событие: {event}{source}",
"ui.dismissed": "Кандидат скрыт",
"ui.candidates": "Возможные записи журнала",
"ui.candidate": "Возможная запись журнала",
"ui.review": "Проверить",
"ui.dismiss": "Скрыть",
"ui.event.workspaceSelected": "Выбрано Дело",
"ui.event.fileOpened": "Открыт файл",
"ui.event.fileChanged": "Изменён файл",
"ui.event.noteEdited": "Изменена заметка",
"ui.event.workSessionDetected": "Обнаружена рабочая сессия",
"ui.event.browserCaptureReceived": "Получено сохранённое из браузера",
"ui.event.pageCaptured": "Сохранена страница",
"ui.event.selectionCaptured": "Сохранено выделение",
"ui.event.linkCaptured": "Сохранена ссылка",
"ui.event.fileCaptured": "Сохранён файл",
"ui.event.captureConverted": "Сохранённое преобразовано",
"ui.event.browserDomainActivity": "Активность на домене в браузере",
"ui.event.activity": "Событие активности",
"ui.kind.capture": "Сохранённое",
"ui.kind.file": "Файл",
"ui.kind.note": "Заметка",
"ui.kind.deal": "Дело",
"ui.kind.work": "Работа",
"ui.kind.activity": "Активность",
"ui.candidateDeal": "Дело: {deal}",
"ui.candidateTime": "Время: {time}",
"ui.candidateDuration": "Оценка длительности: {minutes} мин.",
"ui.candidateActivities": "Активностей: {count}",
"ui.includedActivities": "Учтённые активности ({count})",
"ui.minutes": "{count} мин.",
"ui.eventCount.one": "Событий: {count}",
"ui.eventCount.many": "Событий: {count}",
"ui.listeningGlobal": "Учёт всей активности включён",
"ui.listeningDeal": "Учёт активности Дела включён"
}
+2 -1
View File
@@ -4,7 +4,8 @@
"name": "Activity",
"version": "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.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official",
"icon": "activity",
"provides": [
+376 -164
View File
@@ -1,5 +1,5 @@
/* ===========================================================
Browser Inbox Plugin — Verstak v2 Frontend Bundle
Browser Plugin — Verstak v2 Frontend Bundle
Contract: window.VerstakPluginRegister(id, { components })
=========================================================== */
@@ -12,6 +12,7 @@
var LEGACY_KEY = 'captures';
var GLOBAL_KEY = 'captures:global';
var WORKSPACE_PREFIX = 'captures:workspace:';
var MUTATION_EVENT = 'browser-inbox.storage.mutate';
function injectStyles() {
if (document.getElementById('browser-inbox-style-injected')) return;
@@ -29,7 +30,9 @@
'.browser-inbox-filters{display:flex;align-items:center;gap:.35rem;min-width:0;flex:1;flex-wrap:wrap}',
'.browser-inbox-input,.browser-inbox-select{box-sizing:border-box;min-height:1.85rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-surface,#15152c);color:var(--vt-color-text-secondary,#b7c0d4);font:inherit;font-size:.76rem;padding:.25rem .42rem}',
'.browser-inbox-input{width:min(15rem,100%)}',
'.browser-inbox-select{max-width:12rem}',
'.browser-inbox-select{max-width:12rem;appearance:none;background-color:var(--vt-color-surface,#15152c);background-image:linear-gradient(45deg,transparent 50%,var(--vt-color-text-muted,#7f8aa3) 50%),linear-gradient(135deg,var(--vt-color-text-muted,#7f8aa3) 50%,transparent 50%);background-position:calc(100% - 14px) 50%,calc(100% - 9px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:1.65rem}',
'.browser-inbox-select option{background:var(--vt-color-surface,#15152c);color:var(--vt-color-text-primary,#f4f7fb)}',
'.browser-inbox-input:focus,.browser-inbox-select:focus{outline:none;border-color:var(--vt-color-accent,#4ecca3);box-shadow:0 0 0 1px var(--vt-color-accent,#4ecca3)}',
'.browser-inbox-spacer{flex:1}',
'.browser-inbox-btn{font-size:.78rem;padding:.32rem .65rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-md,6px);background:var(--vt-color-surface-hover,#1b2440);color:var(--vt-color-text-secondary,#b7c0d4);cursor:pointer}',
'.browser-inbox-btn:hover{background:var(--vt-color-surface-hover,#1b2440);border-color:var(--vt-color-accent,#4ecca3);color:var(--vt-color-text-primary,#f4f7fb)}',
@@ -143,7 +146,7 @@
function scopeFromProps(props) {
var workspaceRoot = workspaceFromProps(props);
if (!workspaceRoot) {
return { mode: 'global', key: GLOBAL_KEY, label: 'All workspaces', workspaceRoot: '' };
return { mode: 'global', key: GLOBAL_KEY, label: '', workspaceRoot: '' };
}
return {
mode: 'workspace',
@@ -158,13 +161,13 @@
return value === 'selection' || value === 'link' || value === 'file' || value === 'page' ? value : 'page';
}
function displayTitle(capture) {
function displayTitle(capture, fallbackTitle) {
if (capture && capture.kind === 'file' && capture.fileName) return capture.fileName;
return capture.title || capture.url || capture.captureId || 'Untitled capture';
return capture.title || capture.url || capture.captureId || fallbackTitle || '';
}
function noteTitle(capture) {
return text((capture && (capture.title || capture.domain || capture.captureId)) || 'Browser Capture').trim() || 'Browser Capture';
function noteTitle(capture, fallbackTitle) {
return text((capture && (capture.title || capture.domain || capture.captureId)) || fallbackTitle).trim() || text(fallbackTitle).trim();
}
function safeNoteFilename(title) {
@@ -180,6 +183,12 @@
return safeNoteFilename(title).replace(/\.md$/, '.url');
}
function numberedLinkFilename(title, number) {
var filename = safeLinkFilename(title);
if (number <= 1) return filename;
return filename.replace(/\.url$/, ' (' + number + ').url');
}
function safeFileFilename(name) {
var base = text(name).trim()
.replace(/[\\/:*?"<>|\r\n\t]+/g, '_')
@@ -190,8 +199,8 @@
return base;
}
function captureToMarkdown(capture) {
var title = noteTitle(capture);
function captureToMarkdown(capture, fallbackTitle) {
var title = noteTitle(capture, fallbackTitle);
var lines = ['# ' + title, ''];
if (capture && capture.url) lines.push('Source: ' + capture.url);
if (capture && capture.capturedAt) lines.push('Captured: ' + capture.capturedAt);
@@ -235,8 +244,10 @@
fileDataBase64: text(payload.fileDataBase64).trim(),
source: text(payload.source).trim(),
browserName: text(payload.browserName).trim(),
// The receiver provides the active workspace. Untagged captures remain unassigned.
workspaceRootPath: workspaceFromPayload(payload)
workspaceRootPath: workspaceFromPayload(payload),
workspaceId: text(payload.workspaceId).trim(),
workspaceState: text(payload.workspaceState || 'unassigned').trim(),
workspaceTrashId: text(payload.workspaceTrashId).trim()
};
}
@@ -255,7 +266,6 @@
return value.filter(function (item) {
return item && typeof item === 'object' && item.captureId;
}).map(function (item) {
// Workspace root paths are the current stable identifiers; core has no immutable workspace ID yet.
var workspaceRootPath = cleanWorkspace(item.workspaceRootPath) || workspaceFromStorageKey(storageKey);
return {
captureId: text(item.captureId),
@@ -275,6 +285,10 @@
browserName: text(item.browserName),
workspaceRootPath: workspaceRootPath,
workspaceName: cleanWorkspace(item.workspaceName || workspaceRootPath),
workspaceId: text(item.workspaceId),
workspaceState: text(item.workspaceState || (workspaceRootPath ? 'active' : 'unassigned')),
workspaceTrashId: text(item.workspaceTrashId),
globalState: text(item.globalState || 'inbox') === 'archived' ? 'archived' : 'inbox',
processed: item.processed === true,
_storageKey: storageKey || ''
};
@@ -301,6 +315,10 @@
browserName: item.browserName,
workspaceRootPath: item.workspaceRootPath,
workspaceName: item.workspaceName || item.workspaceRootPath || '',
workspaceId: item.workspaceId || '',
workspaceState: item.workspaceState || (item.workspaceRootPath ? 'active' : 'unassigned'),
workspaceTrashId: item.workspaceTrashId || '',
globalState: item.globalState === 'archived' ? 'archived' : 'inbox',
processed: item.processed === true
};
});
@@ -345,7 +363,7 @@
var scope = scopeFromProps(props || {});
var captures = [];
var selectedId = '';
var statusText = 'Connecting to receiver events...';
var statusText = '';
var statusClass = '';
var disposed = false;
var unsubscribers = [];
@@ -354,31 +372,59 @@
var statusFilter = 'all';
var workspaceFilter = '';
var searchQuery = '';
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
function localizedItemCount(count) {
var locale = api && api.i18n && typeof api.i18n.getLocale === 'function' ? api.i18n.getLocale() : 'en';
if (locale === 'ru') {
var mod10 = count % 10;
var mod100 = count % 100;
if (mod10 === 1 && mod100 !== 11) return tr('ui.items.one', { count: count }, count + ' материал');
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return tr('ui.items.few', { count: count }, count + ' материала');
return tr('ui.items.many', { count: count }, count + ' материалов');
}
return tr(count === 1 ? 'ui.items.one' : 'ui.items.other', { count: count }, count + (count === 1 ? ' item' : ' items'));
}
function reportError(key, fallback, err) {
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
console.warn('[verstak.browser-inbox] ' + key, err);
}
statusText = tr(key, null, fallback);
statusClass = 'error';
render();
}
statusText = tr('ui.connecting', null, 'Connecting to receiver events...');
var toolbar = el('div', { className: 'browser-inbox-toolbar' });
var titleEl = el('span', { className: 'browser-inbox-title', textContent: scope.mode === 'global' ? 'Browser Inbox' : 'Browser Inbox · ' + scope.label });
var titleEl = el('span', { className: 'browser-inbox-title', textContent: scope.mode === 'global' ? tr('ui.title', null, 'Browser') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Browser · ' + scope.label) });
var countEl = el('span', { className: 'browser-inbox-count' });
var statusEl = el('span', { className: 'browser-inbox-status' });
var filtersEl = el('div', { className: 'browser-inbox-filters' });
var statusFilterEl = el('select', {
className: 'browser-inbox-select',
'data-browser-inbox-filter': 'status',
'aria-label': 'Capture status filter',
'aria-label': tr('ui.statusFilter', null, 'Material status filter'),
onChange: function (event) {
statusFilter = text(event && event.target && event.target.value) || 'all';
selectedId = '';
render();
}
}, [
el('option', { value: 'all', textContent: 'All captures' }),
el('option', { value: 'unassigned', textContent: 'Unassigned' }),
el('option', { value: 'unprocessed', textContent: 'Unprocessed' }),
el('option', { value: 'processed', textContent: 'Processed' })
el('option', { value: 'all', textContent: tr('ui.allCaptures', null, 'All captures') }),
el('option', { value: 'unassigned', textContent: tr('ui.unassigned', null, 'Unassigned') }),
el('option', { value: 'unprocessed', textContent: tr('ui.unprocessed', null, 'Unprocessed') }),
el('option', { value: 'processed', textContent: tr('ui.processed', null, 'Processed') }),
el('option', { value: 'archived', textContent: tr('ui.archive', null, 'Archive') })
]);
var workspaceFilterEl = el('select', {
className: 'browser-inbox-select',
'data-browser-inbox-filter': 'workspace',
'aria-label': 'Workspace filter',
'aria-label': tr('ui.workspaceFilter', null, 'Deal filter'),
onChange: function (event) {
workspaceFilter = cleanWorkspace(event && event.target && event.target.value);
selectedId = '';
@@ -388,9 +434,9 @@
var searchInput = el('input', {
className: 'browser-inbox-input',
type: 'search',
placeholder: 'Search captures',
placeholder: tr('ui.search', null, 'Search captures'),
'data-browser-inbox-filter': 'search',
'aria-label': 'Search captures',
'aria-label': tr('ui.search', null, 'Search captures'),
onInput: function (event) {
searchQuery = text(event && event.target && event.target.value).trim().toLowerCase();
selectedId = '';
@@ -402,7 +448,7 @@
var clearBtn = el('button', {
className: 'browser-inbox-btn danger',
'data-browser-inbox-action': 'clear',
textContent: 'Clear',
textContent: tr('ui.clear', null, 'Clear'),
onClick: function () {
clearScope().then(render);
}
@@ -413,7 +459,7 @@
if (scope.mode === 'global') {
filtersEl.appendChild(workspaceFilterEl);
} else {
filtersEl.appendChild(el('span', { className: 'browser-inbox-count', textContent: 'Assigned to this workspace' }));
filtersEl.appendChild(el('span', { className: 'browser-inbox-count', textContent: tr('ui.assignedHere', null, 'Assigned to this Deal') }));
}
filtersEl.appendChild(searchInput);
toolbar.appendChild(filtersEl);
@@ -448,7 +494,7 @@
function renderWorkspaceFilterOptions() {
if (scope.mode !== 'global') return;
workspaceFilterEl.innerHTML = '';
workspaceFilterEl.appendChild(option('', 'All workspaces'));
workspaceFilterEl.appendChild(option('', tr('ui.allDeals', null, 'All Deals')));
workspaceRoots().forEach(function (root) {
workspaceFilterEl.appendChild(option(root, root));
});
@@ -460,6 +506,8 @@
var workspaceRoot = cleanWorkspace(capture && capture.workspaceRootPath);
if (scope.mode === 'workspace' && workspaceRoot !== scope.workspaceRoot) return false;
if (scope.mode === 'global' && workspaceFilter && workspaceRoot !== workspaceFilter) return false;
if (statusFilter === 'archived' && capture.globalState !== 'archived') return false;
if (statusFilter !== 'archived' && capture.globalState === 'archived') return false;
if (statusFilter === 'unassigned' && workspaceRoot) return false;
if (statusFilter === 'unprocessed' && capture.processed === true) return false;
if (statusFilter === 'processed' && capture.processed !== true) return false;
@@ -468,43 +516,46 @@
});
}
function persist() {
if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
return api.settings.write(GLOBAL_KEY, storageCaptures(sortCaptures(captures))).catch(function (err) {
statusText = 'Could not save inbox: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
function publishMutation(action, payload, verify, verifySettings) {
if (!api || !api.events || typeof api.events.publish !== 'function') {
reportError('ui.saveError', 'Could not update browser materials. Please try again.');
return Promise.resolve(false);
}
return api.events.publish(MUTATION_EVENT, Object.assign({ action: action }, payload || {})).then(function () {
if (!api.settings || typeof api.settings.read !== 'function') throw new Error('settings API unavailable');
return api.settings.read();
}).then(function (settings) {
settings = settings || {};
if (typeof verifySettings === 'function' && !verifySettings(settings)) {
throw new Error('the stored inbox did not reach the expected state');
}
return loadStored(true);
}).then(function () {
if (typeof verify === 'function' && !verify()) {
throw new Error('the stored capture did not change');
}
return true;
}).catch(function (err) {
reportError('ui.saveError', 'Could not update browser materials. Please try again.', err);
return false;
});
}
function removeCaptures(captureIds, successText) {
function archiveCaptures(captureIds, successText) {
var ids = {};
captureIds.forEach(function (captureId) {
ids[captureId] = true;
});
captures = captures.filter(function (item) {
return !ids[item.captureId];
});
if (ids[selectedId]) selectedId = '';
if (!api || !api.settings || typeof api.settings.read !== 'function' || typeof api.settings.write !== 'function') {
return persist().then(function () {
statusText = successText;
statusClass = '';
return publishMutation('archive', { captureIds: captureIds }, function () {
return captures.every(function (capture) {
return !ids[capture.captureId] || capture.globalState === 'archived';
});
}
return api.settings.read().then(function (settings) {
var keys = globalCaptureKeys(settings || {});
return Promise.all(keys.map(function (key) {
var next = normalizeStoredCaptures((settings || {})[key], key).filter(function (item) {
return !ids[item.captureId];
});
return api.settings.write(key, storageCaptures(next));
}));
}).then(function () {
}).then(function (saved) {
if (!saved) return;
if (ids[selectedId]) selectedId = '';
statusText = successText;
statusClass = '';
}).catch(function (err) {
statusText = 'Could not update inbox: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
render();
});
}
@@ -512,7 +563,9 @@
var ids = scope.mode === 'global'
? captures.map(function (capture) { return capture.captureId; })
: captures.filter(function (capture) { return capture.workspaceRootPath === scope.workspaceRoot; }).map(function (capture) { return capture.captureId; });
return removeCaptures(ids, scope.mode === 'global' ? 'Inbox cleared' : 'Workspace captures cleared');
return archiveCaptures(ids, scope.mode === 'global'
? tr('ui.inboxArchived', null, 'Inbox archived')
: tr('ui.workspaceCapturesArchived', null, 'Deal materials archived'));
}
function selectedCapture() {
@@ -529,11 +582,11 @@
return item.captureId === capture.captureId;
});
if (existing) return Promise.resolve();
captures = sortCaptures([capture].concat(captures));
selectedId = capture.captureId;
statusText = 'Capture received';
statusText = tr('ui.captureReceived', null, 'Material received');
statusClass = '';
return persist().then(render);
render();
return Promise.resolve();
}
function applyDomainBinding(capture) {
@@ -547,47 +600,83 @@
function assignWorkspace(captureId, workspaceRoot) {
workspaceRoot = cleanWorkspace(workspaceRoot);
captures = captures.map(function (capture) {
if (capture.captureId !== captureId) return capture;
return Object.assign({}, capture, {
workspaceRootPath: workspaceRoot,
workspaceName: workspaceRoot
return publishMutation('assign', {
captureId: captureId,
workspaceRootPath: workspaceRoot
}, function () {
return captures.some(function (capture) {
return capture.captureId === captureId && cleanWorkspace(capture.workspaceRootPath) === workspaceRoot;
});
}).then(function (saved) {
if (!saved) return;
if (workspaceRoot && workspaceOptions.indexOf(workspaceRoot) === -1) workspaceOptions.push(workspaceRoot);
statusText = workspaceRoot
? tr('ui.assignedToWorkspace', { workspace: workspaceRoot }, 'Material assigned to ' + workspaceRoot)
: tr('ui.captureUnassigned', null, 'Material is unassigned');
statusClass = '';
render();
});
if (workspaceRoot && workspaceOptions.indexOf(workspaceRoot) === -1) workspaceOptions.push(workspaceRoot);
statusText = workspaceRoot ? 'Capture assigned to ' + workspaceRoot : 'Capture is unassigned';
statusClass = '';
return persist().then(render);
}
function removeCapture(captureId) {
return removeCaptures([captureId], 'Capture deleted');
function archiveCapture(captureId) {
return archiveCaptures([captureId], tr('ui.captureArchived', null, 'Material archived'));
}
function restoreCapture(captureId) {
return publishMutation('restore', { captureId: captureId }, function () {
return captures.some(function (capture) {
return capture.captureId === captureId && capture.globalState === 'inbox';
});
}).then(function (saved) {
if (!saved) return;
statusText = tr('ui.captureRestored', null, 'Material restored to Inbox');
statusClass = '';
render();
});
}
function permanentlyDeleteCapture(captureId) {
return publishMutation('delete', { captureId: captureId, permanent: true }, function () {
return !captures.some(function (capture) { return capture.captureId === captureId; });
}).then(function (saved) {
if (!saved) return;
if (selectedId === captureId) selectedId = '';
statusText = tr('ui.captureDeleted', null, 'Material permanently deleted');
statusClass = '';
render();
});
}
function setProcessed(captureId, processed) {
captures = captures.map(function (capture) {
if (capture.captureId !== captureId) return capture;
return Object.assign({}, capture, { processed: processed === true });
return publishMutation('processed', {
captureId: captureId,
processed: processed === true
}, function () {
return captures.some(function (capture) {
return capture.captureId === captureId && capture.processed === (processed === true);
});
}).then(function (saved) {
if (!saved) return;
statusText = processed
? tr('ui.captureProcessed', null, 'Material marked processed')
: tr('ui.captureUnprocessed', null, 'Material marked unprocessed');
statusClass = '';
render();
});
statusText = processed ? 'Capture marked processed' : 'Capture marked unprocessed';
statusClass = '';
return persist().then(render);
}
function createNoteFromCapture(capture) {
if (!capture || !capture.workspaceRootPath) return Promise.resolve();
if (!api || !api.files || typeof api.files.writeText !== 'function') {
statusText = 'Could not create note: files API unavailable';
statusClass = 'error';
render();
reportError('ui.createNoteError', 'Could not create the note. Please try again.');
return Promise.resolve();
}
var title = noteTitle(capture);
var title = noteTitle(capture, tr('ui.untitledCapture', null, 'Untitled material'));
var notePath = capture.workspaceRootPath + '/Notes/' + safeNoteFilename(title);
statusText = 'Creating note...';
statusText = tr('ui.creatingNote', null, 'Creating note...');
statusClass = '';
render();
return api.files.writeText(notePath, captureToMarkdown(capture), {
return api.files.writeText(notePath, captureToMarkdown(capture, tr('ui.untitledCapture', null, 'Untitled material')), {
createIfMissing: true,
overwrite: false
}).then(function () {
@@ -604,33 +693,39 @@
}
return undefined;
}).then(function () {
statusText = 'Created note: ' + notePath;
statusText = tr('ui.noteCreated', { path: notePath }, 'Note created: ' + notePath);
statusClass = '';
return removeCapture(capture.captureId);
return archiveCapture(capture.captureId);
}).catch(function (err) {
statusText = 'Could not create note: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
render();
reportError('ui.createNoteError', 'Could not create the note. Please try again.', err);
});
}
function createLinkFromCapture(capture) {
if (!capture || !capture.workspaceRootPath || !capture.url) return Promise.resolve();
if (!api || !api.files || typeof api.files.writeText !== 'function') {
statusText = 'Could not create link: files API unavailable';
statusClass = 'error';
render();
reportError('ui.createLinkError', 'Could not create the link. Please try again.');
return Promise.resolve();
}
var title = noteTitle(capture);
var linkPath = capture.workspaceRootPath + '/Links/' + safeLinkFilename(title);
statusText = 'Creating link...';
var title = noteTitle(capture, tr('ui.untitledCapture', null, 'Untitled material'));
statusText = tr('ui.creatingLink', null, 'Creating link...');
statusClass = '';
render();
return api.files.writeText(linkPath, captureToUrlShortcut(capture), {
createIfMissing: true,
overwrite: false
}).then(function () {
function writeLink(number) {
var linkPath = capture.workspaceRootPath + '/Links/' + numberedLinkFilename(title, number);
return api.files.writeText(linkPath, captureToUrlShortcut(capture), {
createIfMissing: true,
overwrite: false
}).then(function () {
return linkPath;
}).catch(function (error) {
if (number < 99 && /^conflict:/.test(text(error && error.message ? error.message : error))) {
return writeLink(number + 1);
}
throw error;
});
}
return writeLink(1).then(function (linkPath) {
if (api.events && typeof api.events.publish === 'function') {
return api.events.publish('browser.capture.converted', {
captureId: capture.captureId,
@@ -640,31 +735,36 @@
title: title,
url: capture.url || '',
sourcePluginId: PLUGIN_ID
}).then(function () {
return linkPath;
});
}
return undefined;
return linkPath;
}).then(function () {
statusText = 'Created link: ' + linkPath;
statusText = tr('ui.linkCreated', null, 'Link created');
statusClass = '';
return removeCapture(capture.captureId);
return archiveCapture(capture.captureId);
}).catch(function (err) {
statusText = 'Could not create link: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
render();
reportError('ui.createLinkError', 'Could not create the link. Please try again.', err);
});
}
function openCaptureURL(capture) {
if (!capture || !capture.url || !api || !api.files || typeof api.files.openURL !== 'function') return Promise.resolve();
return api.files.openURL(capture.url).catch(function (err) {
reportError('ui.openLinkError', 'Could not open the link. Please try again.', err);
});
}
function createFileFromCapture(capture) {
if (!capture || !capture.workspaceRootPath || capture.kind !== 'file' || !capture.fileName || (!capture.fileText && !capture.fileDataBase64)) return Promise.resolve();
if (!api || !api.files || (capture.fileDataBase64 ? typeof api.files.writeBytes !== 'function' : typeof api.files.writeText !== 'function')) {
statusText = 'Could not create file: files API unavailable';
statusClass = 'error';
render();
reportError('ui.createFileError', 'Could not create the file. Please try again.');
return Promise.resolve();
}
var fileName = safeFileFilename(capture.fileName);
var filePath = capture.workspaceRootPath + '/Files/' + fileName;
statusText = 'Creating file...';
statusText = tr('ui.creatingFile', null, 'Creating file...');
statusClass = '';
render();
var writeOptions = {
@@ -691,13 +791,11 @@
}
return undefined;
}).then(function () {
statusText = 'Created file: ' + filePath;
statusText = tr('ui.fileCreated', { path: filePath }, 'File created: ' + filePath);
statusClass = '';
return removeCapture(capture.captureId);
return archiveCapture(capture.captureId);
}).catch(function (err) {
statusText = 'Could not create file: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
render();
reportError('ui.createFileError', 'Could not create the file. Please try again.', err);
});
}
@@ -706,8 +804,8 @@
var visible = visibleCaptures();
if (visible.length === 0) {
var emptyText = captures.length === 0
? 'No browser captures yet. Keep this view open, then send a page, selection, or link from the extension.'
: 'No captures match the current filters.';
? tr('ui.empty', null, 'No browser materials yet. Send a page, selection, or link from the extension.')
: tr('ui.emptyFiltered', null, 'No materials match the current filters.');
listEl.appendChild(el('div', { className: 'browser-inbox-empty', textContent: emptyText }));
return;
}
@@ -723,19 +821,22 @@
}, [
el('div', { className: 'browser-inbox-row-head' }, [
el('span', { className: 'browser-inbox-kind', textContent: capture.kind }),
el('span', { className: 'browser-inbox-row-title', textContent: displayTitle(capture) })
el('span', { className: 'browser-inbox-row-title', textContent: displayTitle(capture, tr('ui.untitledCapture', null, 'Untitled material')) })
]),
el('div', { className: 'browser-inbox-row-url', textContent: capture.url || capture.domain || capture.captureId })
]);
row.appendChild(el('div', { className: 'browser-inbox-row-meta' }, [
el('span', {
className: 'browser-inbox-badge' + (workspaceRoot ? '' : ' unassigned'),
textContent: workspaceRoot || 'Unassigned'
textContent: workspaceRoot || tr('ui.unassigned', null, 'Unassigned')
}),
el('span', {
className: 'browser-inbox-badge' + (capture.processed ? ' processed' : ''),
textContent: capture.processed ? 'Processed' : 'Unprocessed'
})
textContent: capture.processed
? tr('ui.processed', null, 'Processed')
: tr('ui.unprocessed', null, 'Unprocessed')
}),
capture.globalState === 'archived' ? el('span', { className: 'browser-inbox-badge', textContent: tr('ui.archive', null, 'Archive') }) : null
]));
if (capture.text) {
row.appendChild(el('div', { className: 'browser-inbox-row-text', textContent: capture.text }));
@@ -748,49 +849,51 @@
detailEl.innerHTML = '';
var capture = selectedCapture();
if (!capture) {
detailEl.appendChild(el('div', { className: 'browser-inbox-detail-empty', textContent: 'Select a capture to inspect it.' }));
detailEl.appendChild(el('div', { className: 'browser-inbox-detail-empty', textContent: tr('ui.selectCapture', null, 'Select a capture to inspect it.') }));
return;
}
selectedId = capture.captureId;
detailEl.appendChild(el('div', { className: 'browser-inbox-detail-title', textContent: displayTitle(capture) }));
detailEl.appendChild(el('div', { className: 'browser-inbox-detail-title', textContent: displayTitle(capture, tr('ui.untitledCapture', null, 'Untitled material')) }));
detailEl.appendChild(el('div', { className: 'browser-inbox-meta' }, [
el('div', { className: 'browser-inbox-meta-label', textContent: 'Kind' }),
el('div', { className: 'browser-inbox-meta-label', textContent: tr('ui.kind', null, 'Kind') }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.kind }),
el('div', { className: 'browser-inbox-meta-label', textContent: 'URL' }),
el('div', { className: 'browser-inbox-meta-label', textContent: tr('ui.url', null, 'URL') }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.url || '-' }),
el('div', { className: 'browser-inbox-meta-label', textContent: 'Domain' }),
el('div', { className: 'browser-inbox-meta-label', textContent: tr('ui.domain', null, 'Domain') }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.domain || '-' }),
el('div', { className: 'browser-inbox-meta-label', textContent: 'Captured' }),
el('div', { className: 'browser-inbox-meta-label', textContent: tr('ui.captured', null, 'Captured') }),
el('div', { className: 'browser-inbox-meta-value', textContent: formatDate(capture.capturedAt) || '-' }),
el('div', { className: 'browser-inbox-meta-label', textContent: 'Browser' }),
el('div', { className: 'browser-inbox-meta-label', textContent: tr('ui.browser', null, 'Browser') }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.browserName || capture.source || '-' }),
el('div', { className: 'browser-inbox-meta-label', textContent: 'Workspace' }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.workspaceRootPath || 'Unassigned' }),
el('div', { className: 'browser-inbox-meta-label', textContent: 'Status' }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.processed ? 'Processed' : 'Unprocessed' })
el('div', { className: 'browser-inbox-meta-label', textContent: tr('ui.workspace', null, 'Deal') }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.workspaceRootPath || tr('ui.unassigned', null, 'Unassigned') }),
el('div', { className: 'browser-inbox-meta-label', textContent: tr('ui.status', null, 'Status') }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.processed
? tr('ui.processed', null, 'Processed')
: tr('ui.unprocessed', null, 'Unprocessed') })
]));
var assignmentSelect = el('select', {
className: 'browser-inbox-select',
'data-browser-inbox-assignment': capture.captureId,
'aria-label': 'Assign capture workspace',
'aria-label': tr('ui.assignment', null, 'Assign to Deal'),
onChange: function (event) {
assignWorkspace(capture.captureId, event && event.target && event.target.value);
}
});
assignmentSelect.appendChild(option('', 'Unassigned'));
assignmentSelect.appendChild(option('', tr('ui.unassigned', null, 'Unassigned')));
workspaceRoots().forEach(function (workspaceRoot) {
assignmentSelect.appendChild(option(workspaceRoot, workspaceRoot));
});
assignmentSelect.value = capture.workspaceRootPath || '';
var assignmentControls = [
el('span', { className: 'browser-inbox-meta-label', textContent: 'Assign workspace' }),
el('span', { className: 'browser-inbox-meta-label', textContent: tr('ui.assignment', null, 'Assign to Deal') }),
assignmentSelect
];
if (capture.workspaceRootPath) {
assignmentControls.push(el('button', {
className: 'browser-inbox-btn',
'data-browser-inbox-action': 'clear-assignment',
textContent: 'Clear assignment',
textContent: tr('ui.clearAssignment', null, 'Clear assignment'),
onClick: function () {
assignWorkspace(capture.captureId, '');
}
@@ -798,7 +901,7 @@
}
detailEl.appendChild(el('div', { className: 'browser-inbox-assignment' }, assignmentControls));
if (!capture.workspaceRootPath) {
detailEl.appendChild(el('div', { className: 'browser-inbox-detail-note', textContent: 'Assign a workspace before creating a note, link, or file.' }));
detailEl.appendChild(el('div', { className: 'browser-inbox-detail-note', textContent: tr('ui.assignBeforeCreation', null, 'Assign a Deal before creating a note, link, or file.') }));
}
if (capture.text) {
detailEl.appendChild(el('div', { className: 'browser-inbox-text', textContent: capture.text }));
@@ -810,16 +913,28 @@
actionButtons.push(el('button', {
className: 'browser-inbox-btn',
'data-browser-inbox-action': 'toggle-processed',
textContent: capture.processed ? 'Mark Unprocessed' : 'Mark Processed',
textContent: capture.processed
? tr('ui.markUnprocessed', null, 'Mark unprocessed')
: tr('ui.markProcessed', null, 'Mark processed'),
onClick: function () {
setProcessed(capture.captureId, !capture.processed);
}
}));
if (capture.url) {
actionButtons.push(el('button', {
className: 'browser-inbox-btn',
'data-browser-inbox-action': 'open-link',
textContent: tr('ui.openLink', null, 'Open link'),
onClick: function () {
openCaptureURL(capture);
}
}));
}
if (capture.workspaceRootPath) {
actionButtons.push(el('button', {
className: 'browser-inbox-btn',
'data-browser-inbox-action': 'create-note',
textContent: 'Create Note',
textContent: tr('ui.createNote', null, 'Create Note'),
onClick: function () {
createNoteFromCapture(capture);
}
@@ -828,7 +943,7 @@
actionButtons.push(el('button', {
className: 'browser-inbox-btn',
'data-browser-inbox-action': 'create-link',
textContent: 'Create Link',
textContent: tr('ui.createLink', null, 'Create Link'),
onClick: function () {
createLinkFromCapture(capture);
}
@@ -838,19 +953,38 @@
actionButtons.push(el('button', {
className: 'browser-inbox-btn',
'data-browser-inbox-action': 'create-file',
textContent: 'Create File',
textContent: tr('ui.createFile', null, 'Create File'),
onClick: function () {
createFileFromCapture(capture);
}
}));
}
}
if (capture.globalState === 'archived') {
actionButtons.push(el('button', {
className: 'browser-inbox-btn',
'data-browser-inbox-action': 'restore',
textContent: tr('ui.restore', null, 'Restore to Inbox'),
onClick: function () {
restoreCapture(capture.captureId);
}
}));
} else {
actionButtons.push(el('button', {
className: 'browser-inbox-btn',
'data-browser-inbox-action': 'archive',
textContent: tr('ui.archive', null, 'Archive'),
onClick: function () {
archiveCapture(capture.captureId);
}
}));
}
actionButtons.push(el('button', {
className: 'browser-inbox-btn danger',
'data-browser-inbox-action': 'remove',
textContent: 'Delete',
'data-browser-inbox-action': 'delete-permanently',
textContent: tr('ui.deletePermanently', null, 'Delete permanently'),
onClick: function () {
removeCapture(capture.captureId);
permanentlyDeleteCapture(capture.captureId);
}
}));
detailEl.appendChild(el('div', { className: 'browser-inbox-detail-actions' }, actionButtons));
@@ -860,8 +994,11 @@
var visibleCount = visibleCaptures().length;
var total = captures.length;
countEl.textContent = visibleCount === total
? total + ' item' + (total === 1 ? '' : 's')
: visibleCount + ' of ' + total + ' items';
? localizedItemCount(total)
: tr('ui.items.filtered', {
visible: localizedItemCount(visibleCount),
total: localizedItemCount(total)
}, localizedItemCount(visibleCount) + ' of ' + localizedItemCount(total));
var scopeCount = scope.mode === 'global'
? total
: captures.filter(function (capture) { return capture.workspaceRootPath === scope.workspaceRoot; }).length;
@@ -879,7 +1016,7 @@
renderDetail();
}
function loadStored() {
function loadStored(skipMigration) {
if (!api || !api.settings || typeof api.settings.read !== 'function') return Promise.resolve();
return api.settings.read().then(function (settings) {
domainBindings = normalizeDomainBindings((settings || {}).domainBindings);
@@ -891,13 +1028,30 @@
if (key !== GLOBAL_KEY && stored.length > 0) hasLegacyCaptures = true;
all = all.concat(stored);
});
captures = sortCaptures(all);
captures = sortCaptures(all).map(applyDomainBinding);
if (!selectedId && captures[0]) selectedId = captures[0].captureId;
// Keep legacy records readable, then mirror the canonical state into the global queue.
return hasLegacyCaptures ? persist() : undefined;
// Read legacy records once, then ask the backend to migrate them atomically.
if (!hasLegacyCaptures || skipMigration) return undefined;
var expectedIds = captures.map(function (capture) { return capture.captureId; });
return publishMutation('migrate', {}, function () {
return expectedIds.every(function (captureId) {
return captures.some(function (capture) { return capture.captureId === captureId; });
});
}, function (migratedSettings) {
var canonicalIds = normalizeStoredCaptures(migratedSettings[GLOBAL_KEY], GLOBAL_KEY).map(function (capture) {
return capture.captureId;
});
var legacyIsEmpty = globalCaptureKeys(migratedSettings).filter(function (key) {
return key !== GLOBAL_KEY;
}).every(function (key) {
return normalizeStoredCaptures(migratedSettings[key], key).length === 0;
});
return legacyIsEmpty && expectedIds.every(function (captureId) {
return canonicalIds.indexOf(captureId) !== -1;
});
});
}).catch(function (err) {
statusText = 'Could not load inbox: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
reportError('ui.loadError', 'Could not load browser materials. Please try again.', err);
});
}
@@ -920,16 +1074,44 @@
if (!api || !api.events || typeof api.events.subscribe !== 'function') return Promise.resolve();
return Promise.all(CAPTURE_EVENTS.map(function (eventName) {
return api.events.subscribe(eventName, function (event) {
return addCapture(captureFromEvent(event));
var received = captureFromEvent(event);
return loadStored(true).then(function () {
var stored = captures.find(function (capture) {
return capture.captureId === received.captureId;
});
if (!stored) return addCapture(received);
if (!received.workspaceRootPath && stored.workspaceRootPath) {
return publishMutation('assign', {
captureId: stored.captureId,
workspaceRootPath: stored.workspaceRootPath
}, function () {
return captures.some(function (capture) {
return capture.captureId === stored.captureId && capture.workspaceRootPath === stored.workspaceRootPath;
});
}).then(function (saved) {
if (!saved) return;
selectedId = received.captureId;
statusText = tr('ui.captureReceived', null, 'Material received');
statusClass = '';
render();
});
}
selectedId = received.captureId;
statusText = tr('ui.captureReceived', null, 'Material received');
statusClass = '';
render();
return undefined;
});
}).then(function (unsubscribe) {
if (typeof unsubscribe === 'function') unsubscribers.push(unsubscribe);
});
})).then(function () {
statusText = scope.mode === 'global' ? 'Receiver ready for all workspaces' : 'Receiver ready for workspace';
statusText = scope.mode === 'global'
? tr('ui.receiverReadyAll', null, 'Receiver ready for all Deals')
: tr('ui.receiverReadyWorkspace', null, 'Receiver ready for this Deal');
statusClass = '';
}).catch(function (err) {
statusText = 'Receiver unavailable: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
reportError('ui.receiverError', 'The browser receiver is unavailable. Please try again.', err);
});
}
@@ -941,6 +1123,17 @@
}).then(function () {
if (!disposed) render();
});
if (api && api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
api.i18n.onDidChangeLocale(function () {
titleEl.textContent = scope.mode === 'global' ? tr('ui.title', null, 'Browser') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Browser · ' + scope.label);
searchInput.setAttribute('placeholder', tr('ui.search', null, 'Search captures'));
statusFilterEl.setAttribute('aria-label', tr('ui.statusFilter', null, 'Material status filter'));
workspaceFilterEl.setAttribute('aria-label', tr('ui.workspaceFilter', null, 'Deal filter'));
searchInput.setAttribute('aria-label', tr('ui.search', null, 'Search captures'));
clearBtn.textContent = tr('ui.clear', null, 'Clear');
render();
});
}
containerEl.__browserInboxUnmount = function () {
disposed = true;
@@ -967,6 +1160,11 @@
containerEl.innerHTML = '';
containerEl.className = 'browser-inbox-settings';
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
var receiverURLInput = el('input', {
className: 'browser-inbox-settings-input',
type: 'text',
@@ -988,27 +1186,27 @@
className: 'browser-inbox-btn',
type: 'button',
'data-browser-inbox-settings-action': 'copy-url',
textContent: 'Copy URL'
textContent: tr('ui.copyUrl', null, 'Copy URL')
});
var copyTokenButton = el('button', {
className: 'browser-inbox-btn',
type: 'button',
'data-browser-inbox-settings-action': 'copy-token',
textContent: 'Copy Token'
textContent: tr('ui.copyToken', null, 'Copy Token')
});
var rotateTokenButton = el('button', {
className: 'browser-inbox-btn danger',
type: 'button',
'data-browser-inbox-settings-action': 'rotate-token',
textContent: 'Rotate Token'
textContent: tr('ui.rotateToken', null, 'Rotate Token')
});
containerEl.appendChild(el('div', { className: 'browser-inbox-settings-field' }, [
el('label', { className: 'browser-inbox-settings-label', textContent: 'Receiver URL' }),
el('label', { className: 'browser-inbox-settings-label', textContent: tr('ui.receiverUrl', null, 'Receiver URL') }),
receiverURLInput
]));
containerEl.appendChild(el('div', { className: 'browser-inbox-settings-field' }, [
el('label', { className: 'browser-inbox-settings-label', textContent: 'Pairing Token' }),
el('label', { className: 'browser-inbox-settings-label', textContent: tr('ui.pairingToken', null, 'Pairing Token') }),
receiverTokenInput
]));
containerEl.appendChild(el('div', { className: 'browser-inbox-settings-actions' }, [
@@ -1023,6 +1221,13 @@
statusEl.className = 'browser-inbox-settings-status' + (isError ? ' error' : '');
}
function reportError(key, fallback, err) {
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
console.warn('[verstak.browser-inbox.settings] ' + key, err);
}
setStatus(tr(key, null, fallback), true);
}
function setBusy(busy) {
copyURLButton.disabled = busy;
copyTokenButton.disabled = busy;
@@ -1041,14 +1246,14 @@
function loadPairing() {
setBusy(true);
setStatus('Loading...', false);
setStatus(tr('ui.loading', null, 'Loading...'), false);
return Promise.resolve().then(function () {
return pairingAPI().pairing();
}).then(function (pairing) {
applyPairing(pairing);
setStatus('', false);
}).catch(function (err) {
setStatus(text(err && err.message ? err.message : err), true);
reportError('ui.pairingLoadError', 'Could not load browser connection settings. Please try again.', err);
}).then(function () {
setBusy(false);
});
@@ -1057,13 +1262,13 @@
function copyValue(value, label) {
if (!value) return;
if (typeof navigator === 'undefined' || !navigator.clipboard || typeof navigator.clipboard.writeText !== 'function') {
setStatus('Clipboard unavailable', true);
setStatus(tr('ui.clipboardUnavailable', null, 'Clipboard unavailable'), true);
return;
}
navigator.clipboard.writeText(value).then(function () {
setStatus(label + ' copied', false);
setStatus(tr('ui.copied', { label: label }, '{label} copied'), false);
}).catch(function (err) {
setStatus(text(err && err.message ? err.message : err), true);
reportError('ui.clipboardError', 'Could not copy to the clipboard. Please try again.', err);
});
}
@@ -1074,22 +1279,29 @@
copyValue(receiverTokenInput.value, 'Token');
});
rotateTokenButton.addEventListener('click', function () {
if (typeof window.confirm === 'function' && !window.confirm('Rotate pairing token?')) return;
if (typeof window.confirm === 'function' && !window.confirm(tr('ui.rotateConfirm', null, 'Rotate pairing token?'))) return;
setBusy(true);
setStatus('Rotating...', false);
setStatus(tr('ui.rotating', null, 'Rotating...'), false);
Promise.resolve().then(function () {
return pairingAPI().rotateToken();
}).then(function (pairing) {
applyPairing(pairing);
setStatus('Token rotated', false);
setStatus(tr('ui.tokenRotated', null, 'Token rotated'), false);
}).catch(function (err) {
setStatus(text(err && err.message ? err.message : err), true);
reportError('ui.tokenRotateError', 'Could not rotate the pairing token. Please try again.', err);
}).then(function () {
setBusy(false);
});
});
loadPairing();
if (api && api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
api.i18n.onDidChangeLocale(function () {
copyURLButton.textContent = tr('ui.copyUrl', null, 'Copy URL');
copyTokenButton.textContent = tr('ui.copyToken', null, 'Copy Token');
rotateTokenButton.textContent = tr('ui.rotateToken', null, 'Rotate Token');
});
}
},
unmount: function (containerEl) {
if (containerEl) containerEl.innerHTML = '';
+90
View File
@@ -0,0 +1,90 @@
{
"manifest.name": "Browser",
"manifest.description": "Global browser materials with explicit Deal assignment delivered through the local receiver event protocol.",
"contributions.views.verstak.browser-inbox.view.title": "Browser",
"contributions.sidebarItems.verstak.browser-inbox.sidebar.title": "Browser",
"contributions.workspaceItems.verstak.browser-inbox.workspace.title": "Browser",
"contributions.settingsPanels.verstak.browser-inbox.settings.title": "Browser",
"ui.connecting": "Connecting to receiver events...",
"ui.title": "Browser",
"ui.workspaceTitle": "Browser · {workspace}",
"ui.allCaptures": "All captures",
"ui.allDeals": "All Deals",
"ui.unassigned": "Unassigned",
"ui.unprocessed": "Unprocessed",
"ui.processed": "Processed",
"ui.search": "Search captures",
"ui.clear": "Clear",
"ui.assignedHere": "Assigned to this Deal",
"ui.selectCapture": "Select a capture to inspect it.",
"ui.kind": "Kind",
"ui.domain": "Domain",
"ui.captured": "Captured",
"ui.workspace": "Deal",
"ui.status": "Status",
"ui.createNote": "Create Note",
"ui.createLink": "Create Link",
"ui.createFile": "Create File",
"ui.delete": "Delete",
"ui.copyUrl": "Copy URL",
"ui.copyToken": "Copy Token",
"ui.rotateToken": "Rotate Token",
"ui.receiverUrl": "Receiver URL",
"ui.pairingToken": "Pairing Token",
"ui.loading": "Loading...",
"ui.clipboardUnavailable": "Clipboard unavailable",
"ui.copied": "{label} copied",
"ui.rotateConfirm": "Rotate pairing token?",
"ui.rotating": "Rotating...",
"ui.tokenRotated": "Token rotated",
"ui.saveError": "Could not update browser materials. Please try again.",
"ui.createNoteError": "Could not create the note. Please try again.",
"ui.createLinkError": "Could not create the link. Please try again.",
"ui.openLinkError": "Could not open the link. Please try again.",
"ui.createFileError": "Could not create the file. Please try again.",
"ui.loadError": "Could not load browser materials. Please try again.",
"ui.receiverError": "The browser receiver is unavailable. Please try again.",
"ui.pairingLoadError": "Could not load browser connection settings. Please try again.",
"ui.clipboardError": "Could not copy to the clipboard. Please try again.",
"ui.tokenRotateError": "Could not rotate the pairing token. Please try again.",
"ui.statusFilter": "Material status filter",
"ui.workspaceFilter": "Deal filter",
"ui.assignment": "Assign to Deal",
"ui.clearAssignment": "Clear assignment",
"ui.empty": "No browser materials yet. Send a page, selection, or link from the extension.",
"ui.emptyFiltered": "No materials match the current filters.",
"ui.untitledCapture": "Untitled material",
"ui.url": "URL",
"ui.browser": "Browser",
"ui.assignBeforeCreation": "Assign a Deal before creating a note, link, or file.",
"ui.markProcessed": "Mark processed",
"ui.markUnprocessed": "Mark unprocessed",
"ui.openLink": "Open link",
"ui.restore": "Restore to Inbox",
"ui.archive": "Archive",
"ui.deletePermanently": "Delete permanently",
"ui.captureReceived": "Material received",
"ui.captureLoadError": "Could not load the received material.",
"ui.inboxArchived": "Inbox archived",
"ui.workspaceCapturesArchived": "Deal materials archived",
"ui.assignedToWorkspace": "Material assigned to {workspace}",
"ui.captureUnassigned": "Material is unassigned",
"ui.captureArchived": "Material archived",
"ui.captureRestored": "Material restored to Inbox",
"ui.captureDeleted": "Material permanently deleted",
"ui.captureProcessed": "Material marked processed",
"ui.captureUnprocessed": "Material marked unprocessed",
"ui.creatingNote": "Creating note...",
"ui.noteCreated": "Note created: {path}",
"ui.creatingLink": "Creating link...",
"ui.linkCreated": "Link created",
"ui.creatingFile": "Creating file...",
"ui.fileCreated": "File created: {path}",
"ui.receiverReadyAll": "Receiver ready for all Deals",
"ui.receiverReadyWorkspace": "Receiver ready for this Deal",
"ui.items.one": "{count} item",
"ui.items.few": "{count} items",
"ui.items.many": "{count} items",
"ui.items.other": "{count} items",
"ui.items.filtered": "{visible} of {total}"
}
+90
View File
@@ -0,0 +1,90 @@
{
"manifest.name": "Браузер",
"manifest.description": "Общие материалы из браузера с явным назначением Дела через локальный протокол приёма.",
"contributions.views.verstak.browser-inbox.view.title": "Браузер",
"contributions.sidebarItems.verstak.browser-inbox.sidebar.title": "Браузер",
"contributions.workspaceItems.verstak.browser-inbox.workspace.title": "Браузер",
"contributions.settingsPanels.verstak.browser-inbox.settings.title": "Браузер",
"ui.connecting": "Подключение к событиям приёмника...",
"ui.title": "Браузер",
"ui.workspaceTitle": "Браузер · {workspace}",
"ui.allCaptures": "Все материалы",
"ui.allDeals": "Все Дела",
"ui.unassigned": "Не назначено",
"ui.unprocessed": "Не обработано",
"ui.processed": "Обработано",
"ui.search": "Поиск материалов",
"ui.clear": "Очистить",
"ui.assignedHere": "Назначено этому Делу",
"ui.selectCapture": "Выберите материал для просмотра.",
"ui.kind": "Тип",
"ui.domain": "Домен",
"ui.captured": "Получено",
"ui.workspace": "Дело",
"ui.status": "Состояние",
"ui.createNote": "Создать заметку",
"ui.createLink": "Создать ссылку",
"ui.createFile": "Создать файл",
"ui.delete": "Удалить",
"ui.copyUrl": "Копировать URL",
"ui.copyToken": "Копировать токен",
"ui.rotateToken": "Сменить токен",
"ui.receiverUrl": "URL приёмника",
"ui.pairingToken": "Токен сопряжения",
"ui.loading": "Загрузка...",
"ui.clipboardUnavailable": "Буфер обмена недоступен",
"ui.copied": "{label} скопирован",
"ui.rotateConfirm": "Сменить токен сопряжения?",
"ui.rotating": "Смена токена...",
"ui.tokenRotated": "Токен изменён",
"ui.saveError": "Не удалось обновить материалы браузера. Повторите попытку.",
"ui.createNoteError": "Не удалось создать заметку. Повторите попытку.",
"ui.createLinkError": "Не удалось создать ссылку. Повторите попытку.",
"ui.openLinkError": "Не удалось открыть ссылку. Повторите попытку.",
"ui.createFileError": "Не удалось создать файл. Повторите попытку.",
"ui.loadError": "Не удалось загрузить материалы браузера. Повторите попытку.",
"ui.receiverError": "Приёмник браузера недоступен. Повторите попытку.",
"ui.pairingLoadError": "Не удалось загрузить параметры подключения браузера. Повторите попытку.",
"ui.clipboardError": "Не удалось скопировать в буфер обмена. Повторите попытку.",
"ui.tokenRotateError": "Не удалось сменить токен сопряжения. Повторите попытку.",
"ui.statusFilter": "Фильтр состояния материала",
"ui.workspaceFilter": "Фильтр Дела",
"ui.assignment": "Назначить Делу",
"ui.clearAssignment": "Снять назначение",
"ui.empty": "Пока нет материалов из браузера. Отправьте страницу, выделенный текст или ссылку с помощью расширения.",
"ui.emptyFiltered": "Нет материалов по текущим фильтрам.",
"ui.untitledCapture": "Материал без названия",
"ui.url": "URL",
"ui.browser": "Браузер",
"ui.assignBeforeCreation": "Назначьте Дело перед созданием заметки, ссылки или файла.",
"ui.markProcessed": "Отметить обработанным",
"ui.markUnprocessed": "Отметить необработанным",
"ui.openLink": "Открыть ссылку",
"ui.restore": "Вернуть во входящие",
"ui.archive": "Архивировать",
"ui.deletePermanently": "Удалить навсегда",
"ui.captureReceived": "Материал получен",
"ui.captureLoadError": "Не удалось загрузить полученный материал.",
"ui.inboxArchived": "Входящие архивированы",
"ui.workspaceCapturesArchived": "Материалы Дела архивированы",
"ui.assignedToWorkspace": "Материал назначен Делу «{workspace}»",
"ui.captureUnassigned": "Материал не назначен",
"ui.captureArchived": "Материал архивирован",
"ui.captureRestored": "Материал возвращён во входящие",
"ui.captureDeleted": "Материал удалён навсегда",
"ui.captureProcessed": "Материал отмечен обработанным",
"ui.captureUnprocessed": "Материал отмечен необработанным",
"ui.creatingNote": "Создание заметки...",
"ui.noteCreated": "Заметка создана: {path}",
"ui.creatingLink": "Создание ссылки...",
"ui.linkCreated": "Ссылка создана",
"ui.creatingFile": "Создание файла...",
"ui.fileCreated": "Файл создан: {path}",
"ui.receiverReadyAll": "Приёмник готов для всех Дел",
"ui.receiverReadyWorkspace": "Приёмник готов для этого Дела",
"ui.items.one": "{count} материал",
"ui.items.few": "{count} материала",
"ui.items.many": "{count} материалов",
"ui.items.other": "{count} материала",
"ui.items.filtered": "{visible} из {total}"
}
+7 -6
View File
@@ -1,10 +1,11 @@
{
"schemaVersion": 1,
"id": "verstak.browser-inbox",
"name": "Browser Inbox",
"name": "Browser",
"version": "0.1.0",
"apiVersion": "0.1.0",
"description": "Global browser capture queue with explicit workspace assignment delivered through the local receiver event protocol.",
"description": "Global browser materials with explicit Deal assignment delivered through the local receiver event protocol.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official",
"icon": "inbox",
"provides": [
@@ -28,7 +29,7 @@
"views": [
{
"id": "verstak.browser-inbox.view",
"title": "Browser Inbox",
"title": "Browser",
"icon": "inbox",
"component": "BrowserInboxView"
}
@@ -36,7 +37,7 @@
"sidebarItems": [
{
"id": "verstak.browser-inbox.sidebar",
"title": "Browser Inbox",
"title": "Browser",
"icon": "inbox",
"view": "verstak.browser-inbox.view",
"position": 30
@@ -45,7 +46,7 @@
"workspaceItems": [
{
"id": "verstak.browser-inbox.workspace",
"title": "Browser Inbox",
"title": "Browser",
"icon": "inbox",
"component": "BrowserInboxView"
}
@@ -53,7 +54,7 @@
"settingsPanels": [
{
"id": "verstak.browser-inbox.settings",
"title": "Browser Inbox",
"title": "Browser",
"component": "BrowserInboxSettings"
}
]
+62 -37
View File
@@ -18,7 +18,6 @@
'.de-root{display:flex;flex-direction:column;height:100%;min-height:0;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;color:#e0e0e0;background:#0d0d1a}',
'.de-toolbar,.de-md-toolbar{display:flex;align-items:center;gap:.45rem;padding:.45rem .75rem;border-bottom:1px solid #16213e;flex-shrink:0;background:#12122a;flex-wrap:wrap}',
'.de-md-toolbar{background:#101028;padding:.38rem .75rem}',
'.de-toolbar-mode{font-size:.75rem;color:#4ecca3;padding:.15rem .5rem;border-radius:3px;background:#1a2a3a}',
'.de-toolbar-context{font-size:.75rem;color:#a0a0bb;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'.de-toolbar-spacer{flex:1}',
'.de-toolbar-btn,.de-md-btn{font-size:.75rem;padding:.28rem .58rem;border:1px solid #333;border-radius:4px;background:#1a1a2e;color:#ccc;cursor:pointer}',
@@ -45,7 +44,6 @@
'.de-preview table{border-collapse:collapse;margin:.8rem 0;max-width:100%;display:block;overflow:auto}.de-preview th,.de-preview td{border:1px solid #333;padding:.35rem .6rem;text-align:left}.de-preview th{background:#1a1a2e}',
'.de-preview img{max-width:100%;height:auto;border-radius:4px}.de-preview .task{margin-right:.4rem}',
'.de-notes-badge{font-size:.65rem;padding:.1rem .4rem;border-radius:3px;background:#2a1a3a;color:#b388ff}',
'.de-notes-info{padding:.45rem .75rem;background:#111126;border-top:1px solid #16213e;font-size:.75rem;color:#8b8ba8;flex-shrink:0}',
'.de-loading,.de-error{flex:1;display:flex;align-items:center;justify-content:center;color:#777;padding:2rem}.de-error{color:#e74c3c;flex-direction:column;gap:.5rem}.de-error-msg{font-size:.85rem;color:#aaa;max-width:420px;text-align:center}',
'@media(max-width:780px){.de-editor-wrap{flex-direction:column}.de-pane+.de-pane{border-left:0;border-top:1px solid #16213e}.de-toolbar-context{max-width:100%}}'
].join('\n');
@@ -259,11 +257,14 @@
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
var value = textarea.value;
var selected = value.slice(start, end) || placeholder || '';
var lineStart = value.lastIndexOf('\n', Math.max(0, start - 1)) + 1;
var lineEnd = value.indexOf('\n', end);
if (lineEnd === -1) lineEnd = value.length;
var selected = value.slice(lineStart, lineEnd) || placeholder || '';
var replacement = selected.split('\n').map(function (line) { return prefix + line; }).join('\n');
textarea.value = value.slice(0, start) + replacement + value.slice(end);
textarea.selectionStart = start;
textarea.selectionEnd = start + replacement.length;
textarea.value = value.slice(0, lineStart) + replacement + value.slice(lineEnd);
textarea.selectionStart = lineStart;
textarea.selectionEnd = lineStart + replacement.length;
textarea.dispatchEvent(new Event('input', { bubbles: true }));
textarea.focus();
}
@@ -291,22 +292,25 @@
var linesEl = null;
var previewEl = null;
var secretLinksAvailable = false;
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
containerEl.setAttribute('data-editor-mode', editorMode);
containerEl.setAttribute('data-resource-path', resourcePath);
containerEl.setAttribute('data-request-mode', requestedMode);
var modeLabel = el('span', { className: 'de-toolbar-mode' }, [editorMode]);
var contextLabel = el('span', { className: 'de-toolbar-context', title: resourcePath }, [resourcePath || fileName(resourcePath)]);
var notesBadge = editorMode === 'notes-markdown' ? el('span', { className: 'de-notes-badge', 'data-notes-badge': '' }, ['notes context']) : null;
var notesBadge = editorMode === 'notes-markdown' ? el('span', { className: 'de-notes-badge', 'data-notes-badge': '' }, [tr('ui.note', null, 'Note')]) : null;
var spacer = el('span', { className: 'de-toolbar-spacer' });
var editBtn = isMarkdown ? el('button', { className: 'de-toolbar-btn', 'data-editor-mode-button': 'edit' }, ['Edit']) : null;
var previewBtn = isMarkdown ? el('button', { className: 'de-toolbar-btn', 'data-editor-mode-button': 'preview' }, ['Preview']) : null;
var splitBtn = isMarkdown ? el('button', { className: 'de-toolbar-btn', 'data-editor-mode-button': 'split' }, ['Split']) : null;
var reloadBtn = el('button', { className: 'de-toolbar-btn', 'data-editor-action': 'reload' }, ['Reload']);
var saveBtn = el('button', { className: 'de-toolbar-btn', 'data-editor-action': 'save' }, ['Save']);
var editBtn = isMarkdown ? el('button', { className: 'de-toolbar-btn', 'data-editor-mode-button': 'edit' }, [tr('ui.edit', null, 'Edit')]) : null;
var previewBtn = isMarkdown ? el('button', { className: 'de-toolbar-btn', 'data-editor-mode-button': 'preview' }, [tr('ui.preview', null, 'Preview')]) : null;
var splitBtn = isMarkdown ? el('button', { className: 'de-toolbar-btn', 'data-editor-mode-button': 'split' }, [tr('ui.split', null, 'Split')]) : null;
var reloadBtn = el('button', { className: 'de-toolbar-btn', 'data-editor-action': 'reload' }, [tr('ui.reload', null, 'Reload')]);
var saveBtn = el('button', { className: 'de-toolbar-btn', 'data-editor-action': 'save' }, [tr('ui.save', null, 'Save')]);
var statusEl = el('span', { className: 'de-status', 'data-save-state': '' });
var toolbarChildren = [modeLabel, contextLabel];
var toolbarChildren = [contextLabel];
if (notesBadge) toolbarChildren.push(notesBadge);
toolbarChildren.push(spacer);
[editBtn, previewBtn, splitBtn, reloadBtn, saveBtn, statusEl].forEach(function (node) { if (node) toolbarChildren.push(node); });
@@ -316,18 +320,18 @@
if (isMarkdown) {
mdToolbar = el('div', { className: 'de-md-toolbar' });
[
['heading', 'H', 'Heading'],
['bold', 'B', 'Bold'],
['italic', 'I', 'Italic'],
['link', 'Link', 'Link'],
['code', 'Code', 'Inline code'],
['code-block', '```', 'Code block'],
['bullet', '• List', 'Bullet list'],
['numbered', '1. List', 'Numbered list'],
['quote', 'Quote', 'Quote'],
['task', 'Task', 'Task item']
['heading', 'H', 'ui.md.heading', 'Heading'],
['bold', 'B', 'ui.md.bold', 'Bold'],
['italic', 'I', 'ui.md.italic', 'Italic'],
['link', 'Link', 'ui.md.link', 'Link'],
['code', 'Code', 'ui.md.inlineCode', 'Inline code'],
['code-block', '```', 'ui.md.codeBlock', 'Code block'],
['bullet', '• List', 'ui.md.bulletList', 'Bullet list'],
['numbered', '1. List', 'ui.md.numberedList', 'Numbered list'],
['quote', 'Quote', 'ui.md.quote', 'Quote'],
['task', 'Task', 'ui.md.taskItem', 'Task item']
].forEach(function (item) {
mdToolbar.appendChild(el('button', { className: 'de-md-btn', 'data-md-action': item[0], title: item[2] }, [item[1]]));
mdToolbar.appendChild(el('button', { className: 'de-md-btn', type: 'button', 'data-md-action': item[0], title: tr(item[2], null, item[3]), 'aria-label': tr(item[2], null, item[3]) }, [item[1]]));
});
containerEl.appendChild(mdToolbar);
}
@@ -335,10 +339,6 @@
var editorWrap = el('div', { className: 'de-editor-wrap' });
containerEl.appendChild(editorWrap);
if (editorMode === 'notes-markdown') {
containerEl.appendChild(el('div', { className: 'de-notes-info' }, ['Notes context active. Note actions, backlinks, and graph tools are reserved for the future Notes plugin.']));
}
function updateLineNumbers() {
if (!linesEl || !textarea) return;
var count = textarea.value.split('\n').length;
@@ -349,16 +349,16 @@
function updateStatus() {
if (saveState === 'saving') {
statusEl.textContent = 'Saving...';
statusEl.textContent = tr('ui.saving', null, 'Saving...');
statusEl.className = 'de-status saving';
} else if (saveState === 'error') {
statusEl.textContent = 'Error saving';
statusEl.textContent = tr('ui.saveError', null, 'Error saving');
statusEl.className = 'de-status error';
} else if (dirty) {
statusEl.textContent = 'Modified';
statusEl.textContent = tr('ui.modified', null, 'Modified');
statusEl.className = 'de-status dirty';
} else if (lastSavedAt) {
statusEl.textContent = saveState === 'saved' ? 'Saved ' + lastSavedAt : 'Saved';
statusEl.textContent = saveState === 'saved' ? tr('ui.savedAt', { time: lastSavedAt }, 'Saved ' + lastSavedAt) : tr('ui.saved', null, 'Saved');
statusEl.className = 'de-status saved';
} else {
statusEl.textContent = '';
@@ -475,9 +475,9 @@
}
function reloadFromDisk() {
if (dirty && !window.confirm('Discard unsaved changes and reload from disk?')) return;
if (dirty && !window.confirm(tr('ui.discardConfirm', null, 'Discard unsaved changes and reload from disk?'))) return;
editorWrap.innerHTML = '';
editorWrap.appendChild(el('div', { className: 'de-loading' }, ['Loading...']));
editorWrap.appendChild(el('div', { className: 'de-loading' }, [tr('ui.loading', null, 'Loading...')]));
var readPromise = api.files.readText(resourcePath);
readPromise.then(function (content) {
if (disposed) return;
@@ -488,10 +488,10 @@
rebuildEditorArea();
}).catch(function (err) {
if (disposed) return;
console.warn('[default-editor] load error:', err);
editorWrap.innerHTML = '';
editorWrap.appendChild(el('div', { className: 'de-error' }, [
el('div', {}, ['Failed to load file']),
el('div', { className: 'de-error-msg' }, [(err && err.message) ? err.message : String(err)])
el('div', {}, [tr('ui.loadFailed', null, 'Could not load the file. Please try again.')])
]));
});
}
@@ -534,6 +534,30 @@
loadSecretProviderAvailability();
reloadFromDisk();
var localeUnsubscribe = api.i18n && typeof api.i18n.onDidChangeLocale === 'function'
? api.i18n.onDidChangeLocale(function () {
if (notesBadge) notesBadge.textContent = tr('ui.note', null, 'Note');
if (editBtn) editBtn.textContent = tr('ui.edit', null, 'Edit');
if (previewBtn) previewBtn.textContent = tr('ui.preview', null, 'Preview');
if (splitBtn) splitBtn.textContent = tr('ui.split', null, 'Split');
reloadBtn.textContent = tr('ui.reload', null, 'Reload');
saveBtn.textContent = tr('ui.save', null, 'Save');
if (mdToolbar) {
[
['heading', 'ui.md.heading', 'Heading'], ['bold', 'ui.md.bold', 'Bold'], ['italic', 'ui.md.italic', 'Italic'],
['link', 'ui.md.link', 'Link'], ['code', 'ui.md.inlineCode', 'Inline code'], ['code-block', 'ui.md.codeBlock', 'Code block'],
['bullet', 'ui.md.bulletList', 'Bullet list'], ['numbered', 'ui.md.numberedList', 'Numbered list'], ['quote', 'ui.md.quote', 'Quote'], ['task', 'ui.md.taskItem', 'Task item']
].forEach(function (item) {
var button = mdToolbar.querySelector('[data-md-action="' + item[0] + '"]');
if (!button) return;
var label = tr(item[1], null, item[2]);
button.setAttribute('title', label);
button.setAttribute('aria-label', label);
});
}
updateStatus();
})
: null;
containerEl.addEventListener('click', function (event) {
var secretLink = event.target.closest('.secret-link');
@@ -582,6 +606,7 @@
containerEl.__deCleanup = function () {
disposed = true;
if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
if (saveTimer) clearTimeout(saveTimer);
};
},
+33
View File
@@ -0,0 +1,33 @@
{
"manifest.name": "Default Editor",
"manifest.description": "Built-in text and markdown editor/viewer for Verstak. Provides openProviders for generic text, generic markdown, and notes-context markdown files.",
"contributions.openProviders.verstak.default-editor.text.title": "Default Text Editor",
"contributions.openProviders.verstak.default-editor.markdown.title": "Default Markdown Editor",
"contributions.openProviders.verstak.default-editor.notes-markdown.title": "Default Notes Markdown Editor",
"ui.notesContext": "notes context",
"ui.note": "Note",
"ui.edit": "Edit",
"ui.preview": "Preview",
"ui.split": "Split",
"ui.reload": "Reload",
"ui.save": "Save",
"ui.notesInfo": "Notes context active. Note actions, backlinks, and graph tools are reserved for the future Notes plugin.",
"ui.saving": "Saving...",
"ui.saveError": "Error saving",
"ui.modified": "Modified",
"ui.savedAt": "Saved {time}",
"ui.saved": "Saved",
"ui.discardConfirm": "Discard unsaved changes and reload from disk?",
"ui.loading": "Loading...",
"ui.loadFailed": "Could not load the file. Please try again.",
"ui.md.heading": "Heading",
"ui.md.bold": "Bold",
"ui.md.italic": "Italic",
"ui.md.link": "Link",
"ui.md.inlineCode": "Inline code",
"ui.md.codeBlock": "Code block",
"ui.md.bulletList": "Bullet list",
"ui.md.numberedList": "Numbered list",
"ui.md.quote": "Quote",
"ui.md.taskItem": "Task item"
}
+33
View File
@@ -0,0 +1,33 @@
{
"manifest.name": "Стандартный редактор",
"manifest.description": "Встроенный редактор и просмотрщик текста и Markdown для Верстака.",
"contributions.openProviders.verstak.default-editor.text.title": "Стандартный текстовый редактор",
"contributions.openProviders.verstak.default-editor.markdown.title": "Стандартный редактор Markdown",
"contributions.openProviders.verstak.default-editor.notes-markdown.title": "Стандартный редактор Markdown-заметок",
"ui.notesContext": "контекст заметки",
"ui.note": "Заметка",
"ui.edit": "Редактировать",
"ui.preview": "Просмотр",
"ui.split": "Разделить",
"ui.reload": "Перезагрузить",
"ui.save": "Сохранить",
"ui.notesInfo": "Активен контекст заметки. Действия, обратные ссылки и граф будут развиваться в плагине заметок.",
"ui.saving": "Сохранение...",
"ui.saveError": "Ошибка сохранения",
"ui.modified": "Изменён",
"ui.savedAt": "Сохранено {time}",
"ui.saved": "Сохранено",
"ui.discardConfirm": "Отменить несохранённые изменения и перечитать файл с диска?",
"ui.loading": "Загрузка...",
"ui.loadFailed": "Не удалось загрузить файл. Повторите попытку.",
"ui.md.heading": "Заголовок",
"ui.md.bold": "Жирный текст",
"ui.md.italic": "Курсив",
"ui.md.link": "Ссылка",
"ui.md.inlineCode": "Встроенный код",
"ui.md.codeBlock": "Блок кода",
"ui.md.bulletList": "Маркированный список",
"ui.md.numberedList": "Нумерованный список",
"ui.md.quote": "Цитата",
"ui.md.taskItem": "Пункт задачи"
}
+1
View File
@@ -5,6 +5,7 @@
"version": "0.1.0",
"apiVersion": "0.1.0",
"description": "Built-in text and markdown editor/viewer for Verstak. Provides openProviders for generic text, generic markdown, and notes-context markdown files.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official",
"icon": "edit",
"provides": [
+14 -4
View File
@@ -108,6 +108,10 @@
var request = props && props.request || {};
var path = request.path || '';
var ext = extension(path, request.extension);
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
containerEl.innerHTML = '';
containerEl.className = 'fp-root';
containerEl.setAttribute('data-plugin-id', 'verstak.file-preview');
@@ -116,19 +120,19 @@
var openButton = el('button', {
className: 'fp-btn',
'data-action': 'open-external',
textContent: 'Open External',
textContent: tr('ui.openExternal', null, 'Open External'),
onClick: function () {
if (api.files.openExternal) api.files.openExternal(path).catch(function (err) { console.error('[file-preview] openExternal:', err); });
}
});
containerEl.appendChild(el('div', { className: 'fp-toolbar' }, [
el('span', { className: 'fp-mode' }, ['Preview']),
el('span', { className: 'fp-mode' }, [tr('ui.preview', null, 'Preview')]),
el('span', { className: 'fp-path' }, [path]),
el('span', { className: 'fp-spacer' }),
openButton
]));
var body = el('div', { className: 'fp-loading' }, ['Loading...']);
var body = el('div', { className: 'fp-loading' }, [tr('ui.loading', null, 'Loading...')]);
containerEl.appendChild(body);
api.files.metadata(path).then(function (meta) {
@@ -139,9 +143,15 @@
return null;
});
}).catch(function (err) {
console.warn('[file-preview] preview error:', err);
body.className = 'fp-error';
body.textContent = 'Preview error: ' + (err && err.message ? err.message : String(err));
body.textContent = tr('ui.error', null, 'Could not preview this file. Please try again.');
});
if (api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
api.i18n.onDidChangeLocale(function () {
openButton.textContent = tr('ui.openExternal', null, 'Open External');
});
}
},
unmount: function (containerEl) {
containerEl.innerHTML = '';
+9
View File
@@ -0,0 +1,9 @@
{
"manifest.name": "File Preview",
"manifest.description": "Read-only inline image preview provider for vault files.",
"contributions.openProviders.verstak.file-preview.image.title": "Image Preview",
"ui.openExternal": "Open External",
"ui.preview": "Preview",
"ui.loading": "Loading...",
"ui.error": "Could not preview this file. Please try again."
}
+9
View File
@@ -0,0 +1,9 @@
{
"manifest.name": "Просмотр файлов",
"manifest.description": "Встроенный просмотр изображений из хранилища без редактирования.",
"contributions.openProviders.verstak.file-preview.image.title": "Просмотр изображения",
"ui.openExternal": "Открыть во внешнем приложении",
"ui.preview": "Просмотр",
"ui.loading": "Загрузка...",
"ui.error": "Не удалось открыть предварительный просмотр файла. Повторите попытку."
}
+1
View File
@@ -5,6 +5,7 @@
"version": "0.1.0",
"apiVersion": "0.1.0",
"description": "Read-only inline image preview provider for vault files.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official",
"icon": "eye",
"provides": [
-162
View File
@@ -1,162 +0,0 @@
# План улучшений файлового менеджера Verstak2
> **Архитектура:** Плагин `verstak.files` в монорепозитории `verstak-official-plugins`.
> Код: `plugins/files/frontend/src/index.js` (vanilla JS, VerstakPluginRegister).
> API: `api.files.*`, `api.workbench.openResource()`, `api.settings`, `api.events`, `api.commands`.
## Порядок реализации
| # | Feature | Приоритет | Зависит от |
|---|---------|-----------|------------|
| 1 | Контекстное меню (правый клик) | 🔥 High | — |
| 2 | Open External / Show in Explorer | ✅ Done | `files.openExternal` |
| 3 | Кастомный ConfirmModal | 🔥 High | — |
| 4 | Duplicate | 🔥 High | — |
| 5 | Cut/Copy/Paste | 🔥 High | — |
| 6 | Множественное выделение | 🔥 High | — |
| 7 | Drag-and-Drop | 🔥 High | — |
| 8 | Клавиатурная навигация (Ctrl+A, Escape, стрелки) | Medium | #6 |
| 9 | Богатые иконки (как в v1) | Medium | — |
| 10 | История навигации | Medium | — |
| 11 | Rename с валидацией | Medium | — |
---
### Feature 1: Контекстное меню (правый клик)
**Описание:** При правом клике на файле/папке показывать меню с действиями.
**Пункты меню:** Open, Open External, Show in Explorer, Rename, Duplicate, Cut, Copy, Delete — с разделителями.
**Архитектура:**
- Событие `contextmenu` на `.files-item`
- Позиционирование у курсора (event.clientX/Y)
- Кастомный `<div>` с пунктами, закрывается по клику вне или Escape
- Класс `.files-context-menu` для стилей
**Файлы:** `plugins/files/frontend/src/index.js`
---
### Feature 2: Open External / Show in Explorer
**Описание:** Пункты меню для открытия файла во внешнем приложении и показа
файла/папки в системном файловом менеджере.
**Текущий v2 статус:** плагин использует публичные методы
`api.files.openExternal(relativePath)` и `api.files.showInFolder(relativePath)`,
guarded by `files.openExternal`. Fallback modal с vault-relative path и кнопкой
`Copy Path` показывается только если API недоступен или вернул ошибку.
**Файлы:** `plugins/files/frontend/src/index.js`
---
### Feature 3: Кастомный ConfirmModal
**Описание:** Вместо `window.confirm()` — стилизованный modal с кнопками Отмена/Удалить, опасным стилем (красная кнопка).
**Архитектура:**
- Функция `confirmModal(message, options)` возвращает Promise
- Создаёт overlay + modal в DOM
- Закрывается по Escape или кнопке Отмена
**Файлы:** `plugins/files/frontend/src/index.js`
---
### Feature 4: Duplicate
**Описание:** Копирование файла/папки в той же папке с добавлением суффикса (напр. "filename_copy.ext", "filename_2.ext").
**Архитектура:**
- Для файла: `api.files.readText()` + `api.files.writeText()` с новым именем
- Для папки: рекурсивное копирование (сложнее, можно defer)
- Пункт в контекстном меню: "Duplicate"
**Файлы:** `plugins/files/frontend/src/index.js`
---
### Feature 5: Cut/Copy/Paste
**Описание:** Буфер обмена внутри файлового менеджера. Cut → перенос. Copy → дублирование. Paste в текущую папку.
**Архитектура:**
- `clipboard: { items: [...], mode: 'cut'|'copy' }`
- Клавиши Ctrl+X/C/V
- Пункты в контекстном меню
**Файлы:** `plugins/files/frontend/src/index.js`
---
### Feature 6: Множественное выделение
**Описание:** Ctrl+click (toggle), Shift+click (range), Ctrl+A (select all). Массовые операции (Trash, Copy, Paste).
**Архитектура:**
- `selectedPaths: Set<string>` вместо `selectedPath: string`
- Ctrl+click → toggle entry в Set
- Shift+click → range select от последнего клика
- Действия применяются ко всем выделенным
**Файлы:** `plugins/files/frontend/src/index.js`
---
### Feature 7: Drag-and-Drop
**Описание:** Перемещение файлов/папок между папками через drag-and-drop.
**Архитектура:**
- HTML5 DnD API: `dragstart`, `dragover`, `drop` на `.files-item`
- При drop на папку → `api.files.move()`
- Визуальная индикация (подсветка папки при наведении)
- Отмена по Escape
**Файлы:** `plugins/files/frontend/src/index.js`
---
### Feature 8: Клавиатурная навигация
**Описание:** Escape (снять выделение), Ctrl+A (select all), стрелки (навигация по строкам).
**Зависит от:** Feature 6 (множественное выделение)
**Файлы:** `plugins/files/frontend/src/index.js`
---
### Feature 9: Богатые иконки
**Описание:** Как в v1 — отдельные SVG иконки для image, video, audio, pdf, document, spreadsheet, presentation, archive, code, text.
**Архитектура:** Расширить функцию `fileIcon()`.
**Файлы:** `plugins/files/frontend/src/index.js`
---
### Feature 10: История навигации
**Описание:** Кнопки Back/Forward в тулбаре, как в браузере. Хранение стека посещённых папок.
**Архитектура:**
- `historyStack: string[]` с индексом
- back() / forward() / push(path)
**Файлы:** `plugins/files/frontend/src/index.js`
---
### Feature 11: Rename с валидацией
**Описание:** Проверять имя через API до применения. Показывать ошибку под полем, а не в placeholder.
**Архитектура:**
- Проверка: не пустое, нет `/`, нет null byte, не `.verstak`
- API коллизии: `api.files.metadata(newPath)` — если существует, ошибка
**Файлы:** `plugins/files/frontend/src/index.js`
+274 -135
View File
@@ -34,10 +34,10 @@
'.files-breadcrumb-item:hover{background:var(--vt-color-accent-muted,rgba(78,204,163,.14))}',
'.files-breadcrumb-current{color:var(--vt-color-text-primary,#f4f7fb);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
'.files-breadcrumb-sep{color:var(--vt-color-text-muted,#7f8aa3)}',
'.files-filter,.files-sort,.files-create-input,.files-rename-input{font-size:.78rem;padding:.32rem .5rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);outline:none}',
'.files-filter,.files-sort{font-size:.78rem;padding:.32rem .5rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);outline:none}',
'.files-filter{width:11rem}',
'.files-sort{width:9.5rem;appearance:none;background-color:#0f1424;background-image:linear-gradient(45deg,transparent 50%,#8b8ba8 50%),linear-gradient(135deg,#8b8ba8 50%,transparent 50%);background-position:calc(100% - 14px) 50%,calc(100% - 9px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:1.6rem}',
'.files-filter:focus,.files-sort:focus,.files-create-input:focus,.files-rename-input:focus{border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.files-sort{width:9.5rem;appearance:none;background-color:#0f1424;background-image:linear-gradient(45deg,transparent 50%,#8b8ba8 50%),linear-gradient(135deg,#8b8ba8 50%,transparent 50%);background-position:calc(100% - 14px) 50%,calc(100% - 9px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:1.6rem}.files-sort option{background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb)}',
'.files-filter:focus,.files-sort:focus{border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.files-list{flex:1;overflow:auto;min-height:0}',
'.files-header,.files-item{display:grid;grid-template-columns:minmax(160px,1fr) 90px 90px 150px 220px;align-items:center;gap:.5rem;padding:.38rem .75rem;border-bottom:1px solid rgba(22,33,62,.55)}',
'.files-header{position:sticky;top:0;background:var(--vt-color-surface-muted,#111629);color:var(--vt-color-text-muted,#7f8aa3);font-size:.7rem;text-transform:uppercase;letter-spacing:.04em;z-index:1}',
@@ -60,10 +60,6 @@
'.files-empty-btn svg{width:15px;height:15px;display:block;fill:currentColor}',
'.files-error{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--vt-color-danger,#e94560);gap:.5rem;padding:1rem}',
'.files-error-msg{font-size:.85rem;color:var(--vt-color-text-secondary,#b7c0d4);max-width:420px;text-align:center}',
'.files-panel{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;border-top:1px solid var(--vt-color-border,#202b46);flex-shrink:0;background:var(--vt-color-surface-muted,#111629)}',
'.files-field-stack{display:flex;flex:1;min-width:160px;flex-direction:column;gap:.25rem}',
'.files-panel-error{display:none;color:#ff9aaa;font-size:.72rem;line-height:1.2}',
'.files-create-input,.files-rename-input{flex:1;min-width:160px}',
'@media(max-width:760px){.files-header,.files-item{grid-template-columns:minmax(130px,1fr) 70px 0 0 150px}.files-header span:nth-child(3),.files-header span:nth-child(4),.files-item-meta.hide-narrow{display:none}.files-toolbar{align-items:stretch}.files-filter,.files-sort{width:100%}}',
'.files-ctx-menu{position:fixed;z-index:9999;min-width:180px;background:var(--vt-color-surface,#15152c);border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-md,6px);padding:6px 0;box-shadow:var(--vt-elevation-menu,0 14px 32px rgba(0,0,0,.42));font-size:.84rem;color:var(--vt-color-text-primary,#f4f7fb);user-select:none}',
'.files-ctx-menu-item{padding:6px 16px;cursor:pointer;white-space:nowrap;display:flex;align-items:center;gap:.5rem}',
@@ -75,6 +71,12 @@
'.files-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:10000;display:flex;align-items:center;justify-content:center}',
'.files-modal{width:400px;max-width:90vw;padding:24px;background:#1a1a2e;border:1px solid #333;border-radius:12px;color:#e0e0e0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;box-shadow:0 12px 40px rgba(0,0,0,.5)}',
'.files-modal-title{font-size:.95rem;line-height:1.5;margin-bottom:20px;word-wrap:break-word}',
'.files-modal-form{display:grid;gap:.65rem}',
'.files-modal-form .files-modal-title{margin-bottom:0}',
'.files-modal-input{width:100%;font:inherit;font-size:.86rem;padding:.52rem .65rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);outline:none}',
'.files-modal-input:focus{border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.files-modal-input[aria-invalid="true"]{border-color:var(--vt-color-danger,#e94560)}',
'.files-modal-error{display:none;color:#ff9aaa;font-size:.78rem;line-height:1.35}',
'.files-modal-actions{display:flex;justify-content:flex-end;gap:8px}',
'.files-modal-btn{font-size:.82rem;padding:.4rem 1rem;border:1px solid #333;border-radius:6px;cursor:pointer;font-family:inherit}',
'.files-modal-btn.cancel{background:#2a2a4e;color:#ccc;border-color:#444}',
@@ -222,7 +224,7 @@
return EXT_MAP[ext] || 'generic';
}
function fileIconLabel(category) {
function fileIconLabel(category, translate) {
var labels = {
folder: 'Folder',
markdown: 'Markdown file',
@@ -242,7 +244,8 @@
json: 'JSON file',
generic: 'File'
};
return labels[category] || labels.generic;
var label = labels[category] || labels.generic;
return typeof translate === 'function' ? translate('ui.icon.' + (labels[category] ? category : 'generic'), null, label) : label;
}
function fileIcon(entry) {
@@ -265,8 +268,8 @@
return date.toLocaleString(undefined, { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
function typeLabel(entry) {
if (entry.type === 'folder') return 'folder';
function typeLabel(entry, folderLabel) {
if (entry.type === 'folder') return folderLabel || 'Folder';
return (entry.extension || extension(entry.name) || 'file').toLowerCase();
}
@@ -320,15 +323,16 @@
return Promise.reject(new Error('clipboard unavailable'));
}
function showExternalFallback(entry, mode, reason) {
function showExternalFallback(entry, mode, labels) {
if (!entry) return;
labels = labels || {};
var pathToShow = entry.relativePath;
if (mode === 'explorer' && entry.type !== 'folder') {
pathToShow = parentPath(entry.relativePath) || entry.relativePath;
}
var title = mode === 'explorer' ? 'Show in Explorer' : 'Open External';
var message = title + ' failed.\n' + (reason ? String(reason) + '\n' : '') + 'Vault-relative path:\n' + pathToShow;
confirmModal(message, { confirmText: 'Copy Path', cancelText: 'Close' }).then(function (copy) {
var title = mode === 'explorer' ? (labels.showInFolder || 'Show in folder') : (labels.openExternal || 'Open externally');
var message = (labels.externalFailed || '{action} failed.').replace('{action}', title) + '\n' + (labels.vaultRelativePath || 'Vault-relative path:') + '\n' + pathToShow;
confirmModal(message, { confirmText: labels.copyPath || 'Copy path', cancelText: labels.close || 'Close' }).then(function (copy) {
if (!copy) return;
copyTextToClipboard(pathToShow).catch(function (err) {
console.error('[files] copy path failed:', err);
@@ -346,7 +350,7 @@
var workspaceNode = props && props.workspaceNode;
var workspaceRoot = cleanPath(props && (props.workspaceRootPath || (workspaceNode && (workspaceNode.rootPath || workspaceNode.name || workspaceNode.id))) || '');
var workspaceName = workspaceRoot || (workspaceNode && (workspaceNode.name || workspaceNode.title || workspaceNode.id)) || 'Workspace';
var workspaceName = workspaceRoot || (workspaceNode && (workspaceNode.name || workspaceNode.title || workspaceNode.id)) || '';
window.__filesHistoryByWorkspace = window.__filesHistoryByWorkspace || {};
var historyKey = workspaceRoot || workspaceName;
var savedHistory = window.__filesHistoryByWorkspace[historyKey] || { stack: [''], index: 0, currentPath: '' };
@@ -358,6 +362,12 @@
var sortMode = 'folder-name';
var createMode = '';
var renameTarget = null;
var createModal = null;
var createInput = null;
var createError = null;
var renameModal = null;
var renameInput = null;
var renameError = null;
var disposed = false;
var fileActions = [];
var contextMenuEntries = [];
@@ -369,6 +379,22 @@
}
var navigatingHistory = false;
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
function displayType(entry) {
return typeLabel(entry, tr('ui.folderType', null, 'Folder'));
}
function reportError(key, fallback, err) {
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
console.warn('[verstak.files] ' + key, err);
}
return tr(key, null, fallback);
}
function scopedPath(local) {
local = cleanPath(local);
return workspaceRoot ? (local ? workspaceRoot + '/' + local : workspaceRoot) : local;
@@ -390,26 +416,26 @@
var toolbar = el('div', { className: 'files-toolbar' });
var breadcrumb = el('div', { className: 'files-breadcrumb' });
var backBtn = iconButton('back', 'Back', 'back', goBack);
var forwardBtn = iconButton('forward', 'Forward', 'forward', goForward);
var upBtn = iconButton('up', 'Up', 'up', goUp);
var refreshBtn = iconButton('refresh', 'Refresh', 'refresh', loadEntries);
var newFolderBtn = iconButton('new-folder', 'New folder', 'folderAdd', function () { startCreate('folder'); });
var newMdBtn = iconButton('new-markdown', 'New markdown file', 'markdownAdd', function () { startCreate('markdown'); });
var newTextBtn = iconButton('new-text', 'New text file', 'textAdd', function () { startCreate('text'); });
var openBtn = iconButton('open', 'Open', 'open', function () { openEntry(selectedEntry()); });
var renameBtn = iconButton('rename', 'Rename', 'rename', function () { beginRename(); });
var trashBtn = iconButton('trash', 'Move to trash', 'trash', function () { trashEntry(); });
var cutBtn = iconButton('cut', 'Cut', 'cut', function () { cutSelection(); });
var copyBtn = iconButton('copy', 'Copy', 'copy', function () { copySelection(); });
var pasteBtn = iconButton('paste', 'Paste', 'paste', function () { pasteEntry(); });
var filterInput = el('input', { className: 'files-filter', 'data-files-filter': '', placeholder: 'Filter current folder' });
var backBtn = iconButton('back', tr('ui.back', null, 'Back'), 'back', goBack);
var forwardBtn = iconButton('forward', tr('ui.forward', null, 'Forward'), 'forward', goForward);
var upBtn = iconButton('up', tr('ui.up', null, 'Up'), 'up', goUp);
var refreshBtn = iconButton('refresh', tr('ui.refresh', null, 'Refresh'), 'refresh', loadEntries);
var newFolderBtn = iconButton('new-folder', tr('ui.newFolder', null, 'New folder'), 'folderAdd', function () { startCreate('folder'); });
var newMdBtn = iconButton('new-markdown', tr('ui.newMarkdown', null, 'New markdown file'), 'markdownAdd', function () { startCreate('markdown'); });
var newTextBtn = iconButton('new-text', tr('ui.newText', null, 'New text file'), 'textAdd', function () { startCreate('text'); });
var openBtn = iconButton('open', tr('ui.open', null, 'Open'), 'open', function () { openEntry(selectedEntry()); });
var renameBtn = iconButton('rename', tr('ui.rename', null, 'Rename'), 'rename', function () { beginRename(); });
var trashBtn = iconButton('trash', tr('ui.trash', null, 'Move to trash'), 'trash', function () { trashEntry(); });
var cutBtn = iconButton('cut', tr('ui.cut', null, 'Cut'), 'cut', function () { cutSelection(); });
var copyBtn = iconButton('copy', tr('ui.copy', null, 'Copy'), 'copy', function () { copySelection(); });
var pasteBtn = iconButton('paste', tr('ui.paste', null, 'Paste'), 'paste', function () { pasteEntry(); });
var filterInput = el('input', { className: 'files-filter', 'data-files-filter': '', placeholder: tr('ui.filter', null, 'Filter current folder') });
var sortSelect = el('select', { className: 'files-sort', 'data-files-sort': '' }, [
el('option', { value: 'folder-name' }, ['Folders + name']),
el('option', { value: 'name-asc' }, ['Name']),
el('option', { value: 'type' }, ['Type']),
el('option', { value: 'modified-desc' }, ['Modified']),
el('option', { value: 'size-desc' }, ['Size'])
el('option', { value: 'folder-name' }, [tr('ui.sort.foldersName', null, 'Folders + name')]),
el('option', { value: 'name-asc' }, [tr('ui.column.name', null, 'Name')]),
el('option', { value: 'type' }, [tr('ui.column.type', null, 'Type')]),
el('option', { value: 'modified-desc' }, [tr('ui.column.modified', null, 'Modified')]),
el('option', { value: 'size-desc' }, [tr('ui.column.size', null, 'Size')])
]);
trashBtn.classList.add('danger');
toolbar.appendChild(breadcrumb);
@@ -426,32 +452,6 @@
var listContainer = el('div', { className: 'files-list', 'data-files-list': '' });
containerEl.appendChild(listContainer);
var createPanel = el('div', { className: 'files-panel', style: { display: 'none' } });
var createField = el('div', { className: 'files-field-stack' });
var createInput = el('input', { className: 'files-create-input', 'data-files-create-input': '' });
var createError = el('div', { className: 'files-panel-error', 'data-files-create-error': '', role: 'alert' });
var createConfirm = el('button', { className: 'files-toolbar-btn', 'data-files-create-confirm': '' }, ['Create']);
var createCancel = el('button', { className: 'files-toolbar-btn' }, ['Cancel']);
createField.appendChild(createInput);
createField.appendChild(createError);
createPanel.appendChild(createField);
createPanel.appendChild(createConfirm);
createPanel.appendChild(createCancel);
containerEl.appendChild(createPanel);
var renamePanel = el('div', { className: 'files-panel', style: { display: 'none' } });
var renameField = el('div', { className: 'files-field-stack' });
var renameInput = el('input', { className: 'files-rename-input', 'data-files-rename-input': '' });
var renameError = el('div', { className: 'files-panel-error', 'data-files-rename-error': '', role: 'alert' });
var renameConfirm = el('button', { className: 'files-toolbar-btn', 'data-files-rename-confirm': '' }, ['Rename']);
var renameCancel = el('button', { className: 'files-toolbar-btn' }, ['Cancel']);
renameField.appendChild(renameInput);
renameField.appendChild(renameError);
renamePanel.appendChild(renameField);
renamePanel.appendChild(renameConfirm);
renamePanel.appendChild(renameCancel);
containerEl.appendChild(renamePanel);
function selectedEntry() {
var keys = Object.keys(selectedPaths);
if (keys.length === 0) return null;
@@ -528,7 +528,7 @@
if (a.type !== 'folder' && b.type === 'folder') return 1;
}
if (sortMode === 'type') {
var typeCmp = typeLabel(a).localeCompare(typeLabel(b));
var typeCmp = displayType(a).localeCompare(displayType(b));
if (typeCmp) return typeCmp;
}
if (sortMode === 'modified-desc') {
@@ -598,27 +598,27 @@
function renderEmptyFolderState() {
return el('div', { className: 'files-empty' }, [
el('div', { className: 'files-empty-title' }, ['Empty folder']),
el('div', { className: 'files-empty-title' }, [tr('ui.emptyFolder', null, 'Empty folder')]),
el('div', { className: 'files-empty-actions' }, [
emptyCreateAction('new-folder', 'New folder', 'folder', 'folderAdd'),
emptyCreateAction('new-markdown', 'New markdown file', 'markdown', 'markdownAdd'),
emptyCreateAction('new-text', 'New text file', 'text', 'textAdd')
emptyCreateAction('new-folder', tr('ui.newFolder', null, 'New folder'), 'folder', 'folderAdd'),
emptyCreateAction('new-markdown', tr('ui.newMarkdown', null, 'New markdown file'), 'markdown', 'markdownAdd'),
emptyCreateAction('new-text', tr('ui.newText', null, 'New text file'), 'text', 'textAdd')
])
]);
}
function renderNoMatchesState() {
return el('div', { className: 'files-empty' }, [
el('div', { className: 'files-empty-title' }, ['No matches']),
el('div', { className: 'files-empty-title' }, [tr('ui.noMatches', null, 'No matches')]),
el('div', { className: 'files-empty-actions' }, [
el('button', {
className: 'files-empty-btn',
'data-files-empty-action': 'clear-filter',
'data-files-icon': 'refresh',
type: 'button',
title: 'Clear filter',
'aria-label': 'Clear filter',
innerHTML: svgIcon(ACTION_ICONS.refresh) + '<span>Clear filter</span>',
title: tr('ui.clearFilter', null, 'Clear filter'),
'aria-label': tr('ui.clearFilter', null, 'Clear filter'),
innerHTML: svgIcon(ACTION_ICONS.refresh) + '<span>' + tr('ui.clearFilter', null, 'Clear filter') + '</span>',
onClick: function () {
filterText = '';
filterInput.value = '';
@@ -633,11 +633,11 @@
function renderList() {
listContainer.innerHTML = '';
var header = el('div', { className: 'files-header' }, [
el('span', {}, ['Name']),
el('span', {}, ['Type']),
el('span', {}, ['Size']),
el('span', {}, ['Modified']),
el('span', {}, ['Actions'])
el('span', {}, [tr('ui.column.name', null, 'Name')]),
el('span', {}, [tr('ui.column.type', null, 'Type')]),
el('span', {}, [tr('ui.column.size', null, 'Size')]),
el('span', {}, [tr('ui.column.modified', null, 'Modified')]),
el('span', {}, [tr('ui.column.actions', null, 'Actions')])
]);
listContainer.appendChild(header);
@@ -650,7 +650,7 @@
shown.forEach(function (entry) {
var iconCategory = fileIconCategory(entry);
var iconLabel = fileIconLabel(iconCategory);
var iconLabel = fileIconLabel(iconCategory, tr);
var row = el('div', {
className: 'files-item' + (selectedPaths[entry.relativePath] ? ' selected' : ''),
'data-file-name': entry.name,
@@ -683,13 +683,13 @@
el('span', { className: 'files-item-icon', 'data-file-icon': iconCategory, title: iconLabel, 'aria-label': iconLabel, innerHTML: fileIcon(entry) }),
el('span', { className: 'files-item-name', textContent: entry.name, title: entry.name })
]),
el('span', { className: 'files-item-meta' }, [typeLabel(entry)]),
el('span', { className: 'files-item-meta' }, [displayType(entry)]),
el('span', { className: 'files-item-meta hide-narrow' }, [entry.type === 'folder' ? '' : formatSize(entry.size)]),
el('span', { className: 'files-item-meta hide-narrow' }, [formatDate(entry.modifiedAt)]),
el('div', { className: 'files-row-actions' }, [
iconButton('row-open', 'Open', 'open', function (event) { event.stopPropagation(); openEntry(entry); }, 'files-row-btn'),
iconButton('row-rename', 'Rename', 'rename', function (event) { event.stopPropagation(); beginRename(entry); }, 'files-row-btn'),
iconButton('row-trash', 'Move to trash', 'trash', function (event) { event.stopPropagation(); trashEntry(entry); }, 'files-row-btn danger')
iconButton('row-open', tr('ui.open', null, 'Open'), 'open', function (event) { event.stopPropagation(); openEntry(entry); }, 'files-row-btn'),
iconButton('row-rename', tr('ui.rename', null, 'Rename'), 'rename', function (event) { event.stopPropagation(); beginRename(entry); }, 'files-row-btn'),
iconButton('row-trash', tr('ui.trash', null, 'Move to trash'), 'trash', function (event) { event.stopPropagation(); trashEntry(entry); }, 'files-row-btn danger')
])
]);
listContainer.appendChild(row);
@@ -701,7 +701,7 @@
selectedPaths = {};
lastClickedPath = '';
listContainer.innerHTML = '';
listContainer.appendChild(el('div', { className: 'files-loading' }, ['Loading...']));
listContainer.appendChild(el('div', { className: 'files-loading' }, [tr('ui.loading', null, 'Loading...')]));
updateBreadcrumb();
api.files.list(scopedPath(currentPath)).then(function (result) {
if (disposed) return;
@@ -709,10 +709,10 @@
renderList();
}).catch(function (err) {
if (disposed) return;
console.warn('[verstak.files] load error:', err);
listContainer.innerHTML = '';
listContainer.appendChild(el('div', { className: 'files-error' }, [
el('div', {}, ['Failed to load files']),
el('div', { className: 'files-error-msg' }, [(err && err.message) ? err.message : String(err)])
el('div', {}, [tr('ui.loadFailed', null, 'Could not load files. Please try again.')])
]));
});
}
@@ -808,35 +808,131 @@
}).catch(function (err) { console.error('[files] openResource error:', err); });
}
function showFileFormModal(mode, details) {
var isRename = mode === 'rename';
var overlay = el('div', {
className: 'files-modal-overlay',
role: 'presentation'
});
overlay.setAttribute('data-files-' + mode + '-modal', '');
var input = el('input', {
className: 'files-modal-input',
type: 'text',
value: details.value || '',
placeholder: details.placeholder || '',
autocomplete: 'off',
'aria-invalid': 'false'
});
input.setAttribute('data-files-' + mode + '-input', '');
input.value = details.value || '';
var error = el('div', {
className: 'files-modal-error',
role: 'alert'
});
error.setAttribute('data-files-' + mode + '-error', '');
function closeForm() {
if (isRename) cancelRename();
else cancelCreate();
}
function confirmForm() {
if (isRename) confirmRename();
else confirmCreate();
}
input.addEventListener('input', function () {
if (isRename) setRenameError('');
else setCreateError('');
});
input.addEventListener('keydown', function (event) {
if (event.key === 'Enter') {
if (event.preventDefault) event.preventDefault();
confirmForm();
}
if (event.key === 'Escape') {
if (event.preventDefault) event.preventDefault();
closeForm();
}
});
var modal = el('div', {
className: 'files-modal files-modal-form',
role: 'dialog',
'aria-modal': 'true',
'aria-label': details.title
}, [
el('div', { className: 'files-modal-title', textContent: details.title }),
input,
error,
el('div', { className: 'files-modal-actions' }, [
el('button', {
className: 'files-modal-btn cancel',
type: 'button',
textContent: tr('ui.cancel', null, 'Cancel'),
onClick: closeForm
}),
el('button', {
className: 'files-modal-btn confirm',
type: 'button',
textContent: details.confirmLabel,
onClick: confirmForm
})
])
]);
overlay.appendChild(modal);
document.body.appendChild(overlay);
if (isRename) {
renameInput = input;
renameError = error;
} else {
createInput = input;
createError = error;
}
input.focus();
if (isRename && input.select) input.select();
return overlay;
}
function startCreate(mode) {
cancelCreate();
createMode = mode;
createInput.value = '';
createInput.placeholder = mode === 'folder' ? 'Folder name' : (mode === 'markdown' ? 'Markdown file name' : 'Text file name');
setCreateError('');
createPanel.style.display = 'flex';
createInput.focus();
createModal = showFileFormModal('create', {
title: mode === 'folder'
? tr('ui.createFolderTitle', null, 'Create folder')
: (mode === 'markdown'
? tr('ui.createMarkdownTitle', null, 'Create Markdown file')
: tr('ui.createTextTitle', null, 'Create text file')),
placeholder: mode === 'folder'
? tr('ui.folderName', null, 'Folder name')
: (mode === 'markdown'
? tr('ui.markdownFileName', null, 'Markdown file name')
: tr('ui.textFileName', null, 'Text file name')),
confirmLabel: tr('ui.create', null, 'Create')
});
}
function setCreateError(message) {
if (!createError || !createInput) return;
createError.textContent = message || '';
createError.style.display = message ? 'block' : 'none';
createInput.setAttribute('aria-invalid', message ? 'true' : 'false');
}
function validateCreateName(name) {
if (!name) return 'Name is required';
if (/[\\/:*?"<>|\x00-\x1f]/.test(name)) return 'Invalid characters in name';
if (name === '.' || name === '..' || name[0] === ' ' || name[name.length - 1] === ' ' || name[name.length - 1] === '.') return 'Invalid name';
if (!name) return tr('ui.nameRequired', null, 'Enter a name.');
if (/[\\/:*?"<>|\x00-\x1f]/.test(name)) return tr('ui.invalidCharacters', null, 'The name contains invalid characters.');
if (name === '.' || name === '..' || name[0] === ' ' || name[name.length - 1] === ' ' || name[name.length - 1] === '.') return tr('ui.invalidName', null, 'Enter a valid name.');
return '';
}
function cancelCreate() {
createMode = '';
setCreateError('');
createPanel.style.display = 'none';
if (createModal && typeof createModal.remove === 'function') createModal.remove();
else if (createModal && createModal.parentNode) createModal.parentNode.removeChild(createModal);
createModal = null;
createInput = null;
createError = null;
}
function confirmCreate() {
if (!createInput || !createMode) return;
var name = createInput.value.trim();
var validationError = validateCreateName(name);
if (validationError) {
@@ -858,46 +954,56 @@
api.workbench.openResource({ kind: 'vault-file', path: full, mode: 'edit', extension: ext ? '.' + ext : '', context: { sourcePluginId: 'verstak.files', sourceView: 'files' } }).catch(function () {});
}
}).catch(function (err) {
setCreateError('Error: ' + ((err && err.message) ? err.message : String(err)));
setCreateError(reportError('ui.createError', 'Could not create this item. Please try again.', err));
});
}
function beginRename(entry) {
entry = entry || selectedEntry();
if (!entry) return;
cancelRename();
renameTarget = entry;
renameInput.value = entry.name;
setRenameError('');
renamePanel.style.display = 'flex';
renameInput.focus();
renameInput.select();
renameModal = showFileFormModal('rename', {
title: tr('ui.renameTitle', { name: entry.name }, 'Rename ' + entry.name),
placeholder: tr('ui.fileName', null, 'File or folder name'),
value: entry.name,
confirmLabel: tr('ui.rename', null, 'Rename')
});
}
function cancelRename() {
renameTarget = null;
setRenameError('');
renamePanel.style.display = 'none';
if (renameModal && typeof renameModal.remove === 'function') renameModal.remove();
else if (renameModal && renameModal.parentNode) renameModal.parentNode.removeChild(renameModal);
renameModal = null;
renameInput = null;
renameError = null;
}
function setRenameError(message) {
if (!renameError || !renameInput) return;
renameError.textContent = message || '';
renameError.style.display = message ? 'block' : 'none';
renameInput.setAttribute('aria-invalid', message ? 'true' : 'false');
}
function confirmRename() {
if (!renameTarget) return;
if (!renameTarget || !renameInput) return;
var newName = renameInput.value.trim();
if (!newName || newName === renameTarget.name) {
if (!newName) {
setRenameError(tr('ui.nameRequired', null, 'Enter a name.'));
return;
}
if (newName === renameTarget.name) {
cancelRename();
return;
}
if (/[\\/:*?"<>|\x00-\x1f]/.test(newName)) {
setRenameError('Invalid characters in name');
setRenameError(tr('ui.invalidCharacters', null, 'The name contains invalid characters.'));
return;
}
if (newName === '.' || newName === '..' || newName[0] === ' ' || newName[newName.length - 1] === ' ' || newName[newName.length - 1] === '.') {
setRenameError('Invalid name');
setRenameError(tr('ui.invalidName', null, 'Enter a valid name.'));
return;
}
var from = renameTarget.relativePath;
@@ -905,20 +1011,20 @@
var to = targetParent ? targetParent + '/' + newName : newName;
api.files.metadata(to).then(function () {
if (to.toLowerCase() === from.toLowerCase() && to !== from) {
setRenameError('Name differs only by case');
setRenameError(tr('ui.nameDiffersOnlyByCase', null, 'The name differs only by letter case.'));
return;
}
setRenameError('A file with that name already exists');
setRenameError(tr('ui.nameConflict', null, 'An item with that name already exists.'));
}, function () {
api.files.move(from, to, { overwrite: false }).then(function () {
cancelRename();
loadEntries();
}).catch(function (err) {
if (isConflictError(err)) {
setRenameError('A file with that name already exists');
setRenameError(tr('ui.nameConflict', null, 'An item with that name already exists.'));
return;
}
setRenameError('Error: ' + ((err && err.message) ? err.message : String(err)));
setRenameError(reportError('ui.renameError', 'Could not rename this item. Please try again.', err));
});
});
}
@@ -926,14 +1032,24 @@
function trashEntry(entry) {
var count = selectedCount();
if (entry) {
confirmModal('Move "' + entry.name + '" to trash?', { danger: true }).then(function (ok) {
confirmModal(tr('ui.trashConfirm', { name: entry.name }, 'Move "' + entry.name + '" to trash?'), {
danger: true,
confirmText: tr('ui.trash', null, 'Move to trash'),
cancelText: tr('ui.cancel', null, 'Cancel')
}).then(function (ok) {
if (!ok) return;
api.files.trash(entry.relativePath).then(function () {
loadEntries();
}).catch(function (err) { window.alert((err && err.message) ? err.message : String(err)); });
}).catch(function (err) {
window.alert(reportError('ui.trashError', 'Could not move this item to trash. Please try again.', err));
});
});
} else if (count > 1) {
confirmModal('Move ' + count + ' items to trash?', { danger: true }).then(function (ok) {
confirmModal(tr('ui.trashManyConfirm', { count: count }, 'Move ' + count + ' items to trash?'), {
danger: true,
confirmText: tr('ui.trash', null, 'Move to trash'),
cancelText: tr('ui.cancel', null, 'Cancel')
}).then(function (ok) {
if (!ok) return;
var paths = Object.keys(selectedPaths);
Promise.allSettled(paths.map(function (p) { return api.files.trash(p); })).then(function () {
@@ -949,14 +1065,6 @@
filterInput.addEventListener('input', function () { filterText = filterInput.value; renderList(); });
sortSelect.addEventListener('change', function () { sortMode = sortSelect.value; renderList(); });
createConfirm.addEventListener('click', confirmCreate);
createCancel.addEventListener('click', cancelCreate);
renameConfirm.addEventListener('click', confirmRename);
renameCancel.addEventListener('click', cancelRename);
createInput.addEventListener('input', function () { setCreateError(''); });
createInput.addEventListener('keydown', function (event) { if (event.key === 'Enter') confirmCreate(); if (event.key === 'Escape') cancelCreate(); });
renameInput.addEventListener('input', function () { setRenameError(''); });
renameInput.addEventListener('keydown', function (event) { if (event.key === 'Enter') confirmRename(); if (event.key === 'Escape') cancelRename(); });
/* --- Context menu --- */
var ctxMenu = el('div', { className: 'files-ctx-menu', style: { display: 'none' } });
document.body.appendChild(ctxMenu);
@@ -986,14 +1094,23 @@
function openExternalEntry(entry, mode) {
if (!entry) return;
var fallbackLabels = {
openExternal: tr('ui.openExternal', null, 'Open externally'),
showInFolder: tr('ui.showInFolder', null, 'Show in folder'),
externalFailed: tr('ui.externalFailed', null, '{action} failed.'),
vaultRelativePath: tr('ui.vaultRelativePath', null, 'Vault-relative path:'),
copyPath: tr('ui.copyPath', null, 'Copy path'),
close: tr('ui.close', null, 'Close')
};
var filesApi = api && api.files;
var action = mode === 'explorer' ? filesApi && filesApi.showInFolder : filesApi && filesApi.openExternal;
if (typeof action !== 'function') {
showExternalFallback(entry, mode, 'files external-open API is unavailable.');
showExternalFallback(entry, mode, fallbackLabels);
return;
}
action(entry.relativePath).catch(function (err) {
showExternalFallback(entry, mode, err && err.message ? err.message : err);
console.warn('[verstak.files] external open error:', err);
showExternalFallback(entry, mode, fallbackLabels);
});
}
@@ -1039,27 +1156,27 @@
renderList();
}
var isFolder = entry.type === 'folder';
ctxMenu.appendChild(ctxItem(isFolder ? 'Open Folder' : 'Open', '', function () { openEntry(entry); }, 'open', 'open'));
ctxMenu.appendChild(ctxItem('Open External', '', function () { openExternalEntry(entry, 'external'); }, 'open-external', 'external'));
ctxMenu.appendChild(ctxItem('Show in Explorer', '', function () { openExternalEntry(entry, 'explorer'); }, 'show-in-explorer', 'explorer'));
ctxMenu.appendChild(ctxItem(isFolder ? tr('ui.openFolder', null, 'Open folder') : tr('ui.open', null, 'Open'), '', function () { openEntry(entry); }, 'open', 'open'));
ctxMenu.appendChild(ctxItem(tr('ui.openExternal', null, 'Open externally'), '', function () { openExternalEntry(entry, 'external'); }, 'open-external', 'external'));
ctxMenu.appendChild(ctxItem(tr('ui.showInFolder', null, 'Show in folder'), '', function () { openExternalEntry(entry, 'explorer'); }, 'show-in-explorer', 'explorer'));
appendContributionMenuItems(entry);
ctxMenu.appendChild(ctxSep());
ctxMenu.appendChild(ctxItem('Rename', '', function () { beginRename(entry); }, 'rename', 'rename'));
ctxMenu.appendChild(ctxItem(tr('ui.rename', null, 'Rename'), '', function () { beginRename(entry); }, 'rename', 'rename'));
if (entry.type !== 'folder') {
ctxMenu.appendChild(ctxItem('Duplicate', '', function () { duplicateEntry(entry); }, 'duplicate', 'duplicate'));
ctxMenu.appendChild(ctxItem(tr('ui.duplicate', null, 'Duplicate'), '', function () { duplicateEntry(entry); }, 'duplicate', 'duplicate'));
}
ctxMenu.appendChild(ctxSep());
ctxMenu.appendChild(ctxItem('Cut', '', function () { cutSelection(); }, 'cut', 'cut'));
ctxMenu.appendChild(ctxItem('Copy', '', function () { copySelection(); }, 'copy', 'copy'));
ctxMenu.appendChild(ctxItem(tr('ui.cut', null, 'Cut'), '', function () { cutSelection(); }, 'cut', 'cut'));
ctxMenu.appendChild(ctxItem(tr('ui.copy', null, 'Copy'), '', function () { copySelection(); }, 'copy', 'copy'));
ctxMenu.appendChild(ctxSep());
ctxMenu.appendChild(ctxItem('Move to Trash', 'danger', function () { trashEntry(); }, 'trash', 'trash'));
ctxMenu.appendChild(ctxItem(tr('ui.trash', null, 'Move to trash'), 'danger', function () { trashEntry(); }, 'trash', 'trash'));
} else {
ctxMenu.appendChild(ctxItem('New Folder', '', function () { startCreate('folder'); }, 'new-folder', 'folderAdd'));
ctxMenu.appendChild(ctxItem('New Markdown', '', function () { startCreate('markdown'); }, 'new-markdown', 'markdownAdd'));
ctxMenu.appendChild(ctxItem('New Text', '', function () { startCreate('text'); }, 'new-text', 'textAdd'));
ctxMenu.appendChild(ctxItem(tr('ui.newFolder', null, 'New folder'), '', function () { startCreate('folder'); }, 'new-folder', 'folderAdd'));
ctxMenu.appendChild(ctxItem(tr('ui.newMarkdown', null, 'New Markdown file'), '', function () { startCreate('markdown'); }, 'new-markdown', 'markdownAdd'));
ctxMenu.appendChild(ctxItem(tr('ui.newText', null, 'New text file'), '', function () { startCreate('text'); }, 'new-text', 'textAdd'));
if (window.__filesClipboard && window.__filesClipboard.items && window.__filesClipboard.items.length) {
ctxMenu.appendChild(ctxSep());
ctxMenu.appendChild(ctxItem('Paste', '', function () { pasteEntry(); }, 'paste', 'paste'));
ctxMenu.appendChild(ctxItem(tr('ui.paste', null, 'Paste'), '', function () { pasteEntry(); }, 'paste', 'paste'));
}
}
ctxMenu.style.display = 'block';
@@ -1152,7 +1269,7 @@
var clip = window.__filesClipboard;
if (!clip || !clip.items || clip.items.length === 0) return;
if (clip.workspaceRoot && clip.workspaceRoot !== workspaceRoot) {
window.alert('Clipboard items belong to another workspace.');
window.alert(tr('ui.clipboardDifferentDeal', null, 'Clipboard items belong to another Deal.'));
return;
}
var destinationDir = scopedPath(currentPath);
@@ -1478,6 +1595,25 @@
loadContributionActions();
loadEntries();
var localeUnsubscribe = null;
if (api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
localeUnsubscribe = api.i18n.onDidChangeLocale(function () {
[
[backBtn, 'ui.back', 'Back'], [forwardBtn, 'ui.forward', 'Forward'], [upBtn, 'ui.up', 'Up'],
[refreshBtn, 'ui.refresh', 'Refresh'], [newFolderBtn, 'ui.newFolder', 'New folder'],
[newMdBtn, 'ui.newMarkdown', 'New markdown file'], [newTextBtn, 'ui.newText', 'New text file'],
[openBtn, 'ui.open', 'Open'], [renameBtn, 'ui.rename', 'Rename'], [trashBtn, 'ui.trash', 'Move to trash'],
[cutBtn, 'ui.cut', 'Cut'], [copyBtn, 'ui.copy', 'Copy'], [pasteBtn, 'ui.paste', 'Paste']
].forEach(function (item) {
var label = tr(item[1], null, item[2]);
item[0].setAttribute('title', label);
item[0].setAttribute('aria-label', label);
});
filterInput.setAttribute('placeholder', tr('ui.filter', null, 'Filter current folder'));
renderList();
});
}
var fileChangedUnsubscribe = null;
if (api.events && typeof api.events.subscribe === 'function') {
api.events.subscribe('file.changed', function (event) {
@@ -1492,6 +1628,9 @@
containerEl.__filesCleanup = function () {
disposed = true;
cancelCreate();
cancelRename();
if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
if (typeof fileChangedUnsubscribe === 'function') fileChangedUnsubscribe();
document.removeEventListener('click', onDocClick);
document.removeEventListener('keydown', onDocKeydown);
+77
View File
@@ -0,0 +1,77 @@
{
"manifest.name": "Files",
"manifest.description": "Deal-scoped vault file explorer with create, rename, trash, filtering, sorting, and Workbench openResource integration.",
"contributions.workspaceItems.verstak.files.workspace.title": "Files",
"ui.back": "Back",
"ui.forward": "Forward",
"ui.up": "Up",
"ui.refresh": "Refresh",
"ui.newFolder": "New folder",
"ui.newMarkdown": "New markdown file",
"ui.newText": "New text file",
"ui.open": "Open",
"ui.openFolder": "Open folder",
"ui.openExternal": "Open externally",
"ui.showInFolder": "Show in folder",
"ui.rename": "Rename",
"ui.duplicate": "Duplicate",
"ui.trash": "Move to trash",
"ui.cut": "Cut",
"ui.copy": "Copy",
"ui.paste": "Paste",
"ui.filter": "Filter current folder",
"ui.sort.foldersName": "Folders + name",
"ui.column.name": "Name",
"ui.column.type": "Type",
"ui.column.modified": "Modified",
"ui.column.size": "Size",
"ui.column.actions": "Actions",
"ui.folderType": "Folder",
"ui.icon.folder": "Folder",
"ui.icon.markdown": "Markdown file",
"ui.icon.image": "Image file",
"ui.icon.video": "Video file",
"ui.icon.audio": "Audio file",
"ui.icon.archive": "Archive file",
"ui.icon.pdf": "PDF file",
"ui.icon.text": "Text file",
"ui.icon.document": "Document file",
"ui.icon.spreadsheet": "Spreadsheet file",
"ui.icon.presentation": "Presentation file",
"ui.icon.code": "Code file",
"ui.icon.database": "Database file",
"ui.icon.font": "Font file",
"ui.icon.config": "Config file",
"ui.icon.json": "JSON file",
"ui.icon.generic": "File",
"ui.create": "Create",
"ui.cancel": "Cancel",
"ui.createFolderTitle": "Create folder",
"ui.createMarkdownTitle": "Create Markdown file",
"ui.createTextTitle": "Create text file",
"ui.renameTitle": "Rename {name}",
"ui.folderName": "Folder name",
"ui.markdownFileName": "Markdown file name",
"ui.textFileName": "Text file name",
"ui.fileName": "File or folder name",
"ui.nameRequired": "Enter a name.",
"ui.invalidCharacters": "The name contains invalid characters.",
"ui.invalidName": "Enter a valid name.",
"ui.nameDiffersOnlyByCase": "The name differs only by letter case.",
"ui.nameConflict": "An item with that name already exists.",
"ui.externalFailed": "{action} failed.",
"ui.vaultRelativePath": "Vault-relative path:",
"ui.copyPath": "Copy path",
"ui.close": "Close",
"ui.trashConfirm": "Move \"{name}\" to trash?",
"ui.trashManyConfirm": "Move {count} items to trash?",
"ui.clipboardDifferentDeal": "Clipboard items belong to another Deal.",
"ui.emptyFolder": "Empty folder",
"ui.noMatches": "No matches",
"ui.clearFilter": "Clear filter",
"ui.loading": "Loading...",
"ui.loadFailed": "Could not load files. Please try again.",
"ui.createError": "Could not create this item. Please try again.",
"ui.renameError": "Could not rename this item. Please try again.",
"ui.trashError": "Could not move this item to trash. Please try again."
}
+77
View File
@@ -0,0 +1,77 @@
{
"manifest.name": "Файлы",
"manifest.description": "Файловый менеджер Дела с созданием, переименованием, корзиной, фильтрацией и сортировкой.",
"contributions.workspaceItems.verstak.files.workspace.title": "Файлы",
"ui.back": "Назад",
"ui.forward": "Вперёд",
"ui.up": "На уровень выше",
"ui.refresh": "Обновить",
"ui.newFolder": "Новая папка",
"ui.newMarkdown": "Новый Markdown-файл",
"ui.newText": "Новый текстовый файл",
"ui.open": "Открыть",
"ui.openFolder": "Открыть папку",
"ui.openExternal": "Открыть во внешнем приложении",
"ui.showInFolder": "Показать в папке",
"ui.rename": "Переименовать",
"ui.duplicate": "Создать копию",
"ui.trash": "Переместить в корзину",
"ui.cut": "Вырезать",
"ui.copy": "Копировать",
"ui.paste": "Вставить",
"ui.filter": "Фильтр текущей папки",
"ui.sort.foldersName": "Сначала папки, затем по имени",
"ui.column.name": "Имя",
"ui.column.type": "Тип",
"ui.column.modified": "Изменён",
"ui.column.size": "Размер",
"ui.column.actions": "Действия",
"ui.folderType": "Папка",
"ui.icon.folder": "Папка",
"ui.icon.markdown": "Файл Markdown",
"ui.icon.image": "Изображение",
"ui.icon.video": "Видео",
"ui.icon.audio": "Аудиофайл",
"ui.icon.archive": "Архив",
"ui.icon.pdf": "PDF-файл",
"ui.icon.text": "Текстовый файл",
"ui.icon.document": "Документ",
"ui.icon.spreadsheet": "Таблица",
"ui.icon.presentation": "Презентация",
"ui.icon.code": "Исходный код",
"ui.icon.database": "База данных",
"ui.icon.font": "Шрифт",
"ui.icon.config": "Файл конфигурации",
"ui.icon.json": "JSON-файл",
"ui.icon.generic": "Файл",
"ui.create": "Создать",
"ui.cancel": "Отмена",
"ui.createFolderTitle": "Создать папку",
"ui.createMarkdownTitle": "Создать Markdown-файл",
"ui.createTextTitle": "Создать текстовый файл",
"ui.renameTitle": "Переименовать {name}",
"ui.folderName": "Название папки",
"ui.markdownFileName": "Название Markdown-файла",
"ui.textFileName": "Название текстового файла",
"ui.fileName": "Название файла или папки",
"ui.nameRequired": "Введите название.",
"ui.invalidCharacters": "В названии есть недопустимые символы.",
"ui.invalidName": "Введите корректное название.",
"ui.nameDiffersOnlyByCase": "Название отличается только регистром букв.",
"ui.nameConflict": "Элемент с таким названием уже существует.",
"ui.externalFailed": "Не удалось выполнить действие «{action}».",
"ui.vaultRelativePath": "Путь относительно хранилища:",
"ui.copyPath": "Скопировать путь",
"ui.close": "Закрыть",
"ui.trashConfirm": "Переместить «{name}» в корзину?",
"ui.trashManyConfirm": "Переместить в корзину {count} элементов?",
"ui.clipboardDifferentDeal": "Элементы буфера обмена принадлежат другому Делу.",
"ui.emptyFolder": "Папка пуста",
"ui.noMatches": "Совпадений нет",
"ui.clearFilter": "Сбросить фильтр",
"ui.loading": "Загрузка...",
"ui.loadFailed": "Не удалось загрузить файлы. Повторите попытку.",
"ui.createError": "Не удалось создать этот элемент. Повторите попытку.",
"ui.renameError": "Не удалось переименовать этот элемент. Повторите попытку.",
"ui.trashError": "Не удалось переместить элемент в корзину. Повторите попытку."
}
+2 -1
View File
@@ -4,7 +4,8 @@
"name": "Files",
"version": "0.1.0",
"apiVersion": "0.1.0",
"description": "Workspace-scoped vault file explorer with create, rename, trash, filtering, sorting, and Workbench openResource integration.",
"description": "Deal-scoped vault file explorer with create, rename, trash, filtering, sorting, and Workbench openResource integration.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official",
"icon": "folder",
"provides": [
+136 -53
View File
@@ -31,6 +31,7 @@
'.journal-status.error{display:inline-flex;border:1px solid rgba(233,69,96,.45);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-danger-muted,rgba(233,69,96,.14));color:#ffc6ce;padding:.18rem .4rem}',
'.journal-input{font-size:.8rem;padding:.38rem .5rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);min-width:0;font-family:inherit}',
'.journal-input.textarea{min-height:7rem;resize:vertical;line-height:1.4}',
'.journal-input.journal-select{appearance:none;background-color:#0f1424;background-image:linear-gradient(45deg,transparent 50%,var(--vt-color-text-muted,#7f8aa3) 50%),linear-gradient(135deg,var(--vt-color-text-muted,#7f8aa3) 50%,transparent 50%);background-position:calc(100% - 14px) 50%,calc(100% - 9px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:1.7rem}.journal-input.journal-select option{background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb)}',
'.journal-input:focus{outline:none;border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.journal-billable{display:flex;align-items:center;gap:.25rem;font-size:.74rem;color:var(--vt-color-text-secondary,#b7c0d4);white-space:nowrap}',
'.journal-list{flex:1;min-height:0;overflow:auto;background:var(--vt-color-background,#101020)}',
@@ -107,7 +108,7 @@
function scopeFromProps(props) {
var workspaceRoot = workspaceFromProps(props || {});
if (!workspaceRoot) return { mode: 'global', key: '', label: 'All workspaces', workspaceRoot: '' };
if (!workspaceRoot) return { mode: 'global', key: '', label: 'All Deals', workspaceRoot: '' };
return {
mode: 'workspace',
key: WORKLOG_PREFIX + encodeKey(workspaceRoot),
@@ -227,6 +228,8 @@
}
return {
candidateId: text(value.candidateId),
sessionId: text(value.sessionId),
handledThrough: text(value.handledThrough || value.endedAt),
workspaceRootPath: workspace,
startedAt: text(value.startedAt),
endedAt: text(value.endedAt),
@@ -273,18 +276,31 @@
var scope = scopeFromProps(props || {});
var entries = [];
var statusText = 'Loading journal...';
var workspaceOptions = [];
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
var statusText = tr('ui.loading', null, 'Loading journal...');
var statusClass = '';
var modalHost = el('div', { className: 'journal-modal-host', hidden: 'hidden' });
function reportError(key, fallback, err) {
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
console.warn('[verstak.journal] ' + key, err);
}
statusText = tr(key, null, fallback);
statusClass = 'error';
}
var toolbar = el('div', { className: 'journal-toolbar' });
var titleEl = el('span', { className: 'journal-title', textContent: scope.mode === 'global' ? 'Journal' : 'Journal · ' + scope.label });
var titleEl = el('span', { className: 'journal-title', textContent: scope.mode === 'global' ? tr('ui.title', null, 'Journal') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Journal · ' + scope.label) });
var countEl = el('span', { className: 'journal-count' });
var statusEl = el('span', { className: 'journal-status' });
var addBtn = el('button', {
className: 'journal-btn primary',
'data-journal-action': 'add',
innerHTML: iconSvg('add') + '<span>Add</span>',
innerHTML: iconSvg('add') + '<span>' + tr('ui.add', null, 'Add') + '</span>',
onClick: function () { showEntryModal(); }
});
toolbar.appendChild(titleEl);
@@ -298,12 +314,23 @@
containerEl.appendChild(listEl);
containerEl.appendChild(modalHost);
function persist() {
if (scope.mode !== 'workspace') return Promise.resolve();
function persist(workspaceRoot, values) {
if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
return api.settings.write(scope.key, storageEntries(entries)).catch(function (err) {
statusText = 'Could not save journal: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
var target = cleanWorkspace(workspaceRoot || scope.workspaceRoot);
if (!target) return Promise.resolve();
return api.settings.write(WORKLOG_PREFIX + encodeKey(target), storageEntries(values || entries)).catch(function (err) {
reportError('ui.saveError', 'Could not save journal. Please try again.', err);
});
}
function loadWorkspaceOptions() {
if (!api || !api.files || typeof api.files.list !== 'function') return Promise.resolve();
return api.files.list('').then(function (items) {
workspaceOptions = (Array.isArray(items) ? items : []).filter(function (item) {
return text(item && item.type).toLowerCase() === 'folder';
}).map(function (item) { return cleanWorkspace(item.relativePath || item.name); }).filter(function (value) {
return value && value.indexOf('/') === -1;
});
});
}
@@ -316,20 +343,18 @@
all = all.concat(normalizeEntries((settings || {})[key], key));
});
entries = sortEntries(all);
statusText = 'Aggregating worklogs';
statusText = tr('ui.aggregating', null, 'Aggregating worklogs');
statusClass = '';
}).catch(function (err) {
statusText = 'Could not load journal: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
reportError('ui.loadError', 'Could not load journal. Please try again.', err);
});
}
return api.settings.read(scope.key).then(function (stored) {
entries = sortEntries(normalizeEntries(stored, scope.key));
statusText = 'Ready';
statusText = tr('ui.ready', null, 'Ready');
statusClass = '';
}).catch(function (err) {
statusText = 'Could not load journal: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
reportError('ui.loadError', 'Could not load journal. Please try again.', err);
});
}
@@ -338,18 +363,50 @@
modalHost.setAttribute('hidden', 'hidden');
}
function activityLabel(activity) {
var type = text(activity && activity.type);
var labels = {
'browser.capture.link': ['ui.activity.captureLink', 'Link captured'],
'browser.capture.selection': ['ui.activity.captureSelection', 'Selection captured'],
'browser.capture.page': ['ui.activity.capturePage', 'Page captured'],
'file.created': ['ui.activity.fileCreated', 'File created'],
'file.updated': ['ui.activity.fileUpdated', 'File updated'],
'file.deleted': ['ui.activity.fileDeleted', 'File deleted'],
'note.saved': ['ui.activity.noteSaved', 'Note saved'],
'workspace.created': ['ui.activity.dealCreated', 'Deal created'],
'workspace.renamed': ['ui.activity.dealRenamed', 'Deal renamed']
};
var label = labels[type] || ['ui.activity.generic', 'Activity'];
return tr(label[0], null, label[1]);
}
function entryMeta(entry) {
var facts = [entry.workspaceRootPath, entry.billable ? tr('ui.meta.billable', null, 'billable') : tr('ui.meta.nonBillable', null, 'non-billable')];
if (entry.activityIds.length) {
facts.push(tr(entry.activityIds.length === 1 ? 'ui.meta.activities.one' : 'ui.meta.activities.many', { count: entry.activityIds.length }, entry.activityIds.length + ' linked activities'));
}
if (entry.sourceTodoId) facts.push(tr('ui.meta.todo', null, 'linked todo'));
return facts.join(' · ');
}
function showEntryModal(existingEntry, candidate, completedTodo) {
if (scope.mode !== 'workspace') return;
var editing = !!existingEntry;
var reviewingCandidate = !editing && !!candidate;
var reviewingTodo = !editing && !!completedTodo;
var dateInput = el('input', { className: 'journal-input', type: 'date', value: editing ? existingEntry.date : (reviewingCandidate ? candidateDate(candidate.startedAt) : (reviewingTodo ? candidateDate(completedTodo.completedAt) : today())), 'data-journal-input': 'date' });
var titleInput = el('input', { className: 'journal-input', type: 'text', placeholder: 'Work item', value: editing ? existingEntry.title : (reviewingTodo ? completedTodo.title : ''), 'data-journal-input': 'title' });
var summaryInput = el('textarea', { className: 'journal-input textarea', placeholder: 'Body', 'data-journal-input': 'summary' });
var titleInput = el('input', { className: 'journal-input', type: 'text', placeholder: tr('ui.workItem', null, 'Work item'), value: editing ? existingEntry.title : (reviewingTodo ? completedTodo.title : ''), 'data-journal-input': 'title' });
var summaryInput = el('textarea', { className: 'journal-input textarea', placeholder: tr('ui.body', null, 'Body'), 'data-journal-input': 'summary' });
summaryInput.value = editing ? existingEntry.summary : (reviewingTodo ? completedTodo.description : '');
var minutesInput = el('input', { className: 'journal-input', type: 'number', min: '0', step: '1', value: editing ? existingEntry.minutes : (reviewingCandidate ? candidate.estimatedMinutes : (reviewingTodo ? '0' : '30')), 'data-journal-input': 'minutes' });
var billableInput = el('input', { type: 'checkbox', 'data-journal-input': 'billable' });
billableInput.checked = editing ? existingEntry.billable === true : false;
var workspaceInput = null;
if (scope.mode === 'global') {
workspaceInput = el('select', { className: 'journal-input journal-select', 'data-journal-input': 'workspaceRootPath' });
workspaceOptions.forEach(function (workspace) {
workspaceInput.appendChild(el('option', { value: workspace, textContent: workspace }));
});
}
var activityInputs = reviewingCandidate ? candidate.activities.map(function (activity) {
var input = el('input', { type: 'checkbox', value: activity.activityId, checked: 'checked', 'data-journal-candidate-activity': activity.activityId });
input.checked = true;
@@ -363,7 +420,10 @@
summary: summaryInput.value,
minutes: minutesInput.value,
billable: billableInput.checked === true,
workspaceRootPath: workspaceInput ? workspaceInput.value : scope.workspaceRoot,
sourceCandidateId: reviewingCandidate ? candidate.candidateId : (existingEntry ? existingEntry.sourceCandidateId : ''),
sessionId: reviewingCandidate ? candidate.sessionId : '',
handledThrough: reviewingCandidate ? candidate.handledThrough : '',
sourceTodoId: reviewingTodo ? completedTodo.id : (existingEntry ? existingEntry.sourceTodoId : ''),
activityIds: reviewingCandidate
? activityInputs.filter(function (item) { return item.input.checked === true; }).map(function (item) { return item.activity.activityId; })
@@ -372,22 +432,22 @@
}
var candidateContext = reviewingCandidate ? el('div', { className: 'journal-candidate-context', 'data-journal-candidate': candidate.candidateId }, [
el('strong', { textContent: 'Possible journal entry' }),
el('div', { textContent: 'Workspace: ' + candidate.workspaceRootPath }),
el('div', { textContent: 'Time: ' + candidateTime(candidate.startedAt) + ' - ' + candidateTime(candidate.endedAt) }),
el('div', { textContent: 'Estimated duration: ' + candidate.estimatedMinutes + ' min' }),
el('div', { textContent: 'Activities: ' + candidate.activityCount })
el('strong', { textContent: tr('ui.candidate.title', null, 'Possible journal entry') }),
el('div', { textContent: tr('ui.candidate.deal', { deal: candidate.workspaceRootPath }, 'Deal: ' + candidate.workspaceRootPath) }),
el('div', { textContent: tr('ui.candidate.time', { start: candidateTime(candidate.startedAt), end: candidateTime(candidate.endedAt) }, 'Time: ' + candidateTime(candidate.startedAt) + ' – ' + candidateTime(candidate.endedAt)) }),
el('div', { textContent: tr('ui.candidate.duration', { minutes: candidate.estimatedMinutes }, 'Estimated duration: ' + candidate.estimatedMinutes + ' min') }),
el('div', { textContent: tr('ui.candidate.activities', { count: candidate.activityCount }, 'Activities: ' + candidate.activityCount) })
]) : null;
var candidateActivities = reviewingCandidate ? el('fieldset', { className: 'journal-candidate-activities' }, [
el('legend', { textContent: 'Linked activities' })
el('legend', { textContent: tr('ui.candidate.linkedActivities', null, 'Linked activities') })
].concat(activityInputs.map(function (item) {
var detail = (item.activity.occurredAt ? candidateTime(item.activity.occurredAt) + ' · ' : '') + item.activity.type + ' · ' + item.activity.activityId;
var detail = (item.activity.occurredAt ? candidateTime(item.activity.occurredAt) + ' · ' : '') + activityLabel(item.activity);
return el('label', { className: 'journal-candidate-activity' }, [item.input, detail]);
}))) : null;
var todoContext = reviewingTodo ? el('div', { className: 'journal-candidate-context', 'data-journal-todo': completedTodo.id }, [
el('strong', { textContent: 'Completed todo' }),
el('div', { textContent: 'Workspace: ' + completedTodo.workspaceRootPath }),
completedTodo.completedAt ? el('div', { textContent: 'Completed: ' + candidateTime(completedTodo.completedAt) }) : null
el('strong', { textContent: tr('ui.todo.title', null, 'Completed todo') }),
el('div', { textContent: tr('ui.candidate.deal', { deal: completedTodo.workspaceRootPath }, 'Deal: ' + completedTodo.workspaceRootPath) }),
completedTodo.completedAt ? el('div', { textContent: tr('ui.todo.completed', { time: candidateTime(completedTodo.completedAt) }, 'Completed: ' + candidateTime(completedTodo.completedAt)) }) : null
]) : null;
modalHost.innerHTML = '';
@@ -397,20 +457,21 @@
if (event.target === event.currentTarget) closeEntryModal();
} }, [
el('div', { className: 'journal-modal' }, [
el('div', { className: 'journal-modal-title', textContent: editing ? 'Edit journal entry' : (reviewingCandidate ? 'Review possible journal entry' : (reviewingTodo ? 'Create journal entry from completed todo' : 'Add journal entry')) }),
el('div', { className: 'journal-modal-title', textContent: editing ? tr('ui.editEntry', null, 'Edit journal entry') : (reviewingCandidate ? tr('ui.reviewCandidate', null, 'Review possible journal entry') : (reviewingTodo ? tr('ui.fromTodo', null, 'Create journal entry from completed todo') : tr('ui.addEntry', null, 'Add journal entry'))) }),
candidateContext,
todoContext,
el('div', { className: 'journal-modal-grid' }, [
el('label', { className: 'journal-field' }, ['Date', dateInput]),
el('label', { className: 'journal-field' }, ['Minutes', minutesInput]),
el('label', { className: 'journal-field wide' }, ['Title', titleInput]),
el('label', { className: 'journal-field wide' }, ['Body', summaryInput]),
el('label', { className: 'journal-billable' }, [billableInput, 'Billable'])
el('label', { className: 'journal-field' }, [tr('ui.date', null, 'Date'), dateInput]),
el('label', { className: 'journal-field' }, [tr('ui.minutes', null, 'Minutes'), minutesInput]),
workspaceInput ? el('label', { className: 'journal-field wide' }, [tr('ui.workspace', null, 'Deal'), workspaceInput]) : null,
el('label', { className: 'journal-field wide' }, [tr('ui.fieldTitle', null, 'Title'), titleInput]),
el('label', { className: 'journal-field wide' }, [tr('ui.body', null, 'Body'), summaryInput]),
el('label', { className: 'journal-billable' }, [billableInput, tr('ui.billable', null, 'Billable')])
]),
candidateActivities,
el('div', { className: 'journal-modal-actions' }, [
el('button', { className: 'journal-btn ghost', type: 'button', textContent: 'Cancel', onClick: closeEntryModal }),
el('button', { className: 'journal-btn primary', type: 'button', 'data-journal-action': 'save-entry', textContent: editing ? 'Save changes' : 'Add entry', onClick: saveEntry })
el('button', { className: 'journal-btn ghost', type: 'button', textContent: tr('ui.cancel', null, 'Cancel'), onClick: closeEntryModal }),
el('button', { className: 'journal-btn primary', type: 'button', 'data-journal-action': 'save-entry', textContent: editing ? tr('ui.saveChanges', null, 'Save changes') : tr('ui.addEntryShort', null, 'Add entry'), onClick: saveEntry })
])
])
]));
@@ -418,31 +479,34 @@
}
function addOrUpdateEntry(existingEntry, formValue) {
if (scope.mode !== 'workspace') return;
var title = text(formValue && formValue.title).trim();
if (!title) {
statusText = 'Title is required';
statusText = tr('ui.titleRequired', null, 'Title is required');
statusClass = 'error';
render();
return;
}
var workspaceRoot = cleanWorkspace(formValue && formValue.workspaceRootPath || scope.workspaceRoot);
if (!workspaceRoot) return;
var sourceCandidateId = text(formValue && formValue.sourceCandidateId || (existingEntry && existingEntry.sourceCandidateId)).trim();
var sessionID = text(formValue && formValue.sessionId).trim();
var handledThrough = text(formValue && formValue.handledThrough).trim();
var sourceTodoId = text(formValue && formValue.sourceTodoId || (existingEntry && existingEntry.sourceTodoId)).trim();
if (!existingEntry && sourceCandidateId && entries.some(function (entry) { return entry.sourceCandidateId === sourceCandidateId; })) {
statusText = 'A journal entry already references this candidate';
statusText = tr('ui.candidate.duplicate', null, 'A journal entry already references this candidate');
statusClass = 'error';
render();
return;
}
if (!existingEntry && sourceTodoId && entries.some(function (entry) { return entry.sourceTodoId === sourceTodoId; })) {
statusText = 'A journal entry already references this todo';
statusText = tr('ui.todo.duplicate', null, 'A journal entry already references this todo');
statusClass = 'error';
render();
return;
}
var entry = normalizeEntry({
entryId: existingEntry ? existingEntry.entryId : entryId(scope.workspaceRoot, formValue.date || today(), title),
workspaceRootPath: scope.workspaceRoot,
entryId: existingEntry ? existingEntry.entryId : entryId(workspaceRoot, formValue.date || today(), title),
workspaceRootPath: workspaceRoot,
date: formValue.date || today(),
title: title,
summary: formValue.summary,
@@ -460,16 +524,24 @@
entries = [entry].concat(entries);
}
entries = sortEntries(entries);
var targetEntries = entries.filter(function (item) { return item.workspaceRootPath === workspaceRoot; });
closeEntryModal();
statusText = existingEntry ? 'Entry updated' : 'Entry added';
statusText = existingEntry ? tr('ui.updated', null, 'Entry updated') : tr('ui.added', null, 'Entry added');
statusClass = '';
persist().then(render);
persist(workspaceRoot, targetEntries).then(function () {
if (!sessionID || !handledThrough || !api || !api.events || typeof api.events.publish !== 'function') return undefined;
return api.events.publish('activity.session.handled', {
sessionId: sessionID,
handledThrough: handledThrough,
status: 'accepted'
});
}).then(render);
}
function deleteEntry(entry) {
if (scope.mode !== 'workspace' || !entry) return;
entries = entries.filter(function (item) { return item.entryId !== entry.entryId; });
statusText = 'Entry deleted';
statusText = tr('ui.deleted', null, 'Entry deleted');
statusClass = '';
persist().then(render);
}
@@ -477,7 +549,7 @@
function renderList() {
listEl.innerHTML = '';
if (!entries.length) {
listEl.appendChild(el('div', { className: 'journal-empty', textContent: scope.mode === 'global' ? 'No worklog entries yet.' : 'No worklog entries yet.' }));
listEl.appendChild(el('div', { className: 'journal-empty', textContent: tr('ui.empty', null, 'No worklog entries yet.') }));
return;
}
entries.forEach(function (entry) {
@@ -489,33 +561,44 @@
el('div', { className: 'journal-main' }, [
el('div', { className: 'journal-entry-title', textContent: entry.title }),
entry.summary ? el('div', { className: 'journal-summary', textContent: entry.summary }) : null,
el('div', { className: 'journal-meta', textContent: entry.workspaceRootPath + (entry.billable ? ' · billable' : ' · non-billable') + (entry.activityIds.length ? ' · ' + entry.activityIds.length + ' linked activities' : '') + (entry.sourceTodoId ? ' · linked todo' : '') })
el('div', { className: 'journal-meta', textContent: entryMeta(entry) })
]),
el('div', { className: 'journal-minutes', textContent: entry.minutes + ' min' }),
el('div', { className: 'journal-minutes', textContent: tr('ui.minutesValue', { minutes: entry.minutes }, entry.minutes + ' min') }),
scope.mode === 'workspace' ? el('div', { className: 'journal-row-actions' }, [
el('button', { className: 'journal-icon-btn', type: 'button', title: 'Edit', 'aria-label': 'Edit', 'data-journal-action': 'edit', innerHTML: iconSvg('edit'), onClick: function () { showEntryModal(entry); } }),
el('button', { className: 'journal-icon-btn danger', type: 'button', title: 'Delete', 'aria-label': 'Delete', 'data-journal-action': 'delete', innerHTML: iconSvg('trash'), onClick: function () { deleteEntry(entry); } })
el('button', { className: 'journal-icon-btn', type: 'button', title: tr('ui.edit', null, 'Edit'), 'aria-label': tr('ui.edit', null, 'Edit'), 'data-journal-action': 'edit', innerHTML: iconSvg('edit'), onClick: function () { showEntryModal(entry); } }),
el('button', { className: 'journal-icon-btn danger', type: 'button', title: tr('ui.delete', null, 'Delete'), 'aria-label': tr('ui.delete', null, 'Delete'), 'data-journal-action': 'delete', innerHTML: iconSvg('trash'), onClick: function () { deleteEntry(entry); } })
]) : null
]));
});
}
function render() {
countEl.textContent = entries.length + ' entr' + (entries.length === 1 ? 'y' : 'ies');
countEl.textContent = tr(
entries.length === 1 ? 'ui.entryCount.one' : 'ui.entryCount.many',
{ count: entries.length },
entries.length === 1 ? '{count} entry' : '{count} entries'
);
statusEl.textContent = statusText;
statusEl.className = 'journal-status' + (statusClass ? ' ' + statusClass : '');
addBtn.disabled = scope.mode !== 'workspace';
addBtn.disabled = false;
renderList();
}
render();
loadStored().then(function () {
Promise.all([loadStored(), loadWorkspaceOptions()]).then(function () {
render();
var candidate = candidateFromRequest(props && props.toolRequest, scope.workspaceRoot);
var completedTodo = completedTodoFromRequest(props && props.toolRequest, scope.workspaceRoot);
if (candidate) showEntryModal(null, candidate);
else if (completedTodo) showEntryModal(null, null, completedTodo);
});
if (api && api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
api.i18n.onDidChangeLocale(function () {
titleEl.textContent = scope.mode === 'global' ? tr('ui.title', null, 'Journal') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Journal · ' + scope.label);
addBtn.innerHTML = iconSvg('add') + '<span>' + tr('ui.add', null, 'Add') + '</span>';
render();
});
}
};
JournalView.unmount = function (containerEl) {
+63
View File
@@ -0,0 +1,63 @@
{
"manifest.name": "Journal",
"manifest.description": "Deal-scoped journal with user-authored entries and optional Activity links.",
"contributions.views.verstak.journal.view.title": "Journal",
"contributions.sidebarItems.verstak.journal.sidebar.title": "Journal",
"contributions.workspaceItems.verstak.journal.workspace.title": "Journal",
"ui.loading": "Loading journal...",
"ui.title": "Journal",
"ui.workspaceTitle": "Journal · {workspace}",
"ui.add": "Add",
"ui.saveError": "Could not save journal. Please try again.",
"ui.aggregating": "Aggregating worklogs",
"ui.loadError": "Could not load journal. Please try again.",
"ui.ready": "Ready",
"ui.workItem": "Work item",
"ui.body": "Body",
"ui.editEntry": "Edit journal entry",
"ui.reviewCandidate": "Review possible journal entry",
"ui.fromTodo": "Create journal entry from completed todo",
"ui.addEntry": "Add journal entry",
"ui.date": "Date",
"ui.minutes": "Minutes",
"ui.fieldTitle": "Title",
"ui.billable": "Billable",
"ui.cancel": "Cancel",
"ui.saveChanges": "Save changes",
"ui.addEntryShort": "Add entry",
"ui.titleRequired": "Title is required",
"ui.deleted": "Entry deleted",
"ui.empty": "No worklog entries yet.",
"ui.edit": "Edit",
"ui.delete": "Delete",
"ui.entryCount.one": "{count} entry",
"ui.entryCount.many": "{count} entries",
"ui.candidate.title": "Possible journal entry",
"ui.candidate.deal": "Deal: {deal}",
"ui.candidate.time": "Time: {start} – {end}",
"ui.candidate.duration": "Estimated duration: {minutes} min",
"ui.candidate.activities": "Activities: {count}",
"ui.candidate.linkedActivities": "Linked activities",
"ui.candidate.duplicate": "A journal entry already references this candidate",
"ui.todo.title": "Completed todo",
"ui.todo.completed": "Completed: {time}",
"ui.todo.duplicate": "A journal entry already references this todo",
"ui.activity.captureLink": "Link captured",
"ui.activity.captureSelection": "Selection captured",
"ui.activity.capturePage": "Page captured",
"ui.activity.fileCreated": "File created",
"ui.activity.fileUpdated": "File updated",
"ui.activity.fileDeleted": "File deleted",
"ui.activity.noteSaved": "Note saved",
"ui.activity.dealCreated": "Deal created",
"ui.activity.dealRenamed": "Deal renamed",
"ui.activity.generic": "Activity",
"ui.updated": "Entry updated",
"ui.added": "Entry added",
"ui.minutesValue": "{minutes} min",
"ui.meta.billable": "billable",
"ui.meta.nonBillable": "non-billable",
"ui.meta.activities.one": "{count} linked activity",
"ui.meta.activities.many": "{count} linked activities",
"ui.meta.todo": "linked todo"
}
+63
View File
@@ -0,0 +1,63 @@
{
"manifest.name": "Журнал",
"manifest.description": "Журнал Дела с ручными записями и необязательными связями с активностью.",
"contributions.views.verstak.journal.view.title": "Журнал",
"contributions.sidebarItems.verstak.journal.sidebar.title": "Журнал",
"contributions.workspaceItems.verstak.journal.workspace.title": "Журнал",
"ui.loading": "Загрузка журнала...",
"ui.title": "Журнал",
"ui.workspaceTitle": "Журнал · {workspace}",
"ui.add": "Добавить",
"ui.saveError": "Не удалось сохранить журнал. Повторите попытку.",
"ui.aggregating": "Сбор записей о работе",
"ui.loadError": "Не удалось загрузить журнал. Повторите попытку.",
"ui.ready": "Готово",
"ui.workItem": "Выполненная работа",
"ui.body": "Описание",
"ui.editEntry": "Изменить запись журнала",
"ui.reviewCandidate": "Проверить возможную запись журнала",
"ui.fromTodo": "Создать запись журнала из выполненной задачи",
"ui.addEntry": "Добавить запись журнала",
"ui.date": "Дата",
"ui.minutes": "Минуты",
"ui.fieldTitle": "Название",
"ui.billable": "Оплачиваемая работа",
"ui.cancel": "Отмена",
"ui.saveChanges": "Сохранить изменения",
"ui.addEntryShort": "Добавить запись",
"ui.titleRequired": "Введите название",
"ui.deleted": "Запись удалена",
"ui.empty": "Записей о работе пока нет.",
"ui.edit": "Изменить",
"ui.delete": "Удалить",
"ui.entryCount.one": "{count} запись",
"ui.entryCount.many": "Записей: {count}",
"ui.candidate.title": "Возможная запись журнала",
"ui.candidate.deal": "Дело: {deal}",
"ui.candidate.time": "Время: {start} – {end}",
"ui.candidate.duration": "Расчётная длительность: {minutes} мин",
"ui.candidate.activities": "Активностей: {count}",
"ui.candidate.linkedActivities": "Связанные активности",
"ui.candidate.duplicate": "Запись журнала уже ссылается на этого кандидата",
"ui.todo.title": "Выполненная задача",
"ui.todo.completed": "Выполнена: {time}",
"ui.todo.duplicate": "Запись журнала уже ссылается на эту задачу",
"ui.activity.captureLink": "Сохранена ссылка",
"ui.activity.captureSelection": "Захвачено выделение",
"ui.activity.capturePage": "Сохранена страница",
"ui.activity.fileCreated": "Создан файл",
"ui.activity.fileUpdated": "Изменён файл",
"ui.activity.fileDeleted": "Удалён файл",
"ui.activity.noteSaved": "Сохранена заметка",
"ui.activity.dealCreated": "Создано Дело",
"ui.activity.dealRenamed": "Переименовано Дело",
"ui.activity.generic": "Активность",
"ui.updated": "Запись обновлена",
"ui.added": "Запись добавлена",
"ui.minutesValue": "{minutes} мин",
"ui.meta.billable": "оплачиваемая",
"ui.meta.nonBillable": "неоплачиваемая",
"ui.meta.activities.one": "{count} связанная активность",
"ui.meta.activities.many": "Связанных активностей: {count}",
"ui.meta.todo": "связанная задача"
}
+4 -1
View File
@@ -4,7 +4,8 @@
"name": "Journal",
"version": "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.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official",
"icon": "book-open",
"provides": [
@@ -13,6 +14,8 @@
"report.worklog"
],
"permissions": [
"events.publish",
"files.read",
"storage.namespace",
"ui.register"
],
+199 -96
View File
@@ -25,7 +25,7 @@
'.notes-btn.primary:hover{background:#63d9b3;color:#101827}',
'.notes-filter,.notes-sort{font-size:.78rem;padding:.32rem .5rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);outline:none}',
'.notes-filter{width:11rem}',
'.notes-sort{width:8rem;appearance:none;background-color:#0f1424;background-image:linear-gradient(45deg,transparent 50%,#8b8ba8 50%),linear-gradient(135deg,#8b8ba8 50%,transparent 50%);background-position:calc(100% - 14px) 50%,calc(100% - 9px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:1.6rem}',
'.notes-sort{width:8rem;appearance:none;background-color:#0f1424;background-image:linear-gradient(45deg,transparent 50%,#8b8ba8 50%),linear-gradient(135deg,#8b8ba8 50%,transparent 50%);background-position:calc(100% - 14px) 50%,calc(100% - 9px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:1.6rem}.notes-sort option{background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb)}',
'.notes-filter:focus,.notes-sort:focus{border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.notes-list{flex:1;overflow:auto;min-height:0}',
'.notes-item{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;border-bottom:1px solid rgba(32,43,70,.72);cursor:pointer;font-size:.85rem}',
@@ -43,14 +43,16 @@
'.notes-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--vt-color-text-muted,#7f8aa3);font-size:.85rem;padding:2rem;gap:.5rem;text-align:center}',
'.notes-empty-hint{font-size:.75rem;color:var(--vt-color-text-muted,#7f8aa3)}',
'.notes-error{flex:1;display:flex;align-items:center;justify-content:center;color:var(--vt-color-danger,#e94560);padding:1rem;font-size:.85rem}',
'.notes-panel{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;border-top:1px solid var(--vt-color-border,#202b46);flex-shrink:0;background:var(--vt-color-surface-muted,#111629)}',
'.notes-input{flex:1;font-size:.78rem;padding:.32rem .5rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);outline:none;min-width:120px}',
'.notes-input:focus{border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.notes-title-bar{padding:.4rem .75rem;font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3);background:var(--vt-color-surface-muted,#111629);border-bottom:1px solid var(--vt-color-border,#202b46);text-transform:uppercase;letter-spacing:.04em}',
'.notes-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:10000;display:flex;align-items:center;justify-content:center}',
'.notes-modal{width:380px;max-width:90vw;padding:20px;background:#1a1a2e;border:1px solid #333;border-radius:10px;color:#e0e0e0;font-family:inherit;box-shadow:0 12px 40px rgba(0,0,0,.5)}',
'.notes-modal-title{font-size:.9rem;font-weight:600;margin-bottom:12px}',
'.notes-modal-msg{font-size:.82rem;color:#aaa;margin-bottom:16px;word-wrap:break-word}',
'.notes-modal-form{display:grid;gap:.55rem}',
'.notes-modal-input{width:100%;font:inherit;font-size:.84rem;padding:.5rem .6rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);outline:none}',
'.notes-modal-input:focus{border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.notes-modal-input[aria-invalid="true"]{border-color:var(--vt-color-danger,#e94560)}',
'.notes-modal-error{display:none;font-size:.78rem;line-height:1.35;color:#ffc6ce}',
'.notes-modal-actions{display:flex;justify-content:flex-end;gap:8px}',
'.notes-modal-btn{font-size:.8rem;padding:.35rem .9rem;border:1px solid #333;border-radius:5px;cursor:pointer;font-family:inherit}',
'.notes-modal-btn.cancel{background:#2a2a4e;color:#ccc}',
@@ -171,7 +173,7 @@
var workspaceNode = props && props.workspaceNode;
var workspaceRoot = (workspaceNode && (workspaceNode.rootPath || workspaceNode.name || workspaceNode.id)) || '';
var workspaceName = workspaceRoot || (workspaceNode && (workspaceNode.name || workspaceNode.title)) || 'Workspace';
var workspaceName = workspaceRoot || (workspaceNode && (workspaceNode.name || workspaceNode.title)) || '';
var notes = [];
var selectedPath = '';
@@ -183,6 +185,11 @@
var sortMode = 'title-asc';
var renameTarget = null;
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
function notesParent() {
return workspaceRoot || '';
}
@@ -262,8 +269,8 @@
// ─── UI Elements ────────────────────────────────────────
var toolbar = el('div', { className: 'notes-toolbar' });
var createBtn = el('button', { className: 'notes-btn primary', 'data-action': 'create', innerHTML: iconSvg('add') + ' New Note' });
var filterInput = el('input', { className: 'notes-filter', 'data-notes-filter': '', placeholder: 'Filter notes' });
var createBtn = el('button', { className: 'notes-btn primary', 'data-action': 'create', innerHTML: iconSvg('add') + ' ' + tr('ui.newNote', null, 'New Note') });
var filterInput = el('input', { className: 'notes-filter', 'data-notes-filter': '', placeholder: tr('ui.filter', null, 'Filter notes') });
var sortSelect = el('select', { className: 'notes-sort', 'data-notes-sort': '' }, [
el('option', { value: 'title-asc' }, ['A-Z']),
el('option', { value: 'title-desc' }, ['Z-A'])
@@ -276,29 +283,18 @@
toolbar.appendChild(statusEl);
containerEl.appendChild(toolbar);
var titleBar = el('div', { className: 'notes-title-bar' }, ['Notes in ' + workspaceName]);
var titleBar = el('div', { className: 'notes-title-bar' }, [tr('ui.title', { workspace: workspaceName }, 'Notes in ' + workspaceName)]);
containerEl.appendChild(titleBar);
var listContainer = el('div', { className: 'notes-list', 'data-notes-list': '' });
containerEl.appendChild(listContainer);
var createPanel = el('div', { className: 'notes-panel', style: { display: 'none' } });
var createInput = el('input', { className: 'notes-input', 'data-notes-create-input': '', placeholder: 'Note title' });
var createConfirm = el('button', { className: 'notes-btn', textContent: 'Create' });
var createCancel = el('button', { className: 'notes-btn', textContent: 'Cancel' });
createPanel.appendChild(createInput);
createPanel.appendChild(createConfirm);
createPanel.appendChild(createCancel);
containerEl.appendChild(createPanel);
var renamePanel = el('div', { className: 'notes-panel', style: { display: 'none' } });
var renameInput = el('input', { className: 'notes-input', 'data-notes-rename-input': '', placeholder: 'New title' });
var renameConfirm = el('button', { className: 'notes-btn', textContent: 'Rename' });
var renameCancel = el('button', { className: 'notes-btn', textContent: 'Cancel' });
renamePanel.appendChild(renameInput);
renamePanel.appendChild(renameConfirm);
renamePanel.appendChild(renameCancel);
containerEl.appendChild(renamePanel);
var createModal = null;
var createInput = null;
var createError = null;
var renameModal = null;
var renameInput = null;
var renameError = null;
// ─── Core Functions ─────────────────────────────────────
@@ -319,9 +315,16 @@
}
}
function userFacingError(key, fallback, err) {
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
console.warn('[verstak.notes] ' + key, err);
}
return tr(key, null, fallback);
}
function loadNotes() {
listContainer.innerHTML = '';
listContainer.appendChild(el('div', { className: 'notes-empty' }, ['Loading...']));
listContainer.appendChild(el('div', { className: 'notes-empty' }, [tr('ui.loading', null, 'Loading...')]));
var parent = notesParent();
listNotes(parent).then(function (result) {
@@ -330,7 +333,7 @@
renderList();
}).catch(function (err) {
if (disposed) return;
renderEmpty('Error: ' + (err.message || err));
renderEmpty(userFacingError('ui.loadError', 'Could not load notes. Please try again.', err));
});
}
@@ -374,8 +377,7 @@
}).then(function () {
if (!disposed) setStatus(action.label + ' complete', 'success');
}).catch(function (err) {
console.error('[notes] contribution action failed:', err);
if (!disposed) setStatus('Error: ' + (err && err.message ? err.message : err), 'error');
if (!disposed) setStatus(userFacingError('ui.actionError', 'Could not complete this note action. Please try again.', err), 'error');
});
}
@@ -392,36 +394,36 @@
function renderList() {
listContainer.innerHTML = '';
if (!notes || notes.length === 0) {
renderEmpty('No notes yet');
renderEmpty(tr('ui.empty', null, 'No notes yet'));
return;
}
var shown = visibleNotes();
if (shown.length === 0) {
renderEmpty('No matching notes', 'Clear the filter to show all notes');
renderEmpty(tr('ui.noMatches', null, 'No matching notes'), tr('ui.clearFilterHint', null, 'Clear the filter to show all notes'));
return;
}
shown.forEach(function (note) {
var actionButtons = [
el('button', {
className: 'notes-item-btn',
title: 'Open',
'aria-label': 'Open',
title: tr('ui.open', null, 'Open'),
'aria-label': tr('ui.open', null, 'Open'),
'data-note-action': 'open',
innerHTML: iconSvg('open'),
onClick: function (e) { e.stopPropagation(); openNote(note); }
}),
el('button', {
className: 'notes-item-btn',
title: 'Rename',
'aria-label': 'Rename',
title: tr('ui.rename', null, 'Rename'),
'aria-label': tr('ui.rename', null, 'Rename'),
'data-note-action': 'rename',
innerHTML: iconSvg('rename'),
onClick: function (e) { e.stopPropagation(); beginRename(note); }
}),
el('button', {
className: 'notes-item-btn',
title: 'Move to Trash',
'aria-label': 'Move to Trash',
title: tr('ui.trash', null, 'Move to Trash'),
'aria-label': tr('ui.trash', null, 'Move to Trash'),
'data-note-action': 'trash',
innerHTML: iconSvg('trash'),
onClick: function (e) { e.stopPropagation(); confirmTrashNote(note); }
@@ -457,7 +459,7 @@
listContainer.appendChild(el('div', { className: 'notes-empty' }, [
el('div', { innerHTML: iconSvg('note') }),
el('div', {}, [msg]),
el('div', { className: 'notes-empty-hint' }, [hint || 'Click "New Note" to create one'])
el('div', { className: 'notes-empty-hint' }, [hint || tr('ui.emptyHint', null, 'Click "New Note" to create one')])
]));
}
@@ -484,32 +486,138 @@
}).catch(function (err) { console.error('[notes] openResource:', err); });
}
function conflictMessage(title, existingPath) {
return [
tr('ui.conflictMessage', { title: title }, 'A note with the title "' + title + '" already exists.'),
existingPath ? tr('ui.existingFile', { path: existingPath }, ' Existing file: ' + existingPath + '.') : '',
tr('ui.chooseDifferent', null, ' Please choose a different title.')
].join('');
}
function showNoteFormModal(mode, note) {
var isRename = mode === 'rename';
var modalTitle = isRename
? tr('ui.renameTitle', null, 'Rename note')
: tr('ui.createTitle', null, 'Create note');
var overlay = el('div', {
className: 'notes-modal-overlay',
role: 'presentation'
});
overlay.setAttribute('data-notes-' + mode + '-modal', '');
var input = el('input', {
className: 'notes-modal-input',
type: 'text',
value: isRename ? (note.title || fileName(note.path)) : '',
placeholder: isRename
? tr('ui.newTitle', null, 'New title')
: tr('ui.noteTitle', null, 'Note title'),
autocomplete: 'off',
'aria-invalid': 'false'
});
input.setAttribute('data-notes-' + mode + '-input', '');
input.value = isRename ? (note.title || fileName(note.path)) : '';
var error = el('div', {
className: 'notes-modal-error',
role: 'alert'
});
error.setAttribute('data-notes-' + mode + '-error', '');
var confirm = el('button', {
className: 'notes-modal-btn confirm',
type: 'button',
textContent: isRename ? tr('ui.rename', null, 'Rename') : tr('ui.create', null, 'Create'),
onClick: function () {
if (isRename) confirmRename();
else confirmCreate();
}
});
var cancel = el('button', {
className: 'notes-modal-btn cancel',
type: 'button',
textContent: tr('ui.cancel', null, 'Cancel'),
onClick: function () {
if (isRename) hideRename();
else hideCreate();
}
});
function handleFormKeydown(event) {
if (event.key === 'Enter') {
if (event.preventDefault) event.preventDefault();
if (isRename) confirmRename();
else confirmCreate();
}
if (event.key === 'Escape') {
if (event.preventDefault) event.preventDefault();
if (isRename) hideRename();
else hideCreate();
}
}
input.addEventListener('keydown', handleFormKeydown);
var modal = el('div', {
className: 'notes-modal notes-modal-form',
role: 'dialog',
'aria-modal': 'true',
'aria-label': modalTitle
}, [
el('div', { className: 'notes-modal-title', textContent: modalTitle }),
input,
error,
el('div', { className: 'notes-modal-actions' }, [cancel, confirm])
]);
overlay.appendChild(modal);
document.body.appendChild(overlay);
if (isRename) {
renameInput = input;
renameError = error;
} else {
createInput = input;
createError = error;
}
input.focus();
if (isRename && input.select) input.select();
return overlay;
}
// ─── Create ────────────────────────────────────────────
function showCreate() {
createInput.value = '';
createPanel.style.display = 'flex';
createInput.focus();
hideCreate();
createModal = showNoteFormModal('create');
}
function hideCreate() {
createPanel.style.display = 'none';
if (createModal) createModal.remove();
createModal = null;
createInput = null;
createError = null;
}
function setCreateError(message) {
if (!createError || !createInput) return;
createError.textContent = message || '';
createError.style.display = message ? 'block' : 'none';
createInput.setAttribute('aria-invalid', message ? 'true' : 'false');
}
function confirmCreate() {
if (!createInput) return;
var title = createInput.value.trim();
if (!title) return;
setStatus('Creating note...', 'loading');
if (!title) {
setCreateError(tr('ui.titleRequired', null, 'Enter a note title.'));
createInput.focus();
return;
}
setStatus(tr('ui.creating', null, 'Creating note...'), 'loading');
var parent = notesParent();
createNote(parent, title).then(function (data) {
if (disposed) return;
data = data || {};
if (data.conflict) {
showConflictModal(title, data.path, createInput);
setCreateError(conflictMessage(title, data.path));
createInput.focus();
return;
}
hideCreate();
setStatus('Note created', 'success');
setStatus(tr('ui.created', null, 'Note created'), 'success');
loadNotes();
// Open the newly created note
if (data.path) {
@@ -528,42 +636,55 @@
}).catch(function () {});
}
}).catch(function (err) {
setStatus('Error: ' + (err.message || err), 'error');
setCreateError(userFacingError('ui.createError', 'Could not create the note. Please try again.', err));
});
}
// ─── Rename ─────────────────────────────────────────────
function beginRename(note) {
hideRename();
renameTarget = note;
renameInput.value = note.title || fileName(note.path);
renamePanel.style.display = 'flex';
renameInput.focus();
renameInput.select();
renameModal = showNoteFormModal('rename', note);
}
function hideRename() {
renameTarget = null;
renamePanel.style.display = 'none';
if (renameModal) renameModal.remove();
renameModal = null;
renameInput = null;
renameError = null;
}
function setRenameError(message) {
if (!renameError || !renameInput) return;
renameError.textContent = message || '';
renameError.style.display = message ? 'block' : 'none';
renameInput.setAttribute('aria-invalid', message ? 'true' : 'false');
}
function confirmRename() {
if (!renameTarget) return;
if (!renameTarget || !renameInput) return;
var newTitle = renameInput.value.trim();
if (!newTitle) return;
setStatus('Renaming...', 'loading');
if (!newTitle) {
setRenameError(tr('ui.titleRequired', null, 'Enter a note title.'));
renameInput.focus();
return;
}
setStatus(tr('ui.renaming', null, 'Renaming...'), 'loading');
renameNote(renameTarget.path, newTitle).then(function (data) {
if (disposed) return;
data = data || {};
if (data.conflict) {
showConflictModal(newTitle, data.path, renameInput);
setRenameError(conflictMessage(newTitle, data.path));
renameInput.focus();
return;
}
hideRename();
setStatus('Note renamed', 'success');
setStatus(tr('ui.renamed', null, 'Note renamed'), 'success');
loadNotes();
}).catch(function (err) {
setStatus('Error: ' + (err.message || err), 'error');
setRenameError(userFacingError('ui.renameError', 'Could not rename the note. Please try again.', err));
});
}
@@ -573,37 +694,18 @@
if (!note) return;
showTrashModal(note).then(function (confirmed) {
if (!confirmed || disposed) return;
setStatus('Moving note to trash...', 'loading');
setStatus(tr('ui.trashing', null, 'Moving note to trash...'), 'loading');
api.files.trash(note.path).then(function () {
if (disposed) return;
if (selectedPath === note.path) selectedPath = '';
setStatus('Note moved to trash', 'success');
setStatus(tr('ui.trashed', null, 'Note moved to trash'), 'success');
loadNotes();
}).catch(function (err) {
setStatus('Error: ' + (err.message || err), 'error');
setStatus(userFacingError('ui.trashError', 'Could not move the note to trash. Please try again.', err), 'error');
});
});
}
// ─── Conflict Modal ─────────────────────────────────────
function showConflictModal(title, existingPath, focusTarget) {
var overlay = el('div', { className: 'notes-modal-overlay' });
var modal = el('div', { className: 'notes-modal' }, [
el('div', { className: 'notes-modal-title' }, ['Name Conflict']),
el('div', { className: 'notes-modal-msg' }, [
'A note with the title "' + title + '" already exists.',
existingPath ? ' Existing file: ' + existingPath + '.' : '',
' Please choose a different title.'
].join('')),
el('div', { className: 'notes-modal-actions' }, [
el('button', { className: 'notes-modal-btn confirm', textContent: 'OK', onClick: function () { overlay.remove(); (focusTarget || createInput).focus(); } })
])
]);
overlay.appendChild(modal);
document.body.appendChild(overlay);
}
function showTrashModal(note) {
return new Promise(function (resolve) {
var overlay = el('div', { className: 'notes-modal-overlay' });
@@ -612,13 +714,13 @@
resolve(value);
}
var modal = el('div', { className: 'notes-modal' }, [
el('div', { className: 'notes-modal-title' }, ['Move Note to Trash']),
el('div', { className: 'notes-modal-title' }, [tr('ui.trashTitle', null, 'Move Note to Trash')]),
el('div', { className: 'notes-modal-msg' }, [
'Move "' + (note.title || fileName(note.path)) + '" to trash?'
tr('ui.trashConfirm', { title: note.title || fileName(note.path) }, 'Move "' + (note.title || fileName(note.path)) + '" to trash?')
]),
el('div', { className: 'notes-modal-actions' }, [
el('button', { className: 'notes-modal-btn cancel', textContent: 'Cancel', onClick: function () { close(false); } }),
el('button', { className: 'notes-modal-btn danger', 'data-notes-confirm-trash': '', textContent: 'Move to Trash', onClick: function () { close(true); } })
el('button', { className: 'notes-modal-btn cancel', textContent: tr('ui.cancel', null, 'Cancel'), onClick: function () { close(false); } }),
el('button', { className: 'notes-modal-btn danger', 'data-notes-confirm-trash': '', textContent: tr('ui.trash', null, 'Move to Trash'), onClick: function () { close(true); } })
])
]);
overlay.appendChild(modal);
@@ -637,24 +739,22 @@
sortMode = sortSelect.value || 'title-asc';
renderList();
});
createConfirm.addEventListener('click', confirmCreate);
createCancel.addEventListener('click', hideCreate);
renameConfirm.addEventListener('click', confirmRename);
renameCancel.addEventListener('click', hideRename);
createInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') confirmCreate();
if (e.key === 'Escape') hideCreate();
});
renameInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') confirmRename();
if (e.key === 'Escape') hideRename();
});
// ─── Init ───────────────────────────────────────────────
loadContributionActions();
loadNotes();
var localeUnsubscribe = null;
if (api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
localeUnsubscribe = api.i18n.onDidChangeLocale(function () {
createBtn.innerHTML = iconSvg('add') + ' ' + tr('ui.newNote', null, 'New Note');
filterInput.setAttribute('placeholder', tr('ui.filter', null, 'Filter notes'));
titleBar.textContent = tr('ui.title', { workspace: workspaceName }, 'Notes in ' + workspaceName);
renderList();
});
}
var fileChangedUnsubscribe = null;
if (api.events && typeof api.events.subscribe === 'function') {
api.events.subscribe('file.changed', function (event) {
@@ -669,6 +769,9 @@
containerEl.__notesCleanup = function () {
disposed = true;
hideCreate();
hideRename();
if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
if (typeof fileChangedUnsubscribe === 'function') fileChangedUnsubscribe();
};
},
+40
View File
@@ -0,0 +1,40 @@
{
"manifest.name": "Notes",
"manifest.description": "Deal-scoped notes manager for Markdown files in canonical Notes folders with create, rename, trash, and Workbench integration.",
"contributions.workspaceItems.verstak.notes.workspace.title": "Notes",
"ui.newNote": "New Note",
"ui.filter": "Filter notes",
"ui.title": "Notes in {workspace}",
"ui.noteTitle": "Note title",
"ui.createTitle": "Create note",
"ui.renameTitle": "Rename note",
"ui.titleRequired": "Enter a note title.",
"ui.create": "Create",
"ui.cancel": "Cancel",
"ui.newTitle": "New title",
"ui.rename": "Rename",
"ui.loading": "Loading...",
"ui.empty": "No notes yet",
"ui.noMatches": "No matching notes",
"ui.clearFilterHint": "Clear the filter to show all notes",
"ui.open": "Open",
"ui.trash": "Move to Trash",
"ui.emptyHint": "Click \"New Note\" to create one",
"ui.creating": "Creating note...",
"ui.created": "Note created",
"ui.renaming": "Renaming...",
"ui.renamed": "Note renamed",
"ui.trashing": "Moving note to trash...",
"ui.trashed": "Note moved to trash",
"ui.conflictTitle": "Name Conflict",
"ui.conflictMessage": "A note with the title \"{title}\" already exists.",
"ui.existingFile": " Existing file: {path}.",
"ui.chooseDifferent": " Please choose a different title.",
"ui.trashTitle": "Move Note to Trash",
"ui.trashConfirm": "Move \"{title}\" to trash?",
"ui.loadError": "Could not load notes. Please try again.",
"ui.actionError": "Could not complete this note action. Please try again.",
"ui.createError": "Could not create the note. Please try again.",
"ui.renameError": "Could not rename the note. Please try again.",
"ui.trashError": "Could not move the note to trash. Please try again."
}
+40
View File
@@ -0,0 +1,40 @@
{
"manifest.name": "Заметки",
"manifest.description": "Менеджер Markdown-заметок Дела с созданием, переименованием и корзиной.",
"contributions.workspaceItems.verstak.notes.workspace.title": "Заметки",
"ui.newNote": "Новая заметка",
"ui.filter": "Фильтр заметок",
"ui.title": "Заметки · {workspace}",
"ui.noteTitle": "Название заметки",
"ui.createTitle": "Создать заметку",
"ui.renameTitle": "Переименовать заметку",
"ui.titleRequired": "Введите название заметки.",
"ui.create": "Создать",
"ui.cancel": "Отмена",
"ui.newTitle": "Новое название",
"ui.rename": "Переименовать",
"ui.loading": "Загрузка...",
"ui.empty": "Заметок пока нет",
"ui.noMatches": "Подходящих заметок нет",
"ui.clearFilterHint": "Сбросьте фильтр, чтобы показать все заметки",
"ui.open": "Открыть",
"ui.trash": "Переместить в корзину",
"ui.emptyHint": "Нажмите «Новая заметка», чтобы создать первую",
"ui.creating": "Создание заметки...",
"ui.created": "Заметка создана",
"ui.renaming": "Переименование...",
"ui.renamed": "Заметка переименована",
"ui.trashing": "Перемещение заметки в корзину...",
"ui.trashed": "Заметка перемещена в корзину",
"ui.conflictTitle": "Конфликт названия",
"ui.conflictMessage": "Заметка с названием «{title}» уже существует.",
"ui.existingFile": " Существующий файл: {path}.",
"ui.chooseDifferent": " Выберите другое название.",
"ui.trashTitle": "Переместить заметку в корзину",
"ui.trashConfirm": "Переместить «{title}» в корзину?",
"ui.loadError": "Не удалось загрузить заметки. Повторите попытку.",
"ui.actionError": "Не удалось выполнить действие с заметкой. Повторите попытку.",
"ui.createError": "Не удалось создать заметку. Повторите попытку.",
"ui.renameError": "Не удалось переименовать заметку. Повторите попытку.",
"ui.trashError": "Не удалось переместить заметку в корзину. Повторите попытку."
}
+2 -1
View File
@@ -4,7 +4,8 @@
"name": "Notes",
"version": "0.1.0",
"apiVersion": "0.1.0",
"description": "Workspace-scoped notes manager for Markdown files in canonical Notes folders with create, rename, trash, and Workbench integration.",
"description": "Deal-scoped notes manager for Markdown files in canonical Notes folders with create, rename, trash, and Workbench integration.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official",
"icon": "edit",
"provides": [
+164 -115
View File
@@ -63,7 +63,34 @@
containerEl.innerHTML = '';
containerEl.className = 'pt-root';
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
containerEl.__ptCleanup = [];
var localizedNodes = [];
function localized(tag, attrs, key, fallback) {
var node = el(tag, attrs, [tr(key, null, fallback)]);
var item = { node: node, key: key, params: null, fallback: fallback };
node.__ptLocalized = item;
localizedNodes.push(item);
return node;
}
function setLocalized(node, key, params, fallback) {
var item = node.__ptLocalized;
if (!item) {
item = { node: node };
node.__ptLocalized = item;
localizedNodes.push(item);
}
item.key = key;
item.params = params || null;
item.fallback = fallback;
node.textContent = tr(key, params, fallback);
}
function trackCleanup(fn) {
if (typeof fn === 'function') {
@@ -74,12 +101,19 @@
containerEl.__ptCleanup.push(fn);
}
}
if (api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
trackCleanup(api.i18n.onDidChangeLocale(function () {
localizedNodes.forEach(function (item) {
if (item.key) item.node.textContent = tr(item.key, item.params, item.fallback);
});
}));
}
/* ── Header ─────────────────────────────────────────────────── */
var header = div('pt-header', [
span('pt-icon', '◉'),
div('pt-title-group', [
el('h2', { className: 'pt-plugin-name' }, ['Platform Diagnostics']),
localized('h2', { className: 'pt-plugin-name' }, 'ui.diagnostics', 'Platform Diagnostics'),
el('p', { className: 'pt-plugin-id' }, [api.pluginId]),
]),
span('pt-version', 'v' + (props && props.version ? props.version : '0.1.0')),
@@ -88,49 +122,49 @@
/* ── Status badge ──────────────────────────────────────────── */
var badge = div('pt-badge pt-badge-success', [
el('span', {}, ['✅']),
el('span', {}, ['Frontend Bundle Loaded']),
localized('span', {}, 'ui.bundleLoaded', 'Frontend Bundle Loaded'),
]);
var badgeRow = div('', [badge]);
/* ── Real Plugin API bridge checks ─────────────────────────── */
var savedValue = span('pt-list-value pt-saved-setting', 'Saved setting: loading...');
var capabilityValue = span('pt-list-value pt-capability-result', 'Capabilities: loading...');
var commandValue = span('pt-list-value pt-command-result', 'Command: registering...');
var eventValue = span('pt-list-value pt-event-result', 'Event: subscribing...');
var filesValue = span('pt-list-value pt-files-result', 'Files: running...');
var filesErrorValue = span('pt-list-value pt-files-error-result', 'Files error path: checking...');
var workbenchValue = span('pt-list-value pt-workbench-result', 'Workbench: ready');
function makeWorkbenchButton(className, label, request) {
var savedValue = localized('span', { className: 'pt-list-value pt-saved-setting' }, 'ui.savedLoading', 'Saved setting: loading...');
var capabilityValue = localized('span', { className: 'pt-list-value pt-capability-result' }, 'ui.capabilitiesLoading', 'Capabilities: loading...');
var commandValue = localized('span', { className: 'pt-list-value pt-command-result' }, 'ui.commandRegistering', 'Command: registering...');
var eventValue = localized('span', { className: 'pt-list-value pt-event-result' }, 'ui.eventSubscribing', 'Event: subscribing...');
var filesValue = localized('span', { className: 'pt-list-value pt-files-result' }, 'ui.filesRunning', 'Files: running...');
var filesErrorValue = localized('span', { className: 'pt-list-value pt-files-error-result' }, 'ui.filesPathChecking', 'Files error path: checking...');
var workbenchValue = localized('span', { className: 'pt-list-value pt-workbench-result' }, 'ui.workbenchReady', 'Workbench: ready');
function makeWorkbenchButton(className, key, label, request) {
return el('button', {
className: 'btn btn-primary ' + className,
onClick: function () {
workbenchValue.textContent = 'Workbench: opening...';
setLocalized(workbenchValue, 'ui.workbenchOpening', null, 'Workbench: opening...');
api.workbench.editResource(request)
.then(function (result) {
workbenchValue.textContent = 'Workbench: opened ' + result.request.path + ' with ' + (result.providerId || 'no-provider');
setLocalized(workbenchValue, 'ui.workbenchOpened', { path: result.request.path, provider: result.providerId || 'no-provider' }, 'Workbench: opened {path} with {provider}');
workbenchValue.setAttribute('data-workbench-status', result.status === 'opened' ? 'ok' : result.status);
})
.catch(function (err) {
workbenchValue.textContent = 'Workbench error: ' + (err && err.message ? err.message : String(err));
setLocalized(workbenchValue, 'ui.workbenchError', { error: err && err.message ? err.message : String(err) }, 'Workbench error: {error}');
workbenchValue.setAttribute('data-workbench-status', 'error');
});
},
}, [label]);
}, [localized('span', {}, key, label)]);
}
var openTextWorkbenchButton = makeWorkbenchButton('pt-open-workbench-text', 'Open Text Diagnostic', {
var openTextWorkbenchButton = makeWorkbenchButton('pt-open-workbench-text', 'ui.openTextDiagnostic', 'Open Text Diagnostic', {
kind: 'vault-file',
path: 'Docs/todo.txt',
extension: '.txt',
mime: 'text/plain',
context: { sourceView: 'files' },
});
var openMarkdownWorkbenchButton = makeWorkbenchButton('pt-open-workbench-markdown', 'Open Markdown Diagnostic', {
var openMarkdownWorkbenchButton = makeWorkbenchButton('pt-open-workbench-markdown', 'ui.openMarkdownDiagnostic', 'Open Markdown Diagnostic', {
kind: 'vault-file',
path: 'Docs/readme.md',
extension: '.md',
context: { sourceView: 'files' },
});
var openNotesWorkbenchButton = makeWorkbenchButton('pt-open-workbench-notes', 'Open Notes Diagnostic', {
var openNotesWorkbenchButton = makeWorkbenchButton('pt-open-workbench-notes', 'ui.openNotesDiagnostic', 'Open Notes Diagnostic', {
kind: 'vault-file',
path: 'Notes/example.md',
extension: '.md',
@@ -143,51 +177,51 @@
var settingInput = el('input', {
className: 'pt-setting-input',
type: 'text',
'aria-label': 'Saved setting',
'aria-label': tr('ui.savedSetting', null, 'Saved setting'),
value: 'changed value',
});
var saveStatus = span('pt-list-value', '');
var saveButton = el('button', {
className: 'btn btn-primary pt-save-setting',
onClick: function () {
saveStatus.textContent = 'Saving...';
setLocalized(saveStatus, 'ui.saving', null, 'Saving...');
api.settings.write('savedText', settingInput.value)
.then(function () {
savedValue.textContent = 'Saved setting: ' + settingInput.value;
saveStatus.textContent = 'Saved';
setLocalized(savedValue, 'ui.savedSettingValue', { value: settingInput.value }, 'Saved setting: {value}');
setLocalized(saveStatus, 'ui.saved', null, 'Saved');
})
.catch(function (err) {
saveStatus.textContent = 'Error: ' + (err && err.message ? err.message : String(err));
setLocalized(saveStatus, 'ui.errorValue', { error: err && err.message ? err.message : String(err) }, 'Error: {error}');
});
},
}, ['Save Setting']);
}, [localized('span', {}, 'ui.saveSetting', 'Save Setting')]);
api.settings.read('savedText')
.then(function (value) {
var text = value || '';
settingInput.value = text || 'changed value';
savedValue.textContent = 'Saved setting: ' + text;
setLocalized(savedValue, 'ui.savedSettingValue', { value: text }, 'Saved setting: {value}');
})
.catch(function (err) {
savedValue.textContent = 'Settings error: ' + (err && err.message ? err.message : String(err));
setLocalized(savedValue, 'ui.settingsError', { error: err && err.message ? err.message : String(err) }, 'Settings error: {error}');
});
api.capabilities.list()
.then(function (caps) {
capabilityValue.textContent = 'Capabilities: ' + caps.length + ' available';
setLocalized(capabilityValue, 'ui.capabilitiesAvailable', { count: caps.length }, 'Capabilities: {count} available');
})
.catch(function (err) {
capabilityValue.textContent = 'Capabilities error: ' + (err && err.message ? err.message : String(err));
setLocalized(capabilityValue, 'ui.capabilitiesError', { error: err && err.message ? err.message : String(err) }, 'Capabilities error: {error}');
});
api.capabilities.has('verstak/platform-test/v1')
.then(function (available) {
badge.setAttribute('data-capability-status', available ? 'available' : 'missing');
badge.lastChild.textContent = 'Frontend Bundle Loaded | capability ' + (available ? 'available' : 'missing');
setLocalized(badge.lastChild, available ? 'ui.bundleCapabilityAvailable' : 'ui.bundleCapabilityMissing', null, available ? 'Frontend Bundle Loaded | capability available' : 'Frontend Bundle Loaded | capability missing');
})
.catch(function (err) {
badge.setAttribute('data-capability-status', 'error');
badge.lastChild.textContent = 'Capability error: ' + (err && err.message ? err.message : String(err));
setLocalized(badge.lastChild, 'ui.capabilityError', { error: err && err.message ? err.message : String(err) }, 'Capability error: {error}');
});
api.commands.register('verstak.platform-test.show-version', function () {
@@ -202,17 +236,17 @@
})
.then(function (result) {
badge.setAttribute('data-command-status', result.status || '');
commandValue.textContent = 'Command: ' + result.status + ' ' + result.result.version + ' from ' + result.result.source;
setLocalized(commandValue, 'ui.commandResult', { status: result.status, version: result.result.version, source: result.result.source }, 'Command: {status} {version} from {source}');
})
.catch(function (err) {
badge.setAttribute('data-command-status', 'error');
commandValue.textContent = 'Command error: ' + (err && err.message ? err.message : String(err));
setLocalized(commandValue, 'ui.commandError', { error: err && err.message ? err.message : String(err) }, 'Command error: {error}');
console.error('[platform-test] command bridge error:', err);
});
api.events.subscribe('verstak.platform-test.echo', function (event) {
var message = event && event.payload ? event.payload.message : '';
eventValue.textContent = 'Event: received ' + message;
setLocalized(eventValue, 'ui.eventReceived', { message: message }, 'Event: received {message}');
eventValue.setAttribute('data-event-status', 'received');
})
.then(function (unsubscribe) {
@@ -220,7 +254,7 @@
return api.events.publish('verstak.platform-test.echo', { message: 'hello-event' });
})
.catch(function (err) {
eventValue.textContent = 'Event error: ' + (err && err.message ? err.message : String(err));
setLocalized(eventValue, 'ui.eventError', { error: err && err.message ? err.message : String(err) }, 'Event error: {error}');
eventValue.setAttribute('data-event-status', 'error');
});
@@ -249,39 +283,39 @@
return api.files.trash('PlatformTest/files-api-moved.txt');
})
.then(function () {
filesValue.textContent = 'Files: wrote/read/listed/moved/trashed';
setLocalized(filesValue, 'ui.filesPassed', null, 'Files: wrote/read/listed/moved/trashed');
filesValue.setAttribute('data-files-status', 'ok');
})
.catch(function (err) {
filesValue.textContent = 'Files error: ' + (err && err.message ? err.message : String(err));
setLocalized(filesValue, 'ui.filesError', { error: err && err.message ? err.message : String(err) }, 'Files error: {error}');
filesValue.setAttribute('data-files-status', 'error');
});
api.files.readText('.verstak/vault.json')
.then(function () {
filesErrorValue.textContent = 'Files error path: unexpectedly allowed';
setLocalized(filesErrorValue, 'ui.filesPathAllowed', null, 'Files error path: unexpectedly allowed');
filesErrorValue.setAttribute('data-files-error-status', 'error');
})
.catch(function (err) {
var message = err && err.message ? err.message : String(err);
if (message.indexOf('reserved-path') === -1 && message.indexOf('.verstak') === -1) {
filesErrorValue.textContent = 'Files error path: wrong error ' + message;
setLocalized(filesErrorValue, 'ui.filesPathWrongError', { error: message }, 'Files error path: wrong error {error}');
filesErrorValue.setAttribute('data-files-error-status', 'error');
return;
}
filesErrorValue.textContent = 'Files error path: rejected reserved-path';
setLocalized(filesErrorValue, 'ui.filesPathRejected', null, 'Files error path: rejected reserved-path');
filesErrorValue.setAttribute('data-files-error-status', 'expected');
});
var bridgeCard = div('pt-card', [
el('h3', { className: 'pt-card-title' }, ['Real Plugin API Bridge']),
localized('h3', { className: 'pt-card-title' }, 'ui.apiBridge', 'Real Plugin API Bridge'),
el('ul', { className: 'pt-list' }, [
el('li', { className: 'pt-list-item' }, [
span('pt-list-label', 'Persisted setting'),
localized('span', { className: 'pt-list-label' }, 'ui.persistedSetting', 'Persisted setting'),
savedValue,
]),
el('li', { className: 'pt-list-item' }, [
span('pt-list-label', 'New value'),
localized('span', { className: 'pt-list-label' }, 'ui.newValue', 'New value'),
settingInput,
]),
el('li', { className: 'pt-list-item' }, [
@@ -289,27 +323,27 @@
saveStatus,
]),
el('li', { className: 'pt-list-item' }, [
span('pt-list-label', 'Capabilities'),
localized('span', { className: 'pt-list-label' }, 'ui.capabilitiesLabel', 'Capabilities'),
capabilityValue,
]),
el('li', { className: 'pt-list-item' }, [
span('pt-list-label', 'Command runtime'),
localized('span', { className: 'pt-list-label' }, 'ui.commandRuntime', 'Command runtime'),
commandValue,
]),
el('li', { className: 'pt-list-item' }, [
span('pt-list-label', 'Event runtime'),
localized('span', { className: 'pt-list-label' }, 'ui.eventRuntime', 'Event runtime'),
eventValue,
]),
el('li', { className: 'pt-list-item' }, [
span('pt-list-label', 'Files runtime'),
localized('span', { className: 'pt-list-label' }, 'ui.filesRuntime', 'Files runtime'),
filesValue,
]),
el('li', { className: 'pt-list-item' }, [
span('pt-list-label', 'Files reserved path'),
localized('span', { className: 'pt-list-label' }, 'ui.filesReservedPath', 'Files reserved path'),
filesErrorValue,
]),
el('li', { className: 'pt-list-item' }, [
span('pt-list-label', 'Workbench routing'),
localized('span', { className: 'pt-list-label' }, 'ui.workbenchRouting', 'Workbench routing'),
openTextWorkbenchButton,
openMarkdownWorkbenchButton,
openNotesWorkbenchButton,
@@ -320,12 +354,12 @@
/* ── Test results summary ──────────────────────────────────── */
var testsData = [
{ label: 'Plugin Registration', status: 'pass' },
{ label: 'Capability: verstak/platform-test/v1', status: 'pass' },
{ label: 'Capability: verstak/diagnostics/v1', status: 'pass' },
{ label: 'Capability: verstak/core/files/v1', status: 'pass' },
{ label: 'Capability: verstak/core/workbench/v1', status: 'pass' },
{ label: 'API Contract Compliance', status: 'pass' },
{ key: 'registration', label: 'Plugin Registration', status: 'pass' },
{ key: 'platformCapability', label: 'Capability: verstak/platform-test/v1', status: 'pass' },
{ key: 'diagnosticsCapability', label: 'Capability: verstak/diagnostics/v1', status: 'pass' },
{ key: 'filesCapability', label: 'Capability: verstak/core/files/v1', status: 'pass' },
{ key: 'workbenchCapability', label: 'Capability: verstak/core/workbench/v1', status: 'pass' },
{ key: 'apiContract', label: 'API Contract Compliance', status: 'pass' },
];
var totalTests = testsData.length;
@@ -334,19 +368,19 @@
var summaryRow = div('pt-test-summary', [
div('pt-test-stat', [
span('pt-test-stat-value pt-pass', String(passedTests)),
span('pt-test-stat-label', 'Passed'),
localized('span', { className: 'pt-test-stat-label' }, 'ui.passed', 'Passed'),
]),
div('pt-test-stat', [
span('pt-test-stat-value pt-fail', String(totalTests - passedTests)),
span('pt-test-stat-label', 'Failed'),
localized('span', { className: 'pt-test-stat-label' }, 'ui.failed', 'Failed'),
]),
div('pt-test-stat', [
span('pt-test-stat-value', String(totalTests)),
span('pt-test-stat-label', 'Total'),
localized('span', { className: 'pt-test-stat-label' }, 'ui.total', 'Total'),
]),
div('pt-test-stat', [
span('pt-test-stat-value pt-pass', '100%'),
span('pt-test-stat-label', 'Success Rate'),
localized('span', { className: 'pt-test-stat-label' }, 'ui.successRate', 'Success Rate'),
]),
]);
@@ -354,14 +388,14 @@
testsData.forEach(function (t) {
var dot = el('span', { className: 'pt-cap-dot pt-cap-dot-ok' });
var item = el('li', { className: 'pt-list-item' }, [
el('span', { className: 'pt-list-label' }, [dot, ' ', t.label]),
span('pt-list-value pt-pass', t.status === 'pass' ? '✓ PASS' : '✗ FAIL'),
el('span', { className: 'pt-list-label' }, [dot, ' ', localized('span', {}, 'ui.test.' + t.key, t.label)]),
localized('span', { className: 'pt-list-value pt-pass' }, t.status === 'pass' ? 'ui.pass' : 'ui.fail', t.status === 'pass' ? '✓ PASS' : '✗ FAIL'),
]);
testsList.appendChild(item);
});
var testsCard = div('pt-card', [
el('h3', { className: 'pt-card-title' }, ['Test Results']),
localized('h3', { className: 'pt-card-title' }, 'ui.testResults', 'Test Results'),
summaryRow,
testsList,
]);
@@ -380,7 +414,7 @@
var dot = el('span', {
className: 'pt-cap-dot pt-cap-dot-missing',
});
var statusText = span('pt-list-value', 'Checking...');
var statusText = localized('span', { className: 'pt-list-value' }, 'ui.checking', 'Checking...');
var item = el('li', { className: 'pt-list-item' }, [
el('span', { className: 'pt-list-label' }, [dot, ' ', cap.label]),
statusText,
@@ -389,39 +423,39 @@
api.capabilities.has(cap.id)
.then(function (available) {
dot.className = 'pt-cap-dot ' + (available ? 'pt-cap-dot-ok' : 'pt-cap-dot-missing');
statusText.textContent = available ? '✓ Available' : '— Unavailable';
setLocalized(statusText, available ? 'ui.available' : 'ui.unavailable', null, available ? '✓ Available' : '— Unavailable');
})
.catch(function () {
dot.className = 'pt-cap-dot pt-cap-dot-missing';
statusText.textContent = 'Error';
setLocalized(statusText, 'ui.error', null, 'Error');
});
});
var capsCard = div('pt-card', [
el('h3', { className: 'pt-card-title' }, ['Registered Capabilities']),
localized('h3', { className: 'pt-card-title' }, 'ui.capabilities', 'Registered Capabilities'),
capList,
]);
/* ── Plugin info ───────────────────────────────────────────── */
var infoList = el('ul', { className: 'pt-list' });
var infoItems = [
{ label: 'Plugin ID', value: api.pluginId },
{ label: 'Bundle Status', value: 'Loaded ✓' },
{ label: 'Registration Scheme', value: 'VerstakPluginRegister' },
{ label: 'Components', value: 'DiagnosticsPanel, PlatformTestSettings' },
{ label: 'Container', value: containerEl.tagName.toLowerCase() + (containerEl.id ? '#' + containerEl.id : '') },
{ key: 'pluginId', label: 'Plugin ID', value: api.pluginId },
{ key: 'bundleStatus', label: 'Bundle Status', value: tr('ui.loaded', null, 'Loaded ✓') },
{ key: 'registration', label: 'Registration Scheme', value: 'VerstakPluginRegister' },
{ key: 'components', label: 'Components', value: 'DiagnosticsPanel, PlatformTestSettings' },
{ key: 'container', label: 'Container', value: containerEl.tagName.toLowerCase() + (containerEl.id ? '#' + containerEl.id : '') },
];
infoItems.forEach(function (item) {
infoList.appendChild(
el('li', { className: 'pt-list-item' }, [
span('pt-list-label', item.label),
localized('span', { className: 'pt-list-label' }, 'ui.info.' + item.key, item.label),
span('pt-list-value', item.value),
])
);
});
var infoCard = div('pt-card', [
el('h3', { className: 'pt-card-title' }, ['Plugin Info']),
localized('h3', { className: 'pt-card-title' }, 'ui.pluginInfo', 'Plugin Info'),
infoList,
]);
@@ -450,13 +484,13 @@
apiStatusList.appendChild(
el('li', { className: 'pt-list-item' }, [
el('span', { className: 'pt-list-label' }, [dot, ' ', chk.label]),
span('pt-list-value', chk.ok ? '✓ Ready' : '✗ Missing'),
localized('span', { className: 'pt-list-value' }, chk.ok ? 'ui.ready' : 'ui.missing', chk.ok ? '✓ Ready' : '✗ Missing'),
])
);
});
var apiCard = div('pt-card', [
el('h3', { className: 'pt-card-title' }, ['Host API Methods']),
localized('h3', { className: 'pt-card-title' }, 'ui.hostMethods', 'Host API Methods'),
apiStatusList,
]);
@@ -487,7 +521,7 @@
function renderMouseLog() {
if (mouseLog.length === 0) {
mouseLogContainer.textContent = '(no events captured)';
setLocalized(mouseLogContainer, 'ui.noMouseEvents', null, '(no events captured)');
mouseCountSpan.textContent = '0';
return;
}
@@ -497,6 +531,7 @@
' defaultPrevented=' + e.defaultPrevented +
' target=' + e.targetTag + (e.targetClass ? '.' + e.targetClass : '');
});
if (mouseLogContainer.__ptLocalized) mouseLogContainer.__ptLocalized.key = '';
mouseLogContainer.textContent = lines.join('\n');
mouseCountSpan.textContent = String(mouseLog.length);
mouseLogContainer.scrollTop = mouseLogContainer.scrollHeight;
@@ -526,7 +561,7 @@
window.addEventListener(type, handler, true);
mouseHandlers.push({ type: type, handler: handler });
});
startStopBtn.textContent = '■ Stop Capture';
setLocalized(startStopBtn, 'ui.stopCapture', null, '■ Stop Capture');
startStopBtn.setAttribute('data-mouse-capturing', 'true');
renderMouseLog();
}
@@ -538,35 +573,35 @@
window.removeEventListener(h.type, h.handler, true);
});
mouseHandlers = [];
startStopBtn.textContent = '▶ Start Capture';
setLocalized(startStopBtn, 'ui.startCapture', null, '▶ Start Capture');
startStopBtn.setAttribute('data-mouse-capturing', 'false');
}
var startStopBtn = el('button', {
var startStopBtn = localized('button', {
className: 'btn btn-primary',
style: { marginRight: '0.5rem' },
onClick: function () {
if (mouseCapturing) { stopMouseCapture(); } else { startMouseCapture(); }
},
}, ['▶ Start Capture']);
}, 'ui.startCapture', '▶ Start Capture');
var clearBtn = el('button', {
var clearBtn = localized('button', {
className: 'btn btn-secondary',
style: { marginRight: '0.5rem' },
onClick: function () {
mouseLog = [];
renderMouseLog();
},
}, ['Clear']);
}, 'ui.clear', 'Clear');
var copyBtn = el('button', {
var copyBtn = localized('button', {
className: 'btn btn-secondary',
onClick: function () {
var json = JSON.stringify(mouseLog, null, 2);
if (navigator.clipboard) {
navigator.clipboard.writeText(json).then(function () {
copyBtn.textContent = '✓ Copied!';
setTimeout(function () { copyBtn.textContent = 'Copy JSON'; }, 1500);
setLocalized(copyBtn, 'ui.copied', null, '✓ Copied!');
setTimeout(function () { setLocalized(copyBtn, 'ui.copyJson', null, 'Copy JSON'); }, 1500);
});
} else {
var ta = document.createElement('textarea');
@@ -575,24 +610,22 @@
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
copyBtn.textContent = '✓ Copied!';
setTimeout(function () { copyBtn.textContent = 'Copy JSON'; }, 1500);
setLocalized(copyBtn, 'ui.copied', null, '✓ Copied!');
setTimeout(function () { setLocalized(copyBtn, 'ui.copyJson', null, 'Copy JSON'); }, 1500);
}
},
}, ['Copy JSON']);
}, 'ui.copyJson', 'Copy JSON');
trackCleanup(function () { stopMouseCapture(); });
var mouseCard = div('pt-card', [
el('h3', { className: 'pt-card-title' }, ['Mouse Event Inspector']),
el('p', { style: { margin: '0 0 0.5rem', color: '#a0a0b8', fontSize: '0.8rem' } }, [
'Captures ALL mouse/pointer events on window. Press back/forward buttons to see what WebKitGTK reports.',
]),
localized('h3', { className: 'pt-card-title' }, 'ui.mouseInspector', 'Mouse Event Inspector'),
localized('p', { style: { margin: '0 0 0.5rem', color: '#a0a0b8', fontSize: '0.8rem' } }, 'ui.mouseHint', 'Captures ALL mouse/pointer events on window. Press back/forward buttons to see what WebKitGTK reports.'),
el('div', { style: { display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' } }, [
startStopBtn,
clearBtn,
copyBtn,
span('pt-list-label', ' Events:'),
localized('span', { className: 'pt-list-label' }, 'ui.eventsCount', 'Events:'),
mouseCountSpan,
]),
mouseLogContainer,
@@ -667,6 +700,28 @@
containerEl.innerHTML = '';
containerEl.className = 'pt-root';
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
var localizedNodes = [];
var unsubscribeLocale = null;
function localized(tag, attrs, key, fallback) {
var node = el(tag, attrs, [tr(key, null, fallback)]);
localizedNodes.push({ node: node, key: key, fallback: fallback });
return node;
}
if (api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
unsubscribeLocale = api.i18n.onDidChangeLocale(function () {
localizedNodes.forEach(function (item) {
item.node.textContent = tr(item.key, null, item.fallback);
});
});
containerEl.__ptSettingsCleanup = unsubscribeLocale;
}
/* ── Counter state (local, not persisted) ──────────────────── */
var counterState = { value: 0 };
@@ -674,77 +729,69 @@
var header = div('pt-header', [
span('pt-icon', '⚙️'),
div('pt-title-group', [
el('h2', { className: 'pt-plugin-name' }, ['Platform Test Settings']),
localized('h2', { className: 'pt-plugin-name' }, 'ui.settings', 'Platform Test Settings'),
el('p', { className: 'pt-plugin-id' }, [api.pluginId]),
]),
]);
/* ── Info card ──────────────────────────────────────────────── */
var infoCard = div('pt-card', [
el('p', { style: { margin: '0', color: '#a0a0b8', fontSize: '0.85rem' } }, [
'Settings panel loaded from plugin frontend bundle via ',
el('code', { style: { background: '#1a1a2e', padding: '0.1rem 0.3rem', borderRadius: '3px', color: '#4ecca3' } }, ['VerstakPluginRegister']),
' contract.',
]),
localized('p', { style: { margin: '0', color: '#a0a0b8', fontSize: '0.85rem' } }, 'ui.settingsInfo', 'Settings panel loaded from the plugin frontend bundle through the VerstakPluginRegister contract.'),
]);
/* ── Counter section ────────────────────────────────────────── */
var counterDisplay = div('pt-counter', [
el('span', { className: 'pt-counter-value' }, [String(counterState.value)]),
span('pt-counter-label', 'clicks (session only, no persistence)'),
localized('span', { className: 'pt-counter-label' }, 'ui.counterClicks', 'clicks (session only, no persistence)'),
]);
var incrementBtn = el('button', { className: 'btn btn-primary', onClick: function () {
counterState.value += 1;
counterDisplay.firstChild.textContent = String(counterState.value);
}}, ['+ Increment']);
}}, [localized('span', {}, 'ui.increment', '+ Increment')]);
var decrementBtn = el('button', { className: 'btn btn-secondary', onClick: function () {
counterState.value = Math.max(0, counterState.value - 1);
counterDisplay.firstChild.textContent = String(counterState.value);
}}, ['− Decrement']);
}}, [localized('span', {}, 'ui.decrement', '− Decrement')]);
var resetBtn = el('button', { className: 'btn btn-secondary', onClick: function () {
counterState.value = 0;
counterDisplay.firstChild.textContent = '0';
}}, ['↺ Reset']);
}}, [localized('span', {}, 'ui.reset', '↺ Reset')]);
var btnGroup = el('div', { style: { display: 'flex', gap: '0.5rem' } }, [
incrementBtn, decrementBtn, resetBtn,
]);
var counterCard = div('pt-card', [
el('h3', { className: 'pt-card-title' }, ['Interactive Counter (Local State)']),
localized('h3', { className: 'pt-card-title' }, 'ui.counter', 'Interactive Counter (Local State)'),
counterDisplay,
btnGroup,
el('p', { style: { marginTop: '0.75rem', color: '#6c6c8a', fontSize: '0.7rem' } }, [
'This counter is a local demo. State is not persisted — refreshing resets it.',
]),
localized('p', { style: { marginTop: '0.75rem', color: '#6c6c8a', fontSize: '0.7rem' } }, 'ui.counterHint', 'This counter is a local demo. State is not persisted — refreshing resets it.'),
]);
/* ── Settings stub ─────────────────────────────────────────── */
var settingsDemoList = el('ul', { className: 'pt-list' });
var settingsItems = [
{ label: 'Auto-run on load', value: 'true' },
{ label: 'Verbose logging', value: 'false' },
{ label: 'Theme override', value: 'inherit' },
{ label: 'Notifications', value: 'enabled' },
{ key: 'autoRun', label: 'Auto-run on load', valueKey: 'true', value: 'true' },
{ key: 'verbose', label: 'Verbose logging', valueKey: 'false', value: 'false' },
{ key: 'theme', label: 'Theme override', valueKey: 'inherit', value: 'inherit' },
{ key: 'notifications', label: 'Notifications', valueKey: 'enabled', value: 'enabled' },
];
settingsItems.forEach(function (s) {
settingsDemoList.appendChild(
el('li', { className: 'pt-list-item' }, [
span('pt-list-label', s.label),
span('pt-list-value', s.value),
localized('span', { className: 'pt-list-label' }, 'ui.setting.' + s.key, s.label),
localized('span', { className: 'pt-list-value' }, 'ui.value.' + s.valueKey, s.value),
])
);
});
var settingsCard = div('pt-card', [
el('h3', { className: 'pt-card-title' }, ['Plugin Settings (Demo)']),
localized('h3', { className: 'pt-card-title' }, 'ui.demoSettings', 'Plugin Settings (Demo)'),
settingsDemoList,
el('p', { style: { marginTop: '0.5rem', color: '#6c6c8a', fontSize: '0.7rem' } }, [
'Use api.settings.read() / api.settings.write() for persisted settings.',
]),
localized('p', { style: { marginTop: '0.5rem', color: '#6c6c8a', fontSize: '0.7rem' } }, 'ui.settingsHint', 'Use api.settings.read() / api.settings.write() for persisted settings.'),
]);
/* ── Assemble ──────────────────────────────────────────────── */
@@ -755,8 +802,10 @@
},
unmount: function (containerEl) {
if (typeof containerEl.__ptSettingsCleanup === 'function') containerEl.__ptSettingsCleanup();
containerEl.innerHTML = '';
containerEl.className = '';
delete containerEl.__ptSettingsCleanup;
},
};
+111
View File
@@ -0,0 +1,111 @@
{
"manifest.name": "Platform Test",
"manifest.description": "Runtime test plugin for verifying the Verstak platform: manifest loading, capability registration, contribution injection, event bus, and UI rendering.",
"contributions.views.verstak.platform-test.diagnostics.title": "Platform Diagnostics",
"contributions.commands.verstak.platform-test.run-tests.title": "Run Platform Tests",
"contributions.commands.verstak.platform-test.show-version.title": "Show Version Info",
"contributions.sidebarItems.verstak.platform-test.sidebar.title": "Platform Test",
"contributions.statusBarItems.verstak.platform-test.status.label": "[OK] All Tests Pass",
"contributions.settingsPanels.verstak.platform-test.settings.title": "Platform Test Settings",
"contributions.openProviders.verstak.platform-test.markdown-diagnostic.title": "Platform Test Markdown Diagnostic",
"ui.diagnostics": "Platform Diagnostics",
"ui.bundleLoaded": "Plugin Bundle Loaded",
"ui.apiBridge": "API Bridge",
"ui.testResults": "Test Results",
"ui.capabilities": "Granted Capabilities",
"ui.pluginInfo": "Plugin Info",
"ui.hostMethods": "Host Methods",
"ui.mouseInspector": "Mouse Event Inspector",
"ui.settings": "Platform Test Settings",
"ui.increment": "+ Increment",
"ui.decrement": "− Decrement",
"ui.reset": "Reset",
"ui.counter": "Interactive Counter",
"ui.demoSettings": "Demo Settings",
"ui.savedLoading": "Saved setting: loading...",
"ui.capabilitiesLoading": "Capabilities: loading...",
"ui.commandRegistering": "Command: registering...",
"ui.eventSubscribing": "Event: subscribing...",
"ui.filesRunning": "Files: running...",
"ui.filesPathChecking": "Files error path: checking...",
"ui.workbenchReady": "Workbench: ready",
"ui.workbenchOpening": "Workbench: opening...",
"ui.workbenchOpened": "Workbench: opened {path} with {provider}",
"ui.workbenchError": "Workbench error: {error}",
"ui.openTextDiagnostic": "Open Text Diagnostic",
"ui.openMarkdownDiagnostic": "Open Markdown Diagnostic",
"ui.openNotesDiagnostic": "Open Notes Diagnostic",
"ui.savedSetting": "Saved setting",
"ui.saving": "Saving...",
"ui.savedSettingValue": "Saved setting: {value}",
"ui.saved": "Saved",
"ui.errorValue": "Error: {error}",
"ui.saveSetting": "Save Setting",
"ui.settingsError": "Settings error: {error}",
"ui.capabilitiesAvailable": "Capabilities: {count} available",
"ui.capabilitiesError": "Capabilities error: {error}",
"ui.bundleCapabilityAvailable": "Frontend Bundle Loaded | capability available",
"ui.bundleCapabilityMissing": "Frontend Bundle Loaded | capability missing",
"ui.capabilityError": "Capability error: {error}",
"ui.commandResult": "Command: {status} {version} from {source}",
"ui.commandError": "Command error: {error}",
"ui.eventReceived": "Event: received {message}",
"ui.eventError": "Event error: {error}",
"ui.filesPassed": "Files: wrote/read/listed/moved/trashed",
"ui.filesError": "Files error: {error}",
"ui.filesPathAllowed": "Files error path: unexpectedly allowed",
"ui.filesPathWrongError": "Files error path: wrong error {error}",
"ui.filesPathRejected": "Files error path: rejected reserved-path",
"ui.persistedSetting": "Persisted setting",
"ui.newValue": "New value",
"ui.capabilitiesLabel": "Capabilities",
"ui.commandRuntime": "Command runtime",
"ui.eventRuntime": "Event runtime",
"ui.filesRuntime": "Files runtime",
"ui.filesReservedPath": "Files reserved path",
"ui.workbenchRouting": "Workbench routing",
"ui.passed": "Passed",
"ui.failed": "Failed",
"ui.total": "Total",
"ui.successRate": "Success Rate",
"ui.checking": "Checking...",
"ui.available": "✓ Available",
"ui.unavailable": "— Unavailable",
"ui.error": "Error",
"ui.ready": "✓ Ready",
"ui.missing": "✗ Missing",
"ui.loaded": "Loaded ✓",
"ui.info.pluginId": "Plugin ID",
"ui.info.bundleStatus": "Bundle Status",
"ui.info.registration": "Registration Scheme",
"ui.info.components": "Components",
"ui.info.container": "Container",
"ui.test.registration": "Plugin Registration",
"ui.test.platformCapability": "Capability: verstak/platform-test/v1",
"ui.test.diagnosticsCapability": "Capability: verstak/diagnostics/v1",
"ui.test.filesCapability": "Capability: verstak/core/files/v1",
"ui.test.workbenchCapability": "Capability: verstak/core/workbench/v1",
"ui.test.apiContract": "API Contract Compliance",
"ui.pass": "✓ PASS",
"ui.fail": "✗ FAIL",
"ui.noMouseEvents": "(no events captured)",
"ui.stopCapture": "■ Stop Capture",
"ui.startCapture": "▶ Start Capture",
"ui.clear": "Clear",
"ui.copyJson": "Copy JSON",
"ui.copied": "✓ Copied!",
"ui.mouseHint": "Captures ALL mouse/pointer events on window. Press back/forward buttons to see what WebKitGTK reports.",
"ui.eventsCount": "Events:",
"ui.settingsInfo": "Settings panel loaded from the plugin frontend bundle through the VerstakPluginRegister contract.",
"ui.counterClicks": "clicks (session only, no persistence)",
"ui.counterHint": "This counter is a local demo. State is not persisted — refreshing resets it.",
"ui.setting.autoRun": "Auto-run on load",
"ui.setting.verbose": "Verbose logging",
"ui.setting.theme": "Theme override",
"ui.setting.notifications": "Notifications",
"ui.value.true": "true",
"ui.value.false": "false",
"ui.value.inherit": "inherit",
"ui.value.enabled": "enabled",
"ui.settingsHint": "Use api.settings.read() / api.settings.write() for persisted settings."
}
+111
View File
@@ -0,0 +1,111 @@
{
"manifest.name": "Тест платформы",
"manifest.description": "Диагностический плагин для проверки загрузки manifest, capabilities, contributions, шины событий и интерфейса Верстака.",
"contributions.views.verstak.platform-test.diagnostics.title": "Диагностика платформы",
"contributions.commands.verstak.platform-test.run-tests.title": "Запустить тесты платформы",
"contributions.commands.verstak.platform-test.show-version.title": "Показать сведения о версии",
"contributions.sidebarItems.verstak.platform-test.sidebar.title": "Тест платформы",
"contributions.statusBarItems.verstak.platform-test.status.label": "[OK] Все тесты пройдены",
"contributions.settingsPanels.verstak.platform-test.settings.title": "Настройки теста платформы",
"contributions.openProviders.verstak.platform-test.markdown-diagnostic.title": "Диагностика Markdown платформы",
"ui.diagnostics": "Диагностика платформы",
"ui.bundleLoaded": "Пакет плагина загружен",
"ui.apiBridge": "Мост API",
"ui.testResults": "Результаты тестов",
"ui.capabilities": "Предоставленные возможности",
"ui.pluginInfo": "Сведения о плагине",
"ui.hostMethods": "Методы хоста",
"ui.mouseInspector": "Инспектор событий мыши",
"ui.settings": "Настройки теста платформы",
"ui.increment": "+ Увеличить",
"ui.decrement": "− Уменьшить",
"ui.reset": "Сбросить",
"ui.counter": "Интерактивный счётчик",
"ui.demoSettings": "Демонстрационные настройки",
"ui.savedLoading": "Загрузка сохранённой настройки...",
"ui.capabilitiesLoading": "Загрузка возможностей...",
"ui.commandRegistering": "Регистрация команды...",
"ui.eventSubscribing": "Подписка на событие...",
"ui.filesRunning": "Проверка файлов...",
"ui.filesPathChecking": "Проверка запрещённого пути...",
"ui.workbenchReady": "Workbench готов",
"ui.workbenchOpening": "Открытие Workbench...",
"ui.workbenchOpened": "Workbench открыл {path} через {provider}",
"ui.workbenchError": "Ошибка Workbench: {error}",
"ui.openTextDiagnostic": "Открыть текстовую диагностику",
"ui.openMarkdownDiagnostic": "Открыть диагностику Markdown",
"ui.openNotesDiagnostic": "Открыть диагностику заметок",
"ui.savedSetting": "Сохранённая настройка",
"ui.saving": "Сохранение...",
"ui.savedSettingValue": "Сохранённая настройка: {value}",
"ui.saved": "Сохранено",
"ui.errorValue": "Ошибка: {error}",
"ui.saveSetting": "Сохранить настройку",
"ui.settingsError": "Ошибка настроек: {error}",
"ui.capabilitiesAvailable": "Доступно возможностей: {count}",
"ui.capabilitiesError": "Ошибка возможностей: {error}",
"ui.bundleCapabilityAvailable": "Frontend bundle загружен | capability доступна",
"ui.bundleCapabilityMissing": "Frontend bundle загружен | capability отсутствует",
"ui.capabilityError": "Ошибка capability: {error}",
"ui.commandResult": "Команда: {status} {version}, источник: {source}",
"ui.commandError": "Ошибка команды: {error}",
"ui.eventReceived": "Получено событие: {message}",
"ui.eventError": "Ошибка события: {error}",
"ui.filesPassed": "Файлы: запись/чтение/список/перемещение/удаление выполнены",
"ui.filesError": "Ошибка файлов: {error}",
"ui.filesPathAllowed": "Ошибка: запрещённый путь неожиданно разрешён",
"ui.filesPathWrongError": "Неверная ошибка запрещённого пути: {error}",
"ui.filesPathRejected": "Зарезервированный путь отклонён",
"ui.persistedSetting": "Сохранённая настройка",
"ui.newValue": "Новое значение",
"ui.capabilitiesLabel": "Возможности",
"ui.commandRuntime": "Выполнение команд",
"ui.eventRuntime": "Обработка событий",
"ui.filesRuntime": "Работа с файлами",
"ui.filesReservedPath": "Зарезервированный путь",
"ui.workbenchRouting": "Маршрутизация Workbench",
"ui.passed": "Пройдено",
"ui.failed": "Ошибок",
"ui.total": "Всего",
"ui.successRate": "Успешность",
"ui.checking": "Проверка...",
"ui.available": "✓ Доступно",
"ui.unavailable": "— Недоступно",
"ui.error": "Ошибка",
"ui.ready": "✓ Готово",
"ui.missing": "✗ Отсутствует",
"ui.loaded": "Загружено ✓",
"ui.info.pluginId": "ID плагина",
"ui.info.bundleStatus": "Состояние bundle",
"ui.info.registration": "Схема регистрации",
"ui.info.components": "Компоненты",
"ui.info.container": "Контейнер",
"ui.test.registration": "Регистрация плагина",
"ui.test.platformCapability": "Capability: verstak/platform-test/v1",
"ui.test.diagnosticsCapability": "Capability: verstak/diagnostics/v1",
"ui.test.filesCapability": "Capability: verstak/core/files/v1",
"ui.test.workbenchCapability": "Capability: verstak/core/workbench/v1",
"ui.test.apiContract": "Соответствие контракту API",
"ui.pass": "✓ ПРОЙДЕНО",
"ui.fail": "✗ ОШИБКА",
"ui.noMouseEvents": "(события не зафиксированы)",
"ui.stopCapture": "■ Остановить запись",
"ui.startCapture": "▶ Начать запись",
"ui.clear": "Очистить",
"ui.copyJson": "Копировать JSON",
"ui.copied": "✓ Скопировано!",
"ui.mouseHint": "Записывает все события мыши и указателя в окне. Нажмите кнопки назад/вперёд, чтобы увидеть события WebKitGTK.",
"ui.eventsCount": "События:",
"ui.settingsInfo": "Панель настроек загружена из frontend bundle плагина через контракт VerstakPluginRegister.",
"ui.counterClicks": "нажатий (только в текущем сеансе)",
"ui.counterHint": "Это локальный демонстрационный счётчик. После обновления его значение сбрасывается.",
"ui.setting.autoRun": "Автозапуск при загрузке",
"ui.setting.verbose": "Подробный журнал",
"ui.setting.theme": "Переопределение темы",
"ui.setting.notifications": "Уведомления",
"ui.value.true": "да",
"ui.value.false": "нет",
"ui.value.inherit": "наследовать",
"ui.value.enabled": "включены",
"ui.settingsHint": "Для постоянных настроек используйте api.settings.read() / api.settings.write()."
}
+1
View File
@@ -5,6 +5,7 @@
"version": "0.1.0",
"apiVersion": "0.1.0",
"description": "Runtime test plugin for verifying the Verstak platform: manifest loading, capability registration, contribution injection, event bus, and UI rendering.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official",
"icon": "flask",
"provides": [
+30 -14
View File
@@ -277,7 +277,8 @@
try {
providers = await api.contributions.list('searchProviders');
} catch (err) {
output.errors.push(err && err.message ? err.message : String(err));
console.warn('[verstak.search] list search providers:', err);
output.errors.push(true);
return output;
}
providers = Array.isArray(providers) ? providers : [];
@@ -294,7 +295,8 @@
var normalized = normalizeProviderResults(provider, response && response.result);
output.results = output.results.concat(normalized.slice(0, remaining - output.results.length));
} catch (err) {
output.errors.push(err && err.message ? err.message : String(err));
console.warn('[verstak.search] provider search:', err);
output.errors.push(true);
}
}
return output;
@@ -315,6 +317,11 @@
var statusEl = null;
var alertEl = null;
var resultsEl = null;
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
state.status = tr('ui.minChars', null, 'Enter at least 2 characters.');
function ensureLayout() {
if (input) return;
@@ -325,7 +332,7 @@
input = el('input', {
className: 'search-input',
type: 'search',
placeholder: 'Search files, folders, text',
placeholder: tr('ui.placeholder', null, 'Search files, folders, text'),
value: state.query,
'data-search-input': 'query',
onInput: function (event) {
@@ -341,7 +348,7 @@
containerEl.appendChild(el('div', { className: 'search-toolbar' }, [
input,
button,
el('span', { className: 'search-scope', title: rootPath || 'Vault' }, [rootPath || 'Vault'])
el('span', { className: 'search-scope', title: rootPath || tr('ui.vault', null, 'Vault') }, [rootPath || tr('ui.vault', null, 'Vault')])
]));
statusEl = el('div', { className: 'search-status' });
@@ -357,7 +364,7 @@
if (document.activeElement !== input && input.value !== state.query) {
input.value = state.query;
}
button.textContent = state.searching ? 'Searching...' : 'Search';
button.textContent = state.searching ? tr('ui.searching', null, 'Searching...') : tr('ui.search', null, 'Search');
button.disabled = !!state.searching;
statusEl.className = 'search-status' + (state.error ? ' error' : '');
statusEl.textContent = state.error || state.status;
@@ -365,15 +372,17 @@
if (state.providerErrors && state.providerErrors.length) {
if (typeof alertEl.removeAttribute === 'function') alertEl.removeAttribute('hidden');
alertEl.appendChild(el('details', {}, [
el('summary', {}, ['Some plugin search providers did not respond']),
el('div', {}, [state.providerErrors.join('; ')])
el('summary', {}, [tr('ui.providersFailed', null, 'Some plugin search providers did not respond')]),
el('div', {}, [state.providerErrors.map(function () {
return tr('ui.providerUnavailable', null, 'A search provider is unavailable.');
}).join(' ')])
]));
} else if (typeof alertEl.setAttribute === 'function') {
alertEl.setAttribute('hidden', 'hidden');
}
resultsEl.innerHTML = '';
if (!state.results.length) {
resultsEl.appendChild(el('div', { className: 'search-empty' }, [state.searching ? 'Searching...' : 'No results']));
resultsEl.appendChild(el('div', { className: 'search-empty' }, [state.searching ? tr('ui.searching', null, 'Searching...') : tr('ui.noResults', null, 'No results')]));
return;
}
state.results.forEach(function (result) {
@@ -391,7 +400,7 @@
]),
result.openable ? el('button', {
className: 'search-btn search-open-btn',
textContent: 'Open',
textContent: tr('ui.open', null, 'Open'),
'data-search-open': result.path,
onClick: function () {
api.workbench.openResource({
@@ -415,7 +424,7 @@
if (query.length < 2) {
state.searching = false;
state.results = [];
state.status = 'Enter at least 2 characters.';
state.status = tr('ui.minChars', null, 'Enter at least 2 characters.');
state.error = '';
state.providerErrors = [];
render();
@@ -432,7 +441,7 @@
state.query = String(state.query || '').trim();
if (state.query.length < 2) {
state.results = [];
state.status = 'Enter at least 2 characters.';
state.status = tr('ui.minChars', null, 'Enter at least 2 characters.');
state.error = '';
state.providerErrors = [];
render();
@@ -441,7 +450,7 @@
state.searching = true;
state.error = '';
state.providerErrors = [];
state.status = 'Searching...';
state.status = tr('ui.searching', null, 'Searching...');
var seq = searchSeq + 1;
searchSeq = seq;
render();
@@ -451,12 +460,13 @@
var external = await runExternalProviders(api, rootPath, state.query, MAX_RESULTS - results.length);
if (seq !== searchSeq) return;
state.results = results.concat(external.results);
state.status = state.results.length + ' result' + (state.results.length === 1 ? '' : 's');
state.status = tr('ui.count', { count: state.results.length }, state.results.length + ' result' + (state.results.length === 1 ? '' : 's'));
state.providerErrors = external.errors;
} catch (err) {
if (seq !== searchSeq) return;
console.warn('[verstak.search] search:', err);
state.results = [];
state.error = err && err.message ? err.message : String(err);
state.error = tr('ui.searchError', null, 'Could not search the vault. Please try again.');
state.providerErrors = [];
} finally {
if (seq !== searchSeq) return;
@@ -507,6 +517,12 @@
setupIntegrations();
render();
if (api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
cleanupFns.push(api.i18n.onDidChangeLocale(function () {
if (input) input.setAttribute('placeholder', tr('ui.placeholder', null, 'Search files, folders, text'));
render();
}));
}
containerEl.__verstakSearchCleanup = function () {
if (searchTimer) clearTimeout(searchTimer);
searchSeq += 1;
+18
View File
@@ -0,0 +1,18 @@
{
"manifest.name": "Search",
"manifest.description": "Deal-scoped vault text search provider.",
"contributions.workspaceItems.verstak.search.workspace.title": "Search",
"contributions.commands.verstak.search.searchVaultText.title": "Search Vault Text",
"contributions.searchProviders.verstak.search.vault-text.label": "Vault Text Search",
"ui.minChars": "Enter at least 2 characters.",
"ui.placeholder": "Search files, folders, text",
"ui.vault": "Vault",
"ui.searching": "Searching...",
"ui.search": "Search",
"ui.providersFailed": "Some plugin search providers did not respond",
"ui.providerUnavailable": "A search provider is unavailable.",
"ui.searchError": "Could not search the vault. Please try again.",
"ui.noResults": "No results",
"ui.open": "Open",
"ui.count": "{count} result(s)"
}
+18
View File
@@ -0,0 +1,18 @@
{
"manifest.name": "Поиск",
"manifest.description": "Поиск текста в хранилище в пределах Дела.",
"contributions.workspaceItems.verstak.search.workspace.title": "Поиск",
"contributions.commands.verstak.search.searchVaultText.title": "Искать текст в хранилище",
"contributions.searchProviders.verstak.search.vault-text.label": "Поиск по тексту хранилища",
"ui.minChars": "Введите не менее 2 символов.",
"ui.placeholder": "Поиск файлов, папок и текста",
"ui.vault": "Хранилище",
"ui.searching": "Поиск...",
"ui.search": "Искать",
"ui.providersFailed": "Некоторые поставщики поиска не ответили",
"ui.providerUnavailable": "Один из источников поиска недоступен.",
"ui.searchError": "Не удалось выполнить поиск в хранилище. Повторите попытку.",
"ui.noResults": "Ничего не найдено",
"ui.open": "Открыть",
"ui.count": "Результатов: {count}"
}
+2 -1
View File
@@ -4,7 +4,8 @@
"name": "Search",
"version": "0.1.0",
"apiVersion": "0.1.0",
"description": "Workspace-scoped vault text search provider.",
"description": "Deal-scoped vault text search provider.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official",
"icon": "search",
"provides": [
+304 -58
View File
@@ -23,6 +23,7 @@
'.secrets-panel{min-height:0;overflow:auto;border-right:1px solid var(--vt-color-border,#202b46);background:var(--vt-color-surface-muted,#111629)}',
'.secrets-main{min-width:0;min-height:0;overflow:auto;padding:1rem;background:var(--vt-color-background,#101020)}',
'.secrets-toolbar{display:flex;align-items:center;gap:.5rem;min-height:2.75rem;padding:.65rem .75rem;border-bottom:1px solid var(--vt-color-border,#202b46)}',
'.secrets-filters{display:grid;grid-template-columns:minmax(0,1fr) minmax(8rem,.8fr);gap:.4rem;padding:.5rem .55rem;border-bottom:1px solid var(--vt-color-border,#202b46)}',
'.secrets-title{font-weight:600;font-size:.88rem}',
'.secrets-count{color:var(--vt-color-text-muted,#7f8aa3);font-size:.76rem}',
'.secrets-spacer{flex:1}',
@@ -44,10 +45,11 @@
'.secrets-form{display:grid;gap:.65rem;border:1px solid var(--vt-color-border,#202b46);border-radius:var(--vt-radius-lg,8px);padding:.9rem;background:var(--vt-color-surface,#15152c)}',
'.secrets-row{display:grid;grid-template-columns:8rem minmax(0,1fr);gap:.65rem;align-items:center}',
'.secrets-label{font-size:.78rem;color:var(--vt-color-text-muted,#7f8aa3)}',
'.secrets-input,.secrets-textarea,.secrets-select{width:100%;box-sizing:border-box;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);font:inherit;font-size:.84rem;padding:.45rem .55rem;outline:none}',
'.secrets-select{appearance:none;background-color:#0d1117;background-image:linear-gradient(45deg,transparent 50%,#8b949e 50%),linear-gradient(135deg,#8b949e 50%,transparent 50%);background-position:calc(100% - 16px) 50%,calc(100% - 11px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:2rem}',
'.secrets-input,.secrets-textarea,.secrets-select,.secrets-search{width:100%;box-sizing:border-box;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);font:inherit;font-size:.84rem;padding:.45rem .55rem;outline:none}',
'.secrets-search{font-size:.76rem}',
'.secrets-select{appearance:none;background-color:#0d1117;background-image:linear-gradient(45deg,transparent 50%,#8b949e 50%),linear-gradient(135deg,#8b949e 50%,transparent 50%);background-position:calc(100% - 16px) 50%,calc(100% - 11px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:2rem}.secrets-select option{background:#0d1117;color:var(--vt-color-text-primary,#f4f7fb)}',
'.secrets-textarea{min-height:6rem;resize:vertical;font-family:ui-monospace,SFMono-Regular,Consolas,monospace}',
'.secrets-input:focus,.secrets-textarea:focus,.secrets-select:focus{border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.secrets-input:focus,.secrets-textarea:focus,.secrets-select:focus,.secrets-search:focus{border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.secrets-actions{display:flex;gap:.5rem;flex-wrap:wrap}',
'.secrets-status{font-size:.78rem;color:var(--vt-color-text-muted,#7f8aa3);min-height:1rem}',
'.secrets-status.error{color:#ffc6ce}',
@@ -58,7 +60,12 @@
'.secrets-table th{width:9rem;color:var(--vt-color-text-muted,#7f8aa3);font-weight:500;background:var(--vt-color-surface-muted,#111629)}',
'.secrets-table td{color:var(--vt-color-text-primary,#f4f7fb);overflow-wrap:anywhere}',
'.secrets-table tr:last-child th,.secrets-table tr:last-child td{border-bottom:0}',
'@media(max-width:780px){.secrets-root{grid-template-columns:1fr}.secrets-panel{border-right:0;border-bottom:1px solid #252b36;max-height:45vh}.secrets-row{grid-template-columns:1fr}}'
'.secrets-modal-overlay{position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.6)}',
'.secrets-modal{width:24rem;max-width:90vw;display:grid;gap:.8rem;padding:1rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-lg,8px);background:var(--vt-color-surface,#15152c);box-shadow:var(--vt-elevation-menu,0 14px 32px rgba(0,0,0,.42))}',
'.secrets-modal-title{margin:0;font-size:.96rem}',
'.secrets-modal-message{margin:0;color:var(--vt-color-text-secondary,#b7c0d4);font-size:.84rem;line-height:1.45}',
'.secrets-modal-actions{display:flex;justify-content:flex-end;gap:.5rem}',
'@media(max-width:780px){.secrets-root{grid-template-columns:1fr}.secrets-panel{border-right:0;border-bottom:1px solid #252b36;max-height:45vh}.secrets-row,.secrets-filters{grid-template-columns:1fr}}'
].join('\n');
function el(tag, attrs, children) {
@@ -95,29 +102,30 @@
|| (node && (node.rootPath || node.name || node.id)));
}
function scopeLabel(record) {
function scopeLabel(record, translate) {
var scope = record && record.scope || {};
if (scope.kind === ScopeWorkspace) return cleanWorkspace(scope.workspaceRootPath) || 'Workspace';
return 'Global';
if (scope.kind === ScopeWorkspace) return cleanWorkspace(scope.workspaceRootPath) || translate('ui.deal', null, 'Deal');
return translate('ui.global', null, 'Global');
}
function selectedIDFromProps(props) {
var resource = props && props.resource || {};
var path = text(resource.path || props && props.secretId);
var request = props && props.request || {};
var path = text(resource.path || request.path || props && props.secretId);
if (path.indexOf('verstak-secret://') === 0) return decodeURIComponent(path.slice('verstak-secret://'.length));
return decodeURIComponent(path.replace(/^\/+/, ''));
}
function groupRecords(records) {
function groupRecords(records, translate) {
var groups = {};
records.forEach(function (record) {
var label = scopeLabel(record);
var label = scopeLabel(record, translate);
groups[label] = groups[label] || [];
groups[label].push(record);
});
return Object.keys(groups).sort(function (a, b) {
if (a === 'Global') return -1;
if (b === 'Global') return 1;
if (a === translate('ui.global', null, 'Global')) return -1;
if (b === translate('ui.global', null, 'Global')) return 1;
return a.localeCompare(b);
}).map(function (label) {
groups[label].sort(function (a, b) {
@@ -144,12 +152,21 @@
var workspaceRoot = workspaceFromProps(props || {});
var selectedID = selectedIDFromProps(props || {});
var records = [];
var workspaceOptions = [];
var scopeFilter = 'all';
var searchQuery = '';
var selectedRecord = null;
var selectedValue = '';
var isValueVisible = false;
var deleteModal = null;
var initialized = false;
var unlocked = false;
var statusText = '';
var statusError = false;
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
function setStatus(message, isError) {
statusText = message || '';
@@ -157,18 +174,104 @@
render();
}
function userFacingError(action, err) {
var raw = (err && err.message) ? err.message : String(err || '');
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
console.warn('[verstak.secrets] ' + action + ' failed', err);
}
if (action === 'unlock') {
var minimumLength = raw.match(/master password must be at least (\d+) characters/i);
if (minimumLength) {
return tr('ui.masterPasswordMinLength', { count: minimumLength[1] }, 'Master password must be at least ' + minimumLength[1] + ' characters.');
}
if (/master password is empty/i.test(raw)) {
return tr('ui.masterPasswordRequired', null, 'Enter a master password.');
}
if (/invalid master password/i.test(raw)) {
return tr('ui.masterPasswordInvalid', null, 'The master password is incorrect.');
}
}
return tr('ui.' + action + 'Error', null, {
status: 'Could not check the secret storage. Please try again.',
load: 'Could not load secrets. Please try again.',
read: 'Could not open this secret. Please try again.',
save: 'Could not save the secret. Please try again.',
delete: 'Could not delete the secret. Please try again.',
copyValue: 'Could not copy the secret value. Please try again.',
copyLink: 'Could not copy the secret link. Please try again.',
unlock: 'Could not unlock secrets. Check the master password and try again.'
}[action] || 'Could not complete this action. Please try again.');
}
function setUserFacingError(action, err) {
setStatus(userFacingError(action, err), true);
}
function recordWorkspaceRoot(record) {
var scope = record && record.scope || {};
return scope.kind === ScopeWorkspace ? cleanWorkspace(scope.workspaceRootPath) : '';
}
function workspaceRoots() {
var seen = {};
var values = [];
workspaceOptions.concat(records.map(recordWorkspaceRoot)).forEach(function (value) {
value = cleanWorkspace(value);
if (!value || seen[value]) return;
seen[value] = true;
values.push(value);
});
return values.sort(function (a, b) { return a.localeCompare(b); });
}
function filteredRecords() {
var query = text(searchQuery).trim().toLowerCase();
return records.filter(function (record) {
var recordWorkspace = recordWorkspaceRoot(record);
if (scopeFilter === ScopeGlobal && recordWorkspace) return false;
if (scopeFilter.indexOf(ScopeWorkspace + ':') === 0 && recordWorkspace !== scopeFilter.slice((ScopeWorkspace + ':').length)) return false;
if (!query) return true;
return [record.title, record.id, record.username, recordWorkspace].some(function (value) {
return text(value).toLowerCase().indexOf(query) !== -1;
});
});
}
function clearHiddenSelection() {
if (!selectedRecord) return;
var visible = filteredRecords();
if (visible.some(function (record) { return record.id === selectedRecord.id; })) return;
selectedRecord = null;
selectedValue = '';
}
function loadWorkspaceOptions() {
if (!api || !api.files || typeof api.files.list !== 'function') return Promise.resolve();
return api.files.list('').then(function (items) {
workspaceOptions = (Array.isArray(items) ? items : []).filter(function (item) {
return text(item && item.type).toLowerCase() === 'folder';
}).map(function (item) {
return cleanWorkspace(item.relativePath || item.name);
}).filter(function (value) {
return value && value.indexOf('/') === -1;
});
}).catch(function () {
workspaceOptions = [];
});
}
function renderLocked() {
var passwordInput = el('input', {
className: 'secrets-input',
type: 'password',
'data-secret-master-password': '',
placeholder: 'Master password'
placeholder: tr('ui.masterPassword', null, 'Master password')
});
var confirmInput = initialized ? null : el('input', {
className: 'secrets-input',
type: 'password',
'data-secret-master-password-confirm': '',
placeholder: 'Repeat master password'
placeholder: tr('ui.repeatPassword', null, 'Repeat master password')
});
var unlockBtn = el('button', {
className: 'secrets-btn primary',
@@ -176,7 +279,7 @@
'data-secret-unlock': '',
onClick: function () {
if (!initialized && passwordInput.value !== confirmInput.value) {
setStatus('Master passwords do not match', true);
setStatus(tr('ui.passwordMismatch', null, 'Master passwords do not match'), true);
return;
}
unlockBtn.disabled = true;
@@ -186,20 +289,20 @@
return loadRecords();
}).catch(function (err) {
unlockBtn.disabled = false;
setStatus((err && err.message) ? err.message : String(err), true);
setUserFacingError('unlock', err);
});
}
}, ['Unlock']);
if (!initialized) unlockBtn.textContent = 'Create master password';
}, [tr('ui.unlock', null, 'Unlock')]);
if (!initialized) unlockBtn.textContent = tr('ui.createMaster', null, 'Create master password');
var rows = [
el('div', { className: 'secrets-row' }, [
el('label', { className: 'secrets-label' }, ['Password']),
el('label', { className: 'secrets-label' }, [tr('ui.password', null, 'Password')]),
passwordInput
])
];
if (confirmInput) {
rows.push(el('div', { className: 'secrets-row' }, [
el('label', { className: 'secrets-label' }, ['Repeat']),
el('label', { className: 'secrets-label' }, [tr('ui.repeat', null, 'Repeat')]),
confirmInput
]));
}
@@ -209,12 +312,12 @@
containerEl.appendChild(el('div', { className: 'secrets-root' }, [
el('div', { className: 'secrets-panel' }, [
el('div', { className: 'secrets-toolbar' }, [
el('span', { className: 'secrets-title' }, ['Secrets'])
el('span', { className: 'secrets-title' }, [tr('ui.title', null, 'Secrets')])
])
]),
el('div', { className: 'secrets-main' }, [
el('div', { className: 'secrets-card' }, [
el('h2', {}, [initialized ? 'Unlock secrets' : 'Create master password']),
el('h2', {}, [initialized ? tr('ui.unlockSecrets', null, 'Unlock secrets') : tr('ui.createMaster', null, 'Create master password')]),
el('div', { className: 'secrets-form' }, rows)
])
])
@@ -222,19 +325,48 @@
}
function renderList() {
var visibleRecords = filteredRecords();
var scopeSelect = el('select', {
className: 'secrets-select',
'data-secret-scope-filter': '',
onChange: function (event) {
scopeFilter = text(event.target.value) || 'all';
clearHiddenSelection();
render();
}
}, [
el('option', { value: 'all' }, [tr('ui.scopeAll', null, 'All scopes')]),
el('option', { value: ScopeGlobal }, [tr('ui.global', null, 'Global')])
].concat(workspaceRoots().map(function (workspace) {
return el('option', { value: ScopeWorkspace + ':' + workspace }, [workspace]);
})));
scopeSelect.value = scopeFilter;
var searchInput = el('input', {
className: 'secrets-search',
type: 'search',
'data-secret-search': '',
placeholder: tr('ui.search', null, 'Search secrets'),
value: searchQuery,
onInput: function (event) {
searchQuery = text(event.target.value);
clearHiddenSelection();
render();
}
});
var children = [
el('div', { className: 'secrets-toolbar' }, [
el('span', { className: 'secrets-title' }, ['Secrets']),
el('span', { className: 'secrets-count' }, [String(records.length)]),
el('span', { className: 'secrets-title' }, [tr('ui.title', null, 'Secrets')]),
el('span', { className: 'secrets-count' }, [String(visibleRecords.length)]),
el('span', { className: 'secrets-spacer' }),
el('button', { className: 'secrets-btn', type: 'button', onClick: showNewSecret }, ['New'])
])
el('button', { className: 'secrets-btn', type: 'button', onClick: showNewSecret }, [tr('ui.new', null, 'New')])
]),
el('div', { className: 'secrets-filters' }, [searchInput, scopeSelect])
];
if (!records.length) {
children.push(el('div', { className: 'secrets-empty' }, ['No secrets']));
if (!visibleRecords.length) {
children.push(el('div', { className: 'secrets-empty' }, [tr('ui.empty', null, 'No secrets')]));
return children;
}
groupRecords(records).forEach(function (group) {
groupRecords(visibleRecords, tr).forEach(function (group) {
children.push(el('div', { className: 'secrets-group' }, [group.label]));
children.push(el('div', { className: 'secrets-list' }, group.records.map(function (record) {
var active = selectedRecord && selectedRecord.id === record.id;
@@ -255,17 +387,20 @@
function renderSelected() {
if (!selectedRecord) return el('div', { className: 'secrets-card' }, [
el('h2', {}, ['Select a secret'])
el('h2', {}, [tr('ui.select', null, 'Select a secret')]),
el('div', { className: statusError ? 'secrets-status error' : 'secrets-status' }, [statusText])
]);
var valueIsAvailable = !!selectedValue;
var valueDisplay = valueIsAvailable && isValueVisible ? selectedValue : '••••••••••••';
return el('div', { className: 'secrets-card' }, [
el('h2', {}, [selectedRecord.title || selectedRecord.id]),
el('table', { className: 'secrets-table' }, [
el('tbody', {}, [
fieldRow('Group', scopeLabel(selectedRecord)),
fieldRow(tr('ui.group', null, 'Group'), scopeLabel(selectedRecord, tr)),
fieldRow('ID', selectedRecord.id),
fieldRow('Username', selectedRecord.username || ''),
fieldRow('Password', selectedValue ? selectedValue : '••••••••••••', selectedValue ? '' : 'secrets-hidden-value'),
fieldRow('Updated', selectedRecord.updatedAt || '')
fieldRow(tr('ui.username', null, 'Username'), selectedRecord.username || ''),
fieldRow(tr('ui.password', null, 'Password'), valueDisplay, isValueVisible && valueIsAvailable ? 'secrets-secret-value' : 'secrets-hidden-value'),
fieldRow(tr('ui.updated', null, 'Updated'), selectedRecord.updatedAt || '')
])
]),
el('div', { className: 'secrets-actions' }, [
@@ -274,19 +409,37 @@
type: 'button',
'data-secret-copy-link': selectedRecord.id,
onClick: function () { copySecretLink(selectedRecord.id); }
}, ['Copy secret link']),
}, [tr('ui.copyLink', null, 'Copy secret link')]),
el('button', {
className: 'secrets-btn',
type: 'button',
disabled: !valueIsAvailable,
'data-secret-copy-value': selectedRecord.id,
onClick: function () { copySecretValue(selectedValue); }
}, [tr('ui.copyValue', null, 'Copy value')]),
el('button', {
className: 'secrets-btn',
type: 'button',
disabled: !valueIsAvailable,
'data-secret-toggle-value': selectedRecord.id,
onClick: function () {
if (!selectedValue) return;
isValueVisible = !isValueVisible;
render();
}
}, [isValueVisible ? tr('ui.hideValue', null, 'Hide value') : tr('ui.showValue', null, 'Show value')]),
el('button', {
className: 'secrets-btn',
type: 'button',
'data-secret-edit': selectedRecord.id,
onClick: function () { showEditSecret(); }
}, ['Edit']),
}, [tr('ui.edit', null, 'Edit')]),
el('button', {
className: 'secrets-btn danger',
type: 'button',
'data-secret-delete': selectedRecord.id,
onClick: function () { deleteSecret(selectedRecord.id); }
}, ['Delete'])
}, [tr('ui.delete', null, 'Delete')])
]),
el('div', { className: statusError ? 'secrets-status error' : 'secrets-status' }, [statusText])
]);
@@ -301,28 +454,46 @@
function renderSecretForm(existing) {
var isEdit = !!existing;
var title = el('input', { className: 'secrets-input', type: 'text', 'data-secret-title': '', placeholder: 'Title' });
var title = el('input', { className: 'secrets-input', type: 'text', 'data-secret-title': '', placeholder: tr('ui.fieldTitle', null, 'Title') });
title.value = existing ? text(existing.title) : '';
var id = el('input', { className: 'secrets-input', type: 'text', placeholder: 'stable.id' });
id.value = existing ? text(existing.id) : '';
id.disabled = isEdit;
var username = el('input', { className: 'secrets-input', type: 'text', placeholder: 'optional username' });
var username = el('input', { className: 'secrets-input', type: 'text', placeholder: tr('ui.optionalUsername', null, 'optional username') });
username.value = existing ? text(existing.username) : '';
var value = el('textarea', { className: 'secrets-textarea', 'data-secret-value': '', placeholder: 'Secret value' });
var value = el('textarea', { className: 'secrets-textarea', 'data-secret-value': '', placeholder: tr('ui.secretValue', null, 'Secret value') });
value.value = isEdit ? selectedValue : '';
var scope = el('select', { className: 'secrets-select' }, [
el('option', { value: ScopeGlobal }, ['Global']),
el('option', { value: ScopeWorkspace }, [workspaceRoot || 'Workspace'])
el('option', { value: ScopeGlobal }, [tr('ui.global', null, 'Global')]),
el('option', { value: ScopeWorkspace }, [tr('ui.deal', null, 'Deal')])
]);
scope.setAttribute('data-secret-scope', '');
var existingWorkspace = existing ? recordWorkspaceRoot(existing) : '';
var defaultWorkspace = existingWorkspace || workspaceRoot || workspaceRoots()[0] || '';
var workspace = el('select', { className: 'secrets-select', 'data-secret-workspace': '' }, [
el('option', { value: '' }, [tr('ui.chooseWorkspace', null, 'Choose a Deal')])
].concat(workspaceRoots().map(function (item) {
return el('option', { value: item }, [item]);
})));
workspace.value = defaultWorkspace;
var workspaceRow = el('div', { className: 'secrets-row' }, [
el('label', { className: 'secrets-label' }, [tr('ui.deal', null, 'Deal')]), workspace
]);
function updateWorkspaceVisibility() {
workspaceRow.hidden = scope.value !== ScopeWorkspace;
}
scope.addEventListener('change', updateWorkspaceVisibility);
scope.value = existing && existing.scope && existing.scope.kind ? existing.scope.kind : (workspaceRoot ? ScopeWorkspace : ScopeGlobal);
updateWorkspaceVisibility();
return el('div', { className: 'secrets-card' }, [
el('h2', {}, [isEdit ? 'Edit secret' : 'New secret']),
el('h2', {}, [isEdit ? tr('ui.editSecret', null, 'Edit secret') : tr('ui.newSecret', null, 'New secret')]),
el('div', { className: 'secrets-form' }, [
el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, ['Title']), title]),
el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, [tr('ui.fieldTitle', null, 'Title')]), title]),
el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, ['ID']), id]),
el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, ['Username']), username]),
el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, ['Scope']), scope]),
el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, ['Value']), value]),
el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, [tr('ui.username', null, 'Username')]), username]),
el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, [tr('ui.scope', null, 'Scope')]), scope]),
workspaceRow,
el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, [tr('ui.value', null, 'Value')]), value]),
el('div', { className: 'secrets-actions' }, [
el('button', {
className: 'secrets-btn primary',
@@ -330,23 +501,28 @@
'data-secret-save': '',
onClick: function () {
var nextID = text(id.value).trim() || text(title.value).trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '.').replace(/^\.+|\.+$/g, '');
var targetWorkspace = cleanWorkspace(workspace.value);
if (scope.value === ScopeWorkspace && !targetWorkspace) {
setStatus(tr('ui.workspaceRequired', null, 'Choose a Deal for this secret.'), true);
return;
}
api.secrets.write({
id: nextID,
title: text(title.value).trim() || nextID,
username: text(username.value).trim(),
value: text(value.value),
scope: scope.value === ScopeWorkspace ? { kind: ScopeWorkspace, workspaceRootPath: workspaceRoot } : { kind: ScopeGlobal }
scope: scope.value === ScopeWorkspace ? { kind: ScopeWorkspace, workspaceRootPath: targetWorkspace } : { kind: ScopeGlobal }
}).then(function (record) {
selectedID = record.id;
selectedRecord = record;
selectedValue = '';
return loadRecords();
}).catch(function (err) {
setStatus((err && err.message) ? err.message : String(err), true);
setUserFacingError('save', err);
});
}
}, ['Save']),
el('button', { className: 'secrets-btn', type: 'button', onClick: function () { mode = 'selected'; render(); } }, ['Cancel'])
}, [tr('ui.save', null, 'Save')]),
el('button', { className: 'secrets-btn', type: 'button', onClick: function () { mode = 'selected'; render(); } }, [tr('ui.cancel', null, 'Cancel')])
]),
el('div', { className: statusError ? 'secrets-status error' : 'secrets-status' }, [statusText])
])
@@ -377,11 +553,20 @@
if (wanted) {
var found = records.find(function (record) { return record.id === wanted; });
if (found) return selectRecord(found.id);
selectedRecord = null;
selectedValue = '';
mode = 'selected';
statusText = tr('ui.requestedUnavailable', null, 'The requested secret is unavailable.');
statusError = true;
render();
return;
}
selectedRecord = records[0] || null;
selectedValue = '';
mode = 'selected';
render();
}).catch(function (err) {
setUserFacingError('load', err);
});
}
@@ -390,6 +575,7 @@
selectedID = id;
selectedRecord = records.find(function (record) { return record.id === id; }) || null;
selectedValue = '';
isValueVisible = false;
render();
if (!id) return Promise.resolve();
return api.secrets.read(id).then(function (record) {
@@ -399,7 +585,7 @@
render();
}).catch(function (err) {
if (disposed) return;
setStatus((err && err.message) ? err.message : String(err), true);
setUserFacingError('read', err);
});
}
@@ -419,40 +605,100 @@
}
function deleteSecret(id) {
if (!id || !window.confirm('Delete this secret?')) return;
if (!id) return;
removeDeleteModal();
var title = selectedRecord && selectedRecord.id === id ? selectedRecord.title || selectedRecord.id : id;
var overlay = el('div', {
className: 'secrets-modal-overlay',
'data-secret-delete-modal': '',
role: 'presentation'
});
var cancel = el('button', {
className: 'secrets-btn',
type: 'button',
'data-secret-delete-cancel': '',
onClick: removeDeleteModal
}, [tr('ui.cancel', null, 'Cancel')]);
var confirm = el('button', {
className: 'secrets-btn danger',
type: 'button',
'data-secret-delete-confirm': '',
onClick: function () {
removeDeleteModal();
confirmDeleteSecret(id);
}
}, [tr('ui.delete', null, 'Delete')]);
overlay.appendChild(el('div', {
className: 'secrets-modal',
role: 'dialog',
'aria-modal': 'true',
'aria-label': tr('ui.deleteSecretTitle', null, 'Delete secret')
}, [
el('h2', { className: 'secrets-modal-title' }, [tr('ui.deleteSecretTitle', null, 'Delete secret')]),
el('p', { className: 'secrets-modal-message' }, [tr('ui.deleteConfirm', { title: title }, 'Delete "' + title + '"?')]),
el('div', { className: 'secrets-modal-actions' }, [cancel, confirm])
]));
document.body.appendChild(overlay);
deleteModal = overlay;
if (cancel.focus) cancel.focus();
}
function removeDeleteModal() {
if (deleteModal && typeof deleteModal.remove === 'function') deleteModal.remove();
else if (deleteModal && deleteModal.parentNode) deleteModal.parentNode.removeChild(deleteModal);
deleteModal = null;
}
function confirmDeleteSecret(id) {
api.secrets.delete(id).then(function () {
selectedID = '';
selectedRecord = null;
selectedValue = '';
isValueVisible = false;
return loadRecords();
}).catch(function (err) {
setStatus((err && err.message) ? err.message : String(err), true);
setUserFacingError('delete', err);
});
}
function copySecretValue(value) {
if (!value) return;
writeClipboard(api, value).then(function () {
setStatus(tr('ui.valueCopied', null, 'Secret value copied'), false);
}).catch(function (err) {
setUserFacingError('copyValue', err);
});
}
function copySecretLink(id) {
api.secrets.copyLink(id).then(function (link) {
return writeClipboard(api, link).then(function () {
setStatus('Secret link copied', false);
setStatus(tr('ui.linkCopied', null, 'Secret link copied'), false);
});
}).catch(function (err) {
setStatus((err && err.message) ? err.message : String(err), true);
setUserFacingError('copyLink', err);
});
}
api.secrets.status().then(function (status) {
initialized = !!(status && status.initialized);
unlocked = !!(status && status.unlocked);
if (unlocked) return loadRecords();
if (unlocked) return loadWorkspaceOptions().then(loadRecords);
render();
}).catch(function (err) {
statusText = (err && err.message) ? err.message : String(err);
statusText = userFacingError('status', err);
statusError = true;
renderLocked();
});
var localeUnsubscribe = api.i18n && typeof api.i18n.onDidChangeLocale === 'function'
? api.i18n.onDidChangeLocale(render)
: null;
containerEl.__secretsCleanup = function () {
disposed = true;
removeDeleteModal();
if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
};
},
+62
View File
@@ -0,0 +1,62 @@
{
"manifest.name": "Secrets",
"manifest.description": "Encrypted global and Deal-scoped secret manager.",
"contributions.views.verstak.secrets.view.title": "Secrets",
"contributions.sidebarItems.verstak.secrets.sidebar.title": "Secrets",
"contributions.openProviders.verstak.secrets.secret.title": "Secrets",
"contributions.settingsPanels.verstak.secrets.settings.title": "Secrets",
"contributions.workspaceItems.verstak.secrets.workspace.title": "Secrets",
"ui.masterPassword": "Master password",
"ui.repeatPassword": "Repeat master password",
"ui.passwordMismatch": "Master passwords do not match",
"ui.masterPasswordMinLength": "Master password must be at least {count} characters.",
"ui.masterPasswordRequired": "Enter a master password.",
"ui.masterPasswordInvalid": "The master password is incorrect.",
"ui.unlock": "Unlock",
"ui.createMaster": "Create master password",
"ui.password": "Password",
"ui.repeat": "Repeat",
"ui.title": "Secrets",
"ui.unlockSecrets": "Unlock secrets",
"ui.new": "New",
"ui.empty": "No secrets",
"ui.select": "Select a secret",
"ui.requestedUnavailable": "The requested secret is unavailable.",
"ui.group": "Group",
"ui.username": "Username",
"ui.updated": "Updated",
"ui.copyLink": "Copy secret link",
"ui.edit": "Edit",
"ui.delete": "Delete",
"ui.copyValue": "Copy value",
"ui.showValue": "Show value",
"ui.hideValue": "Hide value",
"ui.fieldTitle": "Title",
"ui.optionalUsername": "optional username",
"ui.secretValue": "Secret value",
"ui.global": "Global",
"ui.workspace": "Deal",
"ui.editSecret": "Edit secret",
"ui.newSecret": "New secret",
"ui.scope": "Scope",
"ui.deal": "Deal",
"ui.scopeAll": "All scopes",
"ui.search": "Search secrets",
"ui.chooseWorkspace": "Choose a Deal",
"ui.workspaceRequired": "Choose a Deal for this secret.",
"ui.statusError": "Could not check the secret storage. Please try again.",
"ui.loadError": "Could not load secrets. Please try again.",
"ui.readError": "Could not open this secret. Please try again.",
"ui.saveError": "Could not save the secret. Please try again.",
"ui.deleteError": "Could not delete the secret. Please try again.",
"ui.copyValueError": "Could not copy the secret value. Please try again.",
"ui.copyLinkError": "Could not copy the secret link. Please try again.",
"ui.unlockError": "Could not unlock secrets. Check the master password and try again.",
"ui.value": "Value",
"ui.save": "Save",
"ui.cancel": "Cancel",
"ui.deleteSecretTitle": "Delete secret",
"ui.deleteConfirm": "Delete \"{title}\"? This cannot be undone.",
"ui.linkCopied": "Secret link copied",
"ui.valueCopied": "Secret value copied"
}
+62
View File
@@ -0,0 +1,62 @@
{
"manifest.name": "Секреты",
"manifest.description": "Зашифрованное хранилище общих секретов и секретов Дел.",
"contributions.views.verstak.secrets.view.title": "Секреты",
"contributions.sidebarItems.verstak.secrets.sidebar.title": "Секреты",
"contributions.openProviders.verstak.secrets.secret.title": "Секреты",
"contributions.settingsPanels.verstak.secrets.settings.title": "Секреты",
"contributions.workspaceItems.verstak.secrets.workspace.title": "Секреты",
"ui.masterPassword": "Мастер-пароль",
"ui.repeatPassword": "Повторите мастер-пароль",
"ui.passwordMismatch": "Мастер-пароли не совпадают",
"ui.masterPasswordMinLength": "Мастер-пароль должен содержать не менее {count} символов.",
"ui.masterPasswordRequired": "Введите мастер-пароль.",
"ui.masterPasswordInvalid": "Мастер-пароль указан неверно.",
"ui.unlock": "Разблокировать",
"ui.createMaster": "Создать мастер-пароль",
"ui.password": "Пароль",
"ui.repeat": "Повтор",
"ui.title": "Секреты",
"ui.unlockSecrets": "Разблокировать секреты",
"ui.new": "Новый",
"ui.empty": "Секретов нет",
"ui.select": "Выберите секрет",
"ui.requestedUnavailable": "Запрошенный секрет недоступен.",
"ui.group": "Группа",
"ui.username": "Имя пользователя",
"ui.updated": "Обновлён",
"ui.copyLink": "Копировать ссылку на секрет",
"ui.edit": "Изменить",
"ui.delete": "Удалить",
"ui.copyValue": "Копировать значение",
"ui.showValue": "Показать значение",
"ui.hideValue": "Скрыть значение",
"ui.fieldTitle": "Название",
"ui.optionalUsername": "необязательное имя пользователя",
"ui.secretValue": "Значение секрета",
"ui.global": "Общий",
"ui.workspace": "Дело",
"ui.editSecret": "Изменить секрет",
"ui.newSecret": "Новый секрет",
"ui.scope": "Область",
"ui.deal": "Дело",
"ui.scopeAll": "Все области",
"ui.search": "Поиск секретов",
"ui.chooseWorkspace": "Выберите дело",
"ui.workspaceRequired": "Выберите дело для этого секрета.",
"ui.statusError": "Не удалось проверить хранилище секретов. Повторите попытку.",
"ui.loadError": "Не удалось загрузить секреты. Повторите попытку.",
"ui.readError": "Не удалось открыть секрет. Повторите попытку.",
"ui.saveError": "Не удалось сохранить секрет. Повторите попытку.",
"ui.deleteError": "Не удалось удалить секрет. Повторите попытку.",
"ui.copyValueError": "Не удалось скопировать значение секрета. Повторите попытку.",
"ui.copyLinkError": "Не удалось скопировать ссылку на секрет. Повторите попытку.",
"ui.unlockError": "Не удалось разблокировать секреты. Проверьте мастер-пароль и повторите попытку.",
"ui.value": "Значение",
"ui.save": "Сохранить",
"ui.cancel": "Отмена",
"ui.deleteSecretTitle": "Удалить секрет",
"ui.deleteConfirm": "Удалить «{title}»? Это действие нельзя отменить.",
"ui.linkCopied": "Ссылка на секрет скопирована",
"ui.valueCopied": "Значение секрета скопировано"
}
+20 -1
View File
@@ -4,7 +4,8 @@
"name": "Secrets",
"version": "0.1.0",
"apiVersion": "0.1.0",
"description": "Encrypted global and workspace-scoped secret manager.",
"description": "Encrypted global and Deal-scoped secret manager.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official",
"icon": "key-round",
"provides": [
@@ -13,6 +14,7 @@
"secrets.write-ui"
],
"permissions": [
"files.read",
"secrets.read",
"secrets.write",
"ui.register"
@@ -21,6 +23,23 @@
"entry": "frontend/src/index.js"
},
"contributes": {
"views": [
{
"id": "verstak.secrets.view",
"title": "Secrets",
"icon": "key-round",
"component": "SecretsView"
}
],
"sidebarItems": [
{
"id": "verstak.secrets.sidebar",
"title": "Secrets",
"icon": "key-round",
"view": "verstak.secrets.view",
"position": 45
}
],
"openProviders": [
{
"id": "verstak.secrets.secret",
+87 -64
View File
@@ -1,4 +1,6 @@
<script>
import { onDestroy, onMount } from 'svelte'
export let api = null
let settings = null
@@ -11,23 +13,32 @@
let connectionOk = null
let serverUrl = ''
let vaultId = ''
let username = ''
let password = ''
let syncInterval = 5
let autoSync = false
let locale = api?.i18n?.getLocale?.() || 'en'
let unsubscribeLocale
function tr(key, params, fallback) {
locale
return api?.i18n?.t?.(key, params, fallback) || fallback || key
}
const INPUT_STYLE = 'width:100%;background:#0f3460;border:1px solid #1a3a5c;color:#e0e0f0;padding:8px 10px;border-radius:4px;font-size:0.85rem;box-sizing:border-box;height:36px;'
const INPUT_FOCUS_STYLE = INPUT_STYLE + 'outline:none;border-color:#4ecca3;'
function sanitizeError(msg) {
if (!msg) return 'Unknown error'
let s = String(msg).replace(/<[^>]+>/g, '')
if (s.length > 200) s = s.substring(0, 200) + '...'
return s
function reportError(key, fallback, error) {
console.warn('[verstak.sync] operation failed:', error)
const code = String(error?.message || error || '').match(/sync-server:([a-z_]+)/)?.[1]
const localized = code ? tr(`ui.serverError.${code}`, null, '') : ''
if (localized && localized !== `ui.serverError.${code}`) return localized
return tr(key, null, fallback)
}
function syncAPI() {
if (!api?.sync) throw new Error('Plugin API sync namespace not available')
if (!api?.sync) throw new Error(tr('ui.apiUnavailable', null, 'Plugin API sync namespace not available'))
return api.sync
}
@@ -37,27 +48,35 @@
const saved = await api.settings.read()
if (saved) {
serverUrl = saved.serverUrl || ''
vaultId = saved.vaultId || ''
username = saved.username || ''
autoSync = !!saved.autoSync
syncInterval = saved.syncInterval || 5
}
}
} catch (_) {}
} catch (error) {
console.warn('[verstak.sync] settings load failed:', error)
}
try {
settings = await syncAPI().status()
if (settings) {
if (settings.serverUrl) serverUrl = settings.serverUrl
if (settings.vaultId) vaultId = settings.vaultId
if (settings.syncInterval != null) syncInterval = settings.syncInterval
if (settings.syncInterval > 0) autoSync = true
if (settings.lastError) console.warn('[verstak.sync] last sync failed:', settings.lastError)
}
} catch (_) { settings = null }
} catch (error) {
console.warn('[verstak.sync] status load failed:', error)
settings = null
}
}
load()
async function saveSettings() {
if (syncInterval < 1 || syncInterval > 1440) {
errorMsg = 'Sync interval must be between 1 and 1440 minutes.'
errorMsg = tr('ui.intervalError', null, 'Sync interval must be between 1 and 1440 minutes.')
return
}
loading = true
@@ -65,19 +84,19 @@
resultMsg = ''
try {
if (api?.settings?.writeAll) {
await api.settings.writeAll({ serverUrl, username, autoSync, syncInterval })
await api.settings.writeAll({ serverUrl, vaultId, username, autoSync, syncInterval })
}
await syncAPI().setInterval(autoSync ? syncInterval : 0)
resultMsg = 'Settings saved.'
resultMsg = tr('ui.settingsSaved', null, 'Settings saved.')
resultKind = ''
} catch (e) {
errorMsg = sanitizeError(e.message || e)
errorMsg = reportError('ui.saveFailed', 'Could not save sync settings. Please try again.', e)
}
loading = false
}
async function testConnection() {
if (!serverUrl) { errorMsg = 'Server URL is required.'; return }
if (!serverUrl) { errorMsg = tr('ui.serverRequired', null, 'Server URL is required.'); return }
loading = true
connectionResult = ''
connectionOk = null
@@ -85,28 +104,28 @@
try {
await syncAPI().testConnection(serverUrl, username, password)
connectionOk = true
connectionResult = 'Connection successful.'
connectionResult = tr('ui.connectionSuccessful', null, 'Connection successful.')
} catch (e) {
connectionOk = false
connectionResult = 'Connection failed: ' + sanitizeError(e.message || e)
connectionResult = reportError('ui.connectionFailed', 'Could not connect. Check the server address and credentials.', e)
}
loading = false
}
async function configureSync() {
if (!serverUrl) { errorMsg = 'Server URL is required.'; return }
if (!serverUrl) { errorMsg = tr('ui.serverRequired', null, 'Server URL is required.'); return }
loading = true
errorMsg = ''
connectionResult = ''
try {
await syncAPI().configure(serverUrl, username, password)
connectionResult = 'Connected successfully.'
await syncAPI().configure(serverUrl, username, password, vaultId)
connectionResult = tr('ui.connectedSuccessfully', null, 'Connected successfully.')
connectionOk = true
username = ''
password = ''
await load()
} catch (e) {
errorMsg = sanitizeError(e.message || e)
errorMsg = reportError('ui.connectFailed', 'Could not connect this device. Please try again.', e)
}
loading = false
}
@@ -115,28 +134,13 @@
const conflicts = Array.isArray(result?.conflicts) ? result.conflicts : []
const applyErrors = Array.isArray(result?.applyErrors) ? result.applyErrors : []
const parts = []
if (conflicts.length > 0) parts.push(conflicts.length + ' conflict(s)')
if (applyErrors.length > 0) parts.push(applyErrors.length + ' error(s)')
if (conflicts.length > 0) parts.push(tr('ui.conflictsCount', { count: conflicts.length }, '{count} conflict(s)'))
if (applyErrors.length > 0) parts.push(tr('ui.errorsCount', { count: applyErrors.length }, '{count} error(s)'))
return parts.join(' · ')
}
function conflictField(conflict, keys) {
for (const key of keys) {
const value = conflict && conflict[key]
if (value != null && String(value).trim()) return String(value)
}
return ''
}
function formatSyncConflict(conflict) {
const entityType = conflictField(conflict, ['entity_type', 'entityType']) || 'item'
const entityId = conflictField(conflict, ['entity_id', 'entityId', 'path']) || 'unknown'
const opId = conflictField(conflict, ['op_id', 'opId'])
const reason = conflictField(conflict, ['reason', 'message'])
const parts = [entityType + ': ' + entityId]
if (opId) parts.push('op ' + opId)
if (reason) parts.push(reason)
return parts.join(' · ')
function formatSyncConflict() {
return tr('ui.syncConflictItem', null, 'A synchronization conflict needs attention.')
}
async function runSyncNow() {
@@ -146,7 +150,7 @@
conflictDetails = []
try {
const r = await syncAPI().now()
const summary = 'Pushed ' + (r?.pushed || 0) + ', pulled ' + (r?.pulled || 0)
const summary = tr('ui.syncSummary', { pushed: r?.pushed || 0, pulled: r?.pulled || 0 }, 'Pushed {pushed}, pulled {pulled}')
const warning = syncResultWarning(r)
const conflicts = Array.isArray(r?.conflicts) ? r.conflicts : []
conflictDetails = conflicts.slice(0, 5).map(formatSyncConflict)
@@ -154,7 +158,7 @@
resultKind = warning ? 'warning' : ''
await load()
} catch (e) {
errorMsg = sanitizeError(e.message || e)
errorMsg = reportError('ui.syncFailed', 'Could not synchronize. Please try again.', e)
}
loading = false
}
@@ -167,7 +171,7 @@
function saveInterval() {
if (syncInterval < 1 || syncInterval > 1440) {
errorMsg = 'Sync interval must be between 1 and 1440 minutes.'
errorMsg = tr('ui.intervalError', null, 'Sync interval must be between 1 and 1440 minutes.')
return
}
autoSync = syncInterval > 0
@@ -180,12 +184,12 @@
resultMsg = ''
try {
await syncAPI().disconnect()
resultMsg = 'Disconnected from server.'
resultMsg = tr('ui.disconnected', null, 'Disconnected from server.')
resultKind = ''
settings = null
await load()
} catch (e) {
errorMsg = sanitizeError(e.message || e)
errorMsg = reportError('ui.disconnectFailed', 'Could not disconnect from the server. Please try again.', e)
}
loading = false
}
@@ -196,19 +200,27 @@
resultMsg = ''
try {
await syncAPI().resetKey()
resultMsg = 'Sync key reset. Connect again to pair this device.'
resultMsg = tr('ui.keyReset', null, 'Sync key reset. Connect again to pair this device.')
resultKind = ''
await load()
} catch (e) {
errorMsg = sanitizeError(e.message || e)
errorMsg = reportError('ui.resetKeyFailed', 'Could not reset the sync key. Please try again.', e)
}
loading = false
}
onMount(() => {
unsubscribeLocale = api?.i18n?.onDidChangeLocale?.((nextLocale) => {
locale = nextLocale
})
})
onDestroy(() => unsubscribeLocale?.())
</script>
<div style="padding:1.5rem;max-width:500px;">
<h2 style="margin:0 0 0.25rem;color:#e0e0f0;font-size:1.2rem;">Sync</h2>
<p style="color:#a0a0b8;font-size:0.85rem;margin-bottom:1.25rem;">Synchronize your vault across devices.</p>
<h2 style="margin:0 0 0.25rem;color:#e0e0f0;font-size:1.2rem;">{tr('ui.title', null, 'Sync')}</h2>
<p style="color:#a0a0b8;font-size:0.85rem;margin-bottom:1.25rem;">{tr('ui.description', null, 'Synchronize your vault across devices.')}</p>
{#if errorMsg}
<div style="padding:0.5rem 0.75rem;margin-bottom:0.75rem;background:rgba(255,107,107,0.1);border:1px solid rgba(255,107,107,0.3);border-radius:6px;color:#ff6b6b;font-size:0.85rem;">{errorMsg}</div>
@@ -218,7 +230,7 @@
{/if}
{#if conflictDetails.length > 0 && !errorMsg}
<div style="padding:0.5rem 0.75rem;margin-bottom:0.75rem;background:rgba(245,158,11,0.1);border:1px solid rgba(245,158,11,0.3);border-radius:6px;color:#f59e0b;font-size:0.85rem;">
<div style="font-weight:600;margin-bottom:0.35rem;">Sync conflicts</div>
<div style="font-weight:600;margin-bottom:0.35rem;">{tr('ui.syncConflicts', null, 'Sync conflicts')}</div>
{#each conflictDetails as detail}
<div>{detail}</div>
{/each}
@@ -226,31 +238,42 @@
{/if}
{#if settings && settings.lastError && !errorMsg}
<div style="padding:0.5rem 0.75rem;margin-bottom:0.75rem;background:rgba(255,107,107,0.1);border:1px solid rgba(255,107,107,0.3);border-radius:6px;color:#ff6b6b;font-size:0.85rem;">
Last sync error: {sanitizeError(settings.lastError)}
{tr('ui.lastSyncError', null, 'The last synchronization did not finish. Try again.')}
</div>
{/if}
{#if settings && settings.lastWarning && !errorMsg}
<div style="padding:0.5rem 0.75rem;margin-bottom:0.75rem;background:rgba(245,158,11,0.1);border:1px solid rgba(245,158,11,0.3);border-radius:6px;color:#f59e0b;font-size:0.85rem;">
{tr('ui.unresolvedFilesWarning', null, 'Some files are not synchronized yet. Resolve the file warning and run sync again.')}
</div>
{/if}
<div style="background:#16213e;border:1px solid #0f3460;border-radius:8px;padding:1rem 1.25rem;margin-bottom:1rem;">
<h3 style="margin:0 0 0.75rem;color:#e0e0f0;font-size:0.95rem;">Server</h3>
<h3 style="margin:0 0 0.75rem;color:#e0e0f0;font-size:0.95rem;">{tr('ui.server', null, 'Server')}</h3>
<div style="margin-bottom:0.75rem;">
<label for="sync-server-url" style="display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem;">Server URL</label>
<label for="sync-server-url" style="display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem;">{tr('ui.serverUrl', null, 'Server URL')}</label>
<input id="sync-server-url" type="text" style={INPUT_STYLE} on:focus={e => e.target.style.cssText = INPUT_FOCUS_STYLE} on:blur={e => e.target.style.cssText = INPUT_STYLE} bind:value={serverUrl} placeholder="https://example.com" />
</div>
<div style="margin-bottom:0.75rem;">
<label for="sync-username" style="display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem;">Username</label>
<label for="sync-vault-id" style="display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem;">{tr('ui.remoteVaultId', null, 'Remote vault ID (optional)')}</label>
<input id="sync-vault-id" type="text" style={INPUT_STYLE} on:focus={e => e.target.style.cssText = INPUT_FOCUS_STYLE} on:blur={e => e.target.style.cssText = INPUT_STYLE} bind:value={vaultId} />
<div style="color:#a0a0b8;font-size:0.78rem;margin-top:0.3rem;">{tr('ui.remoteVaultHint', null, 'Leave empty for this vault. Enter an existing remote vault ID to restore it on this device.')}</div>
</div>
<div style="margin-bottom:0.75rem;">
<label for="sync-username" style="display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem;">{tr('ui.username', null, 'Username')}</label>
<input id="sync-username" type="text" style={INPUT_STYLE} on:focus={e => e.target.style.cssText = INPUT_FOCUS_STYLE} on:blur={e => e.target.style.cssText = INPUT_STYLE} bind:value={username} />
</div>
<div style="margin-bottom:0.75rem;">
<label for="sync-password" style="display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem;">Password</label>
<label for="sync-password" style="display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem;">{tr('ui.password', null, 'Password')}</label>
<input id="sync-password" type="password" style={INPUT_STYLE} on:focus={e => e.target.style.cssText = INPUT_FOCUS_STYLE} on:blur={e => e.target.style.cssText = INPUT_STYLE} bind:value={password} />
</div>
<div style="display:flex;gap:0.5rem;margin-top:1rem;">
<button style="background:#1a1a2e;color:#e0e0f0;border:1px solid #1a3a5c;padding:0.4rem 0.75rem;border-radius:4px;cursor:pointer;font-size:0.85rem;" on:click={testConnection} disabled={loading || !serverUrl}>Test Connection</button>
<button style="background:#4ecca3;color:#1a1a2e;border:1px solid #4ecca3;padding:0.4rem 0.75rem;border-radius:4px;cursor:pointer;font-size:0.85rem;font-weight:600;" on:click={configureSync} disabled={loading || !serverUrl}>Connect</button>
<button style="background:#1a1a2e;color:#e0e0f0;border:1px solid #1a3a5c;padding:0.4rem 0.75rem;border-radius:4px;cursor:pointer;font-size:0.85rem;" on:click={testConnection} disabled={loading || !serverUrl}>{tr('ui.testConnection', null, 'Test Connection')}</button>
<button style="background:#4ecca3;color:#1a1a2e;border:1px solid #4ecca3;padding:0.4rem 0.75rem;border-radius:4px;cursor:pointer;font-size:0.85rem;font-weight:600;" on:click={configureSync} disabled={loading || !serverUrl}>{tr('ui.connect', null, 'Connect')}</button>
</div>
{#if connectionResult}
@@ -259,34 +282,34 @@
</div>
<div style="background:#16213e;border:1px solid #0f3460;border-radius:8px;padding:1rem 1.25rem;margin-bottom:1rem;">
<h3 style="margin:0 0 0.75rem;color:#e0e0f0;font-size:0.95rem;">Sync Behavior</h3>
<h3 style="margin:0 0 0.75rem;color:#e0e0f0;font-size:0.95rem;">{tr('ui.behavior', null, 'Sync Behavior')}</h3>
<div style="margin-bottom:0.75rem;display:flex;align-items:center;gap:0.5rem;">
<input type="checkbox" id="auto-sync" bind:checked={autoSync} on:change={toggleAutoSync} style="width:16px;height:16px;accent-color:#4ecca3;" />
<label for="auto-sync" style="color:#e0e0f0;font-size:0.9rem;cursor:pointer;">Enable auto-sync</label>
<label for="auto-sync" style="color:#e0e0f0;font-size:0.9rem;cursor:pointer;">{tr('ui.enableAutoSync', null, 'Enable auto-sync')}</label>
</div>
<div style="margin-bottom:0.75rem;">
<label for="sync-interval" style="display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem;">Sync interval</label>
<label for="sync-interval" style="display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem;">{tr('ui.interval', null, 'Sync interval')}</label>
<div style="display:flex;align-items:center;gap:0.5rem;">
<input id="sync-interval" type="number" min="1" max="1440" bind:value={syncInterval} on:change={saveInterval} style="width:100px;background:#0f3460;border:1px solid #1a3a5c;color:#e0e0f0;padding:8px 10px;border-radius:4px;font-size:0.85rem;height:36px;" />
<span style="color:#a0a0b8;font-size:0.85rem;">minutes</span>
<span style="color:#a0a0b8;font-size:0.85rem;">{tr('ui.minutes', null, 'minutes')}</span>
</div>
</div>
{#if settings && settings.lastSyncAt}
<div style="color:#a0a0b8;font-size:0.85rem;">
Last sync: {settings.lastSyncAt}
{tr('ui.lastSync', { date: settings.lastSyncAt }, 'Last sync: {date}')}
</div>
{/if}
</div>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;">
<button style="background:#4ecca3;color:#1a1a2e;border:1px solid #4ecca3;padding:0.4rem 0.75rem;border-radius:4px;cursor:pointer;font-size:0.85rem;font-weight:600;" on:click={saveSettings} disabled={loading}>Save</button>
<button style="background:#4ecca3;color:#1a1a2e;border:1px solid #4ecca3;padding:0.4rem 0.75rem;border-radius:4px;cursor:pointer;font-size:0.85rem;font-weight:600;" on:click={saveSettings} disabled={loading}>{tr('ui.save', null, 'Save')}</button>
{#if settings && settings.configured}
<button style="background:#1a1a2e;color:#e0e0f0;border:1px solid #1a3a5c;padding:0.4rem 0.75rem;border-radius:4px;cursor:pointer;font-size:0.85rem;" on:click={runSyncNow} disabled={loading}>Sync Now</button>
<button style="background:#1a1a2e;color:#e0e0f0;border:1px solid #1a3a5c;padding:0.4rem 0.75rem;border-radius:4px;cursor:pointer;font-size:0.85rem;" on:click={resetKey} disabled={loading}>Reset Key</button>
<button style="background:#e94560;color:#fff;border:1px solid #e94560;padding:0.4rem 0.75rem;border-radius:4px;cursor:pointer;font-size:0.85rem;" on:click={doDisconnect} disabled={loading}>Disconnect</button>
<button style="background:#1a1a2e;color:#e0e0f0;border:1px solid #1a3a5c;padding:0.4rem 0.75rem;border-radius:4px;cursor:pointer;font-size:0.85rem;" on:click={runSyncNow} disabled={loading}>{tr('ui.syncNow', null, 'Sync Now')}</button>
<button style="background:#1a1a2e;color:#e0e0f0;border:1px solid #1a3a5c;padding:0.4rem 0.75rem;border-radius:4px;cursor:pointer;font-size:0.85rem;" on:click={resetKey} disabled={loading}>{tr('ui.resetKey', null, 'Reset Key')}</button>
<button style="background:#e94560;color:#fff;border:1px solid #e94560;padding:0.4rem 0.75rem;border-radius:4px;cursor:pointer;font-size:0.85rem;" on:click={doDisconnect} disabled={loading}>{tr('ui.disconnect', null, 'Disconnect')}</button>
{/if}
</div>
</div>
+17 -6
View File
@@ -5,6 +5,13 @@
let status = 'disabled'
let interval
let locale = api?.i18n?.getLocale?.() || 'en'
let unsubscribeLocale
function tr(key, params, fallback) {
locale
return api?.i18n?.t?.(key, params, fallback) || fallback || key
}
async function loadStatus() {
try {
@@ -32,11 +39,11 @@
function statusText() {
const labels = {
connected: 'Connected',
disconnected: 'Disconnected',
disabled: 'Not configured',
error: 'Error',
revoked: 'Revoked',
connected: tr('ui.status.connected', null, 'Connected'),
disconnected: tr('ui.status.disconnected', null, 'Disconnected'),
disabled: tr('ui.status.disabled', null, 'Not configured'),
error: tr('ui.status.error', null, 'Error'),
revoked: tr('ui.status.revoked', null, 'Revoked'),
}
return labels[status] || status
}
@@ -50,14 +57,18 @@
onMount(() => {
loadStatus()
interval = setInterval(loadStatus, 15000)
unsubscribeLocale = api?.i18n?.onDidChangeLocale?.((nextLocale) => {
locale = nextLocale
})
})
onDestroy(() => {
if (interval) clearInterval(interval)
unsubscribeLocale?.()
})
</script>
<button class="sync-status-bar" on:click={openSettings} title="Sync: {statusText()}">
<button class="sync-status-bar" on:click={openSettings} title="{tr('ui.title', null, 'Sync')}: {statusText()}">
<span class="status-dot" style="background: {statusColor()}"></span>
<span class="status-label">{statusText()}</span>
</button>
+61
View File
@@ -0,0 +1,61 @@
{
"manifest.name": "Sync",
"contributions.settingsPanels.verstak.sync.settings.title": "Sync",
"contributions.statusBarItems.verstak.sync.status.label": "Sync",
"ui.title": "Sync",
"ui.description": "Synchronize your vault across devices.",
"ui.unknownError": "Unknown error",
"ui.apiUnavailable": "Synchronization is unavailable right now.",
"ui.intervalError": "Sync interval must be between 1 and 1440 minutes.",
"ui.settingsSaved": "Settings saved.",
"ui.serverRequired": "Server URL is required.",
"ui.connectionSuccessful": "Connection successful.",
"ui.connectionFailed": "Could not connect. Check the server address and credentials.",
"ui.saveFailed": "Could not save sync settings. Please try again.",
"ui.connectFailed": "Could not connect this device. Please try again.",
"ui.syncFailed": "Could not synchronize. Please try again.",
"ui.disconnectFailed": "Could not disconnect from the server. Please try again.",
"ui.resetKeyFailed": "Could not reset the sync key. Please try again.",
"ui.connectedSuccessfully": "Connected successfully.",
"ui.conflictsCount": "{count} conflict(s)",
"ui.errorsCount": "{count} error(s)",
"ui.syncSummary": "Pushed {pushed}, pulled {pulled}",
"ui.disconnected": "Disconnected from server.",
"ui.keyReset": "Sync key reset. Connect again to pair this device.",
"ui.syncConflicts": "Sync conflicts",
"ui.lastSyncError": "The last synchronization did not finish. Try again.",
"ui.unresolvedFilesWarning": "Some files are not synchronized yet. Resolve the file warning and run sync again.",
"ui.syncConflictItem": "A synchronization conflict needs attention.",
"ui.server": "Server",
"ui.serverUrl": "Server URL",
"ui.remoteVaultId": "Remote vault ID (optional)",
"ui.remoteVaultHint": "Leave empty for this vault. Enter an existing remote vault ID to restore it on this device.",
"ui.username": "Username",
"ui.password": "Password",
"ui.testConnection": "Test Connection",
"ui.connect": "Connect",
"ui.behavior": "Sync Behavior",
"ui.enableAutoSync": "Enable auto-sync",
"ui.interval": "Sync interval",
"ui.minutes": "minutes",
"ui.lastSync": "Last sync: {date}",
"ui.save": "Save",
"ui.syncNow": "Sync Now",
"ui.resetKey": "Reset Key",
"ui.disconnect": "Disconnect",
"ui.status.connected": "Connected",
"ui.status.disconnected": "Disconnected",
"ui.status.disabled": "Not configured",
"ui.status.error": "Error",
"ui.status.revoked": "Revoked"
,"ui.serverError.rate_limited": "Too many attempts. Wait a little and try again."
,"ui.serverError.request_too_large": "The synchronization request is too large."
,"ui.serverError.payload_too_large": "A file operation is too large to send inline."
,"ui.serverError.too_many_operations": "Too many pending changes were sent at once. Try synchronization again."
,"ui.serverError.blob_too_large": "This file exceeds the server blob size limit."
,"ui.serverError.quota_exceeded": "The server storage quota for this vault has been reached."
,"ui.serverError.blob_not_owned": "The file data must be uploaded before it can be synchronized."
,"ui.serverError.blob_not_found": "The required file data is unavailable on the server."
,"ui.serverError.unauthorized": "This device is no longer authorized. Connect it again."
,"ui.serverError.forbidden": "The server denied this synchronization request."
}
+61
View File
@@ -0,0 +1,61 @@
{
"manifest.name": "Синхронизация",
"contributions.settingsPanels.verstak.sync.settings.title": "Синхронизация",
"contributions.statusBarItems.verstak.sync.status.label": "Синхронизация",
"ui.title": "Синхронизация",
"ui.description": "Синхронизируйте хранилище между устройствами.",
"ui.unknownError": "Неизвестная ошибка",
"ui.apiUnavailable": "Синхронизация сейчас недоступна.",
"ui.intervalError": "Интервал синхронизации должен быть от 1 до 1440 минут.",
"ui.settingsSaved": "Настройки сохранены.",
"ui.serverRequired": "Укажите URL сервера.",
"ui.connectionSuccessful": "Подключение успешно.",
"ui.connectionFailed": "Не удалось подключиться. Проверьте адрес сервера и учётные данные.",
"ui.saveFailed": "Не удалось сохранить настройки синхронизации. Повторите попытку.",
"ui.connectFailed": "Не удалось подключить это устройство. Повторите попытку.",
"ui.syncFailed": "Не удалось синхронизировать данные. Повторите попытку.",
"ui.disconnectFailed": "Не удалось отключиться от сервера. Повторите попытку.",
"ui.resetKeyFailed": "Не удалось сбросить ключ синхронизации. Повторите попытку.",
"ui.connectedSuccessfully": "Подключение установлено.",
"ui.conflictsCount": "Конфликтов: {count}",
"ui.errorsCount": "Ошибок: {count}",
"ui.syncSummary": "Отправлено: {pushed}, получено: {pulled}",
"ui.disconnected": "Сервер отключён.",
"ui.keyReset": "Ключ синхронизации сброшен. Подключитесь снова, чтобы связать устройство.",
"ui.syncConflicts": "Конфликты синхронизации",
"ui.lastSyncError": "Последняя синхронизация не завершилась. Повторите попытку.",
"ui.unresolvedFilesWarning": "Некоторые файлы ещё не синхронизированы. Устраните предупреждение по файлу и повторите синхронизацию.",
"ui.syncConflictItem": "Конфликт синхронизации требует внимания.",
"ui.server": "Сервер",
"ui.serverUrl": "URL сервера",
"ui.remoteVaultId": "ID удалённого хранилища (необязательно)",
"ui.remoteVaultHint": "Оставьте пустым для этого хранилища. Укажите ID существующего удалённого хранилища, чтобы восстановить его на этом устройстве.",
"ui.username": "Имя пользователя",
"ui.password": "Пароль",
"ui.testConnection": "Проверить подключение",
"ui.connect": "Подключить",
"ui.behavior": "Параметры синхронизации",
"ui.enableAutoSync": "Включить автосинхронизацию",
"ui.interval": "Интервал синхронизации",
"ui.minutes": "минут",
"ui.lastSync": "Последняя синхронизация: {date}",
"ui.save": "Сохранить",
"ui.syncNow": "Синхронизировать",
"ui.resetKey": "Сбросить ключ",
"ui.disconnect": "Отключить",
"ui.status.connected": "Подключено",
"ui.status.disconnected": "Отключено",
"ui.status.disabled": "Не настроено",
"ui.status.error": "Ошибка",
"ui.status.revoked": "Доступ отозван"
,"ui.serverError.rate_limited": "Слишком много попыток. Подождите и повторите попытку."
,"ui.serverError.request_too_large": "Запрос синхронизации слишком велик."
,"ui.serverError.payload_too_large": "Операция с файлом слишком велика для отправки в этом виде."
,"ui.serverError.too_many_operations": "Одновременно отправлено слишком много изменений. Повторите синхронизацию."
,"ui.serverError.blob_too_large": "Размер файла превышает разрешённый сервером лимит."
,"ui.serverError.quota_exceeded": "Достигнута квота серверного хранилища для этого vault."
,"ui.serverError.blob_not_owned": "Данные файла должны быть загружены до синхронизации операции."
,"ui.serverError.blob_not_found": "Необходимые данные файла недоступны на сервере."
,"ui.serverError.unauthorized": "Устройство больше не авторизовано. Подключите его заново."
,"ui.serverError.forbidden": "Сервер отклонил этот запрос синхронизации."
}
+1
View File
@@ -4,6 +4,7 @@
"name": "Sync",
"version": "0.1.0",
"apiVersion": "0.1.0",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official",
"provides": ["verstak/sync/v1", "verstak/sync.status/v1"],
"requires": ["verstak/core/files/v1"],
+171 -60
View File
@@ -17,8 +17,8 @@
'.todo-toolbar{display:flex;align-items:center;gap:.5rem;min-height:2.75rem;padding:.5rem .75rem;border-bottom:1px solid var(--vt-color-border,#202b46);background:var(--vt-color-surface-muted,#111629);flex-wrap:wrap;flex-shrink:0}',
'.todo-title{font-size:.86rem;font-weight:600}.todo-count,.todo-status,.todo-scope{font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3)}.todo-spacer{flex:1}',
'.todo-filters{display:flex;align-items:center;gap:.35rem;min-width:0;flex:1;flex-wrap:wrap}',
'.todo-input,.todo-select{box-sizing:border-box;min-height:1.9rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-surface,#15152c);color:var(--vt-color-text-primary,#f4f7fb);font:inherit;font-size:.78rem;padding:.32rem .45rem}',
'.todo-input.search{width:min(15rem,100%)}.todo-input.textarea{min-height:6.5rem;resize:vertical;line-height:1.4}.todo-select{max-width:12rem}',
'.todo-input,.todo-select{box-sizing:border-box;min-height:1.9rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-surface,#15152c);color:var(--vt-color-text-primary,#f4f7fb);color-scheme:dark;font:inherit;font-size:.78rem;padding:.32rem .45rem}',
'.todo-input.search{width:min(15rem,100%)}.todo-input.textarea{min-height:6.5rem;resize:vertical;line-height:1.4}.todo-input.todo-time-input{width:100%;font-variant-numeric:tabular-nums}.todo-select{max-width:12rem;appearance:none;background-color:var(--vt-color-surface,#15152c);background-image:linear-gradient(45deg,transparent 50%,var(--vt-color-text-muted,#7f8aa3) 50%),linear-gradient(135deg,var(--vt-color-text-muted,#7f8aa3) 50%,transparent 50%);background-position:calc(100% - 14px) 50%,calc(100% - 9px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:1.7rem}.todo-select option{background:var(--vt-color-surface,#15152c);color:var(--vt-color-text-primary,#f4f7fb)}',
'.todo-btn{min-height:1.9rem;padding:.32rem .62rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-md,6px);background:var(--vt-color-surface-hover,#1b2440);color:var(--vt-color-text-secondary,#b7c0d4);font-size:.78rem;cursor:pointer}.todo-btn:hover{border-color:var(--vt-color-accent,#4ecca3);color:var(--vt-color-text-primary,#f4f7fb)}.todo-btn.primary{background:var(--vt-color-accent,#4ecca3);border-color:var(--vt-color-accent,#4ecca3);color:#101827}.todo-btn.danger{border-color:rgba(233,69,96,.5);color:#ff9a9a}.todo-btn:disabled{opacity:.45;cursor:default}',
'.todo-list{flex:1;min-height:0;overflow:auto;padding:.5rem .75rem .85rem}.todo-empty{height:100%;display:flex;align-items:center;justify-content:center;padding:2rem;text-align:center;color:var(--vt-color-text-muted,#7f8aa3);font-size:.86rem}',
'.todo-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.75rem;align-items:start;margin-top:.5rem;padding:.75rem .85rem;border:1px solid var(--vt-color-border,#202b46);border-radius:var(--vt-radius-lg,8px);background:var(--vt-color-surface,#15152c)}.todo-row:hover{background:var(--vt-color-surface-hover,#1b2440)}.todo-row.done .todo-row-title{text-decoration:line-through;color:var(--vt-color-text-muted,#7f8aa3)}',
@@ -78,7 +78,7 @@
var workspaceRoot = workspaceFromProps(props || {});
return workspaceRoot
? { mode: 'workspace', workspaceRoot: workspaceRoot, label: workspaceRoot }
: { mode: 'global', workspaceRoot: '', label: 'All workspaces' };
: { mode: 'global', workspaceRoot: '', label: 'All Deals' };
}
function now() {
@@ -96,7 +96,28 @@
}
function cleanDate(value) {
return /^\d{4}-\d{2}-\d{2}$/.test(text(value).trim()) ? text(value).trim() : '';
value = text(value).trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
var match = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(value);
var day;
var month;
var year;
if (match) {
month = Number(match[1]);
day = Number(match[2]);
year = Number(match[3]);
} else {
match = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(value);
if (!match) return '';
day = Number(match[1]);
month = Number(match[2]);
year = Number(match[3]);
}
var date = new Date(Date.UTC(year, month - 1, day));
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return '';
return String(year).padStart(4, '0') + '-' + String(month).padStart(2, '0') + '-' + String(day).padStart(2, '0');
}
function cleanDateTime(value) {
@@ -106,6 +127,27 @@
return isNaN(date.getTime()) ? '' : value;
}
function splitReminderDateTime(value) {
var match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(cleanDateTime(value));
return match ? { date: match[1], time: match[2] } : { date: '', time: '' };
}
function cleanReminderTime(value) {
var match = /^(\d{1,2}):(\d{2})$/.exec(text(value).trim());
if (!match) return '';
var hour = Number(match[1]);
var minute = Number(match[2]);
if (hour > 23 || minute > 59) return '';
return String(hour).padStart(2, '0') + ':' + String(minute).padStart(2, '0');
}
function joinReminderDateTime(date, time) {
date = cleanDate(date);
time = cleanReminderTime(time);
if (!date || !time) return '';
return cleanDateTime(date + 'T' + time);
}
function todoId(workspaceRoot, title) {
return 'todo:' + (cleanWorkspace(workspaceRoot) || 'global') + ':' + Date.now() + ':' + Math.random().toString(36).slice(2, 8) + ':' + text(title).trim().slice(0, 20).replace(/\s+/g, '-');
}
@@ -113,6 +155,8 @@
function normalizeTodo(value) {
value = value || {};
var status = cleanStatus(value.status);
var reminderAt = cleanDateTime(value.reminderAt || value.reminderDateTime);
var reminder = splitReminderDateTime(reminderAt);
var createdAt = text(value.createdAt).trim() || now();
var completedAt = status === 'done' ? (text(value.completedAt).trim() || createdAt) : '';
return {
@@ -124,7 +168,8 @@
status: status,
priority: cleanPriority(value.priority),
dueAt: cleanDate(value.dueAt || value.dueDate),
reminderAt: cleanDateTime(value.reminderAt || value.reminderDateTime),
reminderDate: cleanDate(value.reminderDate) || reminder.date,
reminderAt: reminderAt,
createdAt: createdAt,
updatedAt: text(value.updatedAt).trim() || createdAt,
completedAt: completedAt,
@@ -153,6 +198,7 @@
status: todo.status,
priority: todo.priority,
dueAt: todo.dueAt,
reminderDate: todo.reminderDate,
reminderAt: todo.reminderAt,
createdAt: todo.createdAt,
updatedAt: todo.updatedAt,
@@ -215,8 +261,13 @@
var statusText = '';
var statusClass = '';
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
var toolbar = el('div', { className: 'todo-toolbar' });
var titleEl = el('span', { className: 'todo-title', textContent: scope.mode === 'global' ? 'Todos' : 'Todos · ' + scope.label });
var titleEl = el('span', { className: 'todo-title', textContent: scope.mode === 'global' ? tr('ui.title', null, 'Todos') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Todos · ' + scope.label) });
var countEl = el('span', { className: 'todo-count' });
var statusEl = el('span', { className: 'todo-status' });
var filtersEl = el('div', { className: 'todo-filters' });
@@ -228,10 +279,10 @@
render();
}
}, [
option('all', 'All statuses'),
option('open', 'Open'),
option('done', 'Done'),
option('cancelled', 'Cancelled')
option('all', tr('ui.allStatuses', null, 'All statuses')),
option('open', tr('ui.status.open', null, 'Open')),
option('done', tr('ui.status.done', null, 'Done')),
option('cancelled', tr('ui.status.cancelled', null, 'Cancelled'))
]);
var workspaceFilterEl = el('select', {
className: 'todo-select',
@@ -249,14 +300,14 @@
render();
}
}, [
option('due', 'Sort by due date'),
option('reminder', 'Sort by reminder'),
option('updated', 'Sort by updated')
option('due', tr('ui.sort.due', null, 'Sort by due date')),
option('reminder', tr('ui.sort.reminder', null, 'Sort by reminder')),
option('updated', tr('ui.sort.updated', null, 'Sort by updated'))
]);
var searchInput = el('input', {
className: 'todo-input search',
type: 'search',
placeholder: 'Search todos',
placeholder: tr('ui.search', null, 'Search todos'),
'data-todo-filter': 'search',
onInput: function (event) {
searchQuery = text(event.target.value).trim().toLowerCase();
@@ -267,7 +318,7 @@
className: 'todo-btn primary',
type: 'button',
'data-todo-action': 'add',
textContent: 'Add Todo',
textContent: tr('ui.add', null, 'Add Todo'),
onClick: function () { showTodoModal(null); }
});
var listEl = el('div', { className: 'todo-list' });
@@ -309,8 +360,8 @@
function renderWorkspaceFilterOptions() {
if (scope.mode !== 'global') return;
workspaceFilterEl.innerHTML = '';
workspaceFilterEl.appendChild(option('', 'All workspaces'));
workspaceFilterEl.appendChild(option('__unassigned__', 'Unassigned'));
workspaceFilterEl.appendChild(option('', tr('ui.allWorkspaces', null, 'All Deals')));
workspaceFilterEl.appendChild(option('__unassigned__', tr('ui.unassigned', null, 'Unassigned')));
workspaceRoots().forEach(function (workspace) {
workspaceFilterEl.appendChild(option(workspace, workspace));
});
@@ -335,21 +386,53 @@
});
}
function notificationRequests() {
return sortTodos(todos).filter(function (todo) {
return todo.status === 'open' && todo.reminderAt;
}).map(function (todo) {
var dueAt = new Date(todo.reminderAt);
if (isNaN(dueAt.getTime())) return null;
var title = todo.title || tr('ui.untitled', null, 'Untitled todo');
return {
id: todo.id,
dueAt: dueAt.toISOString(),
title: tr('ui.notificationTitle', null, 'Todo reminder'),
body: tr('ui.notificationBody', { title: title }, title)
};
}).filter(function (item) { return item !== null; });
}
function reportError(key, fallback, err) {
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
console.warn('[verstak.todo] ' + key, err);
}
statusText = tr(key, null, fallback);
statusClass = 'error';
}
function syncNotifications() {
if (!api || !api.notifications || typeof api.notifications.replace !== 'function') return Promise.resolve();
return api.notifications.replace(notificationRequests()).catch(function (err) {
reportError('ui.notificationError', 'Could not schedule reminders. Please try again.', err);
});
}
function persist() {
if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
return api.settings.write(GLOBAL_KEY, storageTodos(sortTodos(todos))).catch(function (err) {
statusText = 'Could not save todos: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
if (!api || !api.settings || typeof api.settings.write !== 'function') return syncNotifications();
return api.settings.write(GLOBAL_KEY, storageTodos(sortTodos(todos))).then(function () {
return syncNotifications();
}).catch(function (err) {
reportError('ui.saveError', 'Could not save tasks. Please try again.', err);
});
}
function loadStored() {
if (!api || !api.settings || typeof api.settings.read !== 'function') return Promise.resolve();
if (!api || !api.settings || typeof api.settings.read !== 'function') return syncNotifications();
return api.settings.read().then(function (settings) {
todos = sortTodos(normalizeTodos((settings || {})[GLOBAL_KEY]));
return syncNotifications();
}).catch(function (err) {
statusText = 'Could not load todos: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
reportError('ui.loadError', 'Could not load tasks. Please try again.', err);
});
}
@@ -375,18 +458,20 @@
function showTodoModal(existingTodo) {
var editing = !!existingTodo;
var titleInput = el('input', { className: 'todo-input', type: 'text', value: editing ? existingTodo.title : '', placeholder: 'Todo title', 'data-todo-input': 'title' });
var descriptionInput = el('textarea', { className: 'todo-input textarea', placeholder: 'Optional description', 'data-todo-input': 'description' });
var titleInput = el('input', { className: 'todo-input', type: 'text', value: editing ? existingTodo.title : '', placeholder: tr('ui.titlePlaceholder', null, 'Todo title'), 'data-todo-input': 'title' });
var descriptionInput = el('textarea', { className: 'todo-input textarea', placeholder: tr('ui.descriptionPlaceholder', null, 'Optional description'), 'data-todo-input': 'description' });
descriptionInput.value = editing ? existingTodo.description : '';
var priorityInput = el('select', { className: 'todo-select', 'data-todo-input': 'priority' }, [option('low', 'Low'), option('normal', 'Normal'), option('high', 'High')]);
var priorityInput = el('select', { className: 'todo-select', 'data-todo-input': 'priority' }, [option('low', tr('ui.priority.low', null, 'Low')), option('normal', tr('ui.priority.normal', null, 'Normal')), option('high', tr('ui.priority.high', null, 'High'))]);
priorityInput.value = editing ? existingTodo.priority : 'normal';
var dueInput = el('input', { className: 'todo-input', type: 'date', value: editing ? existingTodo.dueAt : '', 'data-todo-input': 'dueAt' });
var reminderInput = el('input', { className: 'todo-input', type: 'datetime-local', value: editing ? existingTodo.reminderAt : '', 'data-todo-input': 'reminderAt' });
var reminder = splitReminderDateTime(editing ? existingTodo.reminderAt : '');
var reminderDateInput = el('input', { className: 'todo-input', type: 'date', value: editing ? existingTodo.reminderDate || reminder.date : '', 'data-todo-input': 'reminderDate' });
var reminderTimeInput = el('input', { className: 'todo-input todo-time-input', type: 'text', inputmode: 'numeric', maxlength: '5', placeholder: tr('ui.reminderTimePlaceholder', null, '14:30'), value: reminder.time, 'data-todo-input': 'reminderTime', 'aria-label': tr('ui.field.reminderTime', null, 'Reminder time') });
var workspaceInput = null;
var workspace = editing ? existingTodo.workspaceRootPath : scope.workspaceRoot;
if (scope.mode === 'global') {
workspaceInput = el('select', { className: 'todo-select', 'data-todo-input': 'workspaceRootPath' });
workspaceInput.appendChild(option('', 'Unassigned'));
workspaceInput.appendChild(option('', tr('ui.unassigned', null, 'Unassigned')));
workspaceRoots().forEach(function (workspaceRoot) {
workspaceInput.appendChild(option(workspaceRoot, workspaceRoot));
});
@@ -396,12 +481,26 @@
function saveTodo() {
var title = text(titleInput.value).trim();
if (!title) {
statusText = 'Title is required';
statusText = tr('ui.titleRequired', null, 'Title is required');
statusClass = 'error';
render();
return;
}
var workspaceRoot = scope.mode === 'workspace' ? scope.workspaceRoot : cleanWorkspace(workspaceInput && workspaceInput.value);
var reminderDate = cleanDate(reminderDateInput.value);
var reminderTime = cleanReminderTime(reminderTimeInput.value);
if (text(reminderTimeInput.value).trim() && !reminderTime) {
statusText = tr('ui.reminderTimeInvalid', null, 'Enter a valid reminder time (HH:MM)');
statusClass = 'error';
render();
return;
}
if (reminderTime && !reminderDate) {
statusText = tr('ui.reminderDateRequired', null, 'Choose a reminder date before setting its time');
statusClass = 'error';
render();
return;
}
var timestamp = now();
var next = normalizeTodo({
id: editing ? existingTodo.id : todoId(workspaceRoot, title),
@@ -412,7 +511,8 @@
status: editing ? existingTodo.status : 'open',
priority: priorityInput.value,
dueAt: dueInput.value,
reminderAt: reminderInput.value,
reminderDate: reminderDate,
reminderAt: joinReminderDateTime(reminderDate, reminderTime),
createdAt: editing ? existingTodo.createdAt : timestamp,
updatedAt: timestamp,
completedAt: editing ? existingTodo.completedAt : '',
@@ -427,20 +527,21 @@
todos = sortTodos(todos);
if (workspaceRoot && workspaceOptions.indexOf(workspaceRoot) === -1) workspaceOptions.push(workspaceRoot);
closeTodoModal();
statusText = editing ? 'Todo updated' : 'Todo added';
statusText = editing ? tr('ui.updated', null, 'Todo updated') : tr('ui.added', null, 'Todo added');
statusClass = '';
persist().then(render);
}
var fields = [
el('label', { className: 'todo-field wide' }, ['Title', titleInput]),
el('label', { className: 'todo-field wide' }, ['Description', descriptionInput]),
el('label', { className: 'todo-field' }, ['Priority', priorityInput]),
el('label', { className: 'todo-field' }, ['Due date', dueInput]),
el('label', { className: 'todo-field' }, ['Reminder', reminderInput])
el('label', { className: 'todo-field wide' }, [tr('ui.field.title', null, 'Title'), titleInput]),
el('label', { className: 'todo-field wide' }, [tr('ui.field.description', null, 'Description'), descriptionInput]),
el('label', { className: 'todo-field' }, [tr('ui.field.priority', null, 'Priority'), priorityInput]),
el('label', { className: 'todo-field' }, [tr('ui.field.due', null, 'Due date'), dueInput]),
el('label', { className: 'todo-field' }, [tr('ui.field.reminderDate', null, 'Reminder date'), reminderDateInput]),
el('label', { className: 'todo-field' }, [tr('ui.field.reminderTime', null, 'Reminder time'), reminderTimeInput])
];
if (workspaceInput) fields.push(el('label', { className: 'todo-field' }, ['Workspace', workspaceInput]));
else fields.push(el('div', { className: 'todo-field', textContent: 'Workspace: ' + scope.workspaceRoot }));
if (workspaceInput) fields.push(el('label', { className: 'todo-field' }, [tr('ui.field.workspace', null, 'Deal'), workspaceInput]));
else fields.push(el('div', { className: 'todo-field', textContent: tr('ui.workspaceValue', { workspace: scope.workspaceRoot }, 'Deal: ' + scope.workspaceRoot) }));
modalHost.innerHTML = '';
if (typeof modalHost.removeAttribute === 'function') modalHost.removeAttribute('hidden');
@@ -448,11 +549,11 @@
if (event.target === event.currentTarget) closeTodoModal();
} }, [
el('div', { className: 'todo-modal' }, [
el('div', { className: 'todo-modal-title', textContent: editing ? 'Edit Todo' : 'Add Todo' }),
el('div', { className: 'todo-modal-title', textContent: editing ? tr('ui.edit', null, 'Edit Todo') : tr('ui.add', null, 'Add Todo') }),
el('div', { className: 'todo-form-grid' }, fields),
el('div', { className: 'todo-modal-actions' }, [
el('button', { className: 'todo-btn', type: 'button', textContent: 'Cancel', onClick: closeTodoModal }),
el('button', { className: 'todo-btn primary', type: 'button', 'data-todo-action': 'save', textContent: editing ? 'Save changes' : 'Add Todo', onClick: saveTodo })
el('button', { className: 'todo-btn', type: 'button', textContent: tr('ui.cancel', null, 'Cancel'), onClick: closeTodoModal }),
el('button', { className: 'todo-btn primary', type: 'button', 'data-todo-action': 'save', textContent: editing ? tr('ui.saveChanges', null, 'Save changes') : tr('ui.add', null, 'Add Todo'), onClick: saveTodo })
])
])
]));
@@ -469,14 +570,14 @@
updatedAt: timestamp
});
});
statusText = nextStatus === 'done' ? 'Todo marked done' : (nextStatus === 'cancelled' ? 'Todo cancelled' : 'Todo reopened');
statusText = nextStatus === 'done' ? tr('ui.markedDone', null, 'Todo marked done') : (nextStatus === 'cancelled' ? tr('ui.cancelled', null, 'Todo cancelled') : tr('ui.reopened', null, 'Todo reopened'));
statusClass = '';
persist().then(render);
}
function deleteTodo(todo) {
todos = todos.filter(function (item) { return item.id !== todo.id; });
statusText = 'Todo deleted';
statusText = tr('ui.deleted', null, 'Todo deleted');
statusClass = '';
persist().then(render);
}
@@ -513,11 +614,12 @@
var workspace = cleanWorkspace(todo.workspaceRootPath);
var due = dueState(todo);
var reminderDue = reminderIsDue(todo);
if (scope.mode === 'global') meta.push(el('span', { className: 'todo-badge', textContent: workspace || 'Unassigned' }));
meta.push(el('span', { className: 'todo-badge ' + todo.priority, textContent: todo.priority + ' priority' }));
meta.push(el('span', { className: 'todo-badge', textContent: todo.status }));
if (todo.dueAt) meta.push(el('span', { className: 'todo-badge ' + due, textContent: (due === 'overdue' ? 'Overdue · ' : (due === 'due-soon' ? 'Due soon · ' : '')) + 'Due ' + formatDate(todo.dueAt) }));
if (todo.reminderAt) meta.push(el('span', { className: 'todo-badge ' + (reminderDue ? 'reminder-due' : ''), textContent: (reminderDue ? 'Reminder due ' : 'Reminder ') + formatDate(todo.reminderAt) }));
if (scope.mode === 'global') meta.push(el('span', { className: 'todo-badge', textContent: workspace || tr('ui.unassigned', null, 'Unassigned') }));
meta.push(el('span', { className: 'todo-badge ' + todo.priority, textContent: tr('ui.priorityValue', { priority: tr('ui.priority.' + todo.priority, null, todo.priority) }, todo.priority + ' priority') }));
meta.push(el('span', { className: 'todo-badge', textContent: tr('ui.status.' + todo.status, null, todo.status) }));
if (todo.dueAt) meta.push(el('span', { className: 'todo-badge ' + due, textContent: tr('ui.dueValue', { prefix: due === 'overdue' ? tr('ui.overduePrefix', null, 'Overdue · ') : (due === 'due-soon' ? tr('ui.dueSoonPrefix', null, 'Due soon · ') : ''), date: formatDate(todo.dueAt) }, (due === 'overdue' ? 'Overdue · ' : (due === 'due-soon' ? 'Due soon · ' : '')) + 'Due ' + formatDate(todo.dueAt)) }));
if (todo.reminderAt) meta.push(el('span', { className: 'todo-badge ' + (reminderDue ? 'reminder-due' : ''), textContent: tr(reminderDue ? 'ui.reminderDueValue' : 'ui.reminderValue', { date: formatDate(todo.reminderAt) }, (reminderDue ? 'Reminder due ' : 'Reminder ') + formatDate(todo.reminderAt)) }));
else if (todo.reminderDate) meta.push(el('span', { className: 'todo-badge', textContent: tr('ui.reminderDateValue', { date: formatDate(todo.reminderDate) }, 'Reminder date ' + formatDate(todo.reminderDate)) }));
return el('div', { className: 'todo-row-meta' }, meta);
}
@@ -527,30 +629,30 @@
if (!visible.length) {
listEl.appendChild(el('div', {
className: 'todo-empty',
textContent: todos.length ? 'No todos match the current filters.' : 'No todos yet.'
textContent: todos.length ? tr('ui.noMatches', null, 'No todos match the current filters.') : tr('ui.empty', null, 'No todos yet.')
}));
return;
}
visible.forEach(function (todo) {
var actionButtons = [];
if (scope.mode === 'global' && todo.workspaceRootPath) {
actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'open-workspace', textContent: 'Open workspace', onClick: function () { openWorkspace(todo); } }));
actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'open-workspace', textContent: tr('ui.openWorkspace', null, 'Open Deal'), onClick: function () { openWorkspace(todo); } }));
}
if (todo.status === 'open') {
actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'mark-done', textContent: 'Done', onClick: function () { setTodoStatus(todo, 'done'); } }));
actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'cancel', textContent: 'Cancel', onClick: function () { setTodoStatus(todo, 'cancelled'); } }));
actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'mark-done', textContent: tr('ui.status.done', null, 'Done'), onClick: function () { setTodoStatus(todo, 'done'); } }));
actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'cancel', textContent: tr('ui.cancel', null, 'Cancel'), onClick: function () { setTodoStatus(todo, 'cancelled'); } }));
} else {
actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'reopen', textContent: 'Reopen', onClick: function () { setTodoStatus(todo, 'open'); } }));
actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'reopen', textContent: tr('ui.reopen', null, 'Reopen'), onClick: function () { setTodoStatus(todo, 'open'); } }));
}
if (scope.mode === 'workspace' && todo.status === 'done') {
actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'create-journal-entry', textContent: 'Create Journal Entry', onClick: function () { createJournalEntry(todo); } }));
actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'create-journal-entry', textContent: tr('ui.createJournal', null, 'Create Journal Entry'), onClick: function () { createJournalEntry(todo); } }));
}
actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'edit', textContent: 'Edit', onClick: function () { showTodoModal(todo); } }));
actionButtons.push(el('button', { className: 'todo-btn danger', type: 'button', 'data-todo-action': 'delete', textContent: 'Delete', onClick: function () { deleteTodo(todo); } }));
actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'edit', textContent: tr('ui.editAction', null, 'Edit'), onClick: function () { showTodoModal(todo); } }));
actionButtons.push(el('button', { className: 'todo-btn danger', type: 'button', 'data-todo-action': 'delete', textContent: tr('ui.delete', null, 'Delete'), onClick: function () { deleteTodo(todo); } }));
listEl.appendChild(el('div', { className: 'todo-row' + (todo.status === 'done' ? ' done' : ''), 'data-todo-id': todo.id }, [
el('div', { className: 'todo-row-main' }, [
el('div', { className: 'todo-row-head' }, [
el('div', { className: 'todo-row-title', textContent: todo.title || 'Untitled todo' })
el('div', { className: 'todo-row-title', textContent: todo.title || tr('ui.untitled', null, 'Untitled todo') })
]),
todo.description ? el('div', { className: 'todo-row-description', textContent: todo.description }) : null,
renderTodoMeta(todo)
@@ -563,8 +665,8 @@
function render() {
var visible = visibleTodos();
countEl.textContent = visible.length === todos.length
? todos.length + ' todo' + (todos.length === 1 ? '' : 's')
: visible.length + ' of ' + todos.length + ' todos';
? tr('ui.count', { count: todos.length }, todos.length + ' todo' + (todos.length === 1 ? '' : 's'))
: tr('ui.filteredCount', { visible: visible.length, count: todos.length }, visible.length + ' of ' + todos.length + ' todos');
statusFilterEl.value = statusFilter;
sortEl.value = sortMode;
searchInput.value = searchQuery;
@@ -578,6 +680,15 @@
Promise.all([loadStored(), loadWorkspaceOptions()]).then(function () {
render();
});
if (api && api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
api.i18n.onDidChangeLocale(function () {
titleEl.textContent = scope.mode === 'global' ? tr('ui.title', null, 'Todos') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Todos · ' + scope.label);
searchInput.setAttribute('placeholder', tr('ui.search', null, 'Search todos'));
addBtn.textContent = tr('ui.add', null, 'Add Todo');
render();
syncNotifications().then(render);
});
}
};
TodoView.unmount = function (containerEl) {
+69
View File
@@ -0,0 +1,69 @@
{
"manifest.name": "Todos",
"manifest.description": "Global and Deal-scoped todo tracking with due and reminder metadata.",
"contributions.views.verstak.todo.view.title": "Todos",
"contributions.sidebarItems.verstak.todo.sidebar.title": "Todos",
"contributions.workspaceItems.verstak.todo.workspace.title": "Todos",
"ui.title": "Todos",
"ui.workspaceTitle": "Todos · {workspace}",
"ui.allStatuses": "All statuses",
"ui.status.open": "Open",
"ui.status.done": "Done",
"ui.status.cancelled": "Cancelled",
"ui.sort.due": "Sort by due date",
"ui.sort.reminder": "Sort by reminder",
"ui.sort.updated": "Sort by updated",
"ui.search": "Search todos",
"ui.add": "Add Todo",
"ui.allWorkspaces": "All Deals",
"ui.unassigned": "Unassigned",
"ui.saveError": "Could not save tasks. Please try again.",
"ui.loadError": "Could not load tasks. Please try again.",
"ui.notificationError": "Could not schedule reminders. Please try again.",
"ui.notificationTitle": "Todo reminder",
"ui.notificationBody": "{title}",
"ui.titlePlaceholder": "Todo title",
"ui.descriptionPlaceholder": "Optional description",
"ui.priority.low": "Low",
"ui.priority.normal": "Normal",
"ui.priority.high": "High",
"ui.titleRequired": "Title is required",
"ui.updated": "Todo updated",
"ui.added": "Todo added",
"ui.field.title": "Title",
"ui.field.description": "Description",
"ui.field.priority": "Priority",
"ui.field.due": "Due date",
"ui.field.reminder": "Reminder",
"ui.field.reminderDate": "Reminder date",
"ui.field.reminderTime": "Reminder time",
"ui.reminderTimePlaceholder": "14:30",
"ui.reminderTimeInvalid": "Enter a valid reminder time (HH:MM)",
"ui.reminderDateRequired": "Choose a reminder date before setting its time",
"ui.field.workspace": "Deal",
"ui.workspaceValue": "Deal: {workspace}",
"ui.edit": "Edit Todo",
"ui.cancel": "Cancel",
"ui.saveChanges": "Save changes",
"ui.markedDone": "Todo marked done",
"ui.cancelled": "Todo cancelled",
"ui.reopened": "Todo reopened",
"ui.deleted": "Todo deleted",
"ui.priorityValue": "{priority} priority",
"ui.overduePrefix": "Overdue · ",
"ui.dueSoonPrefix": "Due soon · ",
"ui.dueValue": "{prefix}Due {date}",
"ui.reminderDueValue": "Reminder due {date}",
"ui.reminderValue": "Reminder {date}",
"ui.reminderDateValue": "Reminder date {date}",
"ui.noMatches": "No todos match the current filters.",
"ui.empty": "No todos yet.",
"ui.openWorkspace": "Open Deal",
"ui.reopen": "Reopen",
"ui.createJournal": "Create Journal Entry",
"ui.editAction": "Edit",
"ui.delete": "Delete",
"ui.untitled": "Untitled todo",
"ui.count": "{count} todo(s)",
"ui.filteredCount": "{visible} of {count} todos"
}
+69
View File
@@ -0,0 +1,69 @@
{
"manifest.name": "Задачи",
"manifest.description": "Общие задачи и задачи Дел со сроками и напоминаниями.",
"contributions.views.verstak.todo.view.title": "Задачи",
"contributions.sidebarItems.verstak.todo.sidebar.title": "Задачи",
"contributions.workspaceItems.verstak.todo.workspace.title": "Задачи",
"ui.title": "Задачи",
"ui.workspaceTitle": "Задачи · {workspace}",
"ui.allStatuses": "Все состояния",
"ui.status.open": "Открыта",
"ui.status.done": "Выполнена",
"ui.status.cancelled": "Отменена",
"ui.sort.due": "Сортировать по сроку",
"ui.sort.reminder": "Сортировать по напоминанию",
"ui.sort.updated": "Сортировать по обновлению",
"ui.search": "Поиск задач",
"ui.add": "Добавить задачу",
"ui.allWorkspaces": "Все Дела",
"ui.unassigned": "Не назначено",
"ui.saveError": "Не удалось сохранить задачи. Повторите попытку.",
"ui.loadError": "Не удалось загрузить задачи. Повторите попытку.",
"ui.notificationError": "Не удалось запланировать напоминания. Повторите попытку.",
"ui.notificationTitle": "Напоминание о задаче",
"ui.notificationBody": "{title}",
"ui.titlePlaceholder": "Название задачи",
"ui.descriptionPlaceholder": "Необязательное описание",
"ui.priority.low": "Низкий",
"ui.priority.normal": "Обычный",
"ui.priority.high": "Высокий",
"ui.titleRequired": "Введите название",
"ui.updated": "Задача обновлена",
"ui.added": "Задача добавлена",
"ui.field.title": "Название",
"ui.field.description": "Описание",
"ui.field.priority": "Приоритет",
"ui.field.due": "Срок",
"ui.field.reminder": "Напоминание",
"ui.field.reminderDate": "Дата напоминания",
"ui.field.reminderTime": "Время напоминания",
"ui.reminderTimePlaceholder": "14:30",
"ui.reminderTimeInvalid": "Введите корректное время напоминания (ЧЧ:ММ)",
"ui.reminderDateRequired": "Сначала выберите дату напоминания",
"ui.field.workspace": "Дело",
"ui.workspaceValue": "Дело: {workspace}",
"ui.edit": "Изменить задачу",
"ui.cancel": "Отмена",
"ui.saveChanges": "Сохранить изменения",
"ui.markedDone": "Задача выполнена",
"ui.cancelled": "Задача отменена",
"ui.reopened": "Задача открыта снова",
"ui.deleted": "Задача удалена",
"ui.priorityValue": "Приоритет: {priority}",
"ui.overduePrefix": "Просрочено · ",
"ui.dueSoonPrefix": "Скоро срок · ",
"ui.dueValue": "{prefix}Срок: {date}",
"ui.reminderDueValue": "Пора напомнить: {date}",
"ui.reminderValue": "Напоминание: {date}",
"ui.reminderDateValue": "Дата напоминания: {date}",
"ui.noMatches": "Нет задач, соответствующих фильтрам.",
"ui.empty": "Задач пока нет.",
"ui.openWorkspace": "Открыть Дело",
"ui.reopen": "Открыть снова",
"ui.createJournal": "Создать запись журнала",
"ui.editAction": "Изменить",
"ui.delete": "Удалить",
"ui.untitled": "Задача без названия",
"ui.count": "Задач: {count}",
"ui.filteredCount": "Показано {visible} из {count}"
}
+7 -2
View File
@@ -4,17 +4,22 @@
"name": "Todos",
"version": "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.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official",
"icon": "list-todo",
"provides": [
"todo.list",
"todo.workspace"
],
"requires": [
"verstak/core/notifications/v1"
],
"permissions": [
"files.read",
"storage.namespace",
"ui.register"
"ui.register",
"notifications.schedule"
],
"frontend": {
"entry": "frontend/src/index.js"
+38 -30
View File
@@ -16,7 +16,7 @@
'.trash-spacer{flex:1}',
'.trash-control{min-height:2rem;box-sizing:border-box;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-surface,#15152c);color:var(--vt-color-text-primary,#f4f7fb);font:inherit;font-size:.78rem;padding:.35rem .5rem}',
'.trash-search{min-width:12rem;flex:1 1 14rem}',
'.trash-select{max-width:11rem}',
'.trash-select{max-width:11rem;appearance:none;background-color:var(--vt-color-surface,#15152c);background-image:linear-gradient(45deg,transparent 50%,var(--vt-color-text-muted,#7f8aa3) 50%),linear-gradient(135deg,var(--vt-color-text-muted,#7f8aa3) 50%,transparent 50%);background-position:calc(100% - 14px) 50%,calc(100% - 9px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:1.7rem}.trash-select option{background:var(--vt-color-surface,#15152c);color:var(--vt-color-text-primary,#f4f7fb)}.trash-control:focus{outline:none;border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.trash-btn{min-height:2rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-surface-hover,#1b2440);color:var(--vt-color-text-secondary,#b7c0d4);font:inherit;font-size:.78rem;padding:.35rem .6rem;cursor:pointer;white-space:nowrap}',
'.trash-btn:hover:not(:disabled){border-color:var(--vt-color-accent,#4ecca3);color:var(--vt-color-text-primary,#f4f7fb)}',
'.trash-btn:disabled{opacity:.48;cursor:default}',
@@ -152,6 +152,10 @@
statusError: false,
disposed: false
};
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
function workspaceOptions() {
var values = {};
@@ -170,18 +174,18 @@
}
function statusText(entries) {
if (state.loading) return 'Loading deleted items...';
if (state.loading) return tr('ui.loading', null, 'Loading deleted items...');
if (state.status) return state.status;
return entries.length === 1 ? '1 deleted item' : entries.length + ' deleted items';
return tr('ui.count', { count: entries.length }, entries.length === 1 ? '1 deleted item' : entries.length + ' deleted items');
}
function renderConfirmation() {
var entry = state.entries.find(function (item) { return item.trashId === state.confirmingId; });
if (!entry) return null;
return el('div', { className: 'trash-confirm-backdrop', 'data-trash-confirm': entry.trashId }, [
el('section', { className: 'trash-confirm', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Delete permanently' }, [
el('h3', {}, ['Delete permanently?']),
el('p', {}, ['This cannot be undone.']),
el('section', { className: 'trash-confirm', role: 'dialog', 'aria-modal': 'true', 'aria-label': tr('ui.deletePermanently', null, 'Delete permanently') }, [
el('h3', {}, [tr('ui.deleteQuestion', null, 'Delete permanently?')]),
el('p', {}, [tr('ui.cannotUndo', null, 'This cannot be undone.')]),
el('p', { className: 'trash-confirm-path' }, [entry.originalPath || nameFor(entry)]),
el('div', { className: 'trash-confirm-actions' }, [
el('button', {
@@ -189,14 +193,14 @@
type: 'button',
'data-trash-confirm-cancel': entry.trashId,
onClick: function () { state.confirmingId = ''; render(); }
}, ['Cancel']),
}, [tr('ui.cancel', null, 'Cancel')]),
el('button', {
className: 'trash-btn trash-btn-danger',
type: 'button',
disabled: state.busyId === entry.trashId,
'data-trash-confirm-delete': entry.trashId,
onClick: function () { deletePermanently(entry); }
}, [state.busyId === entry.trashId ? 'Deleting...' : 'Delete permanently'])
}, [state.busyId === entry.trashId ? tr('ui.deleting', null, 'Deleting...') : tr('ui.deletePermanently', null, 'Delete permanently')])
])
])
]);
@@ -213,7 +217,7 @@
value: state.workspace,
'data-trash-filter-workspace': '',
onChange: function (event) { state.workspace = event.target.value; render(); }
}, [el('option', { value: '' }, ['All workspaces'])]);
}, [el('option', { value: '' }, [tr('ui.allWorkspaces', null, 'All Deals')])]);
workspaceOptions().forEach(function (workspace) {
workspaceSelect.appendChild(el('option', { value: workspace }, [workspace]));
});
@@ -224,9 +228,9 @@
'data-trash-filter-type': '',
onChange: function (event) { state.type = event.target.value; render(); }
}, [
el('option', { value: '' }, ['All types']),
el('option', { value: 'file' }, ['Files']),
el('option', { value: 'folder' }, ['Folders'])
el('option', { value: '' }, [tr('ui.allTypes', null, 'All types')]),
el('option', { value: 'file' }, [tr('ui.files', null, 'Files')]),
el('option', { value: 'folder' }, [tr('ui.folders', null, 'Folders')])
]);
var sortSelect = el('select', {
@@ -235,28 +239,28 @@
'data-trash-sort': '',
onChange: function (event) { state.sort = event.target.value; render(); }
}, [
el('option', { value: 'date-desc' }, ['Deleted: newest']),
el('option', { value: 'date-asc' }, ['Deleted: oldest']),
el('option', { value: 'name-asc' }, ['Name']),
el('option', { value: 'type-asc' }, ['Type'])
el('option', { value: 'date-desc' }, [tr('ui.newest', null, 'Deleted: newest')]),
el('option', { value: 'date-asc' }, [tr('ui.oldest', null, 'Deleted: oldest')]),
el('option', { value: 'name-asc' }, [tr('ui.name', null, 'Name')]),
el('option', { value: 'type-asc' }, [tr('ui.type', null, 'Type')])
]);
containerEl.appendChild(el('div', { className: 'trash-toolbar' }, [
el('span', { className: 'trash-title' }, ['Trash']),
el('span', { className: 'trash-count' }, [state.entries.length + ' total']),
el('span', { className: 'trash-title' }, [tr('ui.title', null, 'Trash')]),
el('span', { className: 'trash-count' }, [tr('ui.total', { count: state.entries.length }, state.entries.length + ' total')]),
el('span', { className: 'trash-spacer' }),
el('input', {
className: 'trash-control trash-search',
type: 'search',
value: state.query,
placeholder: 'Filter name or path',
placeholder: tr('ui.filter', null, 'Filter name or path'),
'data-trash-filter-search': '',
onInput: function (event) { state.query = event.target.value; render(); }
}),
workspaceSelect,
typeSelect,
sortSelect,
el('button', { className: 'trash-btn', type: 'button', onClick: loadEntries }, ['Refresh'])
el('button', { className: 'trash-btn', type: 'button', onClick: loadEntries }, [tr('ui.refresh', null, 'Refresh')])
]));
containerEl.appendChild(el('div', {
className: 'trash-status' + (state.statusError ? ' error' : ''),
@@ -265,17 +269,17 @@
var list = el('div', { className: 'trash-list', 'data-trash-list': '' });
list.appendChild(el('div', { className: 'trash-header' }, [
el('span', {}, ['Name']),
el('span', {}, ['Workspace']),
el('span', {}, ['Original path']),
el('span', {}, ['Deleted']),
el('span', {}, ['Type / size']),
el('span', {}, ['Actions'])
el('span', {}, [tr('ui.name', null, 'Name')]),
el('span', {}, [tr('ui.workspace', null, 'Deal')]),
el('span', {}, [tr('ui.originalPath', null, 'Original path')]),
el('span', {}, [tr('ui.deleted', null, 'Deleted')]),
el('span', {}, [tr('ui.typeSize', null, 'Type / size')]),
el('span', {}, [tr('ui.actions', null, 'Actions')])
]));
if (state.loading) {
list.appendChild(el('div', { className: 'trash-empty' }, ['Loading deleted items...']));
list.appendChild(el('div', { className: 'trash-empty' }, [tr('ui.loading', null, 'Loading deleted items...')]));
} else if (!rows.length) {
list.appendChild(el('div', { className: 'trash-empty' }, [state.entries.length ? 'No deleted items match the current filters.' : 'Trash is empty.']));
list.appendChild(el('div', { className: 'trash-empty' }, [state.entries.length ? tr('ui.noMatches', null, 'No deleted items match the current filters.') : tr('ui.empty', null, 'Trash is empty.')]));
} else {
rows.forEach(function (entry) {
var size = formatSize(entry.size);
@@ -296,14 +300,14 @@
disabled: state.busyId === entry.trashId,
'data-trash-restore': entry.trashId,
onClick: function () { restoreEntry(entry); }
}, [state.busyId === entry.trashId ? 'Restoring...' : 'Restore']),
}, [state.busyId === entry.trashId ? tr('ui.restoring', null, 'Restoring...') : tr('ui.restore', null, 'Restore')]),
el('button', {
className: 'trash-btn trash-btn-danger',
type: 'button',
disabled: state.busyId === entry.trashId,
'data-trash-delete': entry.trashId,
onClick: function () { state.confirmingId = entry.trashId; render(); }
}, ['Delete permanently'])
}, [tr('ui.deletePermanently', null, 'Delete permanently')])
])
]));
});
@@ -386,8 +390,12 @@
}
loadEntries();
var localeUnsubscribe = api.i18n && typeof api.i18n.onDidChangeLocale === 'function'
? api.i18n.onDidChangeLocale(render)
: null;
containerEl.__trashCleanup = function () {
state.disposed = true;
if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
containerEl.innerHTML = '';
};
},
+34
View File
@@ -0,0 +1,34 @@
{
"manifest.name": "Trash",
"manifest.description": "Global vault trash manager for restoring or permanently deleting deleted files and folders.",
"contributions.views.verstak.trash.view.title": "Trash",
"contributions.sidebarItems.verstak.trash.sidebar.title": "Trash",
"ui.loading": "Loading deleted items...",
"ui.count": "{count} deleted item(s)",
"ui.deletePermanently": "Delete permanently",
"ui.deleteQuestion": "Delete permanently?",
"ui.cannotUndo": "This cannot be undone.",
"ui.cancel": "Cancel",
"ui.deleting": "Deleting...",
"ui.allWorkspaces": "All Deals",
"ui.allTypes": "All types",
"ui.files": "Files",
"ui.folders": "Folders",
"ui.newest": "Deleted: newest",
"ui.oldest": "Deleted: oldest",
"ui.name": "Name",
"ui.type": "Type",
"ui.title": "Trash",
"ui.total": "{count} total",
"ui.filter": "Filter name or path",
"ui.refresh": "Refresh",
"ui.workspace": "Deal",
"ui.originalPath": "Original path",
"ui.deleted": "Deleted",
"ui.typeSize": "Type / size",
"ui.actions": "Actions",
"ui.noMatches": "No deleted items match the current filters.",
"ui.empty": "Trash is empty.",
"ui.restoring": "Restoring...",
"ui.restore": "Restore"
}
+34
View File
@@ -0,0 +1,34 @@
{
"manifest.name": "Корзина",
"manifest.description": "Общая корзина хранилища для восстановления или окончательного удаления файлов и папок.",
"contributions.views.verstak.trash.view.title": "Корзина",
"contributions.sidebarItems.verstak.trash.sidebar.title": "Корзина",
"ui.loading": "Загрузка удалённых элементов...",
"ui.count": "Удалённых элементов: {count}",
"ui.deletePermanently": "Удалить навсегда",
"ui.deleteQuestion": "Удалить навсегда?",
"ui.cannotUndo": "Это действие нельзя отменить.",
"ui.cancel": "Отмена",
"ui.deleting": "Удаление...",
"ui.allWorkspaces": "Все Дела",
"ui.allTypes": "Все типы",
"ui.files": "Файлы",
"ui.folders": "Папки",
"ui.newest": "Сначала недавно удалённые",
"ui.oldest": "Сначала давно удалённые",
"ui.name": "Имя",
"ui.type": "Тип",
"ui.title": "Корзина",
"ui.total": "Всего: {count}",
"ui.filter": "Фильтр по имени или пути",
"ui.refresh": "Обновить",
"ui.workspace": "Дело",
"ui.originalPath": "Исходный путь",
"ui.deleted": "Удалён",
"ui.typeSize": "Тип / размер",
"ui.actions": "Действия",
"ui.noMatches": "Нет удалённых элементов, соответствующих фильтрам.",
"ui.empty": "Корзина пуста.",
"ui.restoring": "Восстановление...",
"ui.restore": "Восстановить"
}
+1
View File
@@ -5,6 +5,7 @@
"version": "0.1.0",
"apiVersion": "0.1.0",
"description": "Global vault trash manager for restoring or permanently deleting deleted files and folders.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official",
"icon": "trash",
"provides": [
File diff suppressed because one or more lines are too long
+30
View File
@@ -0,0 +1,30 @@
{
"manifest.name": "Workspace Folders",
"manifest.description": "Nested folders with icons, colors and drag-and-drop for the Deal tree.",
"common.close": "Close",
"common.cancel": "Cancel",
"common.save": "Save",
"common.create": "Create",
"common.select": "Select",
"workspaceTree.title": "Deals",
"workspaceTree.new": "New Deal",
"workspaceTree.nameRequired": "Name is required",
"workspaceTree.namePlaceholder": "Deal name",
"workspaceTree.template": "Template",
"workspaceTree.create": "Create Deal",
"workspaceTree.trash": "Move Deal to trash",
"pluginCard.name": "Name",
"folder.create": "Create Folder",
"folder.edit": "Edit Folder",
"folder.delete": "Delete Folder",
"folder.deleteConfirm": "Delete folder? Deals inside will move to the parent folder.",
"folder.namePlaceholder": "Folder name",
"folder.nameRequired": "Name is required",
"folder.icon": "Icon",
"folder.chooseIcon": "Choose...",
"folder.color": "Color",
"folder.chooseColor": "Choose...",
"folder.iconPickerTitle": "Choose Icon",
"folder.iconSearch": "Search icons...",
"folder.colorPickerTitle": "Choose Color"
}
+30
View File
@@ -0,0 +1,30 @@
{
"manifest.name": "Папки дел",
"manifest.description": "Вложенные папки с иконками, цветами и drag-and-drop для дерева Дел.",
"common.close": "Закрыть",
"common.cancel": "Отмена",
"common.save": "Сохранить",
"common.create": "Создать",
"common.select": "Выбрать",
"workspaceTree.title": "Дела",
"workspaceTree.new": "Новое дело",
"workspaceTree.nameRequired": "Имя обязательно",
"workspaceTree.namePlaceholder": "Название дела",
"workspaceTree.template": "Шаблон",
"workspaceTree.create": "Создать дело",
"workspaceTree.trash": "Переместить дело в корзину",
"pluginCard.name": "Имя",
"folder.create": "Создать папку",
"folder.edit": "Редактировать папку",
"folder.delete": "Удалить папку",
"folder.deleteConfirm": "Удалить папку? Дела внутри переместятся в родительскую папку.",
"folder.namePlaceholder": "Название папки",
"folder.nameRequired": "Имя обязательно",
"folder.icon": "Иконка",
"folder.chooseIcon": "Выбрать...",
"folder.color": "Цвет",
"folder.chooseColor": "Выбрать...",
"folder.iconPickerTitle": "Выбрать иконку",
"folder.iconSearch": "Поиск иконок...",
"folder.colorPickerTitle": "Выбрать цвет"
}
+40
View File
@@ -0,0 +1,40 @@
{
"schemaVersion": 1,
"id": "verstak.workspace-folders",
"name": "Workspace Folders",
"version": "0.1.0",
"apiVersion": "0.1.0",
"description": "Nested folders with icons, colors and drag-and-drop for the Deal tree.",
"source": "official",
"icon": "folder",
"localization": {
"defaultLocale": "en",
"locales": {
"en": "locales/en.json",
"ru": "locales/ru.json"
}
},
"provides": ["workspace.folders"],
"requires": [
"verstak/core/files/v1",
"verstak/core/workspace/v1"
],
"optionalRequires": [],
"permissions": [
"files.read",
"files.write",
"ui.register",
"events.publish",
"events.subscribe",
"storage.namespace"
],
"frontend": {
"entry": "frontend/src/index.js",
"style": "frontend/src/style.css"
},
"contributes": {
"workspaceTree": {
"component": "FolderTreeView"
}
}
}
+7
View File
@@ -0,0 +1,7 @@
## Highlights
- First public bundle of Verstak's official plugins.
- Includes Files, Notes, Journal, Todo, Secrets, Browser Inbox, Activity, Search, Trash, Sync, and supporting editors and previews.
- Adds durable browser captures, archive handling, activity sessions, and localized plugin interfaces.
**Commit history**: https://github.com/mirivlad/verstak-official-plugins/commits/v0.1.0-alpha.1
+5
View File
@@ -0,0 +1,5 @@
## Highlights
- Todo tasks can schedule native reminders through the Desktop notification capability.
**Full Changelog**: https://github.com/mirivlad/verstak-official-plugins/compare/v0.1.0-alpha.1...v0.1.0-alpha.2
+7
View File
@@ -0,0 +1,7 @@
## Highlights
- Makes Todo reminder time selection, global Secrets, and global Journal entry creation usable.
- Localizes plugin errors, browser materials, activity review, files menus, and other key workflows.
- Replaces technical terminology with “Deal” and improves modal forms and secret safety.
**Full Changelog**: https://github.com/mirivlad/verstak-official-plugins/compare/v0.1.0-alpha.2...v0.1.0-alpha.3
+6
View File
@@ -0,0 +1,6 @@
## Highlights
- Folder rows in the Files plugin now use localized labels instead of the internal `folder` type.
- File icons have localized, accessible names in both supported languages.
Install the archive matching your platform into Verstak's plugins directory, replacing the previous official plugins bundle.
+11
View File
@@ -0,0 +1,11 @@
## Highlights
- **Workspace Folders plugin.** A new official plugin `verstak.workspace-folders`
replaces the sidebar Deal tree with a rich folder tree: icons (1703 Lucide
icons), 12 preset colors, drag‑and‑drop between folders, inline folder
creation and editing, and a Deal creation modal with parent folder selector.
## Install
Unpack the archive for your platform into Verstak's `plugins/` directory,
replacing the previous official plugins bundle.
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WINDOWS_DIST="${VERSTAK_WINDOWS_PLUGIN_DIST:-$ROOT/dist-windows}"
WINDOWS_CC="${VERSTAK_WINDOWS_CC:-x86_64-w64-mingw32-gcc}"
GO_BIN="${GO_BIN:-go}"
if ! command -v "$WINDOWS_CC" >/dev/null; then
echo "Windows cross-compiler not found: $WINDOWS_CC" >&2
echo "Install x86_64-w64-mingw32-gcc (for example: sudo apt install gcc-mingw-w64-x86-64)." >&2
exit 1
fi
if ! command -v "$GO_BIN" >/dev/null; then
echo "go is required to build Windows plugin backends" >&2
exit 1
fi
cd "$ROOT"
echo "=== verstak official plugins Windows amd64 build ==="
# Reuse the canonical frontend and manifest packager, then replace every Go
# sidecar with its Windows executable in a separate dist-windows tree.
./scripts/build.sh
rm -rf "$WINDOWS_DIST"
mkdir -p "$WINDOWS_DIST"
cp -R "$ROOT/dist/." "$WINDOWS_DIST/"
backend_count=0
for plugin_dir in "$ROOT"/plugins/*/; do
[[ -d "$plugin_dir" ]] || continue
plugin_name="$(basename "$plugin_dir")"
backend_dir="$plugin_dir/backend"
[[ -d "$backend_dir" ]] || continue
if [[ ! -f "$backend_dir/go.mod" && ! -f "$backend_dir/main.go" ]]; then
continue
fi
package_backend="$WINDOWS_DIST/$plugin_name/backend"
rm -rf "$package_backend"
mkdir -p "$package_backend"
echo " → Windows backend: $plugin_name.exe"
(
cd "$backend_dir"
GOOS=windows GOARCH=amd64 CGO_ENABLED=1 CC="$WINDOWS_CC" \
"$GO_BIN" build -o "$package_backend/$plugin_name.exe" .
)
backend_count=$((backend_count + 1))
done
echo "Windows plugin packages: $WINDOWS_DIST"
echo "Windows backends built: $backend_count"
+9 -2
View File
@@ -79,7 +79,14 @@ with open(path, 'w', encoding='utf-8') as f:
fi
fi
# 3. backend binary
# 3. locale catalogs declared by plugin.json
if [ -d "$plugin_dir/locales" ]; then
mkdir -p "$dist_dir/locales"
cp -r "$plugin_dir/locales/." "$dist_dir/locales/"
echo " └─ locales ($(find "$dist_dir/locales" -type f | wc -l) file(s))"
fi
# 4. backend binary
if [ -d "$plugin_dir/backend" ]; then
# Find the compiled binary (same name as plugin directory)
local bin_name="$plugin_name"
@@ -91,7 +98,7 @@ with open(path, 'w', encoding='utf-8') as f:
fi
fi
# 4. Verify dist package has at least plugin.json
# 5. Verify dist package has at least plugin.json
if [ ! -f "$dist_dir/plugin.json" ]; then
echo " ❌ dist package missing plugin.json"
return 1
+81
View File
@@ -0,0 +1,81 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const pluginsRoot = path.join(root, 'plugins');
const contributionFields = {
views: 'title',
commands: 'title',
settingsPanels: 'title',
sidebarItems: 'title',
fileActions: 'label',
noteActions: 'label',
contextMenuEntries: 'label',
searchProviders: 'label',
statusBarItems: 'label',
openProviders: 'title',
workspaceItems: 'title',
};
const problems = [];
for (const entry of fs.readdirSync(pluginsRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const pluginRoot = path.join(pluginsRoot, entry.name);
const manifestPath = path.join(pluginRoot, 'plugin.json');
if (!fs.existsSync(manifestPath)) continue;
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const localization = manifest.localization;
if (!localization || localization.defaultLocale !== 'en') {
problems.push(`${manifest.id}: localization.defaultLocale must be en`);
continue;
}
if (!localization.locales || !localization.locales.en || !localization.locales.ru) {
problems.push(`${manifest.id}: en and ru catalogs must be declared`);
continue;
}
const catalogs = {};
for (const locale of ['en', 'ru']) {
const relative = localization.locales[locale];
const catalogPath = path.resolve(pluginRoot, relative);
if (!catalogPath.startsWith(pluginRoot + path.sep) || !fs.existsSync(catalogPath)) {
problems.push(`${manifest.id}: missing safe ${locale} catalog at ${relative}`);
continue;
}
try {
catalogs[locale] = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
} catch (error) {
problems.push(`${manifest.id}: invalid ${locale} catalog: ${error.message}`);
continue;
}
for (const [key, value] of Object.entries(catalogs[locale])) {
if (typeof value !== 'string') problems.push(`${manifest.id}: ${locale}.${key} must be a string`);
}
}
if (!catalogs.en || !catalogs.ru) continue;
const enKeys = Object.keys(catalogs.en).sort();
const ruKeys = Object.keys(catalogs.ru).sort();
if (JSON.stringify(enKeys) !== JSON.stringify(ruKeys)) {
problems.push(`${manifest.id}: en/ru catalog keys differ`);
}
const required = ['manifest.name'];
if (manifest.description) required.push('manifest.description');
for (const [point, field] of Object.entries(contributionFields)) {
for (const item of manifest.contributes?.[point] || []) {
required.push(`contributions.${point}.${item.id}.${field}`);
}
}
for (const key of required) {
if (!Object.prototype.hasOwnProperty.call(catalogs.en, key)) problems.push(`${manifest.id}: en catalog missing ${key}`);
if (!Object.prototype.hasOwnProperty.call(catalogs.ru, key)) problems.push(`${manifest.id}: ru catalog missing ${key}`);
}
}
if (problems.length > 0) {
problems.forEach((problem) => console.error(` FAIL ${problem}`));
process.exit(1);
}
console.log(' OK official plugin locale catalogs are complete and aligned');
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
const checks = [
['plugins/todo/frontend/src/index.js', '.todo-select'],
['plugins/files/frontend/src/index.js', '.files-sort'],
['plugins/notes/frontend/src/index.js', '.notes-sort'],
['plugins/browser-inbox/frontend/src/index.js', '.browser-inbox-select'],
['plugins/journal/frontend/src/index.js', '.journal-input.journal-select'],
['plugins/trash/frontend/src/index.js', '.trash-select'],
['plugins/secrets/frontend/src/index.js', '.secrets-select'],
];
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
let failed = false;
for (const [relativePath, selector] of checks) {
const source = fs.readFileSync(path.join(root, relativePath), 'utf8');
const escaped = escapeRegExp(selector);
if (!new RegExp(`${escaped}\\{[^}]*appearance:none`).test(source)) {
console.error(`${relativePath}: ${selector} must hide the native select arrow`);
failed = true;
}
if (!new RegExp(`${escaped} option\\{[^}]*background`).test(source)) {
console.error(`${relativePath}: ${selector} options must use the application surface`);
failed = true;
}
}
if (failed) process.exit(1);
console.log('official plugin select styles are complete');
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
function frontendSources(dir) {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const target = path.join(dir, entry.name);
if (entry.isDirectory()) return frontendSources(target);
return /\.(?:js|svelte)$/.test(entry.name) ? [target] : [];
});
}
const files = fs.readdirSync(path.join(root, 'plugins'))
.flatMap((plugin) => frontendSources(path.join(root, 'plugins', plugin, 'frontend', 'src')));
const renderPatterns = [
/statusText\s*=/,
/state\.error\s*=/,
/errorMsg\s*=/,
/setStatus\(/,
/setCreateError\(/,
/setRenameError\(/,
/renderEmpty\(/,
/output\.errors\.push\(/,
/window\.alert\(/,
/showExternalFallback\(/,
/de-error-msg/,
/files-error-msg/,
/body\.textContent\s*=/
];
const violations = [];
for (const file of files) {
const lines = fs.readFileSync(file, 'utf8').split('\n');
lines.forEach((line, index) => {
if (!/(?:err(?:or)?\.message|String\(err(?:or)?\b)/.test(line)) return;
if (!renderPatterns.some((pattern) => pattern.test(line))) return;
violations.push(`${path.relative(root, file)}:${index + 1}: technical error text reaches a user-facing UI sink`);
});
}
if (violations.length) {
console.error('User-facing UI must not render raw backend/plugin errors:');
violations.forEach((violation) => console.error(` ${violation}`));
process.exit(1);
}
console.log('user-facing error messages do not expose raw backend details');
+37
View File
@@ -76,6 +76,43 @@ else
echo " ℹ️ python3 not available — skipping manifest validation"
fi
echo ""
# Verify plugin-owned English and Russian catalogs.
echo "[localization catalogs]"
if command -v node &>/dev/null; then
set +e
node "$ROOT/scripts/check-locales.mjs"
STATUS=$?
set -e
report "localization catalogs" "$STATUS"
else
echo " ⚠️ node not available — skipping localization catalog validation"
fi
echo ""
echo "[select styles]"
if command -v node &>/dev/null; then
set +e
node "$ROOT/scripts/check-select-styles.js"
STATUS=$?
set -e
report "selects use application styles" "$STATUS"
else
echo " ⚠️ node not available — skipping select style validation"
fi
echo ""
echo "[user-facing errors]"
if command -v node &>/dev/null; then
set +e
node "$ROOT/scripts/check-user-facing-errors.js"
STATUS=$?
set -e
report "user-facing errors" "$STATUS"
else
echo " ⚠️ node not available — skipping user-facing error validation"
fi
echo ""
# Guard official plugins against bypassing the v2 plugin API for note features.
echo "[frontend API boundary]"
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VERSION="${1:-}"
if [[ -z "$VERSION" || ! "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
echo "usage: $0 <version>" >&2
echo "example: $0 v0.1.0-alpha.1" >&2
exit 2
fi
if ! command -v zip >/dev/null; then
echo "zip is required to create the Windows plugin archive" >&2
exit 1
fi
echo "=== verstak official portable plugin packages $VERSION ==="
"$ROOT/scripts/build.sh"
"$ROOT/scripts/build-windows.sh"
RELEASE_ROOT="$ROOT/release"
LINUX_ARCHIVE="$RELEASE_ROOT/verstak-official-plugins-linux-amd64-$VERSION.tar.gz"
WINDOWS_ARCHIVE="$RELEASE_ROOT/verstak-official-plugins-windows-amd64-$VERSION.zip"
rm -rf "$RELEASE_ROOT"
mkdir -p "$RELEASE_ROOT"
# Each archive expands directly into the desktop application's plugins/
# directory. Frontends and manifests are shared; platform-test's native
# sidecar is built for the target operating system.
tar -C "$ROOT/dist" -czf "$LINUX_ARCHIVE" .
(cd "$ROOT/dist-windows" && zip -qr "$WINDOWS_ARCHIVE" .)
(cd "$RELEASE_ROOT" && sha256sum "$(basename "$LINUX_ARCHIVE")" "$(basename "$WINDOWS_ARCHIVE")" > SHA256SUMS)
echo "Linux plugins: $LINUX_ARCHIVE"
echo "Windows plugins: $WINDOWS_ARCHIVE"
echo "checksums: $RELEASE_ROOT/SHA256SUMS"
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VERSION="${1:-}"
REPOSITORY="mirivlad/verstak-official-plugins"
RELEASE_SCRIPT="${VERSTAK_RELEASE_SCRIPT:-$ROOT/scripts/release.sh}"
RELEASE_DIR="${VERSTAK_RELEASE_DIR:-$ROOT/release}"
RELEASE_NOTES_DIR="${VERSTAK_RELEASE_NOTES_DIR:-$ROOT/release-notes}"
GIT_BIN="${GIT_BIN:-git}"
GH_BIN="${GH_BIN:-gh}"
cd "$ROOT"
if [[ -z "$VERSION" || ! "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
echo "usage: $0 <version>" >&2
echo "example: $0 v0.1.0-alpha.1" >&2
exit 2
fi
if ! command -v "$GH_BIN" >/dev/null; then
echo "gh CLI is required to publish a GitHub Release" >&2
exit 1
fi
if [[ "$("$GIT_BIN" branch --show-current)" != "main" ]]; then
echo "GitHub releases must be published from main" >&2
exit 1
fi
if [[ -n "$("$GIT_BIN" status --porcelain)" ]]; then
echo "working tree must be clean before publishing a release" >&2
exit 1
fi
"$GH_BIN" auth status
"$GIT_BIN" fetch origin main --tags
HEAD="$("$GIT_BIN" rev-parse HEAD)"
if [[ "$HEAD" != "$("$GIT_BIN" rev-parse origin/main)" ]]; then
echo "local main must match origin/main before publishing a release" >&2
exit 1
fi
"$RELEASE_SCRIPT" "$VERSION"
ASSETS=(
"$RELEASE_DIR/verstak-official-plugins-linux-amd64-$VERSION.tar.gz"
"$RELEASE_DIR/verstak-official-plugins-windows-amd64-$VERSION.zip"
"$RELEASE_DIR/SHA256SUMS"
)
for asset in "${ASSETS[@]}"; do
if [[ ! -s "$asset" ]]; then
echo "required release asset is missing or empty: $asset" >&2
exit 1
fi
done
if "$GIT_BIN" rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then
if [[ "$("$GIT_BIN" rev-parse "${VERSION}^{commit}")" != "$HEAD" ]]; then
echo "existing tag $VERSION does not point at HEAD" >&2
exit 1
fi
else
"$GIT_BIN" tag -a "$VERSION" -m "Release $VERSION"
"$GIT_BIN" push origin "refs/tags/$VERSION"
fi
if "$GH_BIN" release view "$VERSION" --repo "$REPOSITORY" >/dev/null 2>&1; then
"$GH_BIN" release upload "$VERSION" "${ASSETS[@]}" --repo "$REPOSITORY" --clobber
else
NOTES_FILE="$RELEASE_NOTES_DIR/$VERSION.md"
if [[ ! -s "$NOTES_FILE" ]]; then
echo "human-readable release notes are required: $NOTES_FILE" >&2
exit 1
fi
RELEASE_OPTIONS=(--notes-file "$NOTES_FILE" --generate-notes --verify-tag)
PREVIOUS_TAG="$("$GIT_BIN" describe --tags --abbrev=0 "${HEAD}^" 2>/dev/null || true)"
if [[ -n "$PREVIOUS_TAG" ]]; then
RELEASE_OPTIONS+=(--notes-start-tag "$PREVIOUS_TAG")
fi
if [[ "$VERSION" == *-* ]]; then
RELEASE_OPTIONS+=(--prerelease)
else
RELEASE_OPTIONS+=(--latest)
fi
"$GH_BIN" release create "$VERSION" "${ASSETS[@]}" \
--repo "$REPOSITORY" \
--title "Verstak Official Plugins $VERSION" \
"${RELEASE_OPTIONS[@]}"
fi
echo "GitHub release:"
"$GH_BIN" release view "$VERSION" --repo "$REPOSITORY" --json url --jq .url
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VERSION="${1:-}"
if [[ -z "$VERSION" || ! "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
echo "usage: $0 <version>" >&2
echo "example: $0 v0.1.0-alpha.1" >&2
exit 2
fi
echo "=== verstak official plugins release $VERSION ==="
"$ROOT/scripts/check.sh"
"$ROOT/scripts/package-portable.sh" "$VERSION"
+110 -8
View File
@@ -116,8 +116,9 @@ function loadComponent(document) {
return component;
}
function makeApi(initialSettings = {}) {
function makeApi(initialSettings = {}, initialData = {}) {
const settings = { ...initialSettings };
const data = { ...initialData };
const handlers = {};
const commandHandlers = new Map();
const unsubscribed = [];
@@ -132,6 +133,14 @@ function makeApi(initialSettings = {}) {
return { ...settings };
},
},
storage: {
data: {
readNDJSON: async (name) => Array.isArray(data[name]) ? data[name].slice() : [],
writeNDJSON: async (name, records) => {
data[name] = Array.isArray(records) ? records.slice() : [];
},
},
},
events: {
subscribe: async (name, handler) => {
handlers[name] = handler;
@@ -150,11 +159,14 @@ function makeApi(initialSettings = {}) {
storedEvents(key = 'events') {
return settings[key] || [];
},
storedData(name) {
return Array.isArray(data[name]) ? data[name] : [];
},
};
}
async function flush() {
for (let i = 0; i < 10; i += 1) await Promise.resolve();
for (let i = 0; i < 20; i += 1) await Promise.resolve();
}
async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, workspaceRootPath: 'Project' }, document = makeDocument()) {
@@ -268,14 +280,17 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
}
if (api.storedEvents(globalKey).length !== 0) throw new Error('workspace activity leaked into global storage');
if (!container.textContent.includes('Example Article')) throw new Error('browser capture title was not rendered');
if (!container.textContent.includes('browser.capture.selection')) throw new Error('event type was not rendered');
if (!container.textContent.includes('browser.capture.converted')) throw new Error('conversion event type was not rendered');
if (!container.textContent.includes('Selection captured')) throw new Error('browser capture label was not rendered');
if (!container.textContent.includes('Capture converted')) throw new Error('conversion label was not rendered');
if (container.textContent.includes('browser.capture.selection') || container.textContent.includes('browser.capture.converted') || container.textContent.includes('verstak.browser-inbox')) {
throw new Error('Activity must not expose technical event or plugin identifiers in its UI');
}
if (!container.textContent.includes('Possible journal entries')) throw new Error('work session candidate section was not rendered');
if (container.textContent.includes('Project work on 2026-06-27')) throw new Error('candidate must not invent a worklog title');
const candidateNode = walk(container, (node) => node.getAttribute && node.getAttribute('data-work-session-candidate'));
if (!candidateNode) throw new Error('work session candidate data attribute was not rendered');
if (!candidateNode.textContent.includes('Workspace: Project')) throw new Error('candidate workspace was not rendered');
if (!candidateNode.textContent.includes('Estimated duration: 30 min')) throw new Error('candidate duration was not rendered');
if (!candidateNode.textContent.includes('Deal: Project')) throw new Error('candidate Deal was not rendered');
if (!candidateNode.textContent.includes('Estimated duration: 20 min')) throw new Error('candidate duration was not rendered');
if (!candidateNode.textContent.includes('Activities: 3')) throw new Error('candidate activity count was not rendered');
if (!walk(candidateNode, (node) => node.getAttribute && node.getAttribute('data-work-session-action') === 'review')) throw new Error('candidate review action was not rendered');
if (!walk(candidateNode, (node) => node.getAttribute && node.getAttribute('data-work-session-action') === 'dismiss')) throw new Error('candidate dismiss action was not rendered');
@@ -287,7 +302,7 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
if (!candidate.candidateId) throw new Error('candidate id is missing');
if (candidate.workspaceRootPath !== 'Project') throw new Error('candidate workspace mismatch');
if (candidate.startedAt !== '2026-06-27T00:00:00.000Z' || candidate.endedAt !== '2026-06-27T00:30:00.000Z') throw new Error('candidate range mismatch');
if (candidate.estimatedMinutes !== 30) throw new Error(`expected 30 candidate minutes, got ${candidate.estimatedMinutes}`);
if (candidate.estimatedMinutes !== 20) throw new Error(`expected 20 candidate minutes, got ${candidate.estimatedMinutes}`);
if (candidate.activityCount !== 3) throw new Error(`expected three candidate activities, got ${candidate.activityCount}`);
if (candidate.activityIds.join(',') !== 'capture-1,note-1,capture-1:browser.capture.converted') throw new Error('candidate activity ids mismatch');
if (!Array.isArray(candidate.activities) || candidate.activities.length !== 3) throw new Error('candidate activity list is missing');
@@ -349,11 +364,18 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
if (!clearButton) throw new Error('clear activity button not found');
clearButton.click();
await flush();
if (api.storedEvents(projectKey).length === 0) throw new Error('clear action removed activity before confirmation');
const clearConfirmation = walk(container, (node) => node.getAttribute && node.getAttribute('data-activity-clear-confirmation') === '');
if (!clearConfirmation) throw new Error('clear activity confirmation was not rendered');
const confirmClear = walk(clearConfirmation, (node) => node.getAttribute && node.getAttribute('data-activity-clear-confirm') === '');
if (!confirmClear) throw new Error('clear activity confirmation button was not rendered');
confirmClear.click();
await flush();
if (api.storedEvents(projectKey).length !== 0) throw new Error('clear action did not remove activity events');
if (api.storedEvents('work-session-candidates:workspace:Project').length !== 0) throw new Error('clear action did not remove cached candidates');
component.unmount && component.unmount(container);
if (api.unsubscribed.length !== 33) throw new Error(`expected 33 unsubscribers, got ${api.unsubscribed.length}`);
if (api.unsubscribed.length !== 39) throw new Error(`expected 39 unsubscribers, got ${api.unsubscribed.length}`);
const persistedApi = makeApi({
'events:workspace:Project': [{
@@ -449,6 +471,86 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
}
component.unmount && component.unmount(sessionView.container);
const lateApi = makeApi({
'events:workspace:Project': [
{ activityId: 'late-a', type: 'note.saved', occurredAt: '2026-07-12T10:00:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-b', type: 'file.changed', occurredAt: '2026-07-12T10:10:00Z', workspaceRootPath: 'Project' },
],
});
const lateView = await mountWithApi(lateApi);
const lateCommand = lateApi.commandHandlers.get(WORKLOG_COMMAND_ID);
const firstLateCandidate = (await lateCommand({ workspaceRootPath: 'Project' })).candidates[0];
if (!firstLateCandidate || !firstLateCandidate.sessionId || firstLateCandidate.estimatedMinutes !== 10) {
throw new Error('file activity duration must use the capped adjacent-event algorithm');
}
await lateApi.settings.write('events:workspace:Project', [
{ activityId: 'late-before', type: 'note.saved', occurredAt: '2026-07-12T09:55:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-a', type: 'note.saved', occurredAt: '2026-07-12T10:00:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-b', type: 'file.changed', occurredAt: '2026-07-12T10:10:00Z', workspaceRootPath: 'Project' },
]);
await lateApi.handlers['note.saved']({ name: 'note.saved', payload: {} });
await flush();
const afterLateCandidate = (await lateCommand({ workspaceRootPath: 'Project' })).candidates[0];
if (!afterLateCandidate || afterLateCandidate.sessionId !== firstLateCandidate.sessionId) {
throw new Error('late events changed an immutable session identity');
}
const dismissLate = walk(lateView.container, (node) => node.getAttribute && node.getAttribute('data-work-session-action') === 'dismiss');
dismissLate.click();
await flush();
await lateApi.settings.write('events:workspace:Project', [
{ activityId: 'late-before', type: 'note.saved', occurredAt: '2026-07-12T09:55:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-a', type: 'note.saved', occurredAt: '2026-07-12T10:00:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-b', type: 'file.changed', occurredAt: '2026-07-12T10:10:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-one', type: 'note.saved', occurredAt: '2026-07-12T10:15:00Z', workspaceRootPath: 'Project' },
]);
await lateApi.handlers['note.saved']({ name: 'note.saved', payload: {} });
await flush();
if ((await lateCommand({ workspaceRootPath: 'Project' })).candidates.length !== 0) {
throw new Error('dismissed session was re-offered without substantial new activity');
}
await lateApi.settings.write('events:workspace:Project', [
{ activityId: 'late-before', type: 'note.saved', occurredAt: '2026-07-12T09:55:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-a', type: 'note.saved', occurredAt: '2026-07-12T10:00:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-b', type: 'file.changed', occurredAt: '2026-07-12T10:10:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-one', type: 'note.saved', occurredAt: '2026-07-12T10:15:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-two', type: 'file.changed', occurredAt: '2026-07-12T10:25:00Z', workspaceRootPath: 'Project' },
]);
await lateApi.handlers['file.changed']({ name: 'file.changed', payload: {} });
await flush();
if ((await lateCommand({ workspaceRootPath: 'Project' })).candidates.length !== 1) {
throw new Error('dismissed session was not re-offered after substantial new activity');
}
component.unmount && component.unmount(lateView.container);
const rawApi = makeApi({}, {
'activity-events': [{
activityId: 'browser-domain:batch-1:0',
type: 'browser.activity.domain',
title: 'example.com',
summary: '5 min browser activity',
occurredAt: '2026-07-12T10:05:00Z',
sourcePluginId: 'verstak-browser-extension',
sourceBatchId: 'batch-1',
hostname: 'example.com',
durationSeconds: 300,
payload: { hostname: 'example.com', durationSeconds: 300 },
}],
});
const rawView = await mountWithApi(rawApi, {});
if (!rawView.container.textContent.includes('example.com')) throw new Error('append-only browser activity was not rendered');
const rawClear = walk(rawView.container, (node) => node.getAttribute && node.getAttribute('data-activity-action') === 'clear');
rawClear.click();
await flush();
if (rawApi.storedData('activity-events').length === 0) throw new Error('append-only activity was removed before confirmation');
const rawClearConfirmation = walk(rawView.container, (node) => node.getAttribute && node.getAttribute('data-activity-clear-confirmation') === '');
if (!rawClearConfirmation) throw new Error('append-only activity confirmation was not rendered');
const rawConfirmClear = walk(rawClearConfirmation, (node) => node.getAttribute && node.getAttribute('data-activity-clear-confirm') === '');
if (!rawConfirmClear) throw new Error('append-only activity confirmation button was not rendered');
rawConfirmClear.click();
await flush();
if (rawApi.storedData('activity-events').length !== 0) throw new Error('clear activity did not replace append-only data');
component.unmount && component.unmount(rawView.container);
console.log('activity plugin smoke passed');
})().catch((err) => {
console.error(err);
+183 -19
View File
@@ -6,6 +6,11 @@ const vm = require('vm');
const root = path.resolve(__dirname, '..');
const sourcePath = path.join(root, 'plugins', 'browser-inbox', 'frontend', 'src', 'index.js');
const source = fs.readFileSync(sourcePath, 'utf8');
const catalogs = {
en: JSON.parse(fs.readFileSync(path.join(root, 'plugins', 'browser-inbox', 'locales', 'en.json'), 'utf8')),
ru: JSON.parse(fs.readFileSync(path.join(root, 'plugins', 'browser-inbox', 'locales', 'ru.json'), 'utf8')),
};
const technicalErrors = [];
class FakeNode {
constructor(tagName) {
@@ -113,7 +118,15 @@ function makeDocument() {
function loadComponents(document) {
const registry = {};
const sandbox = {
console,
console: {
...console,
warn(...args) {
technicalErrors.push(args.map((value) => String(value)).join(' '));
},
error(...args) {
technicalErrors.push(args.map((value) => String(value)).join(' '));
},
},
Date,
document,
window: {
@@ -136,12 +149,13 @@ function loadComponent(document) {
return component;
}
function makeApi(initialSettings = {}) {
function makeApi(initialSettings = {}, locale = 'en') {
const settings = { ...initialSettings };
const handlers = {};
const unsubscribed = [];
const fileWrites = [];
const fileByteWrites = [];
const openedURLs = [];
const publishedEvents = [];
const workspaceEntries = [
{ name: 'ClientA', relativePath: 'ClientA', type: 'folder' },
@@ -152,22 +166,83 @@ function makeApi(initialSettings = {}) {
receiverToken: 'initial-browser-token',
};
let nextWriteError = null;
function translate(key, params, fallback) {
let value = catalogs[locale]?.[key] || catalogs.en[key] || fallback || key;
Object.entries(params || {}).forEach(([name, replacement]) => {
value = value.replace(new RegExp(`\\{${name}\\}`, 'g'), String(replacement));
});
return value;
}
function backendCaptures() {
const keys = ['captures:global', 'captures', ...Object.keys(settings).filter((key) => key.startsWith('captures:workspace:'))];
const seen = new Set();
const captures = [];
for (const key of keys) {
for (const original of Array.isArray(settings[key]) ? settings[key] : []) {
if (!original || !original.captureId || seen.has(original.captureId)) continue;
seen.add(original.captureId);
const capture = { ...original };
if (!capture.workspaceRootPath && key.startsWith('captures:workspace:')) {
capture.workspaceRootPath = decodeURIComponent(key.slice('captures:workspace:'.length));
capture.workspaceName = capture.workspaceRootPath;
}
captures.push(capture);
}
}
return { keys, captures };
}
function backendWriteCaptures(captures, keys) {
settings['captures:global'] = captures.slice(0, 100);
keys.forEach((key) => {
if (key !== 'captures:global') settings[key] = [];
});
}
function backendMutate(payload) {
const { keys, captures } = backendCaptures();
const ids = new Set([payload.captureId, ...(payload.captureIds || [])].filter(Boolean));
const next = captures.flatMap((capture) => {
if (!ids.has(capture.captureId)) return [capture];
if (payload.action === 'delete') return [];
if (payload.action === 'archive') return [{ ...capture, globalState: 'archived' }];
if (payload.action === 'restore') return [{ ...capture, globalState: 'inbox' }];
if (payload.action === 'assign') return [{ ...capture, workspaceRootPath: payload.workspaceRootPath || '', workspaceName: payload.workspaceRootPath || '' }];
if (payload.action === 'processed') return [{ ...capture, processed: payload.processed === true }];
return [capture];
});
backendWriteCaptures(next, keys);
}
function backendAppend(event) {
const { keys, captures } = backendCaptures();
const payload = { ...(event.payload || {}) };
const next = [payload, ...captures.filter((capture) => capture.captureId !== payload.captureId)];
backendWriteCaptures(next, keys);
}
return {
settings,
handlers,
unsubscribed,
fileWrites,
fileByteWrites,
openedURLs,
publishedEvents,
i18n: {
getLocale: () => locale,
t: translate,
onDidChangeLocale: () => () => {},
},
failNextWrite(message) {
nextWriteError = new Error(message || 'write failed');
},
events: {
publish: async (name, payload) => {
publishedEvents.push({ name, payload });
if (name === 'browser-inbox.storage.mutate') backendMutate(payload || {});
},
subscribe: async (name, handler) => {
handlers[name] = handler;
handlers[name] = async (event) => {
if (name.startsWith('browser.capture.')) backendAppend(event);
return handler(event);
};
return () => {
unsubscribed.push(name);
delete handlers[name];
@@ -199,6 +274,9 @@ function makeApi(initialSettings = {}) {
}
fileByteWrites.push({ relativePath, dataBase64, options });
},
openURL: async (url) => {
openedURLs.push(url);
},
},
browserReceiver: {
pairing: async () => ({ ...receiverPairing }),
@@ -214,7 +292,7 @@ function makeApi(initialSettings = {}) {
}
async function flush() {
for (let i = 0; i < 8; i += 1) await Promise.resolve();
for (let i = 0; i < 16; i += 1) await Promise.resolve();
}
async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, workspaceRootPath: 'Project' }, document = makeDocument()) {
@@ -237,6 +315,26 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
const settingsComponents = loadComponents(makeDocument());
if (!settingsComponents.BrowserInboxSettings) throw new Error('BrowserInboxSettings was not registered');
const styleDocument = makeDocument();
const styledView = await mountWithApi(makeApi(), {}, styleDocument);
const injectedStyles = styleDocument.head.children.map((node) => node.textContent).join('\n');
if (!injectedStyles.includes('.browser-inbox-select{') || !injectedStyles.includes('appearance:none')) {
throw new Error('Browser Inbox selects do not use the application select styling');
}
styledView.component.unmount && styledView.component.unmount(styledView.container);
const russianView = await mountWithApi(makeApi({}, 'ru'), {});
if (!russianView.container.textContent.includes('Браузер')) {
throw new Error('Browser Inbox does not use the localized Browser title');
}
if (!russianView.container.textContent.includes('Все Дела')) {
throw new Error('Browser Inbox does not use the localized Deal filter');
}
if (!russianView.container.textContent.includes('Пока нет материалов из браузера')) {
throw new Error('Browser Inbox empty state is not localized');
}
russianView.component.unmount && russianView.component.unmount(russianView.container);
const api = makeApi();
const settingsView = await mountSettingsWithApi(makeApi());
const receiverURLInput = walk(settingsView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-pairing-url') === '');
@@ -370,8 +468,8 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
if (!clearButton) throw new Error('clear button not found');
clearButton.click();
await flush();
if (api.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'capture-1')) {
throw new Error('workspace clear action did not remove the Project capture');
if (!api.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'capture-1' && capture.globalState === 'archived')) {
throw new Error('workspace clear action did not archive the Project capture');
}
if (!api.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'capture-2')) {
throw new Error('workspace clear action removed a capture from another workspace');
@@ -414,6 +512,9 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
if (!walk(legacyGlobal.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'legacy-project-capture')) {
throw new Error('legacy workspace capture was not rendered in global view');
}
if (legacyApi.getStoredCaptures('captures').length !== 0) {
throw new Error('legacy captures were not removed after canonical migration');
}
component.unmount && component.unmount(legacyGlobal.container);
const legacyProject = await mountWithApi(legacyApi, { workspaceNode: { name: 'Project' }, workspaceRootPath: 'Project' });
@@ -575,7 +676,8 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
if (!assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned' && capture.processed === false)) {
throw new Error('mark unprocessed did not persist state');
}
const deleteButton = walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'remove');
const deleteButton = walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'delete-permanently');
if (!deleteButton) throw new Error('permanent delete action was not rendered');
deleteButton.click();
await flush();
if (assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned')) {
@@ -583,6 +685,37 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
}
component.unmount && component.unmount(assignmentView.container);
const archiveApi = makeApi({
'captures:global': [{
captureId: 'archived-capture',
capturedAt: '2026-07-12T10:00:00.000Z',
kind: 'page',
url: 'https://example.com/archive',
title: 'Archived capture',
workspaceRootPath: 'Project',
globalState: 'archived',
}],
});
const archiveView = await mountWithApi(archiveApi, {});
if (walk(archiveView.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'archived-capture')) {
throw new Error('archived capture leaked into the active inbox');
}
const archiveFilter = walk(archiveView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-filter') === 'status');
archiveFilter.value = 'archived';
archiveFilter.dispatchEvent('change');
await flush();
if (!walk(archiveView.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'archived-capture')) {
throw new Error('archive filter did not reveal archived capture');
}
const restoreButton = walk(archiveView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'restore');
if (!restoreButton) throw new Error('restore archived capture action was not rendered');
restoreButton.click();
await flush();
if (!archiveApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'archived-capture' && capture.globalState === 'inbox')) {
throw new Error('restore archived capture did not return it to Inbox');
}
component.unmount && component.unmount(archiveView.container);
const conversionApi = makeApi({
'captures:workspace:Project': [{
captureId: 'convert-selection',
@@ -612,8 +745,8 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
if (!noteWrite.content.includes('# Example Article')) throw new Error('note content missing heading');
if (!noteWrite.content.includes('Source: https://example.com/article')) throw new Error('note content missing source URL');
if (!noteWrite.content.includes('Selected text from the page')) throw new Error('note content missing selected text');
if (conversionApi.getStoredCaptures(projectKey).some((capture) => capture.captureId === 'convert-selection')) {
throw new Error('converted capture was not removed from queue');
if (!conversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-selection' && capture.globalState === 'archived')) {
throw new Error('converted capture was not archived');
}
const convertedEvent = conversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted');
if (!convertedEvent) throw new Error('browser.capture.converted event was not published');
@@ -639,12 +772,15 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
failedConversionApi.failNextWrite('file already exists');
failedCreateNoteButton.click();
await flush();
if (!failedConversionApi.getStoredCaptures(projectKey).some((capture) => capture.captureId === 'convert-conflict')) {
if (!failedConversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-conflict')) {
throw new Error('failed conversion removed capture from queue');
}
if (!failedConversionView.container.textContent.includes('Could not create note')) {
if (!failedConversionView.container.textContent.includes('Could not create the note. Please try again.')) {
throw new Error('failed conversion did not render an error status');
}
if (failedConversionView.container.textContent.includes('file already exists')) {
throw new Error('failed conversion exposed a raw backend error');
}
if (failedConversionApi.publishedEvents.some((event) => event.name === 'browser.capture.converted')) {
throw new Error('failed conversion published converted event');
}
@@ -665,6 +801,11 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
const linkConversionView = await mountWithApi(linkConversionApi);
const createLinkButton = walk(linkConversionView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'create-link');
if (!createLinkButton) throw new Error('create link button was not rendered');
const openLinkButton = walk(linkConversionView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'open-link');
if (!openLinkButton) throw new Error('open link action was not rendered');
openLinkButton.click();
await flush();
if (linkConversionApi.openedURLs.join(',') !== 'https://example.com/article') throw new Error('open link did not use browser URL capability');
createLinkButton.click();
await flush();
if (linkConversionApi.fileWrites.length !== 1) throw new Error(`expected one link write, got ${linkConversionApi.fileWrites.length}`);
@@ -677,8 +818,8 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
}
if (!linkWrite.content.includes('[InternetShortcut]')) throw new Error('link content missing InternetShortcut header');
if (!linkWrite.content.includes('URL=https://example.com/article')) throw new Error('link content missing URL');
if (linkConversionApi.getStoredCaptures(projectKey).some((capture) => capture.captureId === 'convert-link')) {
throw new Error('converted link capture was not removed from queue');
if (!linkConversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-link' && capture.globalState === 'archived')) {
throw new Error('converted link capture was not archived');
}
const convertedLinkEvent = linkConversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted');
if (!convertedLinkEvent) throw new Error('browser.capture.converted link event was not published');
@@ -686,6 +827,25 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
if (convertedLinkEvent.payload.linkPath !== 'Project/Links/Example_Article.url') throw new Error('converted link event linkPath mismatch');
component.unmount && component.unmount(linkConversionView.container);
const collisionLinkApi = makeApi({
'captures:workspace:Project': [{
captureId: 'convert-link-collision',
capturedAt: '2026-06-29T01:25:00.000Z',
kind: 'link',
url: 'https://example.com/collision',
title: 'Example Article',
workspaceRootPath: 'Project',
}],
});
collisionLinkApi.failNextWrite('conflict: Project/Links/Example_Article.url');
const collisionLinkView = await mountWithApi(collisionLinkApi);
walk(collisionLinkView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'create-link').click();
await flush();
if (collisionLinkApi.fileWrites.length !== 1 || collisionLinkApi.fileWrites[0].relativePath !== 'Project/Links/Example_Article (2).url') {
throw new Error('link filename collision did not use the numbered suffix');
}
component.unmount && component.unmount(collisionLinkView.container);
const failedLinkApi = makeApi({
'captures:workspace:Project': [{
captureId: 'convert-link-conflict',
@@ -704,10 +864,10 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
failedLinkApi.failNextWrite('link already exists');
failedCreateLinkButton.click();
await flush();
if (!failedLinkApi.getStoredCaptures(projectKey).some((capture) => capture.captureId === 'convert-link-conflict')) {
if (!failedLinkApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-link-conflict')) {
throw new Error('failed link conversion removed capture from queue');
}
if (!failedLinkView.container.textContent.includes('Could not create link')) {
if (!failedLinkView.container.textContent.includes('Could not create the link. Please try again.')) {
throw new Error('failed link conversion did not render an error status');
}
if (failedLinkApi.publishedEvents.some((event) => event.name === 'browser.capture.converted')) {
@@ -745,8 +905,8 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
if (fileWrite.options.createIfMissing !== true || fileWrite.options.overwrite !== false) {
throw new Error(`file write options mismatch: ${JSON.stringify(fileWrite.options)}`);
}
if (fileConversionApi.getStoredCaptures(projectKey).some((capture) => capture.captureId === 'convert-file')) {
throw new Error('converted file capture was not removed from queue');
if (!fileConversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-file' && capture.globalState === 'archived')) {
throw new Error('converted file capture was not archived');
}
const convertedFileEvent = fileConversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted');
if (!convertedFileEvent) throw new Error('browser.capture.converted file event was not published');
@@ -811,10 +971,10 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
failedFileApi.failNextWrite('file already exists');
failedCreateFileButton.click();
await flush();
if (!failedFileApi.getStoredCaptures(projectKey).some((capture) => capture.captureId === 'convert-file-conflict')) {
if (!failedFileApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-file-conflict')) {
throw new Error('failed file conversion removed capture from queue');
}
if (!failedFileView.container.textContent.includes('Could not create file')) {
if (!failedFileView.container.textContent.includes('Could not create the file. Please try again.')) {
throw new Error('failed file conversion did not render an error status');
}
if (failedFileApi.publishedEvents.some((event) => event.name === 'browser.capture.converted')) {
@@ -822,6 +982,10 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
}
component.unmount && component.unmount(failedFileView.container);
if (!technicalErrors.some((entry) => entry.includes('file already exists'))) {
throw new Error('failed conversion did not retain its technical details in the console log');
}
console.log('browser inbox plugin smoke passed');
})().catch((err) => {
console.error(err);
+22 -1
View File
@@ -120,7 +120,7 @@ async function flush() {
}
}
async function mountEditor(secretProviderEnabled) {
async function mountEditor(secretProviderEnabled, translations) {
const document = makeDocument();
const component = loadComponent(document);
const opened = [];
@@ -146,6 +146,15 @@ async function mountEditor(secretProviderEnabled) {
return { status: 'opened', request };
},
},
i18n: {
t: (key, params, fallback) => {
let value = translations && translations[key] ? translations[key] : (fallback || key);
Object.entries(params || {}).forEach(([name, replacement]) => {
value = value.replace(`{${name}}`, String(replacement));
});
return value;
},
},
};
const container = document.createElement('div');
component.mount(container, {
@@ -162,6 +171,12 @@ async function mountEditor(secretProviderEnabled) {
if (disabledPreview.innerHTML.includes('data-secret-id')) throw new Error('secret link rendered without secrets provider');
const enabled = await mountEditor(true);
if (enabled.container.textContent.includes('notes-markdown') || enabled.container.textContent.includes('generic-markdown')) {
throw new Error('editor must not expose the technical editor mode in its toolbar');
}
if (enabled.container.textContent.includes('Notes context active')) {
throw new Error('editor must not show an implementation roadmap in the notes UI');
}
const preview = walk(enabled.container, (node) => node.className === 'de-preview');
if (!preview) throw new Error('enabled preview missing');
if (!preview.innerHTML.includes('data-secret-id="client-a.db"')) throw new Error('secret link did not render with provider');
@@ -182,6 +197,12 @@ async function mountEditor(secretProviderEnabled) {
throw new Error('secret link did not open through workbench');
}
const russian = await mountEditor(true, { 'ui.md.heading': 'Заголовок' });
const headingButton = walk(russian.container, (node) => node.getAttribute && node.getAttribute('data-md-action') === 'heading');
if (!headingButton || headingButton.getAttribute('title') !== 'Заголовок') {
throw new Error('Markdown toolbar titles must use the locale catalog');
}
console.log('default editor smoke passed');
})().catch((err) => {
console.error(err);
+123 -4
View File
@@ -195,9 +195,11 @@ function loadFilesComponent(document) {
return { component, clipboard: sandbox.navigator.clipboard };
}
function makeApi() {
function makeApi(options = {}) {
const externalCalls = [];
const contributionCalls = [];
const created = [];
const moved = [];
const eventHandlers = {};
let restored = false;
let externalVisible = false;
@@ -212,6 +214,8 @@ function makeApi() {
return {
externalCalls,
contributionCalls,
created,
moved,
emitFileChanged(payload) {
(eventHandlers['file.changed'] || []).forEach((handler) => handler({
name: 'file.changed',
@@ -229,6 +233,15 @@ function makeApi() {
size: 12,
modifiedAt: '2026-06-27T00:00:00Z',
}];
if (options.includeFolder) {
entries.push({
name: 'Archive',
relativePath: 'Docs/Archive',
type: 'folder',
size: 0,
modifiedAt: '2026-06-27T00:00:00Z',
});
}
if (restored) {
entries.push({
name: 'deleted.md',
@@ -253,9 +266,15 @@ function makeApi() {
},
metadata: async () => { throw new Error('not-found'); },
readText: async () => '# Readme\n',
writeText: async () => undefined,
createFolder: async () => undefined,
move: async () => undefined,
writeText: async (relativePath, content, options) => {
created.push({ type: 'file', relativePath, content, options });
},
createFolder: async (relativePath) => {
created.push({ type: 'folder', relativePath });
},
move: async (fromRelativePath, toRelativePath) => {
moved.push({ fromRelativePath, toRelativePath });
},
trash: async () => undefined,
listTrash: async () => trashEntries.slice(),
restoreTrash: async (trashId) => {
@@ -307,6 +326,15 @@ function makeApi() {
};
},
},
i18n: {
t: (key, params, fallback) => {
let value = options.translations && options.translations[key] ? options.translations[key] : (fallback || key);
Object.entries(params || {}).forEach(([name, replacement]) => {
value = value.replace(`{${name}}`, String(replacement));
});
return value;
},
},
showExternalFile() {
externalVisible = true;
},
@@ -328,6 +356,62 @@ async function flush() {
const row = walk(container, (node) => node.getAttribute && node.getAttribute('data-file-path') === 'Docs/readme.md');
if (!row) throw new Error('file row not rendered');
const newFolder = walk(container, (node) => node.getAttribute && node.getAttribute('data-files-action') === 'new-folder');
if (!newFolder) throw new Error('new folder button not found');
newFolder.click();
const createModal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-files-create-modal') !== undefined);
if (!createModal) throw new Error('create file modal not found');
const createInput = walk(createModal, (node) => node.getAttribute && node.getAttribute('data-files-create-input') !== undefined);
if (!createInput) throw new Error('create file input not found');
const createConfirm = walk(createModal, (node) => node.tagName === 'BUTTON' && node.textContent === 'Create');
if (!createConfirm) throw new Error('create file confirm button not found');
createConfirm.click();
const createError = walk(createModal, (node) => node.getAttribute && node.getAttribute('data-files-create-error') !== undefined);
if (!createError || !createError.textContent.includes('Enter a name') || createInput.getAttribute('aria-invalid') !== 'true') {
throw new Error('create file modal does not show a validation error for an empty name');
}
createInput.value = 'New folder';
createInput.dispatchEvent('keydown', { key: 'Enter' });
await flush();
if (!api.created.some((entry) => entry.type === 'folder' && entry.relativePath === 'New folder')) {
throw new Error(`new folder was not created: ${JSON.stringify(api.created)}`);
}
if (walk(document.body, (node) => node.getAttribute && node.getAttribute('data-files-create-modal') !== undefined)) {
throw new Error('create file modal should close after a successful create');
}
const newMarkdown = walk(container, (node) => node.getAttribute && node.getAttribute('data-files-action') === 'new-markdown');
if (!newMarkdown) throw new Error('new markdown button not found');
newMarkdown.click();
const markdownModal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-files-create-modal') !== undefined);
const markdownInput = walk(markdownModal, (node) => node.getAttribute && node.getAttribute('data-files-create-input') !== undefined);
markdownInput.value = 'Readme';
markdownInput.dispatchEvent('keydown', { key: 'Enter' });
await flush();
if (!api.created.some((entry) => entry.type === 'file' && entry.relativePath === 'Readme.md')) {
throw new Error(`new markdown file was not created: ${JSON.stringify(api.created)}`);
}
const rename = walk(container, (node) => node.getAttribute && node.getAttribute('data-files-action') === 'row-rename');
if (!rename) throw new Error('rename file button not found');
rename.click();
const renameModal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-files-rename-modal') !== undefined);
if (!renameModal) throw new Error('rename file modal not found');
const renameInput = walk(renameModal, (node) => node.getAttribute && node.getAttribute('data-files-rename-input') !== undefined);
if (!renameInput || renameInput.value !== 'readme.md') throw new Error('rename file input should keep the current filename and extension');
renameInput.value = '';
renameInput.dispatchEvent('keydown', { key: 'Enter' });
const renameError = walk(renameModal, (node) => node.getAttribute && node.getAttribute('data-files-rename-error') !== undefined);
if (!renameError || !renameError.textContent.includes('Enter a name') || renameInput.getAttribute('aria-invalid') !== 'true') {
throw new Error('rename file modal does not show a validation error for an empty name');
}
renameInput.value = 'renamed.md';
renameInput.dispatchEvent('keydown', { key: 'Enter' });
await flush();
if (!api.moved.some((entry) => entry.fromRelativePath === 'Docs/readme.md' && entry.toRelativePath === 'Docs/renamed.md')) {
throw new Error(`rename did not preserve the extension: ${JSON.stringify(api.moved)}`);
}
const list = walk(container, (node) => node.getAttribute && node.getAttribute('data-files-list') !== undefined);
if (!list) throw new Error('files list not rendered');
list.dispatchEvent('contextmenu', { target: row, clientX: 20, clientY: 20 });
@@ -378,6 +462,41 @@ async function flush() {
throw new Error(`external file row not rendered after file.changed: ${container.textContent}`);
}
const russianDocument = makeDocument();
const { component: russianComponent } = loadFilesComponent(russianDocument);
const russianApi = makeApi({
includeFolder: true,
translations: {
'ui.open': 'Открыть',
'ui.openExternal': 'Открыть во внешнем приложении',
'ui.showInFolder': 'Показать в папке',
'ui.rename': 'Переименовать',
'ui.duplicate': 'Создать копию',
'ui.cut': 'Вырезать',
'ui.copy': 'Копировать',
'ui.trash': 'Переместить в корзину',
'ui.folderType': 'Папка',
'ui.icon.markdown': 'Файл Markdown',
},
});
const russianContainer = new FakeNode('div');
russianComponent.mount(russianContainer, {}, russianApi);
await flush();
if (!russianContainer.textContent.includes('Папка') || russianContainer.textContent.includes('folder')) {
throw new Error(`folder type was not localized: ${russianContainer.textContent}`);
}
const russianList = walk(russianContainer, (node) => node.getAttribute && node.getAttribute('data-files-list') !== undefined);
const russianRow = walk(russianContainer, (node) => node.getAttribute && node.getAttribute('data-file-path') === 'Docs/readme.md');
const russianIcon = walk(russianRow, (node) => node.getAttribute && node.getAttribute('data-file-icon') === 'markdown');
if (!russianIcon || russianIcon.getAttribute('title') !== 'Файл Markdown' || russianIcon.getAttribute('aria-label') !== 'Файл Markdown') {
throw new Error(`file icon label was not localized: ${russianIcon && russianIcon.getAttribute('title')}`);
}
russianList.dispatchEvent('contextmenu', { target: russianRow, clientX: 20, clientY: 20 });
const russianMenuText = russianDocument.body.textContent;
['Открыть во внешнем приложении', 'Показать в папке', 'Переименовать', 'Создать копию', 'Вырезать', 'Копировать', 'Переместить в корзину'].forEach((label) => {
if (!russianMenuText.includes(label)) throw new Error(`context menu label was not localized: ${label}`);
});
console.log('files frontend smoke passed');
})().catch((err) => {
console.error(err);
+58 -2
View File
@@ -6,12 +6,14 @@ const vm = require('vm');
const root = path.resolve(__dirname, '..');
const manifestPath = path.join(root, 'plugins', 'journal', 'plugin.json');
const sourcePath = path.join(root, 'plugins', 'journal', 'frontend', 'src', 'index.js');
const russianLocalePath = path.join(root, 'plugins', 'journal', 'locales', 'ru.json');
if (!fs.existsSync(manifestPath)) throw new Error('journal plugin manifest missing');
if (!fs.existsSync(sourcePath)) throw new Error('journal frontend entry missing');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const source = fs.readFileSync(sourcePath, 'utf8');
const russianLocale = JSON.parse(fs.readFileSync(russianLocalePath, 'utf8'));
class FakeNode {
constructor(tagName) {
@@ -131,9 +133,11 @@ function loadComponent(document) {
return component;
}
function makeApi(initialSettings = {}) {
function makeApi(initialSettings = {}, locale = null) {
const settings = { ...initialSettings };
const publishedEvents = [];
return {
publishedEvents,
settings: {
read: async (key) => (key ? settings[key] : { ...settings }),
write: async (key, value) => {
@@ -141,6 +145,22 @@ function makeApi(initialSettings = {}) {
return { ...settings };
},
},
events: {
publish: async (name, payload) => {
publishedEvents.push({ name, payload });
},
},
files: {
list: async () => [
{ type: 'folder', relativePath: 'Project', name: 'Project' },
{ type: 'folder', relativePath: 'Client', name: 'Client' },
],
},
i18n: locale ? {
t(key, params, fallback) {
return String(locale[key] || fallback || key).replace(/\{(\w+)\}/g, (_match, name) => String((params || {})[name] ?? ''));
},
} : null,
storedEntries(key) {
return settings[key] || [];
},
@@ -172,6 +192,7 @@ function byData(container, attr, value) {
}
if ((manifest.optionalRequires || []).includes('activity.reconstruction')) throw new Error('Journal must remain available without Activity');
if (!manifest.permissions.includes('storage.namespace')) throw new Error('journal manifest must request storage.namespace');
if (!manifest.permissions.includes('events.publish')) throw new Error('journal manifest must request events.publish');
if (!manifest.permissions.includes('ui.register')) throw new Error('journal manifest must request ui.register');
if (!(manifest.contributes.workspaceItems || []).some((item) => item.component === 'JournalView')) throw new Error('journal workspace item missing');
if (!(manifest.contributes.sidebarItems || []).some((item) => item.view === 'verstak.journal.view')) throw new Error('journal sidebar item missing');
@@ -212,6 +233,8 @@ function byData(container, attr, value) {
const candidate = {
candidateId: 'work-session:Project:capture-1:note-1',
sessionId: 'session-journal-1',
handledThrough: '2026-06-27T11:03:00.000Z',
workspaceRootPath: 'Project',
startedAt: '2026-06-27T10:12:00.000Z',
endedAt: '2026-06-27T11:03:00.000Z',
@@ -229,8 +252,11 @@ function byData(container, attr, value) {
toolRequest: { type: 'work-session-candidate', candidate },
});
if (!candidateView.container.textContent.includes('Review possible journal entry')) throw new Error('candidate review modal was not opened');
if (!candidateView.container.textContent.includes('Workspace: Project')) throw new Error('candidate workspace was not shown for review');
if (!candidateView.container.textContent.includes('Deal: Project')) throw new Error('candidate Deal was not shown for review');
if (!candidateView.container.textContent.includes('Estimated duration: 51 min')) throw new Error('candidate duration was not shown for review');
if (candidateView.container.textContent.includes('browser.capture.selection') || candidateView.container.textContent.includes('verstak.browser-inbox') || candidateView.container.textContent.includes('capture-1')) {
throw new Error('candidate review exposed technical Activity identifiers');
}
if (byData(candidateView.container, 'data-journal-input', 'title').value !== '') throw new Error('candidate review must start with an empty title');
if (byData(candidateView.container, 'data-journal-input', 'summary').value !== '') throw new Error('candidate review must start with an empty body');
if (byData(candidateView.container, 'data-journal-input', 'minutes').value !== '51') throw new Error('candidate review must prefill the factual duration');
@@ -249,10 +275,23 @@ function byData(container, attr, value) {
throw new Error('candidate review did not keep the user-authored entry fields');
}
if (linkedEntry.activityIds.join(',') !== 'capture-1') throw new Error('candidate review did not persist selected activity ids');
const handledEvent = api.publishedEvents.find((event) => event.name === 'activity.session.handled');
if (!handledEvent || handledEvent.payload.sessionId !== 'session-journal-1' || handledEvent.payload.handledThrough !== '2026-06-27T11:03:00.000Z' || handledEvent.payload.status !== 'accepted') {
throw new Error('accepted Journal candidate did not persist a session watermark');
}
if (walk(candidateView.container, (node) => node.getAttribute && node.getAttribute('data-journal-action') === 'view-activity')) {
throw new Error('journal rows must not navigate to Activity by default');
}
const russianCandidateView = await mountWithApi(makeApi({}, russianLocale), {
workspaceNode: { name: 'Project' },
workspaceRootPath: 'Project',
toolRequest: { type: 'work-session-candidate', candidate },
});
if (!russianCandidateView.container.textContent.includes('Дело: Project') || !russianCandidateView.container.textContent.includes('Захвачено выделение')) {
throw new Error('candidate review was not localized');
}
byData(candidateView.container, 'data-journal-action', 'delete').click();
await flush();
if (api.storedEntries(projectKey).length !== 1) throw new Error('journal entry was not deleted');
@@ -311,9 +350,26 @@ function byData(container, attr, value) {
if (!globalView.container.textContent.includes('Review research capture') && !globalView.container.textContent.includes('Draft brief updated')) {
throw new Error('global journal did not aggregate remaining entries');
}
const globalAdd = byData(globalView.container, 'data-journal-action', 'add');
if (globalAdd.disabled) throw new Error('global Journal Add must be available');
globalAdd.click();
await flush();
const globalWorkspace = byData(globalView.container, 'data-journal-input', 'workspaceRootPath');
if (!globalWorkspace || globalWorkspace.tagName !== 'SELECT') throw new Error('global Journal form did not render the Deal selector');
globalWorkspace.value = 'Client';
byData(globalView.container, 'data-journal-input', 'title').value = 'Prepare client summary';
byData(globalView.container, 'data-journal-input', 'minutes').value = '30';
byData(globalView.container, 'data-journal-action', 'save-entry').click();
await flush();
const clientKey = 'worklog:workspace:Client';
if (api.storedEntries(clientKey).length !== 1 || api.storedEntries(clientKey)[0].title !== 'Prepare client summary') {
throw new Error('global Journal entry was not stored under the selected Deal');
}
if (!globalView.container.textContent.includes('Prepare client summary')) throw new Error('global Journal did not render the created entry');
component.unmount && component.unmount(container);
component.unmount && component.unmount(candidateView.container);
component.unmount && component.unmount(russianCandidateView.container);
component.unmount && component.unmount(todoView.container);
component.unmount && component.unmount(duplicateTodoView.container);
component.unmount && component.unmount(globalView.container);
+24 -13
View File
@@ -264,12 +264,19 @@ async function mountNotes(api) {
const createButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-action') === 'create');
if (!createButton) throw new Error('create button not found');
createButton.click();
const input = walk(container, (node) => node.getAttribute && node.getAttribute('data-notes-create-input') !== undefined);
const createModal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-notes-create-modal') !== undefined);
if (!createModal) throw new Error('create modal not found');
const input = walk(createModal, (node) => node.getAttribute && node.getAttribute('data-notes-create-input') !== undefined);
if (!input) throw new Error('create input not found');
input.value = 'First Note';
const confirm = walk(container, (node) => node.tagName === 'BUTTON' && node.textContent === 'Create');
const confirm = walk(createModal, (node) => node.tagName === 'BUTTON' && node.textContent === 'Create');
if (!confirm) throw new Error('create confirm button not found');
confirm.click();
const createError = walk(createModal, (node) => node.getAttribute && node.getAttribute('data-notes-create-error') !== undefined);
if (!createError || !createError.textContent.includes('Enter a note title') || input.getAttribute('aria-invalid') !== 'true') {
throw new Error('create modal does not show a validation error for an empty title');
}
input.value = 'First Note';
input.dispatchEvent('keydown', { key: 'Enter' });
await flush();
const created = createApi.entries.get('Project/Notes/First_Note.md');
@@ -319,21 +326,25 @@ async function mountNotes(api) {
const renameButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-note-action') === 'rename');
if (!renameButton) throw new Error('rename note button not found');
renameButton.click();
const renameInput = walk(container, (node) => node.getAttribute && node.getAttribute('data-notes-rename-input') !== undefined);
const renameModal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-notes-rename-modal') !== undefined);
if (!renameModal) throw new Error('rename modal not found');
const renameInput = walk(renameModal, (node) => node.getAttribute && node.getAttribute('data-notes-rename-input') !== undefined);
if (!renameInput) throw new Error('rename input not found');
renameInput.value = 'Second Note';
const renameConfirm = walk(container, (node) => node.tagName === 'BUTTON' && node.textContent === 'Rename');
if (!renameConfirm) throw new Error('rename confirm button not found');
renameConfirm.click();
renameInput.dispatchEvent('keydown', { key: 'Enter' });
await flush();
const conflictModal = walk(document.body, (node) => node.className === 'notes-modal-msg');
if (!conflictModal || !conflictModal.textContent.includes('Project/Notes/Second_Note.md')) {
throw new Error(`rename conflict modal should include existing path, got ${conflictModal && conflictModal.textContent}`);
const renameError = walk(renameModal, (node) => node.getAttribute && node.getAttribute('data-notes-rename-error') !== undefined);
if (!renameError || !renameError.textContent.includes('Project/Notes/Second_Note.md')) {
throw new Error(`rename conflict should stay in the form modal, got ${renameError && renameError.textContent}`);
}
const conflictOk = walk(document.body, (node) => node.tagName === 'BUTTON' && node.textContent === 'OK');
if (!conflictOk) throw new Error('rename conflict OK button not found');
conflictOk.click();
if (walk(document.body, (node) => node.className === 'notes-modal-msg')) {
throw new Error('rename conflict opened a second modal instead of reporting in the form');
}
renameInput.dispatchEvent('keydown', { key: 'Escape' });
await flush();
if (walk(document.body, (node) => node.getAttribute && node.getAttribute('data-notes-rename-modal') !== undefined)) {
throw new Error('Escape did not close the rename modal');
}
const providerAction = walk(container, (node) => node.getAttribute && node.getAttribute('data-note-contribution-action') === 'provider.note.action');
if (!providerAction) throw new Error('provider note action button not found');
+59
View File
@@ -6,6 +6,10 @@ const vm = require('vm');
const root = path.resolve(__dirname, '..');
const bundlePath = path.join(root, 'plugins', 'platform-test', 'frontend', 'src', 'index.js');
const source = fs.readFileSync(bundlePath, 'utf8');
const platformCatalogs = {
en: JSON.parse(fs.readFileSync(path.join(root, 'plugins', 'platform-test', 'locales', 'en.json'), 'utf8')),
ru: JSON.parse(fs.readFileSync(path.join(root, 'plugins', 'platform-test', 'locales', 'ru.json'), 'utf8')),
};
class FakeNode {
constructor(tagName) {
@@ -37,6 +41,19 @@ class FakeNode {
addEventListener() {}
}
function findByClass(node, className) {
if (String(node.className || '').split(/\s+/).includes(className)) return node;
for (const child of node.children || []) {
const found = findByClass(child, className);
if (found) return found;
}
return null;
}
function allText(node) {
return [String(node.textContent || '')].concat((node.children || []).map(allText)).join(' ');
}
function makeDocument() {
return {
head: new FakeNode('head'),
@@ -76,6 +93,22 @@ if (!components) {
const api = {
pluginId: 'verstak.platform-test',
i18n: {
locale: 'en',
listeners: new Set(),
messages: platformCatalogs,
getLocale() { return this.locale; },
t(key, params, fallback) { return this.messages[this.locale][key] || fallback || key; },
onDidChangeLocale(listener) {
this.listeners.add(listener);
listener(this.locale);
return () => this.listeners.delete(listener);
},
setLocale(locale) {
this.locale = locale;
this.listeners.forEach((listener) => listener(locale));
},
},
settings: {
read: async () => 'initial value',
write: async () => undefined,
@@ -179,9 +212,35 @@ const api = {
if (container.children.length === 0) {
throw new Error(`${name} mounted no DOM nodes`);
}
if (name === 'DiagnosticsPanel') {
api.i18n.setLocale('ru');
if (findByClass(container, 'pt-plugin-name')?.textContent !== 'Диагностика платформы') {
throw new Error('DiagnosticsPanel did not update after locale change');
}
const diagnosticsText = allText(container);
if (!diagnosticsText.includes('Сохранённая настройка') || !diagnosticsText.includes('Пройдено')) {
throw new Error('DiagnosticsPanel left primary diagnostic chrome untranslated');
}
}
if (name === 'PlatformTestSettings') {
const counter = findByClass(container, 'pt-counter-value');
counter.textContent = '3';
api.i18n.setLocale('ru');
if (findByClass(container, 'pt-plugin-name')?.textContent !== 'Настройки теста платформы') {
throw new Error('PlatformTestSettings did not update after locale change');
}
if (counter.textContent !== '3') {
throw new Error('PlatformTestSettings lost counter state after locale change');
}
const settingsText = allText(container);
if (!settingsText.includes('Панель настроек загружена') || !settingsText.includes('нажатий')) {
throw new Error('PlatformTestSettings left primary settings copy untranslated');
}
}
if (typeof component.unmount === 'function') {
component.unmount(container);
}
api.i18n.setLocale('en');
}
console.log('platform-test frontend smoke passed');
+13 -2
View File
@@ -8,6 +8,7 @@ const sourcePath = path.join(root, 'plugins', 'search', 'frontend', 'src', 'inde
const manifestPath = path.join(root, 'plugins', 'search', 'plugin.json');
const source = fs.readFileSync(sourcePath, 'utf8');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const technicalErrors = [];
class FakeNode {
constructor(tagName) {
@@ -100,7 +101,15 @@ function makeDocument() {
function loadComponent(document) {
const registry = {};
vm.runInNewContext(source, {
console,
console: {
...console,
warn(...args) {
technicalErrors.push(args.map((value) => String(value)).join(' '));
},
error(...args) {
technicalErrors.push(args.map((value) => String(value)).join(' '));
},
},
document,
window: {
VerstakPluginRegister(pluginId, bundle) {
@@ -253,11 +262,13 @@ async function wait(ms) {
if (!container.textContent.includes('Project/Target Assets')) throw new Error('typing should search folder paths');
if (!container.textContent.includes('Project/External/target.note')) throw new Error('external provider result should be rendered');
if (!container.textContent.includes('External Notes')) throw new Error('external provider label should be rendered');
if (!container.textContent.includes('provider unavailable')) throw new Error('provider failure should be reported without failing search');
if (!container.textContent.includes('A search provider is unavailable.')) throw new Error('provider failure should be reported without failing search');
if (container.textContent.includes('provider unavailable')) throw new Error('provider failure leaked a raw backend error');
if (!container.textContent.includes('Content match')) throw new Error('content result type was not rendered');
if (!container.textContent.includes('Folder name')) throw new Error('folder result type was not rendered');
if (!pluginData['search-index'] || !Array.isArray(pluginData['search-index'].files)) throw new Error('search index was not written to plugin data storage');
if (providerCalls.some((call) => call.pluginId === 'verstak.search')) throw new Error('search must not call itself as an external provider');
if (!technicalErrors.some((entry) => entry.includes('provider unavailable'))) throw new Error('provider failure was not retained in the console log');
input = queryInput();
input.value = 'image';
+120 -4
View File
@@ -28,6 +28,16 @@ class FakeNode {
return node;
}
removeChild(node) {
this.children = this.children.filter((child) => child !== node);
node.parentNode = null;
return node;
}
remove() {
if (this.parentNode) this.parentNode.removeChild(this);
}
setAttribute(name, value) {
this.attributes[name] = String(value);
}
@@ -96,10 +106,18 @@ function makeDocument() {
};
}
function loadComponent(document) {
function loadComponent(document, errorLog) {
const registry = {};
vm.runInNewContext(source, {
console,
console: {
...console,
warn(...args) {
errorLog.push(args.map((value) => String(value)).join(' '));
},
error(...args) {
errorLog.push(args.map((value) => String(value)).join(' '));
},
},
document,
window: {
VerstakPluginRegister(pluginId, bundle) {
@@ -129,19 +147,24 @@ async function flush() {
if (!manifest.provides.includes('secrets.write-ui')) throw new Error('secrets manifest must provide secrets.write-ui');
if (!manifest.permissions.includes('secrets.read')) throw new Error('secrets manifest must request secrets.read');
if (!manifest.permissions.includes('secrets.write')) throw new Error('secrets manifest must request secrets.write');
if (!manifest.permissions.includes('files.read')) throw new Error('secrets manifest must request files.read for the Deal selector');
if (!manifest.permissions.includes('ui.register')) throw new Error('secrets manifest must request ui.register');
if (!(manifest.contributes.openProviders || []).some((item) => (item.supports || []).some((support) => support.kind === 'secret'))) throw new Error('secrets secret open provider missing');
if (!(manifest.contributes.views || []).some((item) => item.component === 'SecretsView')) throw new Error('secrets global view missing');
if (!(manifest.contributes.sidebarItems || []).some((item) => item.view === 'verstak.secrets.view')) throw new Error('secrets global sidebar item missing');
if (!(manifest.contributes.workspaceItems || []).some((item) => item.component === 'SecretsView')) throw new Error('secrets workspace item missing');
if (!(manifest.contributes.settingsPanels || []).some((item) => item.component === 'SecretsView')) throw new Error('secrets settings panel missing');
const document = makeDocument();
const component = loadComponent(document);
const errorLog = [];
const component = loadComponent(document, errorLog);
const records = [
{ id: 'global.server', title: 'Global Server', username: 'root', scope: { kind: 'global' }, updatedAt: '2026-06-29T00:00:00Z' },
{ id: 'client-a.db', title: 'Client A DB', username: 'app', scope: { kind: 'workspace', workspaceRootPath: 'ClientA' }, updatedAt: '2026-06-29T00:00:00Z' },
];
let initialized = false;
let unlocked = false;
let unlockError = '';
const readCalls = [];
const copied = [];
const deleted = [];
@@ -149,6 +172,7 @@ async function flush() {
secrets: {
status: async () => ({ initialized, unlocked }),
unlock: async (password) => {
if (unlockError) throw new Error(unlockError);
if (password !== 'master-password') throw new Error('bad password');
initialized = true;
unlocked = true;
@@ -177,6 +201,12 @@ async function flush() {
clipboard: {
writeText: async (text) => copied.push(text),
},
files: {
list: async () => [
{ type: 'folder', relativePath: 'ClientA' },
{ type: 'folder', relativePath: 'ClientB' },
],
},
};
const container = document.createElement('div');
@@ -188,6 +218,21 @@ async function flush() {
const confirmInput = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-master-password-confirm') === '');
const unlockButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-unlock') === '');
if (!passwordInput || !confirmInput || !unlockButton) throw new Error('setup controls missing');
unlockError = '[plugin:verstak.secrets] secrets.unlock failed: master password must be at least 8 characters';
passwordInput.value = 'short';
confirmInput.value = 'short';
unlockButton.click();
await flush();
if (!container.textContent.includes('Master password must be at least 8 characters')) {
throw new Error('weak master password error was not explained clearly');
}
if (container.textContent.includes('[plugin:') || container.textContent.includes('secrets.unlock')) {
throw new Error('technical unlock details leaked into the Secrets UI');
}
if (!errorLog.some((entry) => entry.includes('[plugin:verstak.secrets] secrets.unlock failed'))) {
throw new Error('technical unlock details were not retained in the console log');
}
unlockError = '';
passwordInput.value = 'master-password';
confirmInput.value = 'master-password';
unlockButton.click();
@@ -200,7 +245,17 @@ async function flush() {
if (!container.textContent.includes('Group')) throw new Error('secret field table missing Group row');
if (!container.textContent.includes('Username')) throw new Error('secret field table missing Username row');
if (!container.textContent.includes('Password')) throw new Error('secret field table missing Password row');
if (!container.textContent.includes('secret-value')) throw new Error('secret value was not shown in the field table');
if (container.textContent.includes('secret-value')) throw new Error('secret value must stay hidden until the user explicitly reveals it');
const copyValueButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-copy-value') === 'client-a.db');
if (!copyValueButton) throw new Error('copy value button missing');
copyValueButton.click();
await flush();
if (!copied.includes('secret-value')) throw new Error('secret value was not copied');
const showValueButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-toggle-value') === 'client-a.db');
if (!showValueButton) throw new Error('show value button missing');
showValueButton.click();
await flush();
if (!container.textContent.includes('secret-value')) throw new Error('secret value was not revealed after explicit request');
const copyButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-copy-link') === 'client-a.db');
if (!copyButton) throw new Error('copy link button missing');
@@ -226,8 +281,69 @@ async function flush() {
if (!deleteButton) throw new Error('delete button missing');
deleteButton.click();
await flush();
if (deleted.includes('client-a.db')) throw new Error('secret delete must wait for confirmation');
const deleteConfirmModal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-secret-delete-modal') === '');
if (!deleteConfirmModal) throw new Error('secret delete confirmation modal missing');
const deleteCancelButton = walk(deleteConfirmModal, (node) => node.getAttribute && node.getAttribute('data-secret-delete-cancel') === '');
if (!deleteCancelButton) throw new Error('secret delete cancel button missing');
deleteCancelButton.click();
await flush();
if (deleted.includes('client-a.db')) throw new Error('secret delete ran after cancelling the confirmation');
deleteButton.click();
await flush();
const deleteConfirmButton = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-secret-delete-confirm') === '');
if (!deleteConfirmButton) throw new Error('secret delete confirm button missing');
deleteConfirmButton.click();
await flush();
if (!deleted.includes('client-a.db')) throw new Error('secret delete was not called');
records.push({ id: 'client-a.global-view', title: 'Client A Global View', username: 'app', scope: { kind: 'workspace', workspaceRootPath: 'ClientA' }, updatedAt: '2026-06-29T00:00:00Z' });
const globalContainer = document.createElement('div');
component.mount(globalContainer, {}, api);
await flush();
if (!globalContainer.textContent.includes('Global Server') || !globalContainer.textContent.includes('Client A Global View')) {
throw new Error('global secrets view did not show global and Deal-scoped records');
}
const scopeFilter = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-scope-filter') === '');
if (!scopeFilter) throw new Error('global secrets scope filter missing');
scopeFilter.value = 'workspace:ClientA';
scopeFilter.dispatchEvent('change');
await flush();
if (!globalContainer.textContent.includes('Client A Global View') || globalContainer.textContent.includes('Global Server')) {
throw new Error('global secrets scope filter did not isolate the selected Deal');
}
const searchInput = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-search') === '');
if (!searchInput) throw new Error('global secrets search missing');
searchInput.value = 'no-match';
searchInput.dispatchEvent('input');
await flush();
if (!globalContainer.textContent.includes('No secrets')) throw new Error('global secrets search did not filter records');
scopeFilter.value = 'all';
scopeFilter.dispatchEvent('change');
await flush();
const newButton = walk(globalContainer, (node) => node.tagName === 'BUTTON' && node.textContent === 'New');
if (!newButton) throw new Error('global secrets new button missing');
newButton.click();
await flush();
const globalTitleInput = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-title') === '');
const globalValueInput = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-value') === '');
const scopeInput = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-scope') === '');
const workspaceInput = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-workspace') === '');
const globalSaveButton = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-save') === '');
if (!globalTitleInput || !globalValueInput || !scopeInput || !workspaceInput || !globalSaveButton) throw new Error('global Deal-scoped secret form controls missing');
globalTitleInput.value = 'Client B API';
globalValueInput.value = 'client-b-api-value';
scopeInput.value = 'workspace';
scopeInput.dispatchEvent('change');
workspaceInput.value = 'ClientB';
globalSaveButton.click();
await flush();
if (!records.some((record) => record.title === 'Client B API' && record.scope && record.scope.workspaceRootPath === 'ClientB')) {
throw new Error('global secrets form did not create a Deal-scoped secret');
}
console.log('secrets plugin smoke passed');
})().catch((err) => {
console.error(err);
+17 -5
View File
@@ -9,14 +9,17 @@ const source = fs.readFileSync(sourcePath, 'utf8');
if (!source.includes('settings.lastError')) {
throw new Error('SyncSettings must render persisted settings.lastError');
}
if (!source.includes('sanitizeError(settings.lastError)')) {
throw new Error('SyncSettings must sanitize persisted sync errors before rendering');
if (!source.includes('function reportError')) {
throw new Error('SyncSettings must map technical failures to localized action-specific errors');
}
if (!source.includes('Last sync error')) {
if (/sanitizeError\(/.test(source) || /\{ error: sanitizeError/.test(source)) {
throw new Error('SyncSettings must not interpolate raw technical errors into user-facing messages');
}
if (!source.includes("tr('ui.lastSyncError'")) {
throw new Error('SyncSettings must label the persisted sync error');
}
if (!source.includes('function formatSyncConflict')) {
throw new Error('SyncSettings must format individual sync conflicts');
if (!source.includes("tr('ui.syncConflictItem'")) {
throw new Error('SyncSettings must hide technical conflict identifiers behind a user-facing summary');
}
if (!source.includes('conflictDetails')) {
throw new Error('SyncSettings must store sync conflict details after Sync Now');
@@ -24,5 +27,14 @@ if (!source.includes('conflictDetails')) {
if (!source.includes('Sync conflicts')) {
throw new Error('SyncSettings must label the conflict details section');
}
if (!source.includes('syncAPI().configure(serverUrl, username, password, vaultId)')) {
throw new Error('SyncSettings must pass the optional remote vault ID during pairing');
}
if (!source.includes('settings.lastWarning')) {
throw new Error('SyncSettings must render unresolved scanner warnings');
}
if (!source.includes("tr('ui.remoteVaultHint'")) {
throw new Error('SyncSettings must explain remote vault restoration');
}
console.log('sync plugin smoke passed');
+88 -5
View File
@@ -8,6 +8,10 @@ const sourcePath = path.join(root, 'plugins', 'todo', 'frontend', 'src', 'index.
const manifestPath = path.join(root, 'plugins', 'todo', 'plugin.json');
const source = fs.readFileSync(sourcePath, 'utf8');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const localeCatalogs = {
en: JSON.parse(fs.readFileSync(path.join(root, 'plugins', 'todo', 'locales', 'en.json'), 'utf8')),
ru: JSON.parse(fs.readFileSync(path.join(root, 'plugins', 'todo', 'locales', 'ru.json'), 'utf8')),
};
class FakeNode {
constructor(tagName) {
@@ -140,10 +144,14 @@ function loadComponent(document, emittedEvents) {
return component;
}
function makeApi(initialSettings = {}) {
function makeApi(initialSettings = {}, initialLocale = 'en') {
const settings = { ...initialSettings };
const notificationCalls = [];
let locale = initialLocale;
const localeListeners = [];
return {
settings,
notificationCalls,
settingsApi: {
read: async (key) => (key ? settings[key] : { ...settings }),
write: async (key, value) => {
@@ -157,8 +165,37 @@ function makeApi(initialSettings = {}) {
{ name: 'Project', relativePath: 'Project', type: 'folder' },
],
},
notifications: {
replace: async (items) => {
notificationCalls.push((Array.isArray(items) ? items : []).map((item) => ({ ...item })));
},
},
setLocale(nextLocale) {
locale = nextLocale;
localeListeners.slice().forEach((listener) => listener(locale));
},
get api() {
return { settings: this.settingsApi, files: this.files };
return {
settings: this.settingsApi,
files: this.files,
notifications: this.notifications,
i18n: {
getLocale: () => locale,
t: (key, params, fallback) => {
const message = localeCatalogs[locale]?.[key] || localeCatalogs.en[key] || fallback || key;
return message.replace(/\{([^}]+)\}/g, (placeholder, name) => (
Object.prototype.hasOwnProperty.call(params || {}, name) ? String(params[name]) : placeholder
));
},
onDidChangeLocale: (listener) => {
localeListeners.push(listener);
return () => {
const index = localeListeners.indexOf(listener);
if (index !== -1) localeListeners.splice(index, 1);
};
},
},
};
},
};
}
@@ -179,6 +216,8 @@ async function mountWithApi(apiState, props, emittedEvents = [], document = make
if (manifest.id !== 'verstak.todo') throw new Error('todo manifest id mismatch');
if (!manifest.permissions.includes('storage.namespace')) throw new Error('todo manifest must request storage.namespace');
if (!manifest.permissions.includes('files.read')) throw new Error('todo manifest must request files.read');
if (!manifest.permissions.includes('notifications.schedule')) throw new Error('todo manifest must request notifications.schedule');
if (!manifest.requires.includes('verstak/core/notifications/v1')) throw new Error('todo manifest must require native notifications');
if (!(manifest.contributes.views || []).length) throw new Error('todo manifest must contribute a global view');
if (!(manifest.contributes.workspaceItems || []).length) throw new Error('todo manifest must contribute a workspace item');
@@ -192,8 +231,13 @@ async function mountWithApi(apiState, props, emittedEvents = [], document = make
byData(container, 'data-todo-input', 'title').value = 'Prepare project review';
byData(container, 'data-todo-input', 'description').value = 'Collect factual review notes.';
byData(container, 'data-todo-input', 'priority').value = 'high';
byData(container, 'data-todo-input', 'dueAt').value = '2000-01-01';
byData(container, 'data-todo-input', 'reminderAt').value = '2000-01-01T09:00';
byData(container, 'data-todo-input', 'dueAt').value = '01/02/2000';
const reminderDate = byData(container, 'data-todo-input', 'reminderDate');
const reminderTime = byData(container, 'data-todo-input', 'reminderTime');
if (!reminderDate || reminderDate.getAttribute('type') !== 'date') throw new Error('Todo reminder date input was not rendered');
if (!reminderTime || reminderTime.getAttribute('type') !== 'text') throw new Error('Todo reminder time must be a keyboard-editable text input');
reminderDate.value = '01/02/2000';
reminderTime.value = '09:30';
byData(container, 'data-todo-action', 'save').click();
await flush();
@@ -202,20 +246,56 @@ async function mountWithApi(apiState, props, emittedEvents = [], document = make
const createdTodo = storedAfterCreate[0];
if (createdTodo.workspaceRootPath !== 'Project') throw new Error('workspace Todo did not keep the Project root path');
if (createdTodo.status !== 'open' || createdTodo.priority !== 'high') throw new Error('Todo status or priority was not stored');
if (createdTodo.dueAt !== '2000-01-01' || createdTodo.reminderAt !== '2000-01-01T09:00') throw new Error('Todo due/reminder metadata was not stored');
if (createdTodo.dueAt !== '2000-01-02' || createdTodo.reminderDate !== '2000-01-02' || createdTodo.reminderAt !== '2000-01-02T09:30') throw new Error('Todo due/reminder metadata was not stored');
if (!container.textContent.includes('Overdue') || !container.textContent.includes('Reminder due')) throw new Error('due/reminder indicators were not rendered');
const scheduledAfterCreate = apiState.notificationCalls.at(-1) || [];
if (scheduledAfterCreate.length !== 1
|| scheduledAfterCreate[0].id !== createdTodo.id
|| scheduledAfterCreate[0].dueAt !== new Date(createdTodo.reminderAt).toISOString()
|| scheduledAfterCreate[0].title !== 'Todo reminder'
|| scheduledAfterCreate[0].body !== 'Prepare project review') {
throw new Error('open Todo reminder was not scheduled as a native notification');
}
apiState.setLocale('ru');
if (!container.textContent.includes('Задачи · Project') || !container.textContent.includes('Просрочено')) {
throw new Error('Todo view did not update to Russian without remounting');
}
byData(container, 'data-todo-action', 'add').click();
if (!container.textContent.includes('Дело: Project')) {
throw new Error('Todo editor did not use Deal terminology in Russian');
}
const cancelModal = walk(container, (node) => node.tagName === 'BUTTON' && node.textContent === 'Отмена' && !node.getAttribute('data-todo-action'));
if (!cancelModal) throw new Error('Todo editor cancel action was not rendered in Russian');
cancelModal.click();
if ((apiState.settings['todos:global'] || []).length !== 1) throw new Error('locale change lost Todo state');
apiState.setLocale('en');
byData(container, 'data-todo-action', 'edit').click();
byData(container, 'data-todo-input', 'title').value = 'Prepare project review updated';
byData(container, 'data-todo-input', 'reminderTime').value = '29:30';
byData(container, 'data-todo-action', 'save').click();
await flush();
if (apiState.settings['todos:global'][0].title !== 'Prepare project review' || !container.textContent.includes('Enter a valid reminder time')) {
throw new Error('invalid reminder time was saved instead of showing a human-readable error');
}
byData(container, 'data-todo-input', 'reminderTime').value = '';
byData(container, 'data-todo-action', 'save').click();
await flush();
if (apiState.settings['todos:global'][0].title !== 'Prepare project review updated') throw new Error('Todo edit was not persisted');
if (apiState.settings['todos:global'][0].reminderDate !== '2000-01-02' || apiState.settings['todos:global'][0].reminderAt !== '') {
throw new Error('clearing reminder time did not preserve its date while cancelling the reminder');
}
if ((apiState.notificationCalls.at(-1) || []).length !== 0) throw new Error('clearing reminder time did not remove the native notification');
byData(container, 'data-todo-action', 'mark-done').click();
await flush();
if (apiState.settings['todos:global'][0].status !== 'done' || !apiState.settings['todos:global'][0].completedAt) {
throw new Error('mark done did not persist completed state');
}
if ((apiState.notificationCalls.at(-1) || []).length !== 0) {
throw new Error('completed Todo reminder was not removed from native notification schedules');
}
if (!byData(container, 'data-todo-action', 'reopen')) throw new Error('done Todo did not expose reopen action');
if (!byData(container, 'data-todo-action', 'create-journal-entry')) throw new Error('done workspace Todo did not expose Journal conversion');
@@ -249,6 +329,9 @@ async function mountWithApi(apiState, props, emittedEvents = [], document = make
if (!globalView.container.textContent.includes('Prepare project review updated') || !globalView.container.textContent.includes('Client follow-up')) {
throw new Error('global Todo view did not aggregate workspace todos');
}
if (!globalView.container.textContent.includes('All Deals')) {
throw new Error('global Todo filter did not use Deal terminology');
}
const workspaceFilter = byData(globalView.container, 'data-todo-filter', 'workspace');
workspaceFilter.value = 'ClientA';
workspaceFilter.dispatchEvent('change');
+20 -1
View File
@@ -5,8 +5,10 @@ const vm = require('vm');
const root = path.resolve(__dirname, '..');
const sourcePath = path.join(root, 'plugins', 'trash', 'frontend', 'src', 'index.js');
const manifestPath = path.join(root, 'plugins', 'trash', 'plugin.json');
const russianLocalePath = path.join(root, 'plugins', 'trash', 'locales', 'ru.json');
const source = fs.readFileSync(sourcePath, 'utf8');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const russianLocale = JSON.parse(fs.readFileSync(russianLocalePath, 'utf8'));
class FakeClassList {
constructor(node) {
@@ -159,7 +161,7 @@ function loadTrashComponent(document) {
return component;
}
function makeApi() {
function makeApi(locale = null) {
let entries = [
{
trashId: 'old-project-file',
@@ -194,6 +196,11 @@ function makeApi() {
return {
restoreCalls,
deleteCalls,
i18n: locale ? {
t(key, params, fallback) {
return String(locale[key] || fallback || key).replace(/\{(\w+)\}/g, (_match, name) => String((params || {})[name] ?? ''));
},
} : null,
files: {
listTrash: async () => entries.slice(),
restoreTrash: async (trashId, options) => {
@@ -237,6 +244,10 @@ function findByData(rootNode, name, value) {
component.mount(container, {}, api);
await flush();
if (!container.textContent.includes('All Deals') || !container.textContent.includes('Deal')) {
throw new Error('global Trash did not use Deal terminology');
}
const latestRow = findByData(container, 'data-trash-row', 'latest-client-report');
if (!latestRow || !latestRow.textContent.includes('ClientA') || !latestRow.textContent.includes('ClientA/Archive/report.pdf')) {
throw new Error(`global Trash must render workspace and original path: ${container.textContent}`);
@@ -298,6 +309,14 @@ function findByData(rootNode, name, value) {
}
component.unmount(container);
const russianContainer = new FakeNode('div');
component.mount(russianContainer, {}, makeApi(russianLocale));
await flush();
if (!russianContainer.textContent.includes('Все Дела') || !russianContainer.textContent.includes('Дело')) {
throw new Error('global Trash did not localize Deal terminology');
}
component.unmount(russianContainer);
console.log('trash frontend smoke passed');
})().catch((err) => {
console.error(err);
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BUILDER="$ROOT/scripts/build-windows.sh"
if [[ ! -x "$BUILDER" ]]; then
echo "Windows plugin builder is missing or not executable: $BUILDER" >&2
exit 1
fi
bash -n "$BUILDER"
grep -Fq 'GOOS=windows' "$BUILDER"
grep -Fq 'GOARCH=amd64' "$BUILDER"
grep -Fq 'dist-windows' "$BUILDER"
grep -Fq '.exe' "$BUILDER"
grep -Fq 'x86_64-w64-mingw32-gcc' "$BUILDER"
echo "Windows plugin build script contract passed"
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PACKAGER="$ROOT/scripts/package-portable.sh"
if [[ ! -x "$PACKAGER" ]]; then
echo "portable plugin packager is missing or not executable: $PACKAGER" >&2
exit 1
fi
bash -n "$PACKAGER"
grep -Fq 'scripts/build.sh' "$PACKAGER"
grep -Fq 'scripts/build-windows.sh' "$PACKAGER"
grep -Fq 'verstak-official-plugins-linux-amd64-' "$PACKAGER"
grep -Fq 'verstak-official-plugins-windows-amd64-' "$PACKAGER"
grep -Fq 'tar -C' "$PACKAGER"
grep -Fq 'zip -qr' "$PACKAGER"
grep -Fq 'SHA256SUMS' "$PACKAGER"
grep -Fq 'package-portable.sh' "$ROOT/scripts/release.sh"
echo "portable plugin package script contract passed"
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PUBLISHER="$ROOT/scripts/publish-github-release.sh"
VERSION="v0.0.0-test"
REPOSITORY="mirivlad/verstak-official-plugins"
ASSET_NAMES=(
"verstak-official-plugins-linux-amd64-v0.0.0-test.tar.gz"
"verstak-official-plugins-windows-amd64-v0.0.0-test.zip"
)
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
if [[ ! -x "$PUBLISHER" ]]; then
echo "publisher is missing or not executable: $PUBLISHER" >&2
exit 1
fi
mkdir -p "$WORK/bin" "$WORK/release" "$WORK/release-notes" "$WORK/state"
LOG="$WORK/log"
export LOG
printf '## Highlights\n\nHuman-readable release notes.\n' > "$WORK/release-notes/$VERSION.md"
cat > "$WORK/release.sh" <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
printf 'release:%s\n' "$1" >> "$LOG"
printf 'linux\n' > "$VERSTAK_RELEASE_DIR/verstak-official-plugins-linux-amd64-$1.tar.gz"
printf 'windows\n' > "$VERSTAK_RELEASE_DIR/verstak-official-plugins-windows-amd64-$1.zip"
printf 'checksum\n' > "$VERSTAK_RELEASE_DIR/SHA256SUMS"
SCRIPT
chmod +x "$WORK/release.sh"
cat > "$WORK/bin/git" <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
if [[ "$PWD" != "${EXPECTED_ROOT:?}" ]]; then
echo "publisher did not enter repository root: $PWD" >&2
exit 1
fi
case "${1:-}" in
status) exit 0 ;;
branch) echo main ;;
fetch) printf 'fetch\n' >> "$LOG" ;;
rev-parse)
if [[ "${2:-}" == "-q" ]]; then
if [[ -f "$TEST_STATE/tag" ]]; then echo test-commit; exit 0; fi
exit 1
fi
echo test-commit
;;
describe) echo v0.0.0-previous ;;
tag) touch "$TEST_STATE/tag"; printf 'tag:%s\n' "${3:-}" >> "$LOG" ;;
push) printf 'push:%s:%s\n' "${2:-}" "${3:-}" >> "$LOG" ;;
*) echo "unexpected git invocation: $*" >&2; exit 1 ;;
esac
SCRIPT
chmod +x "$WORK/bin/git"
cat > "$WORK/bin/gh" <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
case "${1:-}:${2:-}" in
auth:status) printf 'auth\n' >> "$LOG" ;;
release:view)
if [[ -f "$TEST_STATE/release" ]]; then echo https://github.example/release; else exit 1; fi
;;
release:create) touch "$TEST_STATE/release"; printf 'create:%s\n' "$*" >> "$LOG" ;;
release:upload) printf 'upload:%s\n' "$*" >> "$LOG" ;;
*) echo "unexpected gh invocation: $*" >&2; exit 1 ;;
esac
SCRIPT
chmod +x "$WORK/bin/gh"
run_publisher() {
VERSTAK_RELEASE_SCRIPT="$WORK/release.sh" \
VERSTAK_RELEASE_DIR="$WORK/release" \
VERSTAK_RELEASE_NOTES_DIR="$WORK/release-notes" \
GIT_BIN="$WORK/bin/git" \
GH_BIN="$WORK/bin/gh" \
EXPECTED_ROOT="$ROOT" \
TEST_STATE="$WORK/state" \
"$PUBLISHER" "$VERSION"
}
run_publisher
grep -Fqx "release:$VERSION" "$LOG"
grep -Fqx "tag:$VERSION" "$LOG"
grep -Fqx "push:origin:refs/tags/$VERSION" "$LOG"
grep -F "release create $VERSION" "$LOG" >/dev/null
for asset in "${ASSET_NAMES[@]}"; do
grep -F "$asset" "$LOG" >/dev/null
done
grep -F "SHA256SUMS" "$LOG" >/dev/null
grep -F -- "--notes-file $WORK/release-notes/$VERSION.md" "$LOG" >/dev/null
grep -F -- "--generate-notes" "$LOG" >/dev/null
grep -F -- "--notes-start-tag v0.0.0-previous" "$LOG" >/dev/null
grep -F -- "--prerelease" "$LOG" >/dev/null
run_publisher
grep -F "release upload $VERSION" "$LOG" >/dev/null
echo "GitHub release publisher test passed"