Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03f5299209 | ||
|
|
1a201bed6d | ||
|
|
1bcad23b58 | ||
|
|
126d90cd68 | ||
|
|
9904d4a962 | ||
|
|
515dec6a7b | ||
|
|
c9c26faf56 | ||
|
|
484d599a9f | ||
|
|
5ddc63bb8d | ||
|
|
f456287c2e | ||
|
|
c8b8045da1 | ||
|
|
3a1920d104 | ||
|
|
713b95b5a8 | ||
|
|
b449e9c616 | ||
|
|
90ad8e8467 | ||
|
|
aa7956dec4 | ||
|
|
44b39ca051 | ||
|
|
c83c15bced | ||
|
|
99172944a1 | ||
|
|
a1b3c31d0d | ||
|
|
1f03226378 | ||
|
|
bee365ad22 | ||
|
|
dfc53f1a5f | ||
|
|
dd75335727 | ||
|
|
f23fb6e993 | ||
|
|
0cd2c2a6ab | ||
|
|
304620952f | ||
|
|
112fb4cbec | ||
|
|
78a9dd31a3 | ||
|
|
eb0f468463 | ||
|
|
7687f410c4 | ||
|
|
7e34a73835 | ||
|
|
47c1db9979 | ||
|
|
22c842d5e3 | ||
|
|
1a15d57033 | ||
|
|
483b6ba46e | ||
|
|
226464f082 | ||
|
|
e93584c37c | ||
|
|
50c4fc5e68 | ||
|
|
b45a56b09d | ||
|
|
0db9f896ce | ||
|
|
69a1938ff3 | ||
|
|
fe97c5ddcc | ||
|
|
1bc5ecfd16 | ||
|
|
3dd0ec9f1f |
@@ -1,4 +1,6 @@
|
|||||||
dist/
|
dist/
|
||||||
|
dist-windows/
|
||||||
|
release/
|
||||||
node_modules/
|
node_modules/
|
||||||
# compiled backend binaries (built from source)
|
# compiled backend binaries (built from source)
|
||||||
plugins/*/backend/platform-test
|
plugins/*/backend/platform-test
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
[](https://github.com/mirivlad/verstak-official-plugins/releases)
|
||||||
|

|
||||||
|
[](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).
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
# Официальные плагины Верстака
|
||||||
|
|
||||||
|
### Базовый набор плагинов — Файлы, Заметки, Редактор, Активность, Журнал и другие.
|
||||||
|
|
||||||
|
[English](README.md) · **Русский**
|
||||||
|
|
||||||
|
[](https://github.com/mirivlad/verstak-official-plugins/releases)
|
||||||
|

|
||||||
|
[](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
@@ -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
|
stored in `captures:global`; previous `captures` and `captures:workspace:*` keys
|
||||||
remain readable for migration compatibility.
|
remain readable for migration compatibility.
|
||||||
|
|
||||||
Each capture contains `workspaceRootPath` and `processed`. An empty
|
Each capture contains a durable `workspaceId`, its current or historical
|
||||||
`workspaceRootPath` means the capture is **Unassigned**. The global view can filter
|
`workspaceRootPath`, and `processed`. An empty `workspaceRootPath` means the
|
||||||
all captures by assignment, workspace, processed state, and text search. A capture
|
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
|
can be assigned, reassigned, made unassigned, marked processed or unprocessed, and
|
||||||
deleted.
|
deleted.
|
||||||
|
|
||||||
The local browser receiver adds the currently active workspace before publishing a
|
The local browser receiver adds the currently active Deal before publishing a
|
||||||
capture event. When it has no active workspace, the capture remains unassigned
|
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
|
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
|
Deal assignment uses the immutable `workspaceId` stored in Deal metadata;
|
||||||
identifier because the core workspace model has no separate immutable ID. In the
|
`workspaceRootPath` is an address and display value. A rename updates the latter,
|
||||||
current model that path is also the displayed workspace name, so a workspace rename
|
while a newly created folder with the old name cannot take over existing captures.
|
||||||
requires a later reassignment of existing captures.
|
|
||||||
|
|
||||||
Workspace Inbox and Overview show only captures whose `workspaceRootPath` exactly
|
Deal Inbox and Overview show only captures whose `workspaceId` matches the selected
|
||||||
matches the selected workspace. Unassigned captures remain visible only in the
|
Deal. Unassigned captures remain visible only in the
|
||||||
global Browser Inbox.
|
global Browser Inbox.
|
||||||
|
|||||||
+14
-16
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
Todos are stored by the official `verstak.todo` plugin under the canonical
|
Todos are stored by the official `verstak.todo` plugin under the canonical
|
||||||
`todos:global` settings key. A todo is global when `workspaceRootPath` is empty;
|
`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,
|
The global Todos view aggregates all records and supports Deal, status, text,
|
||||||
and due/reminder/updated sorting filters. The workspace Todos view shows only
|
and due/reminder/updated sorting filters. The Deal Todos view shows only records
|
||||||
records whose `workspaceRootPath` exactly matches the current workspace. This
|
whose `workspaceRootPath` exactly matches the current Deal. This keeps unassigned
|
||||||
keeps unassigned and other-workspace records out of a workspace tab and its
|
and other-Deal records out of a Deal tab and its
|
||||||
Overview signals.
|
Overview signals.
|
||||||
|
|
||||||
## Stored fields
|
## Stored fields
|
||||||
@@ -20,15 +20,15 @@ Each record has a stable `id`, `title`, optional `description`, optional
|
|||||||
|
|
||||||
## Reminders
|
## Reminders
|
||||||
|
|
||||||
The plugin stores reminder metadata and renders clear indicators for overdue,
|
The plugin stores reminder metadata, renders clear indicators for overdue,
|
||||||
due-soon, and reminder-due todos. Verstak does not yet have a notification
|
due-soon, and reminder-due todos, and schedules native desktop notifications when
|
||||||
scheduler, so a reminder does not create a native desktop notification or run in
|
the Desktop notification capability is available. Without that capability, the
|
||||||
the background.
|
reminder remains visible in Todos but is not treated as an error.
|
||||||
|
|
||||||
## Completed Todo to Journal
|
## Completed Todo to Journal
|
||||||
|
|
||||||
From a workspace Todo tab, a completed todo exposes **Create Journal Entry**. It
|
From a Deal Todo tab, a completed todo exposes **Create Journal Entry**. It opens
|
||||||
opens the current workspace Journal with a normal, editable form. The form copies
|
the current Deal Journal with a normal, editable form. The form copies
|
||||||
only factual data from the todo: title, description, completion date, and zero
|
only factual data from the todo: title, description, completion date, and zero
|
||||||
minutes. It never generates a summary, duration, or billable status.
|
minutes. It never generates a summary, duration, or billable status.
|
||||||
|
|
||||||
@@ -40,8 +40,6 @@ own record.
|
|||||||
|
|
||||||
## Visibility
|
## Visibility
|
||||||
|
|
||||||
The Plugin Manager can globally enable or disable the Todo plugin. The current
|
The Plugin Manager can globally enable or disable the Todo plugin. A Deal template
|
||||||
workspace host has no per-workspace or template-level contribution filter yet, so
|
controls whether its Todos tab is available; disabling the plugin hides the tab
|
||||||
a globally enabled Todo plugin contributes its tab to every workspace. Template
|
without affecting stored todo records.
|
||||||
visibility will be handled with the workspace/template model rather than by a
|
|
||||||
Todo-specific exception.
|
|
||||||
|
|||||||
@@ -8,12 +8,14 @@
|
|||||||
|
|
||||||
var PLUGIN_ID = 'verstak.activity';
|
var PLUGIN_ID = 'verstak.activity';
|
||||||
var MAX_EVENTS = 250;
|
var MAX_EVENTS = 250;
|
||||||
|
var RAW_DATA_NAME = 'activity-events';
|
||||||
var MAX_CANDIDATES = 12;
|
var MAX_CANDIDATES = 12;
|
||||||
var LEGACY_KEY = 'events';
|
var LEGACY_KEY = 'events';
|
||||||
var GLOBAL_KEY = 'events:global';
|
var GLOBAL_KEY = 'events:global';
|
||||||
var WORKSPACE_PREFIX = 'events:workspace:';
|
var WORKSPACE_PREFIX = 'events:workspace:';
|
||||||
var CANDIDATE_PREFIX = 'work-session-candidates:workspace:';
|
var CANDIDATE_PREFIX = 'work-session-candidates:workspace:';
|
||||||
var DISMISSAL_PREFIX = 'work-session-dismissals:workspace:';
|
var DISMISSAL_PREFIX = 'work-session-dismissals:workspace:';
|
||||||
|
var SESSION_REGISTRY_KEY = 'activity-session-registry-v2';
|
||||||
var WORKLOG_COMMAND_ID = 'verstak.activity.suggestWorklog';
|
var WORKLOG_COMMAND_ID = 'verstak.activity.suggestWorklog';
|
||||||
var MIN_SESSION_DURATION_MINUTES = 10;
|
var MIN_SESSION_DURATION_MINUTES = 10;
|
||||||
var MIN_SESSION_ACTIVITY_COUNT = 2;
|
var MIN_SESSION_ACTIVITY_COUNT = 2;
|
||||||
@@ -30,21 +32,24 @@
|
|||||||
'browser.capture.selection',
|
'browser.capture.selection',
|
||||||
'browser.capture.link',
|
'browser.capture.link',
|
||||||
'browser.capture.file',
|
'browser.capture.file',
|
||||||
'browser.capture.converted'
|
'browser.capture.converted',
|
||||||
|
'browser.activity.batch',
|
||||||
|
'activity.session.handled'
|
||||||
];
|
];
|
||||||
var EVENT_LABELS = {
|
var EVENT_LABELS = {
|
||||||
'workspace.selected': 'Workspace selected',
|
'workspace.selected': { key: 'ui.event.workspaceSelected', fallback: 'Deal selected' },
|
||||||
'case.selected': 'Workspace selected',
|
'case.selected': { key: 'ui.event.workspaceSelected', fallback: 'Deal selected' },
|
||||||
'file.opened': 'File opened',
|
'file.opened': { key: 'ui.event.fileOpened', fallback: 'File opened' },
|
||||||
'file.changed': 'File changed',
|
'file.changed': { key: 'ui.event.fileChanged', fallback: 'File changed' },
|
||||||
'note.saved': 'Note edited',
|
'note.saved': { key: 'ui.event.noteEdited', fallback: 'Note edited' },
|
||||||
'action.started': 'Work session detected',
|
'action.started': { key: 'ui.event.workSessionDetected', fallback: 'Work session detected' },
|
||||||
'browser.capture.received': 'Browser capture received',
|
'browser.capture.received': { key: 'ui.event.browserCaptureReceived', fallback: 'Browser capture received' },
|
||||||
'browser.capture.page': 'Page captured',
|
'browser.capture.page': { key: 'ui.event.pageCaptured', fallback: 'Page captured' },
|
||||||
'browser.capture.selection': 'Selection captured',
|
'browser.capture.selection': { key: 'ui.event.selectionCaptured', fallback: 'Selection captured' },
|
||||||
'browser.capture.link': 'Link captured',
|
'browser.capture.link': { key: 'ui.event.linkCaptured', fallback: 'Link captured' },
|
||||||
'browser.capture.file': 'File captured',
|
'browser.capture.file': { key: 'ui.event.fileCaptured', fallback: 'File captured' },
|
||||||
'browser.capture.converted': 'Capture converted'
|
'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 = {
|
var LOW_VALUE_EVENT_TYPES = {
|
||||||
'workspace.selected': true,
|
'workspace.selected': true,
|
||||||
@@ -74,6 +79,7 @@
|
|||||||
'.activity-btn.danger{border-color:rgba(233,69,96,.42);color:#ff9a9a}',
|
'.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{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-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{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-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)}',
|
'.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) {
|
function scopeFromProps(props) {
|
||||||
var workspaceRoot = workspaceFromProps(props);
|
var workspaceRoot = workspaceFromProps(props);
|
||||||
|
var workspaceId = text(props && (props.workspaceId || (props.workspaceNode && props.workspaceNode.workspaceId))).trim();
|
||||||
if (!workspaceRoot) {
|
if (!workspaceRoot) {
|
||||||
return { mode: 'global', key: GLOBAL_KEY, label: 'All workspaces', workspaceRoot: '' };
|
return { mode: 'global', key: GLOBAL_KEY, label: 'All Deals', workspaceRoot: '', workspaceId: '' };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
mode: 'workspace',
|
mode: 'workspace',
|
||||||
key: WORKSPACE_PREFIX + encodeKey(workspaceRoot),
|
key: WORKSPACE_PREFIX + encodeKey(workspaceRoot),
|
||||||
label: workspaceRoot,
|
label: workspaceRoot,
|
||||||
workspaceRoot: workspaceRoot
|
workspaceRoot: workspaceRoot,
|
||||||
|
workspaceId: workspaceId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +183,9 @@
|
|||||||
receivedAt: text(item.receivedAt),
|
receivedAt: text(item.receivedAt),
|
||||||
sourcePluginId: text(item.sourcePluginId || item.pluginId),
|
sourcePluginId: text(item.sourcePluginId || item.pluginId),
|
||||||
workspaceRootPath: cleanWorkspace(item.workspaceRootPath || workspaceFromPayload(item.payload || {})),
|
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 || '',
|
_storageKey: storageKey || '',
|
||||||
payload: item.payload && typeof item.payload === 'object' ? item.payload : {}
|
payload: item.payload && typeof item.payload === 'object' ? item.payload : {}
|
||||||
};
|
};
|
||||||
@@ -192,6 +203,9 @@
|
|||||||
receivedAt: item.receivedAt,
|
receivedAt: item.receivedAt,
|
||||||
sourcePluginId: item.sourcePluginId,
|
sourcePluginId: item.sourcePluginId,
|
||||||
workspaceRootPath: item.workspaceRootPath,
|
workspaceRootPath: item.workspaceRootPath,
|
||||||
|
workspaceId: item.workspaceId,
|
||||||
|
sessionScope: item.sessionScope || {},
|
||||||
|
durationSeconds: item.durationSeconds || 0,
|
||||||
payload: item.payload || {}
|
payload: item.payload || {}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -225,13 +239,22 @@
|
|||||||
return cleanWorkspace(activity && (activity.workspaceRootPath || workspaceFromPayload(activity.payload || {})));
|
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) {
|
function toISOTime(time) {
|
||||||
var date = new Date(time);
|
var date = new Date(time);
|
||||||
return isNaN(date.getTime()) ? '' : date.toISOString();
|
return isNaN(date.getTime()) ? '' : date.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function candidateId(workspaceRootPath, firstActivity, lastActivity) {
|
function newSessionId() {
|
||||||
return 'work-session:' + encodeKey(workspaceRootPath) + ':' + encodeKey(firstActivity.activityId) + ':' + encodeKey(lastActivity.activityId);
|
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) {
|
function candidateActivity(activity) {
|
||||||
@@ -240,82 +263,176 @@
|
|||||||
type: text(activity.type),
|
type: text(activity.type),
|
||||||
occurredAt: toISOTime(eventTimeMs(activity)),
|
occurredAt: toISOTime(eventTimeMs(activity)),
|
||||||
sourcePluginId: text(activity.sourcePluginId),
|
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) {
|
function buildCandidate(session) {
|
||||||
var first = session.activities[0];
|
var activities = session.activities.slice().sort(function (a, b) { return eventTimeMs(a) - eventTimeMs(b); });
|
||||||
var last = session.activities[session.activities.length - 1];
|
var first = activities[0];
|
||||||
var duration = Math.round((eventTimeMs(last) - eventTimeMs(first)) / 60000);
|
var last = activities[activities.length - 1];
|
||||||
if (session.activities.length < MIN_SESSION_ACTIVITY_COUNT || duration < MIN_SESSION_DURATION_MINUTES) return null;
|
var duration = sessionDurationMinutes(session);
|
||||||
|
if (session.scope === 'unassigned' || !session.workspaceRootPath || activities.length < MIN_SESSION_ACTIVITY_COUNT || duration < MIN_SESSION_DURATION_MINUTES) return null;
|
||||||
return {
|
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,
|
workspaceRootPath: session.workspaceRootPath,
|
||||||
|
workspaceId: session.workspaceId,
|
||||||
startedAt: toISOTime(eventTimeMs(first)),
|
startedAt: toISOTime(eventTimeMs(first)),
|
||||||
endedAt: toISOTime(eventTimeMs(last)),
|
endedAt: toISOTime(eventTimeMs(last)),
|
||||||
estimatedMinutes: duration,
|
estimatedMinutes: duration,
|
||||||
activityCount: session.activities.length,
|
activityCount: activities.length,
|
||||||
activityIds: session.activities.map(function (activity) { return activity.activityId; }).filter(Boolean),
|
activityIds: activities.map(function (activity) { return activity.activityId; }).filter(Boolean),
|
||||||
activities: session.activities.map(candidateActivity)
|
activities: activities.map(candidateActivity)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildWorkSessionCandidates(activityList, workspaceFilter) {
|
function buildWorkSessionCandidates(activityList, workspaceFilter, registry) {
|
||||||
var filter = cleanWorkspace(workspaceFilter);
|
var filter = cleanWorkspace(workspaceFilter);
|
||||||
var ordered = sortEvents(activityList || []).filter(function (activity) {
|
registry = normalizeSessionRegistry(registry);
|
||||||
return isMeaningfulActivity(activity) && candidateWorkspace(activity) && eventTimeMs(activity);
|
return logicalSessions(activityList, registry).map(buildCandidate).filter(function (candidate) {
|
||||||
}).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) {
|
|
||||||
return candidate && (!filter || candidate.workspaceRootPath === filter);
|
return candidate && (!filter || candidate.workspaceRootPath === filter);
|
||||||
}).sort(function (a, b) {
|
}).sort(function (a, b) {
|
||||||
return b.endedAt.localeCompare(a.endedAt) || a.workspaceRootPath.localeCompare(b.workspaceRootPath);
|
return b.endedAt.localeCompare(a.endedAt) || a.workspaceRootPath.localeCompare(b.workspaceRootPath);
|
||||||
}).slice(0, MAX_CANDIDATES);
|
}).slice(0, MAX_CANDIDATES);
|
||||||
}
|
}
|
||||||
|
|
||||||
function humanEventType(type) {
|
function humanEventType(type, translate) {
|
||||||
return EVENT_LABELS[text(type).toLowerCase()] || '';
|
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 explicit = text(activity && activity.title).trim();
|
||||||
var type = text(activity && activity.type).trim();
|
var type = text(activity && activity.type).trim();
|
||||||
if (explicit && explicit.toLowerCase() !== type.toLowerCase()) return explicit;
|
var label = humanEventType(type, translate);
|
||||||
return humanEventType(type) || text(activity && (activity.summary || activity.activityId)).trim() || 'Activity event';
|
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();
|
var type = text(activity && activity.type).toLowerCase();
|
||||||
if (type.indexOf('browser.capture') === 0) return 'Capture';
|
if (type.indexOf('browser.capture') === 0) return translate('ui.kind.capture', null, 'Capture');
|
||||||
if (type.indexOf('file.') === 0) return 'File';
|
if (type.indexOf('file.') === 0) return translate('ui.kind.file', null, 'File');
|
||||||
if (type.indexOf('note.') === 0) return 'Note';
|
if (type.indexOf('note.') === 0) return translate('ui.kind.note', null, 'Note');
|
||||||
if (type.indexOf('workspace') !== -1 || type.indexOf('case.') === 0) return 'Workspace';
|
if (type.indexOf('workspace') !== -1 || type.indexOf('case.') === 0) return translate('ui.kind.deal', null, 'Deal');
|
||||||
if (type.indexOf('action.') === 0) return 'Work';
|
if (type.indexOf('action.') === 0) return translate('ui.kind.work', null, 'Work');
|
||||||
return 'Activity';
|
return translate('ui.kind.activity', null, 'Activity');
|
||||||
}
|
}
|
||||||
|
|
||||||
function globalEventKeys(settings) {
|
function globalEventKeys(settings) {
|
||||||
@@ -347,6 +464,15 @@
|
|||||||
return sortEvents(scopedEvents.concat(globalEvents, legacyEvents));
|
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) {
|
function candidateStorageKey(workspaceRoot) {
|
||||||
return CANDIDATE_PREFIX + encodeKey(workspaceRoot);
|
return CANDIDATE_PREFIX + encodeKey(workspaceRoot);
|
||||||
}
|
}
|
||||||
@@ -367,26 +493,45 @@
|
|||||||
function dismissedCandidatesFromSettings(settings) {
|
function dismissedCandidatesFromSettings(settings) {
|
||||||
var dismissed = {};
|
var dismissed = {};
|
||||||
Object.keys(settings || {}).forEach(function (key) {
|
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);
|
var workspace = decodeStoredWorkspace(key, DISMISSAL_PREFIX);
|
||||||
if (!workspace) return;
|
if (!workspace) return;
|
||||||
dismissed[workspace] = {};
|
dismissed[workspace] = {};
|
||||||
settings[key].forEach(function (candidateId) {
|
if (Array.isArray(settings[key])) {
|
||||||
candidateId = text(candidateId).trim();
|
settings[key].forEach(function (candidateId) {
|
||||||
if (candidateId) dismissed[workspace][candidateId] = true;
|
candidateId = text(candidateId).trim();
|
||||||
});
|
if (candidateId) dismissed[workspace][candidateId] = true;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (settings[key] && typeof settings[key] === 'object') dismissed[workspace] = settings[key];
|
||||||
});
|
});
|
||||||
return dismissed;
|
return dismissed;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCandidateDismissed(candidate, dismissedByWorkspace) {
|
function candidateAfterWatermark(candidate, watermark) {
|
||||||
return !!(candidate && dismissedByWorkspace && dismissedByWorkspace[candidate.workspaceRootPath] && dismissedByWorkspace[candidate.workspaceRootPath][candidate.candidateId]);
|
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) {
|
function visibleCandidates(activityList, workspaceFilter, sessionRegistry, dismissedByWorkspace, handledSessions) {
|
||||||
return buildWorkSessionCandidates(activityList, workspaceFilter).filter(function (candidate) {
|
return buildWorkSessionCandidates(activityList, workspaceFilter, sessionRegistry).map(function (candidate) {
|
||||||
return !isCandidateDismissed(candidate, dismissedByWorkspace);
|
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) {
|
function formatDate(value) {
|
||||||
@@ -409,31 +554,34 @@
|
|||||||
var candidateSourceEvents = [];
|
var candidateSourceEvents = [];
|
||||||
var candidates = [];
|
var candidates = [];
|
||||||
var dismissedByWorkspace = {};
|
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 statusClass = '';
|
||||||
var disposed = false;
|
var disposed = false;
|
||||||
var unsubscribers = [];
|
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 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 countEl = el('span', { className: 'activity-count' });
|
||||||
var statusEl = el('span', { className: 'activity-status' });
|
var statusEl = el('span', { className: 'activity-status' });
|
||||||
var clearBtn = el('button', {
|
var clearBtn = el('button', {
|
||||||
className: 'activity-btn danger',
|
className: 'activity-btn danger',
|
||||||
'data-activity-action': 'clear',
|
'data-activity-action': 'clear',
|
||||||
textContent: 'Clear',
|
textContent: tr('ui.clear', null, 'Clear'),
|
||||||
onClick: function () {
|
onClick: showClearConfirmation
|
||||||
if (scope.mode === 'global') {
|
|
||||||
clearGlobal().then(render);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
events = [];
|
|
||||||
candidateSourceEvents = candidateSourceEvents.filter(function (activity) {
|
|
||||||
return candidateWorkspace(activity) !== scope.workspaceRoot;
|
|
||||||
});
|
|
||||||
updateCandidates();
|
|
||||||
persist().then(render);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
toolbar.appendChild(titleEl);
|
toolbar.appendChild(titleEl);
|
||||||
toolbar.appendChild(countEl);
|
toolbar.appendChild(countEl);
|
||||||
@@ -446,12 +594,14 @@
|
|||||||
'data-activity-section': 'work-session-candidates'
|
'data-activity-section': 'work-session-candidates'
|
||||||
});
|
});
|
||||||
var listEl = el('div', { className: 'activity-list' });
|
var listEl = el('div', { className: 'activity-list' });
|
||||||
|
var modalHost = el('div', { className: 'activity-modal-host', hidden: 'hidden' });
|
||||||
containerEl.appendChild(toolbar);
|
containerEl.appendChild(toolbar);
|
||||||
containerEl.appendChild(candidatesEl);
|
containerEl.appendChild(candidatesEl);
|
||||||
containerEl.appendChild(listEl);
|
containerEl.appendChild(listEl);
|
||||||
|
containerEl.appendChild(modalHost);
|
||||||
|
|
||||||
function candidatesForWorkspace(workspaceRoot) {
|
function candidatesForWorkspace(workspaceRoot) {
|
||||||
return visibleCandidates(candidateSourceEvents, workspaceRoot, dismissedByWorkspace);
|
return visibleCandidates(candidateSourceEvents, workspaceRoot, sessionRegistry, dismissedByWorkspace, handledSessions);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCandidates() {
|
function updateCandidates() {
|
||||||
@@ -480,7 +630,12 @@
|
|||||||
function persistDismissals(workspaceRoot) {
|
function persistDismissals(workspaceRoot) {
|
||||||
if (!workspaceRoot || !api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
|
if (!workspaceRoot || !api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
|
||||||
var dismissed = dismissedByWorkspace[workspaceRoot] || {};
|
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() {
|
function persist() {
|
||||||
@@ -488,9 +643,8 @@
|
|||||||
var toStore = scope.mode === 'global'
|
var toStore = scope.mode === 'global'
|
||||||
? events.filter(function (item) { return !item._storageKey || item._storageKey === GLOBAL_KEY; })
|
? events.filter(function (item) { return !item._storageKey || item._storageKey === GLOBAL_KEY; })
|
||||||
: events;
|
: events;
|
||||||
return api.settings.write(scope.key, storageEvents(toStore)).then(persistCandidateCaches).catch(function (err) {
|
return api.settings.write(scope.key, storageEvents(toStore)).then(persistSessionRegistry).then(persistCandidateCaches).catch(function (err) {
|
||||||
statusText = 'Could not save activity: ' + (err && err.message ? err.message : String(err));
|
reportError('ui.saveError', 'Could not save activity. Please try again.', err);
|
||||||
statusClass = 'error';
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,7 +653,12 @@
|
|||||||
events = [];
|
events = [];
|
||||||
return Promise.resolve();
|
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) {
|
var keys = globalEventKeys(settings || {}).concat(Object.keys(settings || {}).filter(function (key) {
|
||||||
return key.indexOf(CANDIDATE_PREFIX) === 0 || key.indexOf(DISMISSAL_PREFIX) === 0;
|
return key.indexOf(CANDIDATE_PREFIX) === 0 || key.indexOf(DISMISSAL_PREFIX) === 0;
|
||||||
}));
|
}));
|
||||||
@@ -507,17 +666,82 @@
|
|||||||
candidateSourceEvents = [];
|
candidateSourceEvents = [];
|
||||||
candidates = [];
|
candidates = [];
|
||||||
dismissedByWorkspace = {};
|
dismissedByWorkspace = {};
|
||||||
|
handledSessions = {};
|
||||||
|
sessionRegistry = normalizeSessionRegistry({});
|
||||||
return Promise.all(keys.filter(function (key, index, all) {
|
return Promise.all(keys.filter(function (key, index, all) {
|
||||||
return all.indexOf(key) === index;
|
return all.indexOf(key) === index;
|
||||||
}).map(function (key) {
|
}).map(function (key) {
|
||||||
return api.settings.write(key, []);
|
return api.settings.write(key, []);
|
||||||
}));
|
}));
|
||||||
}).then(function () {
|
}).then(function () {
|
||||||
statusText = 'Activity cleared';
|
statusText = tr('ui.cleared', null, 'Activity cleared');
|
||||||
statusClass = '';
|
statusClass = '';
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
statusText = 'Could not clear activity: ' + (err && err.message ? err.message : String(err));
|
reportError('ui.clearError', 'Could not clear activity. Please try again.', err);
|
||||||
statusClass = 'error';
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = '';
|
listEl.innerHTML = '';
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
listEl.appendChild(el('div', { className: 'activity-empty' }, [
|
listEl.appendChild(el('div', { className: 'activity-empty' }, [
|
||||||
el('div', { className: 'activity-empty-title', textContent: 'No activity events yet' }),
|
el('div', { className: 'activity-empty-title', textContent: tr('ui.empty', null, 'No activity events yet') }),
|
||||||
el('div', { textContent: 'File changes, browser captures, and conversions will appear here.' })
|
el('div', { textContent: tr('ui.emptyHint', null, 'File changes, browser captures, and conversions will appear here.') })
|
||||||
]));
|
]));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -538,14 +762,10 @@
|
|||||||
el('div', { className: 'activity-time', textContent: formatDate(activity.occurredAt) || '-' }),
|
el('div', { className: 'activity-time', textContent: formatDate(activity.occurredAt) || '-' }),
|
||||||
el('div', { className: 'activity-main' }, [
|
el('div', { className: 'activity-main' }, [
|
||||||
el('div', { className: 'activity-row-head' }, [
|
el('div', { className: 'activity-row-head' }, [
|
||||||
el('span', { className: 'activity-type', textContent: eventKind(activity) }),
|
el('span', { className: 'activity-type', textContent: eventKind(activity, tr) }),
|
||||||
el('span', { className: 'activity-title-text', textContent: humanEventTitle(activity) })
|
el('span', { className: 'activity-title-text', textContent: humanEventTitle(activity, tr) })
|
||||||
]),
|
]),
|
||||||
activity.summary ? el('div', { className: 'activity-summary', textContent: activity.summary }) : null,
|
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 : '') })
|
|
||||||
])
|
|
||||||
])
|
])
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
@@ -568,17 +788,20 @@
|
|||||||
function dismissCandidate(candidate) {
|
function dismissCandidate(candidate) {
|
||||||
if (!candidate || !candidate.workspaceRootPath || !candidate.candidateId) return;
|
if (!candidate || !candidate.workspaceRootPath || !candidate.candidateId) return;
|
||||||
dismissedByWorkspace[candidate.workspaceRootPath] = dismissedByWorkspace[candidate.workspaceRootPath] || {};
|
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();
|
updateCandidates();
|
||||||
Promise.all([
|
Promise.all([
|
||||||
persistDismissals(candidate.workspaceRootPath),
|
persistDismissals(candidate.workspaceRootPath),
|
||||||
persistCandidateCache(candidate.workspaceRootPath)
|
persistCandidateCache(candidate.workspaceRootPath)
|
||||||
]).then(function () {
|
]).then(function () {
|
||||||
statusText = 'Candidate dismissed';
|
statusText = tr('ui.dismissed', null, 'Candidate dismissed');
|
||||||
statusClass = '';
|
statusClass = '';
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
statusText = 'Could not dismiss candidate: ' + (err && err.message ? err.message : String(err));
|
reportError('ui.dismissError', 'Could not dismiss the suggestion. Please try again.', err);
|
||||||
statusClass = 'error';
|
|
||||||
}).then(render);
|
}).then(render);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,37 +813,37 @@
|
|||||||
}
|
}
|
||||||
if (typeof candidatesEl.removeAttribute === 'function') candidatesEl.removeAttribute('hidden');
|
if (typeof candidatesEl.removeAttribute === 'function') candidatesEl.removeAttribute('hidden');
|
||||||
else delete candidatesEl.attributes.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) {
|
candidates.forEach(function (candidate) {
|
||||||
candidatesEl.appendChild(el('div', {
|
candidatesEl.appendChild(el('div', {
|
||||||
className: 'activity-candidate',
|
className: 'activity-candidate',
|
||||||
'data-work-session-candidate': candidate.candidateId
|
'data-work-session-candidate': candidate.candidateId
|
||||||
}, [
|
}, [
|
||||||
el('div', {}, [
|
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', { className: 'activity-candidate-facts' }, [
|
||||||
el('div', { textContent: 'Workspace: ' + candidate.workspaceRootPath }),
|
el('div', { textContent: tr('ui.candidateDeal', { deal: candidate.workspaceRootPath }, 'Deal: ' + candidate.workspaceRootPath) }),
|
||||||
el('div', { textContent: 'Time: ' + candidateTimeRange(candidate) }),
|
el('div', { textContent: tr('ui.candidateTime', { time: candidateTimeRange(candidate) }, 'Time: ' + candidateTimeRange(candidate)) }),
|
||||||
el('div', { textContent: 'Estimated duration: ' + candidate.estimatedMinutes + ' min' }),
|
el('div', { textContent: tr('ui.candidateDuration', { minutes: candidate.estimatedMinutes }, 'Estimated duration: ' + candidate.estimatedMinutes + ' min') }),
|
||||||
el('div', { textContent: 'Activities: ' + candidate.activityCount })
|
el('div', { textContent: tr('ui.candidateActivities', { count: candidate.activityCount }, 'Activities: ' + candidate.activityCount) })
|
||||||
]),
|
]),
|
||||||
el('details', { className: 'activity-candidate-activities' }, [
|
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) {
|
].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-actions' }, [
|
||||||
el('div', { className: 'activity-candidate-duration', textContent: candidate.estimatedMinutes + ' min' }),
|
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: 'Review', onClick: function () { reviewCandidate(candidate); } }),
|
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: 'Dismiss', onClick: function () { dismissCandidate(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() {
|
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;
|
clearBtn.disabled = events.length === 0;
|
||||||
statusEl.textContent = statusText;
|
statusEl.textContent = statusText;
|
||||||
statusEl.className = 'activity-status' + (statusClass ? ' ' + statusClass : '');
|
statusEl.className = 'activity-status' + (statusClass ? ' ' + statusClass : '');
|
||||||
@@ -630,27 +853,43 @@
|
|||||||
|
|
||||||
function loadStored() {
|
function loadStored() {
|
||||||
if (!api || !api.settings || typeof api.settings.read !== 'function') return Promise.resolve();
|
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 || {};
|
settings = settings || {};
|
||||||
candidateSourceEvents = eventsFromSettings(settings, '');
|
sessionRegistry = normalizeSessionRegistry(settings[SESSION_REGISTRY_KEY]);
|
||||||
events = eventsFromSettings(settings, scope.mode === 'workspace' ? scope.workspaceRoot : '');
|
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);
|
dismissedByWorkspace = dismissedCandidatesFromSettings(settings);
|
||||||
updateCandidates();
|
updateCandidates();
|
||||||
return persistCandidateCaches();
|
return persistSessionRegistry().then(persistCandidateCaches);
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
statusText = 'Could not load activity: ' + (err && err.message ? err.message : String(err));
|
reportError('ui.loadError', 'Could not load activity. Please try again.', err);
|
||||||
statusClass = 'error';
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function listWorkSessionCandidates(args) {
|
function listWorkSessionCandidates(args) {
|
||||||
var workspace = cleanWorkspace(args && args.workspaceRootPath);
|
var workspace = cleanWorkspace(args && args.workspaceRootPath);
|
||||||
if (!api || !api.settings || typeof api.settings.read !== 'function') {
|
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) {
|
var readRaw = api.storage && api.storage.data && typeof api.storage.data.readNDJSON === 'function'
|
||||||
settings = settings || {};
|
? api.storage.data.readNDJSON(RAW_DATA_NAME)
|
||||||
return { candidates: visibleCandidates(eventsFromSettings(settings, ''), workspace, dismissedCandidatesFromSettings(settings)) };
|
: 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 () {
|
}).catch(function () {
|
||||||
return { candidates: [] };
|
return { candidates: [] };
|
||||||
});
|
});
|
||||||
@@ -661,8 +900,7 @@
|
|||||||
return api.commands.register(WORKLOG_COMMAND_ID, listWorkSessionCandidates).then(function (unregister) {
|
return api.commands.register(WORKLOG_COMMAND_ID, listWorkSessionCandidates).then(function (unregister) {
|
||||||
if (typeof unregister === 'function') unsubscribers.push(unregister);
|
if (typeof unregister === 'function') unsubscribers.push(unregister);
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
statusText = 'Activity commands unavailable: ' + (err && err.message ? err.message : String(err));
|
reportError('ui.commandsUnavailable', 'Activity actions are unavailable. Please try again.', err);
|
||||||
statusClass = 'error';
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -677,11 +915,12 @@
|
|||||||
if (typeof unsubscribe === 'function') unsubscribers.push(unsubscribe);
|
if (typeof unsubscribe === 'function') unsubscribers.push(unsubscribe);
|
||||||
});
|
});
|
||||||
})).then(function () {
|
})).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 = '';
|
statusClass = '';
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
statusText = 'Activity subscriptions unavailable: ' + (err && err.message ? err.message : String(err));
|
reportError('ui.subscriptionsUnavailable', 'Activity updates are unavailable. Please try again.', err);
|
||||||
statusClass = 'error';
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -696,6 +935,13 @@
|
|||||||
}).then(function () {
|
}).then(function () {
|
||||||
if (!disposed) render();
|
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 () {
|
containerEl.__activityUnmount = function () {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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": "Учёт активности Дела включён"
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
"name": "Activity",
|
"name": "Activity",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"apiVersion": "0.1.0",
|
"apiVersion": "0.1.0",
|
||||||
"description": "Workspace-scoped activity log for public plugin events.",
|
"description": "Deal-scoped activity log for public plugin events.",
|
||||||
|
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
|
||||||
"source": "official",
|
"source": "official",
|
||||||
"icon": "activity",
|
"icon": "activity",
|
||||||
"provides": [
|
"provides": [
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* ===========================================================
|
/* ===========================================================
|
||||||
Browser Inbox Plugin — Verstak v2 Frontend Bundle
|
Browser Plugin — Verstak v2 Frontend Bundle
|
||||||
Contract: window.VerstakPluginRegister(id, { components })
|
Contract: window.VerstakPluginRegister(id, { components })
|
||||||
=========================================================== */
|
=========================================================== */
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
var LEGACY_KEY = 'captures';
|
var LEGACY_KEY = 'captures';
|
||||||
var GLOBAL_KEY = 'captures:global';
|
var GLOBAL_KEY = 'captures:global';
|
||||||
var WORKSPACE_PREFIX = 'captures:workspace:';
|
var WORKSPACE_PREFIX = 'captures:workspace:';
|
||||||
|
var MUTATION_EVENT = 'browser-inbox.storage.mutate';
|
||||||
|
|
||||||
function injectStyles() {
|
function injectStyles() {
|
||||||
if (document.getElementById('browser-inbox-style-injected')) return;
|
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-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,.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-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-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{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)}',
|
'.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) {
|
function scopeFromProps(props) {
|
||||||
var workspaceRoot = workspaceFromProps(props);
|
var workspaceRoot = workspaceFromProps(props);
|
||||||
if (!workspaceRoot) {
|
if (!workspaceRoot) {
|
||||||
return { mode: 'global', key: GLOBAL_KEY, label: 'All workspaces', workspaceRoot: '' };
|
return { mode: 'global', key: GLOBAL_KEY, label: '', workspaceRoot: '' };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
mode: 'workspace',
|
mode: 'workspace',
|
||||||
@@ -158,13 +161,13 @@
|
|||||||
return value === 'selection' || value === 'link' || value === 'file' || value === 'page' ? value : 'page';
|
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;
|
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) {
|
function noteTitle(capture, fallbackTitle) {
|
||||||
return text((capture && (capture.title || capture.domain || capture.captureId)) || 'Browser Capture').trim() || 'Browser Capture';
|
return text((capture && (capture.title || capture.domain || capture.captureId)) || fallbackTitle).trim() || text(fallbackTitle).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeNoteFilename(title) {
|
function safeNoteFilename(title) {
|
||||||
@@ -180,6 +183,12 @@
|
|||||||
return safeNoteFilename(title).replace(/\.md$/, '.url');
|
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) {
|
function safeFileFilename(name) {
|
||||||
var base = text(name).trim()
|
var base = text(name).trim()
|
||||||
.replace(/[\\/:*?"<>|\r\n\t]+/g, '_')
|
.replace(/[\\/:*?"<>|\r\n\t]+/g, '_')
|
||||||
@@ -190,8 +199,8 @@
|
|||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
function captureToMarkdown(capture) {
|
function captureToMarkdown(capture, fallbackTitle) {
|
||||||
var title = noteTitle(capture);
|
var title = noteTitle(capture, fallbackTitle);
|
||||||
var lines = ['# ' + title, ''];
|
var lines = ['# ' + title, ''];
|
||||||
if (capture && capture.url) lines.push('Source: ' + capture.url);
|
if (capture && capture.url) lines.push('Source: ' + capture.url);
|
||||||
if (capture && capture.capturedAt) lines.push('Captured: ' + capture.capturedAt);
|
if (capture && capture.capturedAt) lines.push('Captured: ' + capture.capturedAt);
|
||||||
@@ -235,8 +244,10 @@
|
|||||||
fileDataBase64: text(payload.fileDataBase64).trim(),
|
fileDataBase64: text(payload.fileDataBase64).trim(),
|
||||||
source: text(payload.source).trim(),
|
source: text(payload.source).trim(),
|
||||||
browserName: text(payload.browserName).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 value.filter(function (item) {
|
||||||
return item && typeof item === 'object' && item.captureId;
|
return item && typeof item === 'object' && item.captureId;
|
||||||
}).map(function (item) {
|
}).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);
|
var workspaceRootPath = cleanWorkspace(item.workspaceRootPath) || workspaceFromStorageKey(storageKey);
|
||||||
return {
|
return {
|
||||||
captureId: text(item.captureId),
|
captureId: text(item.captureId),
|
||||||
@@ -275,6 +285,10 @@
|
|||||||
browserName: text(item.browserName),
|
browserName: text(item.browserName),
|
||||||
workspaceRootPath: workspaceRootPath,
|
workspaceRootPath: workspaceRootPath,
|
||||||
workspaceName: cleanWorkspace(item.workspaceName || 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,
|
processed: item.processed === true,
|
||||||
_storageKey: storageKey || ''
|
_storageKey: storageKey || ''
|
||||||
};
|
};
|
||||||
@@ -301,6 +315,10 @@
|
|||||||
browserName: item.browserName,
|
browserName: item.browserName,
|
||||||
workspaceRootPath: item.workspaceRootPath,
|
workspaceRootPath: item.workspaceRootPath,
|
||||||
workspaceName: item.workspaceName || 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
|
processed: item.processed === true
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -345,7 +363,7 @@
|
|||||||
var scope = scopeFromProps(props || {});
|
var scope = scopeFromProps(props || {});
|
||||||
var captures = [];
|
var captures = [];
|
||||||
var selectedId = '';
|
var selectedId = '';
|
||||||
var statusText = 'Connecting to receiver events...';
|
var statusText = '';
|
||||||
var statusClass = '';
|
var statusClass = '';
|
||||||
var disposed = false;
|
var disposed = false;
|
||||||
var unsubscribers = [];
|
var unsubscribers = [];
|
||||||
@@ -354,31 +372,59 @@
|
|||||||
var statusFilter = 'all';
|
var statusFilter = 'all';
|
||||||
var workspaceFilter = '';
|
var workspaceFilter = '';
|
||||||
var searchQuery = '';
|
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 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 countEl = el('span', { className: 'browser-inbox-count' });
|
||||||
var statusEl = el('span', { className: 'browser-inbox-status' });
|
var statusEl = el('span', { className: 'browser-inbox-status' });
|
||||||
var filtersEl = el('div', { className: 'browser-inbox-filters' });
|
var filtersEl = el('div', { className: 'browser-inbox-filters' });
|
||||||
var statusFilterEl = el('select', {
|
var statusFilterEl = el('select', {
|
||||||
className: 'browser-inbox-select',
|
className: 'browser-inbox-select',
|
||||||
'data-browser-inbox-filter': 'status',
|
'data-browser-inbox-filter': 'status',
|
||||||
'aria-label': 'Capture status filter',
|
'aria-label': tr('ui.statusFilter', null, 'Material status filter'),
|
||||||
onChange: function (event) {
|
onChange: function (event) {
|
||||||
statusFilter = text(event && event.target && event.target.value) || 'all';
|
statusFilter = text(event && event.target && event.target.value) || 'all';
|
||||||
selectedId = '';
|
selectedId = '';
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
el('option', { value: 'all', textContent: 'All captures' }),
|
el('option', { value: 'all', textContent: tr('ui.allCaptures', null, 'All captures') }),
|
||||||
el('option', { value: 'unassigned', textContent: 'Unassigned' }),
|
el('option', { value: 'unassigned', textContent: tr('ui.unassigned', null, 'Unassigned') }),
|
||||||
el('option', { value: 'unprocessed', textContent: 'Unprocessed' }),
|
el('option', { value: 'unprocessed', textContent: tr('ui.unprocessed', null, 'Unprocessed') }),
|
||||||
el('option', { value: 'processed', textContent: 'Processed' })
|
el('option', { value: 'processed', textContent: tr('ui.processed', null, 'Processed') }),
|
||||||
|
el('option', { value: 'archived', textContent: tr('ui.archive', null, 'Archive') })
|
||||||
]);
|
]);
|
||||||
var workspaceFilterEl = el('select', {
|
var workspaceFilterEl = el('select', {
|
||||||
className: 'browser-inbox-select',
|
className: 'browser-inbox-select',
|
||||||
'data-browser-inbox-filter': 'workspace',
|
'data-browser-inbox-filter': 'workspace',
|
||||||
'aria-label': 'Workspace filter',
|
'aria-label': tr('ui.workspaceFilter', null, 'Deal filter'),
|
||||||
onChange: function (event) {
|
onChange: function (event) {
|
||||||
workspaceFilter = cleanWorkspace(event && event.target && event.target.value);
|
workspaceFilter = cleanWorkspace(event && event.target && event.target.value);
|
||||||
selectedId = '';
|
selectedId = '';
|
||||||
@@ -388,9 +434,9 @@
|
|||||||
var searchInput = el('input', {
|
var searchInput = el('input', {
|
||||||
className: 'browser-inbox-input',
|
className: 'browser-inbox-input',
|
||||||
type: 'search',
|
type: 'search',
|
||||||
placeholder: 'Search captures',
|
placeholder: tr('ui.search', null, 'Search captures'),
|
||||||
'data-browser-inbox-filter': 'search',
|
'data-browser-inbox-filter': 'search',
|
||||||
'aria-label': 'Search captures',
|
'aria-label': tr('ui.search', null, 'Search captures'),
|
||||||
onInput: function (event) {
|
onInput: function (event) {
|
||||||
searchQuery = text(event && event.target && event.target.value).trim().toLowerCase();
|
searchQuery = text(event && event.target && event.target.value).trim().toLowerCase();
|
||||||
selectedId = '';
|
selectedId = '';
|
||||||
@@ -402,7 +448,7 @@
|
|||||||
var clearBtn = el('button', {
|
var clearBtn = el('button', {
|
||||||
className: 'browser-inbox-btn danger',
|
className: 'browser-inbox-btn danger',
|
||||||
'data-browser-inbox-action': 'clear',
|
'data-browser-inbox-action': 'clear',
|
||||||
textContent: 'Clear',
|
textContent: tr('ui.clear', null, 'Clear'),
|
||||||
onClick: function () {
|
onClick: function () {
|
||||||
clearScope().then(render);
|
clearScope().then(render);
|
||||||
}
|
}
|
||||||
@@ -413,7 +459,7 @@
|
|||||||
if (scope.mode === 'global') {
|
if (scope.mode === 'global') {
|
||||||
filtersEl.appendChild(workspaceFilterEl);
|
filtersEl.appendChild(workspaceFilterEl);
|
||||||
} else {
|
} 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);
|
filtersEl.appendChild(searchInput);
|
||||||
toolbar.appendChild(filtersEl);
|
toolbar.appendChild(filtersEl);
|
||||||
@@ -448,7 +494,7 @@
|
|||||||
function renderWorkspaceFilterOptions() {
|
function renderWorkspaceFilterOptions() {
|
||||||
if (scope.mode !== 'global') return;
|
if (scope.mode !== 'global') return;
|
||||||
workspaceFilterEl.innerHTML = '';
|
workspaceFilterEl.innerHTML = '';
|
||||||
workspaceFilterEl.appendChild(option('', 'All workspaces'));
|
workspaceFilterEl.appendChild(option('', tr('ui.allDeals', null, 'All Deals')));
|
||||||
workspaceRoots().forEach(function (root) {
|
workspaceRoots().forEach(function (root) {
|
||||||
workspaceFilterEl.appendChild(option(root, root));
|
workspaceFilterEl.appendChild(option(root, root));
|
||||||
});
|
});
|
||||||
@@ -460,6 +506,8 @@
|
|||||||
var workspaceRoot = cleanWorkspace(capture && capture.workspaceRootPath);
|
var workspaceRoot = cleanWorkspace(capture && capture.workspaceRootPath);
|
||||||
if (scope.mode === 'workspace' && workspaceRoot !== scope.workspaceRoot) return false;
|
if (scope.mode === 'workspace' && workspaceRoot !== scope.workspaceRoot) return false;
|
||||||
if (scope.mode === 'global' && workspaceFilter && workspaceRoot !== workspaceFilter) 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 === 'unassigned' && workspaceRoot) return false;
|
||||||
if (statusFilter === 'unprocessed' && capture.processed === true) return false;
|
if (statusFilter === 'unprocessed' && capture.processed === true) return false;
|
||||||
if (statusFilter === 'processed' && capture.processed !== true) return false;
|
if (statusFilter === 'processed' && capture.processed !== true) return false;
|
||||||
@@ -468,43 +516,46 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function persist() {
|
function publishMutation(action, payload, verify, verifySettings) {
|
||||||
if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
|
if (!api || !api.events || typeof api.events.publish !== 'function') {
|
||||||
return api.settings.write(GLOBAL_KEY, storageCaptures(sortCaptures(captures))).catch(function (err) {
|
reportError('ui.saveError', 'Could not update browser materials. Please try again.');
|
||||||
statusText = 'Could not save inbox: ' + (err && err.message ? err.message : String(err));
|
return Promise.resolve(false);
|
||||||
statusClass = 'error';
|
}
|
||||||
|
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 = {};
|
var ids = {};
|
||||||
captureIds.forEach(function (captureId) {
|
captureIds.forEach(function (captureId) {
|
||||||
ids[captureId] = true;
|
ids[captureId] = true;
|
||||||
});
|
});
|
||||||
captures = captures.filter(function (item) {
|
return publishMutation('archive', { captureIds: captureIds }, function () {
|
||||||
return !ids[item.captureId];
|
return captures.every(function (capture) {
|
||||||
});
|
return !ids[capture.captureId] || capture.globalState === 'archived';
|
||||||
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 = '';
|
|
||||||
});
|
});
|
||||||
}
|
}).then(function (saved) {
|
||||||
return api.settings.read().then(function (settings) {
|
if (!saved) return;
|
||||||
var keys = globalCaptureKeys(settings || {});
|
if (ids[selectedId]) selectedId = '';
|
||||||
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 () {
|
|
||||||
statusText = successText;
|
statusText = successText;
|
||||||
statusClass = '';
|
statusClass = '';
|
||||||
}).catch(function (err) {
|
render();
|
||||||
statusText = 'Could not update inbox: ' + (err && err.message ? err.message : String(err));
|
|
||||||
statusClass = 'error';
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,7 +563,9 @@
|
|||||||
var ids = scope.mode === 'global'
|
var ids = scope.mode === 'global'
|
||||||
? captures.map(function (capture) { return capture.captureId; })
|
? captures.map(function (capture) { return capture.captureId; })
|
||||||
: captures.filter(function (capture) { return capture.workspaceRootPath === scope.workspaceRoot; }).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() {
|
function selectedCapture() {
|
||||||
@@ -529,11 +582,11 @@
|
|||||||
return item.captureId === capture.captureId;
|
return item.captureId === capture.captureId;
|
||||||
});
|
});
|
||||||
if (existing) return Promise.resolve();
|
if (existing) return Promise.resolve();
|
||||||
captures = sortCaptures([capture].concat(captures));
|
|
||||||
selectedId = capture.captureId;
|
selectedId = capture.captureId;
|
||||||
statusText = 'Capture received';
|
statusText = tr('ui.captureReceived', null, 'Material received');
|
||||||
statusClass = '';
|
statusClass = '';
|
||||||
return persist().then(render);
|
render();
|
||||||
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyDomainBinding(capture) {
|
function applyDomainBinding(capture) {
|
||||||
@@ -547,47 +600,83 @@
|
|||||||
|
|
||||||
function assignWorkspace(captureId, workspaceRoot) {
|
function assignWorkspace(captureId, workspaceRoot) {
|
||||||
workspaceRoot = cleanWorkspace(workspaceRoot);
|
workspaceRoot = cleanWorkspace(workspaceRoot);
|
||||||
captures = captures.map(function (capture) {
|
return publishMutation('assign', {
|
||||||
if (capture.captureId !== captureId) return capture;
|
captureId: captureId,
|
||||||
return Object.assign({}, capture, {
|
workspaceRootPath: workspaceRoot
|
||||||
workspaceRootPath: workspaceRoot,
|
}, function () {
|
||||||
workspaceName: workspaceRoot
|
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) {
|
function archiveCapture(captureId) {
|
||||||
return removeCaptures([captureId], 'Capture deleted');
|
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) {
|
function setProcessed(captureId, processed) {
|
||||||
captures = captures.map(function (capture) {
|
return publishMutation('processed', {
|
||||||
if (capture.captureId !== captureId) return capture;
|
captureId: captureId,
|
||||||
return Object.assign({}, capture, { processed: processed === true });
|
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) {
|
function createNoteFromCapture(capture) {
|
||||||
if (!capture || !capture.workspaceRootPath) return Promise.resolve();
|
if (!capture || !capture.workspaceRootPath) return Promise.resolve();
|
||||||
if (!api || !api.files || typeof api.files.writeText !== 'function') {
|
if (!api || !api.files || typeof api.files.writeText !== 'function') {
|
||||||
statusText = 'Could not create note: files API unavailable';
|
reportError('ui.createNoteError', 'Could not create the note. Please try again.');
|
||||||
statusClass = 'error';
|
|
||||||
render();
|
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
var title = noteTitle(capture);
|
var title = noteTitle(capture, tr('ui.untitledCapture', null, 'Untitled material'));
|
||||||
var notePath = capture.workspaceRootPath + '/Notes/' + safeNoteFilename(title);
|
var notePath = capture.workspaceRootPath + '/Notes/' + safeNoteFilename(title);
|
||||||
statusText = 'Creating note...';
|
statusText = tr('ui.creatingNote', null, 'Creating note...');
|
||||||
statusClass = '';
|
statusClass = '';
|
||||||
render();
|
render();
|
||||||
return api.files.writeText(notePath, captureToMarkdown(capture), {
|
return api.files.writeText(notePath, captureToMarkdown(capture, tr('ui.untitledCapture', null, 'Untitled material')), {
|
||||||
createIfMissing: true,
|
createIfMissing: true,
|
||||||
overwrite: false
|
overwrite: false
|
||||||
}).then(function () {
|
}).then(function () {
|
||||||
@@ -604,33 +693,39 @@
|
|||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}).then(function () {
|
}).then(function () {
|
||||||
statusText = 'Created note: ' + notePath;
|
statusText = tr('ui.noteCreated', { path: notePath }, 'Note created: ' + notePath);
|
||||||
statusClass = '';
|
statusClass = '';
|
||||||
return removeCapture(capture.captureId);
|
return archiveCapture(capture.captureId);
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
statusText = 'Could not create note: ' + (err && err.message ? err.message : String(err));
|
reportError('ui.createNoteError', 'Could not create the note. Please try again.', err);
|
||||||
statusClass = 'error';
|
|
||||||
render();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function createLinkFromCapture(capture) {
|
function createLinkFromCapture(capture) {
|
||||||
if (!capture || !capture.workspaceRootPath || !capture.url) return Promise.resolve();
|
if (!capture || !capture.workspaceRootPath || !capture.url) return Promise.resolve();
|
||||||
if (!api || !api.files || typeof api.files.writeText !== 'function') {
|
if (!api || !api.files || typeof api.files.writeText !== 'function') {
|
||||||
statusText = 'Could not create link: files API unavailable';
|
reportError('ui.createLinkError', 'Could not create the link. Please try again.');
|
||||||
statusClass = 'error';
|
|
||||||
render();
|
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
var title = noteTitle(capture);
|
var title = noteTitle(capture, tr('ui.untitledCapture', null, 'Untitled material'));
|
||||||
var linkPath = capture.workspaceRootPath + '/Links/' + safeLinkFilename(title);
|
statusText = tr('ui.creatingLink', null, 'Creating link...');
|
||||||
statusText = 'Creating link...';
|
|
||||||
statusClass = '';
|
statusClass = '';
|
||||||
render();
|
render();
|
||||||
return api.files.writeText(linkPath, captureToUrlShortcut(capture), {
|
function writeLink(number) {
|
||||||
createIfMissing: true,
|
var linkPath = capture.workspaceRootPath + '/Links/' + numberedLinkFilename(title, number);
|
||||||
overwrite: false
|
return api.files.writeText(linkPath, captureToUrlShortcut(capture), {
|
||||||
}).then(function () {
|
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') {
|
if (api.events && typeof api.events.publish === 'function') {
|
||||||
return api.events.publish('browser.capture.converted', {
|
return api.events.publish('browser.capture.converted', {
|
||||||
captureId: capture.captureId,
|
captureId: capture.captureId,
|
||||||
@@ -640,31 +735,36 @@
|
|||||||
title: title,
|
title: title,
|
||||||
url: capture.url || '',
|
url: capture.url || '',
|
||||||
sourcePluginId: PLUGIN_ID
|
sourcePluginId: PLUGIN_ID
|
||||||
|
}).then(function () {
|
||||||
|
return linkPath;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return undefined;
|
return linkPath;
|
||||||
}).then(function () {
|
}).then(function () {
|
||||||
statusText = 'Created link: ' + linkPath;
|
statusText = tr('ui.linkCreated', null, 'Link created');
|
||||||
statusClass = '';
|
statusClass = '';
|
||||||
return removeCapture(capture.captureId);
|
return archiveCapture(capture.captureId);
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
statusText = 'Could not create link: ' + (err && err.message ? err.message : String(err));
|
reportError('ui.createLinkError', 'Could not create the link. Please try again.', err);
|
||||||
statusClass = 'error';
|
});
|
||||||
render();
|
}
|
||||||
|
|
||||||
|
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) {
|
function createFileFromCapture(capture) {
|
||||||
if (!capture || !capture.workspaceRootPath || capture.kind !== 'file' || !capture.fileName || (!capture.fileText && !capture.fileDataBase64)) return Promise.resolve();
|
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')) {
|
if (!api || !api.files || (capture.fileDataBase64 ? typeof api.files.writeBytes !== 'function' : typeof api.files.writeText !== 'function')) {
|
||||||
statusText = 'Could not create file: files API unavailable';
|
reportError('ui.createFileError', 'Could not create the file. Please try again.');
|
||||||
statusClass = 'error';
|
|
||||||
render();
|
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
var fileName = safeFileFilename(capture.fileName);
|
var fileName = safeFileFilename(capture.fileName);
|
||||||
var filePath = capture.workspaceRootPath + '/Files/' + fileName;
|
var filePath = capture.workspaceRootPath + '/Files/' + fileName;
|
||||||
statusText = 'Creating file...';
|
statusText = tr('ui.creatingFile', null, 'Creating file...');
|
||||||
statusClass = '';
|
statusClass = '';
|
||||||
render();
|
render();
|
||||||
var writeOptions = {
|
var writeOptions = {
|
||||||
@@ -691,13 +791,11 @@
|
|||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}).then(function () {
|
}).then(function () {
|
||||||
statusText = 'Created file: ' + filePath;
|
statusText = tr('ui.fileCreated', { path: filePath }, 'File created: ' + filePath);
|
||||||
statusClass = '';
|
statusClass = '';
|
||||||
return removeCapture(capture.captureId);
|
return archiveCapture(capture.captureId);
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
statusText = 'Could not create file: ' + (err && err.message ? err.message : String(err));
|
reportError('ui.createFileError', 'Could not create the file. Please try again.', err);
|
||||||
statusClass = 'error';
|
|
||||||
render();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,8 +804,8 @@
|
|||||||
var visible = visibleCaptures();
|
var visible = visibleCaptures();
|
||||||
if (visible.length === 0) {
|
if (visible.length === 0) {
|
||||||
var emptyText = captures.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.'
|
? tr('ui.empty', null, 'No browser materials yet. Send a page, selection, or link from the extension.')
|
||||||
: 'No captures match the current filters.';
|
: tr('ui.emptyFiltered', null, 'No materials match the current filters.');
|
||||||
listEl.appendChild(el('div', { className: 'browser-inbox-empty', textContent: emptyText }));
|
listEl.appendChild(el('div', { className: 'browser-inbox-empty', textContent: emptyText }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -723,19 +821,22 @@
|
|||||||
}, [
|
}, [
|
||||||
el('div', { className: 'browser-inbox-row-head' }, [
|
el('div', { className: 'browser-inbox-row-head' }, [
|
||||||
el('span', { className: 'browser-inbox-kind', textContent: capture.kind }),
|
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 })
|
el('div', { className: 'browser-inbox-row-url', textContent: capture.url || capture.domain || capture.captureId })
|
||||||
]);
|
]);
|
||||||
row.appendChild(el('div', { className: 'browser-inbox-row-meta' }, [
|
row.appendChild(el('div', { className: 'browser-inbox-row-meta' }, [
|
||||||
el('span', {
|
el('span', {
|
||||||
className: 'browser-inbox-badge' + (workspaceRoot ? '' : ' unassigned'),
|
className: 'browser-inbox-badge' + (workspaceRoot ? '' : ' unassigned'),
|
||||||
textContent: workspaceRoot || 'Unassigned'
|
textContent: workspaceRoot || tr('ui.unassigned', null, 'Unassigned')
|
||||||
}),
|
}),
|
||||||
el('span', {
|
el('span', {
|
||||||
className: 'browser-inbox-badge' + (capture.processed ? ' processed' : ''),
|
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) {
|
if (capture.text) {
|
||||||
row.appendChild(el('div', { className: 'browser-inbox-row-text', textContent: capture.text }));
|
row.appendChild(el('div', { className: 'browser-inbox-row-text', textContent: capture.text }));
|
||||||
@@ -748,49 +849,51 @@
|
|||||||
detailEl.innerHTML = '';
|
detailEl.innerHTML = '';
|
||||||
var capture = selectedCapture();
|
var capture = selectedCapture();
|
||||||
if (!capture) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
selectedId = capture.captureId;
|
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' }, [
|
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-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-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-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-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-value', textContent: capture.browserName || capture.source || '-' }),
|
||||||
el('div', { className: 'browser-inbox-meta-label', textContent: 'Workspace' }),
|
el('div', { className: 'browser-inbox-meta-label', textContent: tr('ui.workspace', null, 'Deal') }),
|
||||||
el('div', { className: 'browser-inbox-meta-value', textContent: capture.workspaceRootPath || 'Unassigned' }),
|
el('div', { className: 'browser-inbox-meta-value', textContent: capture.workspaceRootPath || tr('ui.unassigned', null, 'Unassigned') }),
|
||||||
el('div', { className: 'browser-inbox-meta-label', textContent: 'Status' }),
|
el('div', { className: 'browser-inbox-meta-label', textContent: tr('ui.status', null, 'Status') }),
|
||||||
el('div', { className: 'browser-inbox-meta-value', textContent: capture.processed ? 'Processed' : 'Unprocessed' })
|
el('div', { className: 'browser-inbox-meta-value', textContent: capture.processed
|
||||||
|
? tr('ui.processed', null, 'Processed')
|
||||||
|
: tr('ui.unprocessed', null, 'Unprocessed') })
|
||||||
]));
|
]));
|
||||||
var assignmentSelect = el('select', {
|
var assignmentSelect = el('select', {
|
||||||
className: 'browser-inbox-select',
|
className: 'browser-inbox-select',
|
||||||
'data-browser-inbox-assignment': capture.captureId,
|
'data-browser-inbox-assignment': capture.captureId,
|
||||||
'aria-label': 'Assign capture workspace',
|
'aria-label': tr('ui.assignment', null, 'Assign to Deal'),
|
||||||
onChange: function (event) {
|
onChange: function (event) {
|
||||||
assignWorkspace(capture.captureId, event && event.target && event.target.value);
|
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) {
|
workspaceRoots().forEach(function (workspaceRoot) {
|
||||||
assignmentSelect.appendChild(option(workspaceRoot, workspaceRoot));
|
assignmentSelect.appendChild(option(workspaceRoot, workspaceRoot));
|
||||||
});
|
});
|
||||||
assignmentSelect.value = capture.workspaceRootPath || '';
|
assignmentSelect.value = capture.workspaceRootPath || '';
|
||||||
var assignmentControls = [
|
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
|
assignmentSelect
|
||||||
];
|
];
|
||||||
if (capture.workspaceRootPath) {
|
if (capture.workspaceRootPath) {
|
||||||
assignmentControls.push(el('button', {
|
assignmentControls.push(el('button', {
|
||||||
className: 'browser-inbox-btn',
|
className: 'browser-inbox-btn',
|
||||||
'data-browser-inbox-action': 'clear-assignment',
|
'data-browser-inbox-action': 'clear-assignment',
|
||||||
textContent: 'Clear assignment',
|
textContent: tr('ui.clearAssignment', null, 'Clear assignment'),
|
||||||
onClick: function () {
|
onClick: function () {
|
||||||
assignWorkspace(capture.captureId, '');
|
assignWorkspace(capture.captureId, '');
|
||||||
}
|
}
|
||||||
@@ -798,7 +901,7 @@
|
|||||||
}
|
}
|
||||||
detailEl.appendChild(el('div', { className: 'browser-inbox-assignment' }, assignmentControls));
|
detailEl.appendChild(el('div', { className: 'browser-inbox-assignment' }, assignmentControls));
|
||||||
if (!capture.workspaceRootPath) {
|
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) {
|
if (capture.text) {
|
||||||
detailEl.appendChild(el('div', { className: 'browser-inbox-text', textContent: capture.text }));
|
detailEl.appendChild(el('div', { className: 'browser-inbox-text', textContent: capture.text }));
|
||||||
@@ -810,16 +913,28 @@
|
|||||||
actionButtons.push(el('button', {
|
actionButtons.push(el('button', {
|
||||||
className: 'browser-inbox-btn',
|
className: 'browser-inbox-btn',
|
||||||
'data-browser-inbox-action': 'toggle-processed',
|
'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 () {
|
onClick: function () {
|
||||||
setProcessed(capture.captureId, !capture.processed);
|
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) {
|
if (capture.workspaceRootPath) {
|
||||||
actionButtons.push(el('button', {
|
actionButtons.push(el('button', {
|
||||||
className: 'browser-inbox-btn',
|
className: 'browser-inbox-btn',
|
||||||
'data-browser-inbox-action': 'create-note',
|
'data-browser-inbox-action': 'create-note',
|
||||||
textContent: 'Create Note',
|
textContent: tr('ui.createNote', null, 'Create Note'),
|
||||||
onClick: function () {
|
onClick: function () {
|
||||||
createNoteFromCapture(capture);
|
createNoteFromCapture(capture);
|
||||||
}
|
}
|
||||||
@@ -828,7 +943,7 @@
|
|||||||
actionButtons.push(el('button', {
|
actionButtons.push(el('button', {
|
||||||
className: 'browser-inbox-btn',
|
className: 'browser-inbox-btn',
|
||||||
'data-browser-inbox-action': 'create-link',
|
'data-browser-inbox-action': 'create-link',
|
||||||
textContent: 'Create Link',
|
textContent: tr('ui.createLink', null, 'Create Link'),
|
||||||
onClick: function () {
|
onClick: function () {
|
||||||
createLinkFromCapture(capture);
|
createLinkFromCapture(capture);
|
||||||
}
|
}
|
||||||
@@ -838,19 +953,38 @@
|
|||||||
actionButtons.push(el('button', {
|
actionButtons.push(el('button', {
|
||||||
className: 'browser-inbox-btn',
|
className: 'browser-inbox-btn',
|
||||||
'data-browser-inbox-action': 'create-file',
|
'data-browser-inbox-action': 'create-file',
|
||||||
textContent: 'Create File',
|
textContent: tr('ui.createFile', null, 'Create File'),
|
||||||
onClick: function () {
|
onClick: function () {
|
||||||
createFileFromCapture(capture);
|
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', {
|
actionButtons.push(el('button', {
|
||||||
className: 'browser-inbox-btn danger',
|
className: 'browser-inbox-btn danger',
|
||||||
'data-browser-inbox-action': 'remove',
|
'data-browser-inbox-action': 'delete-permanently',
|
||||||
textContent: 'Delete',
|
textContent: tr('ui.deletePermanently', null, 'Delete permanently'),
|
||||||
onClick: function () {
|
onClick: function () {
|
||||||
removeCapture(capture.captureId);
|
permanentlyDeleteCapture(capture.captureId);
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
detailEl.appendChild(el('div', { className: 'browser-inbox-detail-actions' }, actionButtons));
|
detailEl.appendChild(el('div', { className: 'browser-inbox-detail-actions' }, actionButtons));
|
||||||
@@ -860,8 +994,11 @@
|
|||||||
var visibleCount = visibleCaptures().length;
|
var visibleCount = visibleCaptures().length;
|
||||||
var total = captures.length;
|
var total = captures.length;
|
||||||
countEl.textContent = visibleCount === total
|
countEl.textContent = visibleCount === total
|
||||||
? total + ' item' + (total === 1 ? '' : 's')
|
? localizedItemCount(total)
|
||||||
: visibleCount + ' of ' + total + ' items';
|
: tr('ui.items.filtered', {
|
||||||
|
visible: localizedItemCount(visibleCount),
|
||||||
|
total: localizedItemCount(total)
|
||||||
|
}, localizedItemCount(visibleCount) + ' of ' + localizedItemCount(total));
|
||||||
var scopeCount = scope.mode === 'global'
|
var scopeCount = scope.mode === 'global'
|
||||||
? total
|
? total
|
||||||
: captures.filter(function (capture) { return capture.workspaceRootPath === scope.workspaceRoot; }).length;
|
: captures.filter(function (capture) { return capture.workspaceRootPath === scope.workspaceRoot; }).length;
|
||||||
@@ -879,7 +1016,7 @@
|
|||||||
renderDetail();
|
renderDetail();
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadStored() {
|
function loadStored(skipMigration) {
|
||||||
if (!api || !api.settings || typeof api.settings.read !== 'function') return Promise.resolve();
|
if (!api || !api.settings || typeof api.settings.read !== 'function') return Promise.resolve();
|
||||||
return api.settings.read().then(function (settings) {
|
return api.settings.read().then(function (settings) {
|
||||||
domainBindings = normalizeDomainBindings((settings || {}).domainBindings);
|
domainBindings = normalizeDomainBindings((settings || {}).domainBindings);
|
||||||
@@ -891,13 +1028,30 @@
|
|||||||
if (key !== GLOBAL_KEY && stored.length > 0) hasLegacyCaptures = true;
|
if (key !== GLOBAL_KEY && stored.length > 0) hasLegacyCaptures = true;
|
||||||
all = all.concat(stored);
|
all = all.concat(stored);
|
||||||
});
|
});
|
||||||
captures = sortCaptures(all);
|
captures = sortCaptures(all).map(applyDomainBinding);
|
||||||
if (!selectedId && captures[0]) selectedId = captures[0].captureId;
|
if (!selectedId && captures[0]) selectedId = captures[0].captureId;
|
||||||
// Keep legacy records readable, then mirror the canonical state into the global queue.
|
// Read legacy records once, then ask the backend to migrate them atomically.
|
||||||
return hasLegacyCaptures ? persist() : undefined;
|
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) {
|
}).catch(function (err) {
|
||||||
statusText = 'Could not load inbox: ' + (err && err.message ? err.message : String(err));
|
reportError('ui.loadError', 'Could not load browser materials. Please try again.', err);
|
||||||
statusClass = 'error';
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -920,16 +1074,44 @@
|
|||||||
if (!api || !api.events || typeof api.events.subscribe !== 'function') return Promise.resolve();
|
if (!api || !api.events || typeof api.events.subscribe !== 'function') return Promise.resolve();
|
||||||
return Promise.all(CAPTURE_EVENTS.map(function (eventName) {
|
return Promise.all(CAPTURE_EVENTS.map(function (eventName) {
|
||||||
return api.events.subscribe(eventName, function (event) {
|
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) {
|
}).then(function (unsubscribe) {
|
||||||
if (typeof unsubscribe === 'function') unsubscribers.push(unsubscribe);
|
if (typeof unsubscribe === 'function') unsubscribers.push(unsubscribe);
|
||||||
});
|
});
|
||||||
})).then(function () {
|
})).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 = '';
|
statusClass = '';
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
statusText = 'Receiver unavailable: ' + (err && err.message ? err.message : String(err));
|
reportError('ui.receiverError', 'The browser receiver is unavailable. Please try again.', err);
|
||||||
statusClass = 'error';
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -941,6 +1123,17 @@
|
|||||||
}).then(function () {
|
}).then(function () {
|
||||||
if (!disposed) render();
|
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 () {
|
containerEl.__browserInboxUnmount = function () {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
@@ -967,6 +1160,11 @@
|
|||||||
containerEl.innerHTML = '';
|
containerEl.innerHTML = '';
|
||||||
containerEl.className = 'browser-inbox-settings';
|
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', {
|
var receiverURLInput = el('input', {
|
||||||
className: 'browser-inbox-settings-input',
|
className: 'browser-inbox-settings-input',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@@ -988,27 +1186,27 @@
|
|||||||
className: 'browser-inbox-btn',
|
className: 'browser-inbox-btn',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
'data-browser-inbox-settings-action': 'copy-url',
|
'data-browser-inbox-settings-action': 'copy-url',
|
||||||
textContent: 'Copy URL'
|
textContent: tr('ui.copyUrl', null, 'Copy URL')
|
||||||
});
|
});
|
||||||
var copyTokenButton = el('button', {
|
var copyTokenButton = el('button', {
|
||||||
className: 'browser-inbox-btn',
|
className: 'browser-inbox-btn',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
'data-browser-inbox-settings-action': 'copy-token',
|
'data-browser-inbox-settings-action': 'copy-token',
|
||||||
textContent: 'Copy Token'
|
textContent: tr('ui.copyToken', null, 'Copy Token')
|
||||||
});
|
});
|
||||||
var rotateTokenButton = el('button', {
|
var rotateTokenButton = el('button', {
|
||||||
className: 'browser-inbox-btn danger',
|
className: 'browser-inbox-btn danger',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
'data-browser-inbox-settings-action': 'rotate-token',
|
'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' }, [
|
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
|
receiverURLInput
|
||||||
]));
|
]));
|
||||||
containerEl.appendChild(el('div', { className: 'browser-inbox-settings-field' }, [
|
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
|
receiverTokenInput
|
||||||
]));
|
]));
|
||||||
containerEl.appendChild(el('div', { className: 'browser-inbox-settings-actions' }, [
|
containerEl.appendChild(el('div', { className: 'browser-inbox-settings-actions' }, [
|
||||||
@@ -1023,6 +1221,13 @@
|
|||||||
statusEl.className = 'browser-inbox-settings-status' + (isError ? ' error' : '');
|
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) {
|
function setBusy(busy) {
|
||||||
copyURLButton.disabled = busy;
|
copyURLButton.disabled = busy;
|
||||||
copyTokenButton.disabled = busy;
|
copyTokenButton.disabled = busy;
|
||||||
@@ -1041,14 +1246,14 @@
|
|||||||
|
|
||||||
function loadPairing() {
|
function loadPairing() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setStatus('Loading...', false);
|
setStatus(tr('ui.loading', null, 'Loading...'), false);
|
||||||
return Promise.resolve().then(function () {
|
return Promise.resolve().then(function () {
|
||||||
return pairingAPI().pairing();
|
return pairingAPI().pairing();
|
||||||
}).then(function (pairing) {
|
}).then(function (pairing) {
|
||||||
applyPairing(pairing);
|
applyPairing(pairing);
|
||||||
setStatus('', false);
|
setStatus('', false);
|
||||||
}).catch(function (err) {
|
}).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 () {
|
}).then(function () {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
});
|
});
|
||||||
@@ -1057,13 +1262,13 @@
|
|||||||
function copyValue(value, label) {
|
function copyValue(value, label) {
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
if (typeof navigator === 'undefined' || !navigator.clipboard || typeof navigator.clipboard.writeText !== 'function') {
|
if (typeof navigator === 'undefined' || !navigator.clipboard || typeof navigator.clipboard.writeText !== 'function') {
|
||||||
setStatus('Clipboard unavailable', true);
|
setStatus(tr('ui.clipboardUnavailable', null, 'Clipboard unavailable'), true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
navigator.clipboard.writeText(value).then(function () {
|
navigator.clipboard.writeText(value).then(function () {
|
||||||
setStatus(label + ' copied', false);
|
setStatus(tr('ui.copied', { label: label }, '{label} copied'), false);
|
||||||
}).catch(function (err) {
|
}).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');
|
copyValue(receiverTokenInput.value, 'Token');
|
||||||
});
|
});
|
||||||
rotateTokenButton.addEventListener('click', function () {
|
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);
|
setBusy(true);
|
||||||
setStatus('Rotating...', false);
|
setStatus(tr('ui.rotating', null, 'Rotating...'), false);
|
||||||
Promise.resolve().then(function () {
|
Promise.resolve().then(function () {
|
||||||
return pairingAPI().rotateToken();
|
return pairingAPI().rotateToken();
|
||||||
}).then(function (pairing) {
|
}).then(function (pairing) {
|
||||||
applyPairing(pairing);
|
applyPairing(pairing);
|
||||||
setStatus('Token rotated', false);
|
setStatus(tr('ui.tokenRotated', null, 'Token rotated'), false);
|
||||||
}).catch(function (err) {
|
}).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 () {
|
}).then(function () {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
loadPairing();
|
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) {
|
unmount: function (containerEl) {
|
||||||
if (containerEl) containerEl.innerHTML = '';
|
if (containerEl) containerEl.innerHTML = '';
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
}
|
||||||
@@ -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}"
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "verstak.browser-inbox",
|
"id": "verstak.browser-inbox",
|
||||||
"name": "Browser Inbox",
|
"name": "Browser",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"apiVersion": "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",
|
"source": "official",
|
||||||
"icon": "inbox",
|
"icon": "inbox",
|
||||||
"provides": [
|
"provides": [
|
||||||
@@ -28,7 +29,7 @@
|
|||||||
"views": [
|
"views": [
|
||||||
{
|
{
|
||||||
"id": "verstak.browser-inbox.view",
|
"id": "verstak.browser-inbox.view",
|
||||||
"title": "Browser Inbox",
|
"title": "Browser",
|
||||||
"icon": "inbox",
|
"icon": "inbox",
|
||||||
"component": "BrowserInboxView"
|
"component": "BrowserInboxView"
|
||||||
}
|
}
|
||||||
@@ -36,7 +37,7 @@
|
|||||||
"sidebarItems": [
|
"sidebarItems": [
|
||||||
{
|
{
|
||||||
"id": "verstak.browser-inbox.sidebar",
|
"id": "verstak.browser-inbox.sidebar",
|
||||||
"title": "Browser Inbox",
|
"title": "Browser",
|
||||||
"icon": "inbox",
|
"icon": "inbox",
|
||||||
"view": "verstak.browser-inbox.view",
|
"view": "verstak.browser-inbox.view",
|
||||||
"position": 30
|
"position": 30
|
||||||
@@ -45,7 +46,7 @@
|
|||||||
"workspaceItems": [
|
"workspaceItems": [
|
||||||
{
|
{
|
||||||
"id": "verstak.browser-inbox.workspace",
|
"id": "verstak.browser-inbox.workspace",
|
||||||
"title": "Browser Inbox",
|
"title": "Browser",
|
||||||
"icon": "inbox",
|
"icon": "inbox",
|
||||||
"component": "BrowserInboxView"
|
"component": "BrowserInboxView"
|
||||||
}
|
}
|
||||||
@@ -53,7 +54,7 @@
|
|||||||
"settingsPanels": [
|
"settingsPanels": [
|
||||||
{
|
{
|
||||||
"id": "verstak.browser-inbox.settings",
|
"id": "verstak.browser-inbox.settings",
|
||||||
"title": "Browser Inbox",
|
"title": "Browser",
|
||||||
"component": "BrowserInboxSettings"
|
"component": "BrowserInboxSettings"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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-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-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-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-context{font-size:.75rem;color:#a0a0bb;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
||||||
'.de-toolbar-spacer{flex:1}',
|
'.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}',
|
'.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 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-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-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}',
|
'.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%}}'
|
'@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');
|
].join('\n');
|
||||||
@@ -259,11 +257,14 @@
|
|||||||
var start = textarea.selectionStart;
|
var start = textarea.selectionStart;
|
||||||
var end = textarea.selectionEnd;
|
var end = textarea.selectionEnd;
|
||||||
var value = textarea.value;
|
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');
|
var replacement = selected.split('\n').map(function (line) { return prefix + line; }).join('\n');
|
||||||
textarea.value = value.slice(0, start) + replacement + value.slice(end);
|
textarea.value = value.slice(0, lineStart) + replacement + value.slice(lineEnd);
|
||||||
textarea.selectionStart = start;
|
textarea.selectionStart = lineStart;
|
||||||
textarea.selectionEnd = start + replacement.length;
|
textarea.selectionEnd = lineStart + replacement.length;
|
||||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
}
|
}
|
||||||
@@ -291,22 +292,25 @@
|
|||||||
var linesEl = null;
|
var linesEl = null;
|
||||||
var previewEl = null;
|
var previewEl = null;
|
||||||
var secretLinksAvailable = false;
|
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-editor-mode', editorMode);
|
||||||
containerEl.setAttribute('data-resource-path', resourcePath);
|
containerEl.setAttribute('data-resource-path', resourcePath);
|
||||||
containerEl.setAttribute('data-request-mode', requestedMode);
|
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 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 spacer = el('span', { className: 'de-toolbar-spacer' });
|
||||||
var editBtn = isMarkdown ? el('button', { className: 'de-toolbar-btn', 'data-editor-mode-button': 'edit' }, ['Edit']) : null;
|
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' }, ['Preview']) : 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' }, ['Split']) : 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' }, ['Reload']);
|
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' }, ['Save']);
|
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 statusEl = el('span', { className: 'de-status', 'data-save-state': '' });
|
||||||
var toolbarChildren = [modeLabel, contextLabel];
|
var toolbarChildren = [contextLabel];
|
||||||
if (notesBadge) toolbarChildren.push(notesBadge);
|
if (notesBadge) toolbarChildren.push(notesBadge);
|
||||||
toolbarChildren.push(spacer);
|
toolbarChildren.push(spacer);
|
||||||
[editBtn, previewBtn, splitBtn, reloadBtn, saveBtn, statusEl].forEach(function (node) { if (node) toolbarChildren.push(node); });
|
[editBtn, previewBtn, splitBtn, reloadBtn, saveBtn, statusEl].forEach(function (node) { if (node) toolbarChildren.push(node); });
|
||||||
@@ -316,18 +320,18 @@
|
|||||||
if (isMarkdown) {
|
if (isMarkdown) {
|
||||||
mdToolbar = el('div', { className: 'de-md-toolbar' });
|
mdToolbar = el('div', { className: 'de-md-toolbar' });
|
||||||
[
|
[
|
||||||
['heading', 'H', 'Heading'],
|
['heading', 'H', 'ui.md.heading', 'Heading'],
|
||||||
['bold', 'B', 'Bold'],
|
['bold', 'B', 'ui.md.bold', 'Bold'],
|
||||||
['italic', 'I', 'Italic'],
|
['italic', 'I', 'ui.md.italic', 'Italic'],
|
||||||
['link', 'Link', 'Link'],
|
['link', 'Link', 'ui.md.link', 'Link'],
|
||||||
['code', 'Code', 'Inline code'],
|
['code', 'Code', 'ui.md.inlineCode', 'Inline code'],
|
||||||
['code-block', '```', 'Code block'],
|
['code-block', '```', 'ui.md.codeBlock', 'Code block'],
|
||||||
['bullet', '• List', 'Bullet list'],
|
['bullet', '• List', 'ui.md.bulletList', 'Bullet list'],
|
||||||
['numbered', '1. List', 'Numbered list'],
|
['numbered', '1. List', 'ui.md.numberedList', 'Numbered list'],
|
||||||
['quote', 'Quote', 'Quote'],
|
['quote', 'Quote', 'ui.md.quote', 'Quote'],
|
||||||
['task', 'Task', 'Task item']
|
['task', 'Task', 'ui.md.taskItem', 'Task item']
|
||||||
].forEach(function (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);
|
containerEl.appendChild(mdToolbar);
|
||||||
}
|
}
|
||||||
@@ -335,10 +339,6 @@
|
|||||||
var editorWrap = el('div', { className: 'de-editor-wrap' });
|
var editorWrap = el('div', { className: 'de-editor-wrap' });
|
||||||
containerEl.appendChild(editorWrap);
|
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() {
|
function updateLineNumbers() {
|
||||||
if (!linesEl || !textarea) return;
|
if (!linesEl || !textarea) return;
|
||||||
var count = textarea.value.split('\n').length;
|
var count = textarea.value.split('\n').length;
|
||||||
@@ -349,16 +349,16 @@
|
|||||||
|
|
||||||
function updateStatus() {
|
function updateStatus() {
|
||||||
if (saveState === 'saving') {
|
if (saveState === 'saving') {
|
||||||
statusEl.textContent = 'Saving...';
|
statusEl.textContent = tr('ui.saving', null, 'Saving...');
|
||||||
statusEl.className = 'de-status saving';
|
statusEl.className = 'de-status saving';
|
||||||
} else if (saveState === 'error') {
|
} else if (saveState === 'error') {
|
||||||
statusEl.textContent = 'Error saving';
|
statusEl.textContent = tr('ui.saveError', null, 'Error saving');
|
||||||
statusEl.className = 'de-status error';
|
statusEl.className = 'de-status error';
|
||||||
} else if (dirty) {
|
} else if (dirty) {
|
||||||
statusEl.textContent = 'Modified';
|
statusEl.textContent = tr('ui.modified', null, 'Modified');
|
||||||
statusEl.className = 'de-status dirty';
|
statusEl.className = 'de-status dirty';
|
||||||
} else if (lastSavedAt) {
|
} 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';
|
statusEl.className = 'de-status saved';
|
||||||
} else {
|
} else {
|
||||||
statusEl.textContent = '';
|
statusEl.textContent = '';
|
||||||
@@ -475,9 +475,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function reloadFromDisk() {
|
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.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);
|
var readPromise = api.files.readText(resourcePath);
|
||||||
readPromise.then(function (content) {
|
readPromise.then(function (content) {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
@@ -488,10 +488,10 @@
|
|||||||
rebuildEditorArea();
|
rebuildEditorArea();
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
|
console.warn('[default-editor] load error:', err);
|
||||||
editorWrap.innerHTML = '';
|
editorWrap.innerHTML = '';
|
||||||
editorWrap.appendChild(el('div', { className: 'de-error' }, [
|
editorWrap.appendChild(el('div', { className: 'de-error' }, [
|
||||||
el('div', {}, ['Failed to load file']),
|
el('div', {}, [tr('ui.loadFailed', null, 'Could not load the file. Please try again.')])
|
||||||
el('div', { className: 'de-error-msg' }, [(err && err.message) ? err.message : String(err)])
|
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -534,6 +534,30 @@
|
|||||||
|
|
||||||
loadSecretProviderAvailability();
|
loadSecretProviderAvailability();
|
||||||
reloadFromDisk();
|
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) {
|
containerEl.addEventListener('click', function (event) {
|
||||||
var secretLink = event.target.closest('.secret-link');
|
var secretLink = event.target.closest('.secret-link');
|
||||||
@@ -582,6 +606,7 @@
|
|||||||
|
|
||||||
containerEl.__deCleanup = function () {
|
containerEl.__deCleanup = function () {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
|
||||||
if (saveTimer) clearTimeout(saveTimer);
|
if (saveTimer) clearTimeout(saveTimer);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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": "Пункт задачи"
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"apiVersion": "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.",
|
"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",
|
"source": "official",
|
||||||
"icon": "edit",
|
"icon": "edit",
|
||||||
"provides": [
|
"provides": [
|
||||||
|
|||||||
@@ -108,6 +108,10 @@
|
|||||||
var request = props && props.request || {};
|
var request = props && props.request || {};
|
||||||
var path = request.path || '';
|
var path = request.path || '';
|
||||||
var ext = extension(path, request.extension);
|
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.innerHTML = '';
|
||||||
containerEl.className = 'fp-root';
|
containerEl.className = 'fp-root';
|
||||||
containerEl.setAttribute('data-plugin-id', 'verstak.file-preview');
|
containerEl.setAttribute('data-plugin-id', 'verstak.file-preview');
|
||||||
@@ -116,19 +120,19 @@
|
|||||||
var openButton = el('button', {
|
var openButton = el('button', {
|
||||||
className: 'fp-btn',
|
className: 'fp-btn',
|
||||||
'data-action': 'open-external',
|
'data-action': 'open-external',
|
||||||
textContent: 'Open External',
|
textContent: tr('ui.openExternal', null, 'Open External'),
|
||||||
onClick: function () {
|
onClick: function () {
|
||||||
if (api.files.openExternal) api.files.openExternal(path).catch(function (err) { console.error('[file-preview] openExternal:', err); });
|
if (api.files.openExternal) api.files.openExternal(path).catch(function (err) { console.error('[file-preview] openExternal:', err); });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
containerEl.appendChild(el('div', { className: 'fp-toolbar' }, [
|
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-path' }, [path]),
|
||||||
el('span', { className: 'fp-spacer' }),
|
el('span', { className: 'fp-spacer' }),
|
||||||
openButton
|
openButton
|
||||||
]));
|
]));
|
||||||
|
|
||||||
var body = el('div', { className: 'fp-loading' }, ['Loading...']);
|
var body = el('div', { className: 'fp-loading' }, [tr('ui.loading', null, 'Loading...')]);
|
||||||
containerEl.appendChild(body);
|
containerEl.appendChild(body);
|
||||||
|
|
||||||
api.files.metadata(path).then(function (meta) {
|
api.files.metadata(path).then(function (meta) {
|
||||||
@@ -139,9 +143,15 @@
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
|
console.warn('[file-preview] preview error:', err);
|
||||||
body.className = 'fp-error';
|
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) {
|
unmount: function (containerEl) {
|
||||||
containerEl.innerHTML = '';
|
containerEl.innerHTML = '';
|
||||||
|
|||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"manifest.name": "Просмотр файлов",
|
||||||
|
"manifest.description": "Встроенный просмотр изображений из хранилища без редактирования.",
|
||||||
|
"contributions.openProviders.verstak.file-preview.image.title": "Просмотр изображения",
|
||||||
|
"ui.openExternal": "Открыть во внешнем приложении",
|
||||||
|
"ui.preview": "Просмотр",
|
||||||
|
"ui.loading": "Загрузка...",
|
||||||
|
"ui.error": "Не удалось открыть предварительный просмотр файла. Повторите попытку."
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"apiVersion": "0.1.0",
|
"apiVersion": "0.1.0",
|
||||||
"description": "Read-only inline image preview provider for vault files.",
|
"description": "Read-only inline image preview provider for vault files.",
|
||||||
|
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
|
||||||
"source": "official",
|
"source": "official",
|
||||||
"icon": "eye",
|
"icon": "eye",
|
||||||
"provides": [
|
"provides": [
|
||||||
|
|||||||
@@ -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
@@ -34,10 +34,10 @@
|
|||||||
'.files-breadcrumb-item:hover{background:var(--vt-color-accent-muted,rgba(78,204,163,.14))}',
|
'.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-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-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-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-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,.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-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-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,.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}',
|
'.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-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{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-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%}}',
|
'@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{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}',
|
'.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-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{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-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-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{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}',
|
'.files-modal-btn.cancel{background:#2a2a4e;color:#ccc;border-color:#444}',
|
||||||
@@ -222,7 +224,7 @@
|
|||||||
return EXT_MAP[ext] || 'generic';
|
return EXT_MAP[ext] || 'generic';
|
||||||
}
|
}
|
||||||
|
|
||||||
function fileIconLabel(category) {
|
function fileIconLabel(category, translate) {
|
||||||
var labels = {
|
var labels = {
|
||||||
folder: 'Folder',
|
folder: 'Folder',
|
||||||
markdown: 'Markdown file',
|
markdown: 'Markdown file',
|
||||||
@@ -242,7 +244,8 @@
|
|||||||
json: 'JSON file',
|
json: 'JSON file',
|
||||||
generic: '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) {
|
function fileIcon(entry) {
|
||||||
@@ -265,8 +268,8 @@
|
|||||||
return date.toLocaleString(undefined, { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
return date.toLocaleString(undefined, { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function typeLabel(entry) {
|
function typeLabel(entry, folderLabel) {
|
||||||
if (entry.type === 'folder') return 'folder';
|
if (entry.type === 'folder') return folderLabel || 'Folder';
|
||||||
return (entry.extension || extension(entry.name) || 'file').toLowerCase();
|
return (entry.extension || extension(entry.name) || 'file').toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,15 +323,16 @@
|
|||||||
return Promise.reject(new Error('clipboard unavailable'));
|
return Promise.reject(new Error('clipboard unavailable'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function showExternalFallback(entry, mode, reason) {
|
function showExternalFallback(entry, mode, labels) {
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
|
labels = labels || {};
|
||||||
var pathToShow = entry.relativePath;
|
var pathToShow = entry.relativePath;
|
||||||
if (mode === 'explorer' && entry.type !== 'folder') {
|
if (mode === 'explorer' && entry.type !== 'folder') {
|
||||||
pathToShow = parentPath(entry.relativePath) || entry.relativePath;
|
pathToShow = parentPath(entry.relativePath) || entry.relativePath;
|
||||||
}
|
}
|
||||||
var title = mode === 'explorer' ? 'Show in Explorer' : 'Open External';
|
var title = mode === 'explorer' ? (labels.showInFolder || 'Show in folder') : (labels.openExternal || 'Open externally');
|
||||||
var message = title + ' failed.\n' + (reason ? String(reason) + '\n' : '') + 'Vault-relative path:\n' + pathToShow;
|
var message = (labels.externalFailed || '{action} failed.').replace('{action}', title) + '\n' + (labels.vaultRelativePath || 'Vault-relative path:') + '\n' + pathToShow;
|
||||||
confirmModal(message, { confirmText: 'Copy Path', cancelText: 'Close' }).then(function (copy) {
|
confirmModal(message, { confirmText: labels.copyPath || 'Copy path', cancelText: labels.close || 'Close' }).then(function (copy) {
|
||||||
if (!copy) return;
|
if (!copy) return;
|
||||||
copyTextToClipboard(pathToShow).catch(function (err) {
|
copyTextToClipboard(pathToShow).catch(function (err) {
|
||||||
console.error('[files] copy path failed:', err);
|
console.error('[files] copy path failed:', err);
|
||||||
@@ -346,7 +350,7 @@
|
|||||||
|
|
||||||
var workspaceNode = props && props.workspaceNode;
|
var workspaceNode = props && props.workspaceNode;
|
||||||
var workspaceRoot = cleanPath(props && (props.workspaceRootPath || (workspaceNode && (workspaceNode.rootPath || workspaceNode.name || workspaceNode.id))) || '');
|
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 || {};
|
window.__filesHistoryByWorkspace = window.__filesHistoryByWorkspace || {};
|
||||||
var historyKey = workspaceRoot || workspaceName;
|
var historyKey = workspaceRoot || workspaceName;
|
||||||
var savedHistory = window.__filesHistoryByWorkspace[historyKey] || { stack: [''], index: 0, currentPath: '' };
|
var savedHistory = window.__filesHistoryByWorkspace[historyKey] || { stack: [''], index: 0, currentPath: '' };
|
||||||
@@ -358,6 +362,12 @@
|
|||||||
var sortMode = 'folder-name';
|
var sortMode = 'folder-name';
|
||||||
var createMode = '';
|
var createMode = '';
|
||||||
var renameTarget = null;
|
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 disposed = false;
|
||||||
var fileActions = [];
|
var fileActions = [];
|
||||||
var contextMenuEntries = [];
|
var contextMenuEntries = [];
|
||||||
@@ -369,6 +379,22 @@
|
|||||||
}
|
}
|
||||||
var navigatingHistory = false;
|
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) {
|
function scopedPath(local) {
|
||||||
local = cleanPath(local);
|
local = cleanPath(local);
|
||||||
return workspaceRoot ? (local ? workspaceRoot + '/' + local : workspaceRoot) : local;
|
return workspaceRoot ? (local ? workspaceRoot + '/' + local : workspaceRoot) : local;
|
||||||
@@ -390,26 +416,26 @@
|
|||||||
|
|
||||||
var toolbar = el('div', { className: 'files-toolbar' });
|
var toolbar = el('div', { className: 'files-toolbar' });
|
||||||
var breadcrumb = el('div', { className: 'files-breadcrumb' });
|
var breadcrumb = el('div', { className: 'files-breadcrumb' });
|
||||||
var backBtn = iconButton('back', 'Back', 'back', goBack);
|
var backBtn = iconButton('back', tr('ui.back', null, 'Back'), 'back', goBack);
|
||||||
var forwardBtn = iconButton('forward', 'Forward', 'forward', goForward);
|
var forwardBtn = iconButton('forward', tr('ui.forward', null, 'Forward'), 'forward', goForward);
|
||||||
var upBtn = iconButton('up', 'Up', 'up', goUp);
|
var upBtn = iconButton('up', tr('ui.up', null, 'Up'), 'up', goUp);
|
||||||
var refreshBtn = iconButton('refresh', 'Refresh', 'refresh', loadEntries);
|
var refreshBtn = iconButton('refresh', tr('ui.refresh', null, 'Refresh'), 'refresh', loadEntries);
|
||||||
var newFolderBtn = iconButton('new-folder', 'New folder', 'folderAdd', function () { startCreate('folder'); });
|
var newFolderBtn = iconButton('new-folder', tr('ui.newFolder', null, 'New folder'), 'folderAdd', function () { startCreate('folder'); });
|
||||||
var newMdBtn = iconButton('new-markdown', 'New markdown file', 'markdownAdd', function () { startCreate('markdown'); });
|
var newMdBtn = iconButton('new-markdown', tr('ui.newMarkdown', null, 'New markdown file'), 'markdownAdd', function () { startCreate('markdown'); });
|
||||||
var newTextBtn = iconButton('new-text', 'New text file', 'textAdd', function () { startCreate('text'); });
|
var newTextBtn = iconButton('new-text', tr('ui.newText', null, 'New text file'), 'textAdd', function () { startCreate('text'); });
|
||||||
var openBtn = iconButton('open', 'Open', 'open', function () { openEntry(selectedEntry()); });
|
var openBtn = iconButton('open', tr('ui.open', null, 'Open'), 'open', function () { openEntry(selectedEntry()); });
|
||||||
var renameBtn = iconButton('rename', 'Rename', 'rename', function () { beginRename(); });
|
var renameBtn = iconButton('rename', tr('ui.rename', null, 'Rename'), 'rename', function () { beginRename(); });
|
||||||
var trashBtn = iconButton('trash', 'Move to trash', 'trash', function () { trashEntry(); });
|
var trashBtn = iconButton('trash', tr('ui.trash', null, 'Move to trash'), 'trash', function () { trashEntry(); });
|
||||||
var cutBtn = iconButton('cut', 'Cut', 'cut', function () { cutSelection(); });
|
var cutBtn = iconButton('cut', tr('ui.cut', null, 'Cut'), 'cut', function () { cutSelection(); });
|
||||||
var copyBtn = iconButton('copy', 'Copy', 'copy', function () { copySelection(); });
|
var copyBtn = iconButton('copy', tr('ui.copy', null, 'Copy'), 'copy', function () { copySelection(); });
|
||||||
var pasteBtn = iconButton('paste', 'Paste', 'paste', function () { pasteEntry(); });
|
var pasteBtn = iconButton('paste', tr('ui.paste', null, 'Paste'), 'paste', function () { pasteEntry(); });
|
||||||
var filterInput = el('input', { className: 'files-filter', 'data-files-filter': '', placeholder: 'Filter current folder' });
|
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': '' }, [
|
var sortSelect = el('select', { className: 'files-sort', 'data-files-sort': '' }, [
|
||||||
el('option', { value: 'folder-name' }, ['Folders + name']),
|
el('option', { value: 'folder-name' }, [tr('ui.sort.foldersName', null, 'Folders + name')]),
|
||||||
el('option', { value: 'name-asc' }, ['Name']),
|
el('option', { value: 'name-asc' }, [tr('ui.column.name', null, 'Name')]),
|
||||||
el('option', { value: 'type' }, ['Type']),
|
el('option', { value: 'type' }, [tr('ui.column.type', null, 'Type')]),
|
||||||
el('option', { value: 'modified-desc' }, ['Modified']),
|
el('option', { value: 'modified-desc' }, [tr('ui.column.modified', null, 'Modified')]),
|
||||||
el('option', { value: 'size-desc' }, ['Size'])
|
el('option', { value: 'size-desc' }, [tr('ui.column.size', null, 'Size')])
|
||||||
]);
|
]);
|
||||||
trashBtn.classList.add('danger');
|
trashBtn.classList.add('danger');
|
||||||
toolbar.appendChild(breadcrumb);
|
toolbar.appendChild(breadcrumb);
|
||||||
@@ -426,32 +452,6 @@
|
|||||||
var listContainer = el('div', { className: 'files-list', 'data-files-list': '' });
|
var listContainer = el('div', { className: 'files-list', 'data-files-list': '' });
|
||||||
containerEl.appendChild(listContainer);
|
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() {
|
function selectedEntry() {
|
||||||
var keys = Object.keys(selectedPaths);
|
var keys = Object.keys(selectedPaths);
|
||||||
if (keys.length === 0) return null;
|
if (keys.length === 0) return null;
|
||||||
@@ -528,7 +528,7 @@
|
|||||||
if (a.type !== 'folder' && b.type === 'folder') return 1;
|
if (a.type !== 'folder' && b.type === 'folder') return 1;
|
||||||
}
|
}
|
||||||
if (sortMode === 'type') {
|
if (sortMode === 'type') {
|
||||||
var typeCmp = typeLabel(a).localeCompare(typeLabel(b));
|
var typeCmp = displayType(a).localeCompare(displayType(b));
|
||||||
if (typeCmp) return typeCmp;
|
if (typeCmp) return typeCmp;
|
||||||
}
|
}
|
||||||
if (sortMode === 'modified-desc') {
|
if (sortMode === 'modified-desc') {
|
||||||
@@ -598,27 +598,27 @@
|
|||||||
|
|
||||||
function renderEmptyFolderState() {
|
function renderEmptyFolderState() {
|
||||||
return el('div', { className: 'files-empty' }, [
|
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' }, [
|
el('div', { className: 'files-empty-actions' }, [
|
||||||
emptyCreateAction('new-folder', 'New folder', 'folder', 'folderAdd'),
|
emptyCreateAction('new-folder', tr('ui.newFolder', null, 'New folder'), 'folder', 'folderAdd'),
|
||||||
emptyCreateAction('new-markdown', 'New markdown file', 'markdown', 'markdownAdd'),
|
emptyCreateAction('new-markdown', tr('ui.newMarkdown', null, 'New markdown file'), 'markdown', 'markdownAdd'),
|
||||||
emptyCreateAction('new-text', 'New text file', 'text', 'textAdd')
|
emptyCreateAction('new-text', tr('ui.newText', null, 'New text file'), 'text', 'textAdd')
|
||||||
])
|
])
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderNoMatchesState() {
|
function renderNoMatchesState() {
|
||||||
return el('div', { className: 'files-empty' }, [
|
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('div', { className: 'files-empty-actions' }, [
|
||||||
el('button', {
|
el('button', {
|
||||||
className: 'files-empty-btn',
|
className: 'files-empty-btn',
|
||||||
'data-files-empty-action': 'clear-filter',
|
'data-files-empty-action': 'clear-filter',
|
||||||
'data-files-icon': 'refresh',
|
'data-files-icon': 'refresh',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
title: 'Clear filter',
|
title: tr('ui.clearFilter', null, 'Clear filter'),
|
||||||
'aria-label': 'Clear filter',
|
'aria-label': tr('ui.clearFilter', null, 'Clear filter'),
|
||||||
innerHTML: svgIcon(ACTION_ICONS.refresh) + '<span>Clear filter</span>',
|
innerHTML: svgIcon(ACTION_ICONS.refresh) + '<span>' + tr('ui.clearFilter', null, 'Clear filter') + '</span>',
|
||||||
onClick: function () {
|
onClick: function () {
|
||||||
filterText = '';
|
filterText = '';
|
||||||
filterInput.value = '';
|
filterInput.value = '';
|
||||||
@@ -633,11 +633,11 @@
|
|||||||
function renderList() {
|
function renderList() {
|
||||||
listContainer.innerHTML = '';
|
listContainer.innerHTML = '';
|
||||||
var header = el('div', { className: 'files-header' }, [
|
var header = el('div', { className: 'files-header' }, [
|
||||||
el('span', {}, ['Name']),
|
el('span', {}, [tr('ui.column.name', null, 'Name')]),
|
||||||
el('span', {}, ['Type']),
|
el('span', {}, [tr('ui.column.type', null, 'Type')]),
|
||||||
el('span', {}, ['Size']),
|
el('span', {}, [tr('ui.column.size', null, 'Size')]),
|
||||||
el('span', {}, ['Modified']),
|
el('span', {}, [tr('ui.column.modified', null, 'Modified')]),
|
||||||
el('span', {}, ['Actions'])
|
el('span', {}, [tr('ui.column.actions', null, 'Actions')])
|
||||||
]);
|
]);
|
||||||
listContainer.appendChild(header);
|
listContainer.appendChild(header);
|
||||||
|
|
||||||
@@ -650,7 +650,7 @@
|
|||||||
|
|
||||||
shown.forEach(function (entry) {
|
shown.forEach(function (entry) {
|
||||||
var iconCategory = fileIconCategory(entry);
|
var iconCategory = fileIconCategory(entry);
|
||||||
var iconLabel = fileIconLabel(iconCategory);
|
var iconLabel = fileIconLabel(iconCategory, tr);
|
||||||
var row = el('div', {
|
var row = el('div', {
|
||||||
className: 'files-item' + (selectedPaths[entry.relativePath] ? ' selected' : ''),
|
className: 'files-item' + (selectedPaths[entry.relativePath] ? ' selected' : ''),
|
||||||
'data-file-name': entry.name,
|
'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-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-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' }, [entry.type === 'folder' ? '' : formatSize(entry.size)]),
|
||||||
el('span', { className: 'files-item-meta hide-narrow' }, [formatDate(entry.modifiedAt)]),
|
el('span', { className: 'files-item-meta hide-narrow' }, [formatDate(entry.modifiedAt)]),
|
||||||
el('div', { className: 'files-row-actions' }, [
|
el('div', { className: 'files-row-actions' }, [
|
||||||
iconButton('row-open', 'Open', 'open', function (event) { event.stopPropagation(); openEntry(entry); }, 'files-row-btn'),
|
iconButton('row-open', tr('ui.open', null, '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-rename', tr('ui.rename', null, '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-trash', tr('ui.trash', null, 'Move to trash'), 'trash', function (event) { event.stopPropagation(); trashEntry(entry); }, 'files-row-btn danger')
|
||||||
])
|
])
|
||||||
]);
|
]);
|
||||||
listContainer.appendChild(row);
|
listContainer.appendChild(row);
|
||||||
@@ -701,7 +701,7 @@
|
|||||||
selectedPaths = {};
|
selectedPaths = {};
|
||||||
lastClickedPath = '';
|
lastClickedPath = '';
|
||||||
listContainer.innerHTML = '';
|
listContainer.innerHTML = '';
|
||||||
listContainer.appendChild(el('div', { className: 'files-loading' }, ['Loading...']));
|
listContainer.appendChild(el('div', { className: 'files-loading' }, [tr('ui.loading', null, 'Loading...')]));
|
||||||
updateBreadcrumb();
|
updateBreadcrumb();
|
||||||
api.files.list(scopedPath(currentPath)).then(function (result) {
|
api.files.list(scopedPath(currentPath)).then(function (result) {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
@@ -709,10 +709,10 @@
|
|||||||
renderList();
|
renderList();
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
|
console.warn('[verstak.files] load error:', err);
|
||||||
listContainer.innerHTML = '';
|
listContainer.innerHTML = '';
|
||||||
listContainer.appendChild(el('div', { className: 'files-error' }, [
|
listContainer.appendChild(el('div', { className: 'files-error' }, [
|
||||||
el('div', {}, ['Failed to load files']),
|
el('div', {}, [tr('ui.loadFailed', null, 'Could not load files. Please try again.')])
|
||||||
el('div', { className: 'files-error-msg' }, [(err && err.message) ? err.message : String(err)])
|
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -808,35 +808,131 @@
|
|||||||
}).catch(function (err) { console.error('[files] openResource error:', err); });
|
}).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) {
|
function startCreate(mode) {
|
||||||
|
cancelCreate();
|
||||||
createMode = mode;
|
createMode = mode;
|
||||||
createInput.value = '';
|
createModal = showFileFormModal('create', {
|
||||||
createInput.placeholder = mode === 'folder' ? 'Folder name' : (mode === 'markdown' ? 'Markdown file name' : 'Text file name');
|
title: mode === 'folder'
|
||||||
setCreateError('');
|
? tr('ui.createFolderTitle', null, 'Create folder')
|
||||||
createPanel.style.display = 'flex';
|
: (mode === 'markdown'
|
||||||
createInput.focus();
|
? 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) {
|
function setCreateError(message) {
|
||||||
|
if (!createError || !createInput) return;
|
||||||
createError.textContent = message || '';
|
createError.textContent = message || '';
|
||||||
createError.style.display = message ? 'block' : 'none';
|
createError.style.display = message ? 'block' : 'none';
|
||||||
createInput.setAttribute('aria-invalid', message ? 'true' : 'false');
|
createInput.setAttribute('aria-invalid', message ? 'true' : 'false');
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateCreateName(name) {
|
function validateCreateName(name) {
|
||||||
if (!name) return 'Name is required';
|
if (!name) return tr('ui.nameRequired', null, 'Enter a name.');
|
||||||
if (/[\\/:*?"<>|\x00-\x1f]/.test(name)) return 'Invalid characters in 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 'Invalid name';
|
if (name === '.' || name === '..' || name[0] === ' ' || name[name.length - 1] === ' ' || name[name.length - 1] === '.') return tr('ui.invalidName', null, 'Enter a valid name.');
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelCreate() {
|
function cancelCreate() {
|
||||||
createMode = '';
|
createMode = '';
|
||||||
setCreateError('');
|
if (createModal && typeof createModal.remove === 'function') createModal.remove();
|
||||||
createPanel.style.display = 'none';
|
else if (createModal && createModal.parentNode) createModal.parentNode.removeChild(createModal);
|
||||||
|
createModal = null;
|
||||||
|
createInput = null;
|
||||||
|
createError = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmCreate() {
|
function confirmCreate() {
|
||||||
|
if (!createInput || !createMode) return;
|
||||||
var name = createInput.value.trim();
|
var name = createInput.value.trim();
|
||||||
var validationError = validateCreateName(name);
|
var validationError = validateCreateName(name);
|
||||||
if (validationError) {
|
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 () {});
|
api.workbench.openResource({ kind: 'vault-file', path: full, mode: 'edit', extension: ext ? '.' + ext : '', context: { sourcePluginId: 'verstak.files', sourceView: 'files' } }).catch(function () {});
|
||||||
}
|
}
|
||||||
}).catch(function (err) {
|
}).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) {
|
function beginRename(entry) {
|
||||||
entry = entry || selectedEntry();
|
entry = entry || selectedEntry();
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
|
cancelRename();
|
||||||
renameTarget = entry;
|
renameTarget = entry;
|
||||||
renameInput.value = entry.name;
|
renameModal = showFileFormModal('rename', {
|
||||||
setRenameError('');
|
title: tr('ui.renameTitle', { name: entry.name }, 'Rename ' + entry.name),
|
||||||
renamePanel.style.display = 'flex';
|
placeholder: tr('ui.fileName', null, 'File or folder name'),
|
||||||
renameInput.focus();
|
value: entry.name,
|
||||||
renameInput.select();
|
confirmLabel: tr('ui.rename', null, 'Rename')
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelRename() {
|
function cancelRename() {
|
||||||
renameTarget = null;
|
renameTarget = null;
|
||||||
setRenameError('');
|
if (renameModal && typeof renameModal.remove === 'function') renameModal.remove();
|
||||||
renamePanel.style.display = 'none';
|
else if (renameModal && renameModal.parentNode) renameModal.parentNode.removeChild(renameModal);
|
||||||
|
renameModal = null;
|
||||||
|
renameInput = null;
|
||||||
|
renameError = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setRenameError(message) {
|
function setRenameError(message) {
|
||||||
|
if (!renameError || !renameInput) return;
|
||||||
renameError.textContent = message || '';
|
renameError.textContent = message || '';
|
||||||
renameError.style.display = message ? 'block' : 'none';
|
renameError.style.display = message ? 'block' : 'none';
|
||||||
renameInput.setAttribute('aria-invalid', message ? 'true' : 'false');
|
renameInput.setAttribute('aria-invalid', message ? 'true' : 'false');
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmRename() {
|
function confirmRename() {
|
||||||
if (!renameTarget) return;
|
if (!renameTarget || !renameInput) return;
|
||||||
var newName = renameInput.value.trim();
|
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();
|
cancelRename();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (/[\\/:*?"<>|\x00-\x1f]/.test(newName)) {
|
if (/[\\/:*?"<>|\x00-\x1f]/.test(newName)) {
|
||||||
setRenameError('Invalid characters in name');
|
setRenameError(tr('ui.invalidCharacters', null, 'The name contains invalid characters.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (newName === '.' || newName === '..' || newName[0] === ' ' || newName[newName.length - 1] === ' ' || newName[newName.length - 1] === '.') {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
var from = renameTarget.relativePath;
|
var from = renameTarget.relativePath;
|
||||||
@@ -905,20 +1011,20 @@
|
|||||||
var to = targetParent ? targetParent + '/' + newName : newName;
|
var to = targetParent ? targetParent + '/' + newName : newName;
|
||||||
api.files.metadata(to).then(function () {
|
api.files.metadata(to).then(function () {
|
||||||
if (to.toLowerCase() === from.toLowerCase() && to !== from) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
setRenameError('A file with that name already exists');
|
setRenameError(tr('ui.nameConflict', null, 'An item with that name already exists.'));
|
||||||
}, function () {
|
}, function () {
|
||||||
api.files.move(from, to, { overwrite: false }).then(function () {
|
api.files.move(from, to, { overwrite: false }).then(function () {
|
||||||
cancelRename();
|
cancelRename();
|
||||||
loadEntries();
|
loadEntries();
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
if (isConflictError(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;
|
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) {
|
function trashEntry(entry) {
|
||||||
var count = selectedCount();
|
var count = selectedCount();
|
||||||
if (entry) {
|
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;
|
if (!ok) return;
|
||||||
api.files.trash(entry.relativePath).then(function () {
|
api.files.trash(entry.relativePath).then(function () {
|
||||||
loadEntries();
|
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) {
|
} 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;
|
if (!ok) return;
|
||||||
var paths = Object.keys(selectedPaths);
|
var paths = Object.keys(selectedPaths);
|
||||||
Promise.allSettled(paths.map(function (p) { return api.files.trash(p); })).then(function () {
|
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(); });
|
filterInput.addEventListener('input', function () { filterText = filterInput.value; renderList(); });
|
||||||
sortSelect.addEventListener('change', function () { sortMode = sortSelect.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 --- */
|
/* --- Context menu --- */
|
||||||
var ctxMenu = el('div', { className: 'files-ctx-menu', style: { display: 'none' } });
|
var ctxMenu = el('div', { className: 'files-ctx-menu', style: { display: 'none' } });
|
||||||
document.body.appendChild(ctxMenu);
|
document.body.appendChild(ctxMenu);
|
||||||
@@ -986,14 +1094,23 @@
|
|||||||
|
|
||||||
function openExternalEntry(entry, mode) {
|
function openExternalEntry(entry, mode) {
|
||||||
if (!entry) return;
|
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 filesApi = api && api.files;
|
||||||
var action = mode === 'explorer' ? filesApi && filesApi.showInFolder : filesApi && filesApi.openExternal;
|
var action = mode === 'explorer' ? filesApi && filesApi.showInFolder : filesApi && filesApi.openExternal;
|
||||||
if (typeof action !== 'function') {
|
if (typeof action !== 'function') {
|
||||||
showExternalFallback(entry, mode, 'files external-open API is unavailable.');
|
showExternalFallback(entry, mode, fallbackLabels);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
action(entry.relativePath).catch(function (err) {
|
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();
|
renderList();
|
||||||
}
|
}
|
||||||
var isFolder = entry.type === 'folder';
|
var isFolder = entry.type === 'folder';
|
||||||
ctxMenu.appendChild(ctxItem(isFolder ? 'Open Folder' : 'Open', '', function () { openEntry(entry); }, 'open', 'open'));
|
ctxMenu.appendChild(ctxItem(isFolder ? tr('ui.openFolder', null, 'Open folder') : tr('ui.open', null, 'Open'), '', function () { openEntry(entry); }, 'open', 'open'));
|
||||||
ctxMenu.appendChild(ctxItem('Open External', '', function () { openExternalEntry(entry, 'external'); }, 'open-external', 'external'));
|
ctxMenu.appendChild(ctxItem(tr('ui.openExternal', null, 'Open externally'), '', function () { openExternalEntry(entry, 'external'); }, 'open-external', 'external'));
|
||||||
ctxMenu.appendChild(ctxItem('Show in Explorer', '', function () { openExternalEntry(entry, 'explorer'); }, 'show-in-explorer', 'explorer'));
|
ctxMenu.appendChild(ctxItem(tr('ui.showInFolder', null, 'Show in folder'), '', function () { openExternalEntry(entry, 'explorer'); }, 'show-in-explorer', 'explorer'));
|
||||||
appendContributionMenuItems(entry);
|
appendContributionMenuItems(entry);
|
||||||
ctxMenu.appendChild(ctxSep());
|
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') {
|
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(ctxSep());
|
||||||
ctxMenu.appendChild(ctxItem('Cut', '', function () { cutSelection(); }, 'cut', 'cut'));
|
ctxMenu.appendChild(ctxItem(tr('ui.cut', null, 'Cut'), '', function () { cutSelection(); }, 'cut', 'cut'));
|
||||||
ctxMenu.appendChild(ctxItem('Copy', '', function () { copySelection(); }, 'copy', 'copy'));
|
ctxMenu.appendChild(ctxItem(tr('ui.copy', null, 'Copy'), '', function () { copySelection(); }, 'copy', 'copy'));
|
||||||
ctxMenu.appendChild(ctxSep());
|
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 {
|
} else {
|
||||||
ctxMenu.appendChild(ctxItem('New Folder', '', function () { startCreate('folder'); }, 'new-folder', 'folderAdd'));
|
ctxMenu.appendChild(ctxItem(tr('ui.newFolder', null, 'New folder'), '', function () { startCreate('folder'); }, 'new-folder', 'folderAdd'));
|
||||||
ctxMenu.appendChild(ctxItem('New Markdown', '', function () { startCreate('markdown'); }, 'new-markdown', 'markdownAdd'));
|
ctxMenu.appendChild(ctxItem(tr('ui.newMarkdown', null, 'New Markdown file'), '', function () { startCreate('markdown'); }, 'new-markdown', 'markdownAdd'));
|
||||||
ctxMenu.appendChild(ctxItem('New Text', '', function () { startCreate('text'); }, 'new-text', 'textAdd'));
|
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) {
|
if (window.__filesClipboard && window.__filesClipboard.items && window.__filesClipboard.items.length) {
|
||||||
ctxMenu.appendChild(ctxSep());
|
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';
|
ctxMenu.style.display = 'block';
|
||||||
@@ -1152,7 +1269,7 @@
|
|||||||
var clip = window.__filesClipboard;
|
var clip = window.__filesClipboard;
|
||||||
if (!clip || !clip.items || clip.items.length === 0) return;
|
if (!clip || !clip.items || clip.items.length === 0) return;
|
||||||
if (clip.workspaceRoot && clip.workspaceRoot !== workspaceRoot) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
var destinationDir = scopedPath(currentPath);
|
var destinationDir = scopedPath(currentPath);
|
||||||
@@ -1478,6 +1595,25 @@
|
|||||||
loadContributionActions();
|
loadContributionActions();
|
||||||
loadEntries();
|
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;
|
var fileChangedUnsubscribe = null;
|
||||||
if (api.events && typeof api.events.subscribe === 'function') {
|
if (api.events && typeof api.events.subscribe === 'function') {
|
||||||
api.events.subscribe('file.changed', function (event) {
|
api.events.subscribe('file.changed', function (event) {
|
||||||
@@ -1492,6 +1628,9 @@
|
|||||||
|
|
||||||
containerEl.__filesCleanup = function () {
|
containerEl.__filesCleanup = function () {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
cancelCreate();
|
||||||
|
cancelRename();
|
||||||
|
if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
|
||||||
if (typeof fileChangedUnsubscribe === 'function') fileChangedUnsubscribe();
|
if (typeof fileChangedUnsubscribe === 'function') fileChangedUnsubscribe();
|
||||||
document.removeEventListener('click', onDocClick);
|
document.removeEventListener('click', onDocClick);
|
||||||
document.removeEventListener('keydown', onDocKeydown);
|
document.removeEventListener('keydown', onDocKeydown);
|
||||||
|
|||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -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": "Не удалось переместить элемент в корзину. Повторите попытку."
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
"name": "Files",
|
"name": "Files",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"apiVersion": "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",
|
"source": "official",
|
||||||
"icon": "folder",
|
"icon": "folder",
|
||||||
"provides": [
|
"provides": [
|
||||||
|
|||||||
@@ -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-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{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.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-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-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)}',
|
'.journal-list{flex:1;min-height:0;overflow:auto;background:var(--vt-color-background,#101020)}',
|
||||||
@@ -107,7 +108,7 @@
|
|||||||
|
|
||||||
function scopeFromProps(props) {
|
function scopeFromProps(props) {
|
||||||
var workspaceRoot = workspaceFromProps(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 {
|
return {
|
||||||
mode: 'workspace',
|
mode: 'workspace',
|
||||||
key: WORKLOG_PREFIX + encodeKey(workspaceRoot),
|
key: WORKLOG_PREFIX + encodeKey(workspaceRoot),
|
||||||
@@ -227,6 +228,8 @@
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
candidateId: text(value.candidateId),
|
candidateId: text(value.candidateId),
|
||||||
|
sessionId: text(value.sessionId),
|
||||||
|
handledThrough: text(value.handledThrough || value.endedAt),
|
||||||
workspaceRootPath: workspace,
|
workspaceRootPath: workspace,
|
||||||
startedAt: text(value.startedAt),
|
startedAt: text(value.startedAt),
|
||||||
endedAt: text(value.endedAt),
|
endedAt: text(value.endedAt),
|
||||||
@@ -273,18 +276,31 @@
|
|||||||
|
|
||||||
var scope = scopeFromProps(props || {});
|
var scope = scopeFromProps(props || {});
|
||||||
var entries = [];
|
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 statusClass = '';
|
||||||
var modalHost = el('div', { className: 'journal-modal-host', hidden: 'hidden' });
|
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 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 countEl = el('span', { className: 'journal-count' });
|
||||||
var statusEl = el('span', { className: 'journal-status' });
|
var statusEl = el('span', { className: 'journal-status' });
|
||||||
var addBtn = el('button', {
|
var addBtn = el('button', {
|
||||||
className: 'journal-btn primary',
|
className: 'journal-btn primary',
|
||||||
'data-journal-action': 'add',
|
'data-journal-action': 'add',
|
||||||
innerHTML: iconSvg('add') + '<span>Add</span>',
|
innerHTML: iconSvg('add') + '<span>' + tr('ui.add', null, 'Add') + '</span>',
|
||||||
onClick: function () { showEntryModal(); }
|
onClick: function () { showEntryModal(); }
|
||||||
});
|
});
|
||||||
toolbar.appendChild(titleEl);
|
toolbar.appendChild(titleEl);
|
||||||
@@ -298,12 +314,23 @@
|
|||||||
containerEl.appendChild(listEl);
|
containerEl.appendChild(listEl);
|
||||||
containerEl.appendChild(modalHost);
|
containerEl.appendChild(modalHost);
|
||||||
|
|
||||||
function persist() {
|
function persist(workspaceRoot, values) {
|
||||||
if (scope.mode !== 'workspace') return Promise.resolve();
|
|
||||||
if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
|
if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
|
||||||
return api.settings.write(scope.key, storageEntries(entries)).catch(function (err) {
|
var target = cleanWorkspace(workspaceRoot || scope.workspaceRoot);
|
||||||
statusText = 'Could not save journal: ' + (err && err.message ? err.message : String(err));
|
if (!target) return Promise.resolve();
|
||||||
statusClass = 'error';
|
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));
|
all = all.concat(normalizeEntries((settings || {})[key], key));
|
||||||
});
|
});
|
||||||
entries = sortEntries(all);
|
entries = sortEntries(all);
|
||||||
statusText = 'Aggregating worklogs';
|
statusText = tr('ui.aggregating', null, 'Aggregating worklogs');
|
||||||
statusClass = '';
|
statusClass = '';
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
statusText = 'Could not load journal: ' + (err && err.message ? err.message : String(err));
|
reportError('ui.loadError', 'Could not load journal. Please try again.', err);
|
||||||
statusClass = 'error';
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return api.settings.read(scope.key).then(function (stored) {
|
return api.settings.read(scope.key).then(function (stored) {
|
||||||
entries = sortEntries(normalizeEntries(stored, scope.key));
|
entries = sortEntries(normalizeEntries(stored, scope.key));
|
||||||
statusText = 'Ready';
|
statusText = tr('ui.ready', null, 'Ready');
|
||||||
statusClass = '';
|
statusClass = '';
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
statusText = 'Could not load journal: ' + (err && err.message ? err.message : String(err));
|
reportError('ui.loadError', 'Could not load journal. Please try again.', err);
|
||||||
statusClass = 'error';
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,18 +363,50 @@
|
|||||||
modalHost.setAttribute('hidden', 'hidden');
|
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) {
|
function showEntryModal(existingEntry, candidate, completedTodo) {
|
||||||
if (scope.mode !== 'workspace') return;
|
|
||||||
var editing = !!existingEntry;
|
var editing = !!existingEntry;
|
||||||
var reviewingCandidate = !editing && !!candidate;
|
var reviewingCandidate = !editing && !!candidate;
|
||||||
var reviewingTodo = !editing && !!completedTodo;
|
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 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 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: 'Body', 'data-journal-input': 'summary' });
|
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 : '');
|
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 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' });
|
var billableInput = el('input', { type: 'checkbox', 'data-journal-input': 'billable' });
|
||||||
billableInput.checked = editing ? existingEntry.billable === true : false;
|
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 activityInputs = reviewingCandidate ? candidate.activities.map(function (activity) {
|
||||||
var input = el('input', { type: 'checkbox', value: activity.activityId, checked: 'checked', 'data-journal-candidate-activity': activity.activityId });
|
var input = el('input', { type: 'checkbox', value: activity.activityId, checked: 'checked', 'data-journal-candidate-activity': activity.activityId });
|
||||||
input.checked = true;
|
input.checked = true;
|
||||||
@@ -363,7 +420,10 @@
|
|||||||
summary: summaryInput.value,
|
summary: summaryInput.value,
|
||||||
minutes: minutesInput.value,
|
minutes: minutesInput.value,
|
||||||
billable: billableInput.checked === true,
|
billable: billableInput.checked === true,
|
||||||
|
workspaceRootPath: workspaceInput ? workspaceInput.value : scope.workspaceRoot,
|
||||||
sourceCandidateId: reviewingCandidate ? candidate.candidateId : (existingEntry ? existingEntry.sourceCandidateId : ''),
|
sourceCandidateId: reviewingCandidate ? candidate.candidateId : (existingEntry ? existingEntry.sourceCandidateId : ''),
|
||||||
|
sessionId: reviewingCandidate ? candidate.sessionId : '',
|
||||||
|
handledThrough: reviewingCandidate ? candidate.handledThrough : '',
|
||||||
sourceTodoId: reviewingTodo ? completedTodo.id : (existingEntry ? existingEntry.sourceTodoId : ''),
|
sourceTodoId: reviewingTodo ? completedTodo.id : (existingEntry ? existingEntry.sourceTodoId : ''),
|
||||||
activityIds: reviewingCandidate
|
activityIds: reviewingCandidate
|
||||||
? activityInputs.filter(function (item) { return item.input.checked === true; }).map(function (item) { return item.activity.activityId; })
|
? 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 }, [
|
var candidateContext = reviewingCandidate ? el('div', { className: 'journal-candidate-context', 'data-journal-candidate': candidate.candidateId }, [
|
||||||
el('strong', { textContent: 'Possible journal entry' }),
|
el('strong', { textContent: tr('ui.candidate.title', null, 'Possible journal entry') }),
|
||||||
el('div', { textContent: 'Workspace: ' + candidate.workspaceRootPath }),
|
el('div', { textContent: tr('ui.candidate.deal', { deal: candidate.workspaceRootPath }, 'Deal: ' + candidate.workspaceRootPath) }),
|
||||||
el('div', { textContent: 'Time: ' + candidateTime(candidate.startedAt) + ' - ' + candidateTime(candidate.endedAt) }),
|
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: 'Estimated duration: ' + candidate.estimatedMinutes + ' min' }),
|
el('div', { textContent: tr('ui.candidate.duration', { minutes: candidate.estimatedMinutes }, 'Estimated duration: ' + candidate.estimatedMinutes + ' min') }),
|
||||||
el('div', { textContent: 'Activities: ' + candidate.activityCount })
|
el('div', { textContent: tr('ui.candidate.activities', { count: candidate.activityCount }, 'Activities: ' + candidate.activityCount) })
|
||||||
]) : null;
|
]) : null;
|
||||||
var candidateActivities = reviewingCandidate ? el('fieldset', { className: 'journal-candidate-activities' }, [
|
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) {
|
].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]);
|
return el('label', { className: 'journal-candidate-activity' }, [item.input, detail]);
|
||||||
}))) : null;
|
}))) : null;
|
||||||
var todoContext = reviewingTodo ? el('div', { className: 'journal-candidate-context', 'data-journal-todo': completedTodo.id }, [
|
var todoContext = reviewingTodo ? el('div', { className: 'journal-candidate-context', 'data-journal-todo': completedTodo.id }, [
|
||||||
el('strong', { textContent: 'Completed todo' }),
|
el('strong', { textContent: tr('ui.todo.title', null, 'Completed todo') }),
|
||||||
el('div', { textContent: 'Workspace: ' + completedTodo.workspaceRootPath }),
|
el('div', { textContent: tr('ui.candidate.deal', { deal: completedTodo.workspaceRootPath }, 'Deal: ' + completedTodo.workspaceRootPath) }),
|
||||||
completedTodo.completedAt ? el('div', { textContent: 'Completed: ' + candidateTime(completedTodo.completedAt) }) : null
|
completedTodo.completedAt ? el('div', { textContent: tr('ui.todo.completed', { time: candidateTime(completedTodo.completedAt) }, 'Completed: ' + candidateTime(completedTodo.completedAt)) }) : null
|
||||||
]) : null;
|
]) : null;
|
||||||
|
|
||||||
modalHost.innerHTML = '';
|
modalHost.innerHTML = '';
|
||||||
@@ -397,20 +457,21 @@
|
|||||||
if (event.target === event.currentTarget) closeEntryModal();
|
if (event.target === event.currentTarget) closeEntryModal();
|
||||||
} }, [
|
} }, [
|
||||||
el('div', { className: 'journal-modal' }, [
|
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,
|
candidateContext,
|
||||||
todoContext,
|
todoContext,
|
||||||
el('div', { className: 'journal-modal-grid' }, [
|
el('div', { className: 'journal-modal-grid' }, [
|
||||||
el('label', { className: 'journal-field' }, ['Date', dateInput]),
|
el('label', { className: 'journal-field' }, [tr('ui.date', null, 'Date'), dateInput]),
|
||||||
el('label', { className: 'journal-field' }, ['Minutes', minutesInput]),
|
el('label', { className: 'journal-field' }, [tr('ui.minutes', null, 'Minutes'), minutesInput]),
|
||||||
el('label', { className: 'journal-field wide' }, ['Title', titleInput]),
|
workspaceInput ? el('label', { className: 'journal-field wide' }, [tr('ui.workspace', null, 'Deal'), workspaceInput]) : null,
|
||||||
el('label', { className: 'journal-field wide' }, ['Body', summaryInput]),
|
el('label', { className: 'journal-field wide' }, [tr('ui.fieldTitle', null, 'Title'), titleInput]),
|
||||||
el('label', { className: 'journal-billable' }, [billableInput, 'Billable'])
|
el('label', { className: 'journal-field wide' }, [tr('ui.body', null, 'Body'), summaryInput]),
|
||||||
|
el('label', { className: 'journal-billable' }, [billableInput, tr('ui.billable', null, 'Billable')])
|
||||||
]),
|
]),
|
||||||
candidateActivities,
|
candidateActivities,
|
||||||
el('div', { className: 'journal-modal-actions' }, [
|
el('div', { className: 'journal-modal-actions' }, [
|
||||||
el('button', { className: 'journal-btn ghost', type: 'button', textContent: 'Cancel', onClick: closeEntryModal }),
|
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 ? 'Save changes' : 'Add entry', onClick: saveEntry })
|
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) {
|
function addOrUpdateEntry(existingEntry, formValue) {
|
||||||
if (scope.mode !== 'workspace') return;
|
|
||||||
var title = text(formValue && formValue.title).trim();
|
var title = text(formValue && formValue.title).trim();
|
||||||
if (!title) {
|
if (!title) {
|
||||||
statusText = 'Title is required';
|
statusText = tr('ui.titleRequired', null, 'Title is required');
|
||||||
statusClass = 'error';
|
statusClass = 'error';
|
||||||
render();
|
render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
var workspaceRoot = cleanWorkspace(formValue && formValue.workspaceRootPath || scope.workspaceRoot);
|
||||||
|
if (!workspaceRoot) return;
|
||||||
var sourceCandidateId = text(formValue && formValue.sourceCandidateId || (existingEntry && existingEntry.sourceCandidateId)).trim();
|
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();
|
var sourceTodoId = text(formValue && formValue.sourceTodoId || (existingEntry && existingEntry.sourceTodoId)).trim();
|
||||||
if (!existingEntry && sourceCandidateId && entries.some(function (entry) { return entry.sourceCandidateId === sourceCandidateId; })) {
|
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';
|
statusClass = 'error';
|
||||||
render();
|
render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!existingEntry && sourceTodoId && entries.some(function (entry) { return entry.sourceTodoId === sourceTodoId; })) {
|
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';
|
statusClass = 'error';
|
||||||
render();
|
render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var entry = normalizeEntry({
|
var entry = normalizeEntry({
|
||||||
entryId: existingEntry ? existingEntry.entryId : entryId(scope.workspaceRoot, formValue.date || today(), title),
|
entryId: existingEntry ? existingEntry.entryId : entryId(workspaceRoot, formValue.date || today(), title),
|
||||||
workspaceRootPath: scope.workspaceRoot,
|
workspaceRootPath: workspaceRoot,
|
||||||
date: formValue.date || today(),
|
date: formValue.date || today(),
|
||||||
title: title,
|
title: title,
|
||||||
summary: formValue.summary,
|
summary: formValue.summary,
|
||||||
@@ -460,16 +524,24 @@
|
|||||||
entries = [entry].concat(entries);
|
entries = [entry].concat(entries);
|
||||||
}
|
}
|
||||||
entries = sortEntries(entries);
|
entries = sortEntries(entries);
|
||||||
|
var targetEntries = entries.filter(function (item) { return item.workspaceRootPath === workspaceRoot; });
|
||||||
closeEntryModal();
|
closeEntryModal();
|
||||||
statusText = existingEntry ? 'Entry updated' : 'Entry added';
|
statusText = existingEntry ? tr('ui.updated', null, 'Entry updated') : tr('ui.added', null, 'Entry added');
|
||||||
statusClass = '';
|
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) {
|
function deleteEntry(entry) {
|
||||||
if (scope.mode !== 'workspace' || !entry) return;
|
if (scope.mode !== 'workspace' || !entry) return;
|
||||||
entries = entries.filter(function (item) { return item.entryId !== entry.entryId; });
|
entries = entries.filter(function (item) { return item.entryId !== entry.entryId; });
|
||||||
statusText = 'Entry deleted';
|
statusText = tr('ui.deleted', null, 'Entry deleted');
|
||||||
statusClass = '';
|
statusClass = '';
|
||||||
persist().then(render);
|
persist().then(render);
|
||||||
}
|
}
|
||||||
@@ -477,7 +549,7 @@
|
|||||||
function renderList() {
|
function renderList() {
|
||||||
listEl.innerHTML = '';
|
listEl.innerHTML = '';
|
||||||
if (!entries.length) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
entries.forEach(function (entry) {
|
entries.forEach(function (entry) {
|
||||||
@@ -489,33 +561,44 @@
|
|||||||
el('div', { className: 'journal-main' }, [
|
el('div', { className: 'journal-main' }, [
|
||||||
el('div', { className: 'journal-entry-title', textContent: entry.title }),
|
el('div', { className: 'journal-entry-title', textContent: entry.title }),
|
||||||
entry.summary ? el('div', { className: 'journal-summary', textContent: entry.summary }) : null,
|
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' }, [
|
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', 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: 'Delete', 'aria-label': 'Delete', 'data-journal-action': 'delete', innerHTML: iconSvg('trash'), onClick: function () { deleteEntry(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
|
]) : null
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
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.textContent = statusText;
|
||||||
statusEl.className = 'journal-status' + (statusClass ? ' ' + statusClass : '');
|
statusEl.className = 'journal-status' + (statusClass ? ' ' + statusClass : '');
|
||||||
addBtn.disabled = scope.mode !== 'workspace';
|
addBtn.disabled = false;
|
||||||
renderList();
|
renderList();
|
||||||
}
|
}
|
||||||
|
|
||||||
render();
|
render();
|
||||||
loadStored().then(function () {
|
Promise.all([loadStored(), loadWorkspaceOptions()]).then(function () {
|
||||||
render();
|
render();
|
||||||
var candidate = candidateFromRequest(props && props.toolRequest, scope.workspaceRoot);
|
var candidate = candidateFromRequest(props && props.toolRequest, scope.workspaceRoot);
|
||||||
var completedTodo = completedTodoFromRequest(props && props.toolRequest, scope.workspaceRoot);
|
var completedTodo = completedTodoFromRequest(props && props.toolRequest, scope.workspaceRoot);
|
||||||
if (candidate) showEntryModal(null, candidate);
|
if (candidate) showEntryModal(null, candidate);
|
||||||
else if (completedTodo) showEntryModal(null, null, completedTodo);
|
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) {
|
JournalView.unmount = function (containerEl) {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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,7 +4,8 @@
|
|||||||
"name": "Journal",
|
"name": "Journal",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"apiVersion": "0.1.0",
|
"apiVersion": "0.1.0",
|
||||||
"description": "Workspace-scoped journal with user-authored entries and optional Activity links.",
|
"description": "Deal-scoped journal with user-authored entries and optional Activity links.",
|
||||||
|
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
|
||||||
"source": "official",
|
"source": "official",
|
||||||
"icon": "book-open",
|
"icon": "book-open",
|
||||||
"provides": [
|
"provides": [
|
||||||
@@ -13,6 +14,8 @@
|
|||||||
"report.worklog"
|
"report.worklog"
|
||||||
],
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
|
"events.publish",
|
||||||
|
"files.read",
|
||||||
"storage.namespace",
|
"storage.namespace",
|
||||||
"ui.register"
|
"ui.register"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
'.notes-btn.primary:hover{background:#63d9b3;color:#101827}',
|
'.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,.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-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-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-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}',
|
'.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{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-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-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-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-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{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-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-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-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{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}',
|
'.notes-modal-btn.cancel{background:#2a2a4e;color:#ccc}',
|
||||||
@@ -171,7 +173,7 @@
|
|||||||
|
|
||||||
var workspaceNode = props && props.workspaceNode;
|
var workspaceNode = props && props.workspaceNode;
|
||||||
var workspaceRoot = (workspaceNode && (workspaceNode.rootPath || workspaceNode.name || workspaceNode.id)) || '';
|
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 notes = [];
|
||||||
var selectedPath = '';
|
var selectedPath = '';
|
||||||
@@ -183,6 +185,11 @@
|
|||||||
var sortMode = 'title-asc';
|
var sortMode = 'title-asc';
|
||||||
var renameTarget = null;
|
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() {
|
function notesParent() {
|
||||||
return workspaceRoot || '';
|
return workspaceRoot || '';
|
||||||
}
|
}
|
||||||
@@ -262,8 +269,8 @@
|
|||||||
// ─── UI Elements ────────────────────────────────────────
|
// ─── UI Elements ────────────────────────────────────────
|
||||||
|
|
||||||
var toolbar = el('div', { className: 'notes-toolbar' });
|
var toolbar = el('div', { className: 'notes-toolbar' });
|
||||||
var createBtn = el('button', { className: 'notes-btn primary', 'data-action': 'create', innerHTML: iconSvg('add') + ' New Note' });
|
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: 'Filter notes' });
|
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': '' }, [
|
var sortSelect = el('select', { className: 'notes-sort', 'data-notes-sort': '' }, [
|
||||||
el('option', { value: 'title-asc' }, ['A-Z']),
|
el('option', { value: 'title-asc' }, ['A-Z']),
|
||||||
el('option', { value: 'title-desc' }, ['Z-A'])
|
el('option', { value: 'title-desc' }, ['Z-A'])
|
||||||
@@ -276,29 +283,18 @@
|
|||||||
toolbar.appendChild(statusEl);
|
toolbar.appendChild(statusEl);
|
||||||
containerEl.appendChild(toolbar);
|
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);
|
containerEl.appendChild(titleBar);
|
||||||
|
|
||||||
var listContainer = el('div', { className: 'notes-list', 'data-notes-list': '' });
|
var listContainer = el('div', { className: 'notes-list', 'data-notes-list': '' });
|
||||||
containerEl.appendChild(listContainer);
|
containerEl.appendChild(listContainer);
|
||||||
|
|
||||||
var createPanel = el('div', { className: 'notes-panel', style: { display: 'none' } });
|
var createModal = null;
|
||||||
var createInput = el('input', { className: 'notes-input', 'data-notes-create-input': '', placeholder: 'Note title' });
|
var createInput = null;
|
||||||
var createConfirm = el('button', { className: 'notes-btn', textContent: 'Create' });
|
var createError = null;
|
||||||
var createCancel = el('button', { className: 'notes-btn', textContent: 'Cancel' });
|
var renameModal = null;
|
||||||
createPanel.appendChild(createInput);
|
var renameInput = null;
|
||||||
createPanel.appendChild(createConfirm);
|
var renameError = null;
|
||||||
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);
|
|
||||||
|
|
||||||
// ─── Core Functions ─────────────────────────────────────
|
// ─── 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() {
|
function loadNotes() {
|
||||||
listContainer.innerHTML = '';
|
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();
|
var parent = notesParent();
|
||||||
listNotes(parent).then(function (result) {
|
listNotes(parent).then(function (result) {
|
||||||
@@ -330,7 +333,7 @@
|
|||||||
renderList();
|
renderList();
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
if (disposed) return;
|
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 () {
|
}).then(function () {
|
||||||
if (!disposed) setStatus(action.label + ' complete', 'success');
|
if (!disposed) setStatus(action.label + ' complete', 'success');
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
console.error('[notes] contribution action failed:', err);
|
if (!disposed) setStatus(userFacingError('ui.actionError', 'Could not complete this note action. Please try again.', err), 'error');
|
||||||
if (!disposed) setStatus('Error: ' + (err && err.message ? err.message : err), 'error');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,36 +394,36 @@
|
|||||||
function renderList() {
|
function renderList() {
|
||||||
listContainer.innerHTML = '';
|
listContainer.innerHTML = '';
|
||||||
if (!notes || notes.length === 0) {
|
if (!notes || notes.length === 0) {
|
||||||
renderEmpty('No notes yet');
|
renderEmpty(tr('ui.empty', null, 'No notes yet'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var shown = visibleNotes();
|
var shown = visibleNotes();
|
||||||
if (shown.length === 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
shown.forEach(function (note) {
|
shown.forEach(function (note) {
|
||||||
var actionButtons = [
|
var actionButtons = [
|
||||||
el('button', {
|
el('button', {
|
||||||
className: 'notes-item-btn',
|
className: 'notes-item-btn',
|
||||||
title: 'Open',
|
title: tr('ui.open', null, 'Open'),
|
||||||
'aria-label': 'Open',
|
'aria-label': tr('ui.open', null, 'Open'),
|
||||||
'data-note-action': 'open',
|
'data-note-action': 'open',
|
||||||
innerHTML: iconSvg('open'),
|
innerHTML: iconSvg('open'),
|
||||||
onClick: function (e) { e.stopPropagation(); openNote(note); }
|
onClick: function (e) { e.stopPropagation(); openNote(note); }
|
||||||
}),
|
}),
|
||||||
el('button', {
|
el('button', {
|
||||||
className: 'notes-item-btn',
|
className: 'notes-item-btn',
|
||||||
title: 'Rename',
|
title: tr('ui.rename', null, 'Rename'),
|
||||||
'aria-label': 'Rename',
|
'aria-label': tr('ui.rename', null, 'Rename'),
|
||||||
'data-note-action': 'rename',
|
'data-note-action': 'rename',
|
||||||
innerHTML: iconSvg('rename'),
|
innerHTML: iconSvg('rename'),
|
||||||
onClick: function (e) { e.stopPropagation(); beginRename(note); }
|
onClick: function (e) { e.stopPropagation(); beginRename(note); }
|
||||||
}),
|
}),
|
||||||
el('button', {
|
el('button', {
|
||||||
className: 'notes-item-btn',
|
className: 'notes-item-btn',
|
||||||
title: 'Move to Trash',
|
title: tr('ui.trash', null, 'Move to Trash'),
|
||||||
'aria-label': 'Move to Trash',
|
'aria-label': tr('ui.trash', null, 'Move to Trash'),
|
||||||
'data-note-action': 'trash',
|
'data-note-action': 'trash',
|
||||||
innerHTML: iconSvg('trash'),
|
innerHTML: iconSvg('trash'),
|
||||||
onClick: function (e) { e.stopPropagation(); confirmTrashNote(note); }
|
onClick: function (e) { e.stopPropagation(); confirmTrashNote(note); }
|
||||||
@@ -457,7 +459,7 @@
|
|||||||
listContainer.appendChild(el('div', { className: 'notes-empty' }, [
|
listContainer.appendChild(el('div', { className: 'notes-empty' }, [
|
||||||
el('div', { innerHTML: iconSvg('note') }),
|
el('div', { innerHTML: iconSvg('note') }),
|
||||||
el('div', {}, [msg]),
|
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); });
|
}).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 ────────────────────────────────────────────
|
// ─── Create ────────────────────────────────────────────
|
||||||
|
|
||||||
function showCreate() {
|
function showCreate() {
|
||||||
createInput.value = '';
|
hideCreate();
|
||||||
createPanel.style.display = 'flex';
|
createModal = showNoteFormModal('create');
|
||||||
createInput.focus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideCreate() {
|
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() {
|
function confirmCreate() {
|
||||||
|
if (!createInput) return;
|
||||||
var title = createInput.value.trim();
|
var title = createInput.value.trim();
|
||||||
if (!title) return;
|
if (!title) {
|
||||||
setStatus('Creating note...', 'loading');
|
setCreateError(tr('ui.titleRequired', null, 'Enter a note title.'));
|
||||||
|
createInput.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus(tr('ui.creating', null, 'Creating note...'), 'loading');
|
||||||
var parent = notesParent();
|
var parent = notesParent();
|
||||||
createNote(parent, title).then(function (data) {
|
createNote(parent, title).then(function (data) {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
data = data || {};
|
data = data || {};
|
||||||
if (data.conflict) {
|
if (data.conflict) {
|
||||||
showConflictModal(title, data.path, createInput);
|
setCreateError(conflictMessage(title, data.path));
|
||||||
|
createInput.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
hideCreate();
|
hideCreate();
|
||||||
setStatus('Note created', 'success');
|
setStatus(tr('ui.created', null, 'Note created'), 'success');
|
||||||
loadNotes();
|
loadNotes();
|
||||||
// Open the newly created note
|
// Open the newly created note
|
||||||
if (data.path) {
|
if (data.path) {
|
||||||
@@ -528,42 +636,55 @@
|
|||||||
}).catch(function () {});
|
}).catch(function () {});
|
||||||
}
|
}
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
setStatus('Error: ' + (err.message || err), 'error');
|
setCreateError(userFacingError('ui.createError', 'Could not create the note. Please try again.', err));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Rename ─────────────────────────────────────────────
|
// ─── Rename ─────────────────────────────────────────────
|
||||||
|
|
||||||
function beginRename(note) {
|
function beginRename(note) {
|
||||||
|
hideRename();
|
||||||
renameTarget = note;
|
renameTarget = note;
|
||||||
renameInput.value = note.title || fileName(note.path);
|
renameModal = showNoteFormModal('rename', note);
|
||||||
renamePanel.style.display = 'flex';
|
|
||||||
renameInput.focus();
|
|
||||||
renameInput.select();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideRename() {
|
function hideRename() {
|
||||||
renameTarget = null;
|
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() {
|
function confirmRename() {
|
||||||
if (!renameTarget) return;
|
if (!renameTarget || !renameInput) return;
|
||||||
var newTitle = renameInput.value.trim();
|
var newTitle = renameInput.value.trim();
|
||||||
if (!newTitle) return;
|
if (!newTitle) {
|
||||||
setStatus('Renaming...', 'loading');
|
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) {
|
renameNote(renameTarget.path, newTitle).then(function (data) {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
data = data || {};
|
data = data || {};
|
||||||
if (data.conflict) {
|
if (data.conflict) {
|
||||||
showConflictModal(newTitle, data.path, renameInput);
|
setRenameError(conflictMessage(newTitle, data.path));
|
||||||
|
renameInput.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
hideRename();
|
hideRename();
|
||||||
setStatus('Note renamed', 'success');
|
setStatus(tr('ui.renamed', null, 'Note renamed'), 'success');
|
||||||
loadNotes();
|
loadNotes();
|
||||||
}).catch(function (err) {
|
}).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;
|
if (!note) return;
|
||||||
showTrashModal(note).then(function (confirmed) {
|
showTrashModal(note).then(function (confirmed) {
|
||||||
if (!confirmed || disposed) return;
|
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 () {
|
api.files.trash(note.path).then(function () {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
if (selectedPath === note.path) selectedPath = '';
|
if (selectedPath === note.path) selectedPath = '';
|
||||||
setStatus('Note moved to trash', 'success');
|
setStatus(tr('ui.trashed', null, 'Note moved to trash'), 'success');
|
||||||
loadNotes();
|
loadNotes();
|
||||||
}).catch(function (err) {
|
}).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) {
|
function showTrashModal(note) {
|
||||||
return new Promise(function (resolve) {
|
return new Promise(function (resolve) {
|
||||||
var overlay = el('div', { className: 'notes-modal-overlay' });
|
var overlay = el('div', { className: 'notes-modal-overlay' });
|
||||||
@@ -612,13 +714,13 @@
|
|||||||
resolve(value);
|
resolve(value);
|
||||||
}
|
}
|
||||||
var modal = el('div', { className: 'notes-modal' }, [
|
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' }, [
|
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('div', { className: 'notes-modal-actions' }, [
|
||||||
el('button', { className: 'notes-modal-btn cancel', textContent: 'Cancel', onClick: function () { close(false); } }),
|
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: 'Move to Trash', onClick: function () { close(true); } })
|
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);
|
overlay.appendChild(modal);
|
||||||
@@ -637,24 +739,22 @@
|
|||||||
sortMode = sortSelect.value || 'title-asc';
|
sortMode = sortSelect.value || 'title-asc';
|
||||||
renderList();
|
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 ───────────────────────────────────────────────
|
// ─── Init ───────────────────────────────────────────────
|
||||||
|
|
||||||
loadContributionActions();
|
loadContributionActions();
|
||||||
loadNotes();
|
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;
|
var fileChangedUnsubscribe = null;
|
||||||
if (api.events && typeof api.events.subscribe === 'function') {
|
if (api.events && typeof api.events.subscribe === 'function') {
|
||||||
api.events.subscribe('file.changed', function (event) {
|
api.events.subscribe('file.changed', function (event) {
|
||||||
@@ -669,6 +769,9 @@
|
|||||||
|
|
||||||
containerEl.__notesCleanup = function () {
|
containerEl.__notesCleanup = function () {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
hideCreate();
|
||||||
|
hideRename();
|
||||||
|
if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
|
||||||
if (typeof fileChangedUnsubscribe === 'function') fileChangedUnsubscribe();
|
if (typeof fileChangedUnsubscribe === 'function') fileChangedUnsubscribe();
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -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": "Не удалось переместить заметку в корзину. Повторите попытку."
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
"name": "Notes",
|
"name": "Notes",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"apiVersion": "0.1.0",
|
"apiVersion": "0.1.0",
|
||||||
"description": "Workspace-scoped notes manager 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",
|
"source": "official",
|
||||||
"icon": "edit",
|
"icon": "edit",
|
||||||
"provides": [
|
"provides": [
|
||||||
|
|||||||
@@ -63,7 +63,34 @@
|
|||||||
|
|
||||||
containerEl.innerHTML = '';
|
containerEl.innerHTML = '';
|
||||||
containerEl.className = 'pt-root';
|
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 = [];
|
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) {
|
function trackCleanup(fn) {
|
||||||
if (typeof fn === 'function') {
|
if (typeof fn === 'function') {
|
||||||
@@ -74,12 +101,19 @@
|
|||||||
containerEl.__ptCleanup.push(fn);
|
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 ─────────────────────────────────────────────────── */
|
/* ── Header ─────────────────────────────────────────────────── */
|
||||||
var header = div('pt-header', [
|
var header = div('pt-header', [
|
||||||
span('pt-icon', '◉'),
|
span('pt-icon', '◉'),
|
||||||
div('pt-title-group', [
|
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]),
|
el('p', { className: 'pt-plugin-id' }, [api.pluginId]),
|
||||||
]),
|
]),
|
||||||
span('pt-version', 'v' + (props && props.version ? props.version : '0.1.0')),
|
span('pt-version', 'v' + (props && props.version ? props.version : '0.1.0')),
|
||||||
@@ -88,49 +122,49 @@
|
|||||||
/* ── Status badge ──────────────────────────────────────────── */
|
/* ── Status badge ──────────────────────────────────────────── */
|
||||||
var badge = div('pt-badge pt-badge-success', [
|
var badge = div('pt-badge pt-badge-success', [
|
||||||
el('span', {}, ['✅']),
|
el('span', {}, ['✅']),
|
||||||
el('span', {}, ['Frontend Bundle Loaded']),
|
localized('span', {}, 'ui.bundleLoaded', 'Frontend Bundle Loaded'),
|
||||||
]);
|
]);
|
||||||
var badgeRow = div('', [badge]);
|
var badgeRow = div('', [badge]);
|
||||||
|
|
||||||
/* ── Real Plugin API bridge checks ─────────────────────────── */
|
/* ── Real Plugin API bridge checks ─────────────────────────── */
|
||||||
var savedValue = span('pt-list-value pt-saved-setting', 'Saved setting: loading...');
|
var savedValue = localized('span', { className: 'pt-list-value pt-saved-setting' }, 'ui.savedLoading', 'Saved setting: loading...');
|
||||||
var capabilityValue = span('pt-list-value pt-capability-result', 'Capabilities: loading...');
|
var capabilityValue = localized('span', { className: 'pt-list-value pt-capability-result' }, 'ui.capabilitiesLoading', 'Capabilities: loading...');
|
||||||
var commandValue = span('pt-list-value pt-command-result', 'Command: registering...');
|
var commandValue = localized('span', { className: 'pt-list-value pt-command-result' }, 'ui.commandRegistering', 'Command: registering...');
|
||||||
var eventValue = span('pt-list-value pt-event-result', 'Event: subscribing...');
|
var eventValue = localized('span', { className: 'pt-list-value pt-event-result' }, 'ui.eventSubscribing', 'Event: subscribing...');
|
||||||
var filesValue = span('pt-list-value pt-files-result', 'Files: running...');
|
var filesValue = localized('span', { className: 'pt-list-value pt-files-result' }, 'ui.filesRunning', 'Files: running...');
|
||||||
var filesErrorValue = span('pt-list-value pt-files-error-result', 'Files error path: checking...');
|
var filesErrorValue = localized('span', { className: 'pt-list-value pt-files-error-result' }, 'ui.filesPathChecking', 'Files error path: checking...');
|
||||||
var workbenchValue = span('pt-list-value pt-workbench-result', 'Workbench: ready');
|
var workbenchValue = localized('span', { className: 'pt-list-value pt-workbench-result' }, 'ui.workbenchReady', 'Workbench: ready');
|
||||||
function makeWorkbenchButton(className, label, request) {
|
function makeWorkbenchButton(className, key, label, request) {
|
||||||
return el('button', {
|
return el('button', {
|
||||||
className: 'btn btn-primary ' + className,
|
className: 'btn btn-primary ' + className,
|
||||||
onClick: function () {
|
onClick: function () {
|
||||||
workbenchValue.textContent = 'Workbench: opening...';
|
setLocalized(workbenchValue, 'ui.workbenchOpening', null, 'Workbench: opening...');
|
||||||
api.workbench.editResource(request)
|
api.workbench.editResource(request)
|
||||||
.then(function (result) {
|
.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);
|
workbenchValue.setAttribute('data-workbench-status', result.status === 'opened' ? 'ok' : result.status);
|
||||||
})
|
})
|
||||||
.catch(function (err) {
|
.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');
|
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',
|
kind: 'vault-file',
|
||||||
path: 'Docs/todo.txt',
|
path: 'Docs/todo.txt',
|
||||||
extension: '.txt',
|
extension: '.txt',
|
||||||
mime: 'text/plain',
|
mime: 'text/plain',
|
||||||
context: { sourceView: 'files' },
|
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',
|
kind: 'vault-file',
|
||||||
path: 'Docs/readme.md',
|
path: 'Docs/readme.md',
|
||||||
extension: '.md',
|
extension: '.md',
|
||||||
context: { sourceView: 'files' },
|
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',
|
kind: 'vault-file',
|
||||||
path: 'Notes/example.md',
|
path: 'Notes/example.md',
|
||||||
extension: '.md',
|
extension: '.md',
|
||||||
@@ -143,51 +177,51 @@
|
|||||||
var settingInput = el('input', {
|
var settingInput = el('input', {
|
||||||
className: 'pt-setting-input',
|
className: 'pt-setting-input',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
'aria-label': 'Saved setting',
|
'aria-label': tr('ui.savedSetting', null, 'Saved setting'),
|
||||||
value: 'changed value',
|
value: 'changed value',
|
||||||
});
|
});
|
||||||
var saveStatus = span('pt-list-value', '');
|
var saveStatus = span('pt-list-value', '');
|
||||||
var saveButton = el('button', {
|
var saveButton = el('button', {
|
||||||
className: 'btn btn-primary pt-save-setting',
|
className: 'btn btn-primary pt-save-setting',
|
||||||
onClick: function () {
|
onClick: function () {
|
||||||
saveStatus.textContent = 'Saving...';
|
setLocalized(saveStatus, 'ui.saving', null, 'Saving...');
|
||||||
api.settings.write('savedText', settingInput.value)
|
api.settings.write('savedText', settingInput.value)
|
||||||
.then(function () {
|
.then(function () {
|
||||||
savedValue.textContent = 'Saved setting: ' + settingInput.value;
|
setLocalized(savedValue, 'ui.savedSettingValue', { value: settingInput.value }, 'Saved setting: {value}');
|
||||||
saveStatus.textContent = 'Saved';
|
setLocalized(saveStatus, 'ui.saved', null, 'Saved');
|
||||||
})
|
})
|
||||||
.catch(function (err) {
|
.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')
|
api.settings.read('savedText')
|
||||||
.then(function (value) {
|
.then(function (value) {
|
||||||
var text = value || '';
|
var text = value || '';
|
||||||
settingInput.value = text || 'changed value';
|
settingInput.value = text || 'changed value';
|
||||||
savedValue.textContent = 'Saved setting: ' + text;
|
setLocalized(savedValue, 'ui.savedSettingValue', { value: text }, 'Saved setting: {value}');
|
||||||
})
|
})
|
||||||
.catch(function (err) {
|
.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()
|
api.capabilities.list()
|
||||||
.then(function (caps) {
|
.then(function (caps) {
|
||||||
capabilityValue.textContent = 'Capabilities: ' + caps.length + ' available';
|
setLocalized(capabilityValue, 'ui.capabilitiesAvailable', { count: caps.length }, 'Capabilities: {count} available');
|
||||||
})
|
})
|
||||||
.catch(function (err) {
|
.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')
|
api.capabilities.has('verstak/platform-test/v1')
|
||||||
.then(function (available) {
|
.then(function (available) {
|
||||||
badge.setAttribute('data-capability-status', available ? 'available' : 'missing');
|
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) {
|
.catch(function (err) {
|
||||||
badge.setAttribute('data-capability-status', 'error');
|
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 () {
|
api.commands.register('verstak.platform-test.show-version', function () {
|
||||||
@@ -202,17 +236,17 @@
|
|||||||
})
|
})
|
||||||
.then(function (result) {
|
.then(function (result) {
|
||||||
badge.setAttribute('data-command-status', result.status || '');
|
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) {
|
.catch(function (err) {
|
||||||
badge.setAttribute('data-command-status', 'error');
|
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);
|
console.error('[platform-test] command bridge error:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
api.events.subscribe('verstak.platform-test.echo', function (event) {
|
api.events.subscribe('verstak.platform-test.echo', function (event) {
|
||||||
var message = event && event.payload ? event.payload.message : '';
|
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');
|
eventValue.setAttribute('data-event-status', 'received');
|
||||||
})
|
})
|
||||||
.then(function (unsubscribe) {
|
.then(function (unsubscribe) {
|
||||||
@@ -220,7 +254,7 @@
|
|||||||
return api.events.publish('verstak.platform-test.echo', { message: 'hello-event' });
|
return api.events.publish('verstak.platform-test.echo', { message: 'hello-event' });
|
||||||
})
|
})
|
||||||
.catch(function (err) {
|
.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');
|
eventValue.setAttribute('data-event-status', 'error');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -249,39 +283,39 @@
|
|||||||
return api.files.trash('PlatformTest/files-api-moved.txt');
|
return api.files.trash('PlatformTest/files-api-moved.txt');
|
||||||
})
|
})
|
||||||
.then(function () {
|
.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');
|
filesValue.setAttribute('data-files-status', 'ok');
|
||||||
})
|
})
|
||||||
.catch(function (err) {
|
.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');
|
filesValue.setAttribute('data-files-status', 'error');
|
||||||
});
|
});
|
||||||
|
|
||||||
api.files.readText('.verstak/vault.json')
|
api.files.readText('.verstak/vault.json')
|
||||||
.then(function () {
|
.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');
|
filesErrorValue.setAttribute('data-files-error-status', 'error');
|
||||||
})
|
})
|
||||||
.catch(function (err) {
|
.catch(function (err) {
|
||||||
var message = err && err.message ? err.message : String(err);
|
var message = err && err.message ? err.message : String(err);
|
||||||
if (message.indexOf('reserved-path') === -1 && message.indexOf('.verstak') === -1) {
|
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');
|
filesErrorValue.setAttribute('data-files-error-status', 'error');
|
||||||
return;
|
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');
|
filesErrorValue.setAttribute('data-files-error-status', 'expected');
|
||||||
});
|
});
|
||||||
|
|
||||||
var bridgeCard = div('pt-card', [
|
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('ul', { className: 'pt-list' }, [
|
||||||
el('li', { className: 'pt-list-item' }, [
|
el('li', { className: 'pt-list-item' }, [
|
||||||
span('pt-list-label', 'Persisted setting'),
|
localized('span', { className: 'pt-list-label' }, 'ui.persistedSetting', 'Persisted setting'),
|
||||||
savedValue,
|
savedValue,
|
||||||
]),
|
]),
|
||||||
el('li', { className: 'pt-list-item' }, [
|
el('li', { className: 'pt-list-item' }, [
|
||||||
span('pt-list-label', 'New value'),
|
localized('span', { className: 'pt-list-label' }, 'ui.newValue', 'New value'),
|
||||||
settingInput,
|
settingInput,
|
||||||
]),
|
]),
|
||||||
el('li', { className: 'pt-list-item' }, [
|
el('li', { className: 'pt-list-item' }, [
|
||||||
@@ -289,27 +323,27 @@
|
|||||||
saveStatus,
|
saveStatus,
|
||||||
]),
|
]),
|
||||||
el('li', { className: 'pt-list-item' }, [
|
el('li', { className: 'pt-list-item' }, [
|
||||||
span('pt-list-label', 'Capabilities'),
|
localized('span', { className: 'pt-list-label' }, 'ui.capabilitiesLabel', 'Capabilities'),
|
||||||
capabilityValue,
|
capabilityValue,
|
||||||
]),
|
]),
|
||||||
el('li', { className: 'pt-list-item' }, [
|
el('li', { className: 'pt-list-item' }, [
|
||||||
span('pt-list-label', 'Command runtime'),
|
localized('span', { className: 'pt-list-label' }, 'ui.commandRuntime', 'Command runtime'),
|
||||||
commandValue,
|
commandValue,
|
||||||
]),
|
]),
|
||||||
el('li', { className: 'pt-list-item' }, [
|
el('li', { className: 'pt-list-item' }, [
|
||||||
span('pt-list-label', 'Event runtime'),
|
localized('span', { className: 'pt-list-label' }, 'ui.eventRuntime', 'Event runtime'),
|
||||||
eventValue,
|
eventValue,
|
||||||
]),
|
]),
|
||||||
el('li', { className: 'pt-list-item' }, [
|
el('li', { className: 'pt-list-item' }, [
|
||||||
span('pt-list-label', 'Files runtime'),
|
localized('span', { className: 'pt-list-label' }, 'ui.filesRuntime', 'Files runtime'),
|
||||||
filesValue,
|
filesValue,
|
||||||
]),
|
]),
|
||||||
el('li', { className: 'pt-list-item' }, [
|
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,
|
filesErrorValue,
|
||||||
]),
|
]),
|
||||||
el('li', { className: 'pt-list-item' }, [
|
el('li', { className: 'pt-list-item' }, [
|
||||||
span('pt-list-label', 'Workbench routing'),
|
localized('span', { className: 'pt-list-label' }, 'ui.workbenchRouting', 'Workbench routing'),
|
||||||
openTextWorkbenchButton,
|
openTextWorkbenchButton,
|
||||||
openMarkdownWorkbenchButton,
|
openMarkdownWorkbenchButton,
|
||||||
openNotesWorkbenchButton,
|
openNotesWorkbenchButton,
|
||||||
@@ -320,12 +354,12 @@
|
|||||||
|
|
||||||
/* ── Test results summary ──────────────────────────────────── */
|
/* ── Test results summary ──────────────────────────────────── */
|
||||||
var testsData = [
|
var testsData = [
|
||||||
{ label: 'Plugin Registration', status: 'pass' },
|
{ key: 'registration', label: 'Plugin Registration', status: 'pass' },
|
||||||
{ label: 'Capability: verstak/platform-test/v1', status: 'pass' },
|
{ key: 'platformCapability', label: 'Capability: verstak/platform-test/v1', status: 'pass' },
|
||||||
{ label: 'Capability: verstak/diagnostics/v1', status: 'pass' },
|
{ key: 'diagnosticsCapability', label: 'Capability: verstak/diagnostics/v1', status: 'pass' },
|
||||||
{ label: 'Capability: verstak/core/files/v1', status: 'pass' },
|
{ key: 'filesCapability', label: 'Capability: verstak/core/files/v1', status: 'pass' },
|
||||||
{ label: 'Capability: verstak/core/workbench/v1', status: 'pass' },
|
{ key: 'workbenchCapability', label: 'Capability: verstak/core/workbench/v1', status: 'pass' },
|
||||||
{ label: 'API Contract Compliance', status: 'pass' },
|
{ key: 'apiContract', label: 'API Contract Compliance', status: 'pass' },
|
||||||
];
|
];
|
||||||
|
|
||||||
var totalTests = testsData.length;
|
var totalTests = testsData.length;
|
||||||
@@ -334,19 +368,19 @@
|
|||||||
var summaryRow = div('pt-test-summary', [
|
var summaryRow = div('pt-test-summary', [
|
||||||
div('pt-test-stat', [
|
div('pt-test-stat', [
|
||||||
span('pt-test-stat-value pt-pass', String(passedTests)),
|
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', [
|
div('pt-test-stat', [
|
||||||
span('pt-test-stat-value pt-fail', String(totalTests - passedTests)),
|
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', [
|
div('pt-test-stat', [
|
||||||
span('pt-test-stat-value', String(totalTests)),
|
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', [
|
div('pt-test-stat', [
|
||||||
span('pt-test-stat-value pt-pass', '100%'),
|
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) {
|
testsData.forEach(function (t) {
|
||||||
var dot = el('span', { className: 'pt-cap-dot pt-cap-dot-ok' });
|
var dot = el('span', { className: 'pt-cap-dot pt-cap-dot-ok' });
|
||||||
var item = el('li', { className: 'pt-list-item' }, [
|
var item = el('li', { className: 'pt-list-item' }, [
|
||||||
el('span', { className: 'pt-list-label' }, [dot, ' ', t.label]),
|
el('span', { className: 'pt-list-label' }, [dot, ' ', localized('span', {}, 'ui.test.' + t.key, t.label)]),
|
||||||
span('pt-list-value pt-pass', t.status === 'pass' ? '✓ PASS' : '✗ FAIL'),
|
localized('span', { className: 'pt-list-value pt-pass' }, t.status === 'pass' ? 'ui.pass' : 'ui.fail', t.status === 'pass' ? '✓ PASS' : '✗ FAIL'),
|
||||||
]);
|
]);
|
||||||
testsList.appendChild(item);
|
testsList.appendChild(item);
|
||||||
});
|
});
|
||||||
|
|
||||||
var testsCard = div('pt-card', [
|
var testsCard = div('pt-card', [
|
||||||
el('h3', { className: 'pt-card-title' }, ['Test Results']),
|
localized('h3', { className: 'pt-card-title' }, 'ui.testResults', 'Test Results'),
|
||||||
summaryRow,
|
summaryRow,
|
||||||
testsList,
|
testsList,
|
||||||
]);
|
]);
|
||||||
@@ -380,7 +414,7 @@
|
|||||||
var dot = el('span', {
|
var dot = el('span', {
|
||||||
className: 'pt-cap-dot pt-cap-dot-missing',
|
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' }, [
|
var item = el('li', { className: 'pt-list-item' }, [
|
||||||
el('span', { className: 'pt-list-label' }, [dot, ' ', cap.label]),
|
el('span', { className: 'pt-list-label' }, [dot, ' ', cap.label]),
|
||||||
statusText,
|
statusText,
|
||||||
@@ -389,39 +423,39 @@
|
|||||||
api.capabilities.has(cap.id)
|
api.capabilities.has(cap.id)
|
||||||
.then(function (available) {
|
.then(function (available) {
|
||||||
dot.className = 'pt-cap-dot ' + (available ? 'pt-cap-dot-ok' : 'pt-cap-dot-missing');
|
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 () {
|
.catch(function () {
|
||||||
dot.className = 'pt-cap-dot pt-cap-dot-missing';
|
dot.className = 'pt-cap-dot pt-cap-dot-missing';
|
||||||
statusText.textContent = 'Error';
|
setLocalized(statusText, 'ui.error', null, 'Error');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
var capsCard = div('pt-card', [
|
var capsCard = div('pt-card', [
|
||||||
el('h3', { className: 'pt-card-title' }, ['Registered Capabilities']),
|
localized('h3', { className: 'pt-card-title' }, 'ui.capabilities', 'Registered Capabilities'),
|
||||||
capList,
|
capList,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/* ── Plugin info ───────────────────────────────────────────── */
|
/* ── Plugin info ───────────────────────────────────────────── */
|
||||||
var infoList = el('ul', { className: 'pt-list' });
|
var infoList = el('ul', { className: 'pt-list' });
|
||||||
var infoItems = [
|
var infoItems = [
|
||||||
{ label: 'Plugin ID', value: api.pluginId },
|
{ key: 'pluginId', label: 'Plugin ID', value: api.pluginId },
|
||||||
{ label: 'Bundle Status', value: 'Loaded ✓' },
|
{ key: 'bundleStatus', label: 'Bundle Status', value: tr('ui.loaded', null, 'Loaded ✓') },
|
||||||
{ label: 'Registration Scheme', value: 'VerstakPluginRegister' },
|
{ key: 'registration', label: 'Registration Scheme', value: 'VerstakPluginRegister' },
|
||||||
{ label: 'Components', value: 'DiagnosticsPanel, PlatformTestSettings' },
|
{ key: 'components', label: 'Components', value: 'DiagnosticsPanel, PlatformTestSettings' },
|
||||||
{ label: 'Container', value: containerEl.tagName.toLowerCase() + (containerEl.id ? '#' + containerEl.id : '') },
|
{ key: 'container', label: 'Container', value: containerEl.tagName.toLowerCase() + (containerEl.id ? '#' + containerEl.id : '') },
|
||||||
];
|
];
|
||||||
infoItems.forEach(function (item) {
|
infoItems.forEach(function (item) {
|
||||||
infoList.appendChild(
|
infoList.appendChild(
|
||||||
el('li', { className: 'pt-list-item' }, [
|
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),
|
span('pt-list-value', item.value),
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
var infoCard = div('pt-card', [
|
var infoCard = div('pt-card', [
|
||||||
el('h3', { className: 'pt-card-title' }, ['Plugin Info']),
|
localized('h3', { className: 'pt-card-title' }, 'ui.pluginInfo', 'Plugin Info'),
|
||||||
infoList,
|
infoList,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -450,13 +484,13 @@
|
|||||||
apiStatusList.appendChild(
|
apiStatusList.appendChild(
|
||||||
el('li', { className: 'pt-list-item' }, [
|
el('li', { className: 'pt-list-item' }, [
|
||||||
el('span', { className: 'pt-list-label' }, [dot, ' ', chk.label]),
|
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', [
|
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,
|
apiStatusList,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -487,7 +521,7 @@
|
|||||||
|
|
||||||
function renderMouseLog() {
|
function renderMouseLog() {
|
||||||
if (mouseLog.length === 0) {
|
if (mouseLog.length === 0) {
|
||||||
mouseLogContainer.textContent = '(no events captured)';
|
setLocalized(mouseLogContainer, 'ui.noMouseEvents', null, '(no events captured)');
|
||||||
mouseCountSpan.textContent = '0';
|
mouseCountSpan.textContent = '0';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -497,6 +531,7 @@
|
|||||||
' defaultPrevented=' + e.defaultPrevented +
|
' defaultPrevented=' + e.defaultPrevented +
|
||||||
' target=' + e.targetTag + (e.targetClass ? '.' + e.targetClass : '');
|
' target=' + e.targetTag + (e.targetClass ? '.' + e.targetClass : '');
|
||||||
});
|
});
|
||||||
|
if (mouseLogContainer.__ptLocalized) mouseLogContainer.__ptLocalized.key = '';
|
||||||
mouseLogContainer.textContent = lines.join('\n');
|
mouseLogContainer.textContent = lines.join('\n');
|
||||||
mouseCountSpan.textContent = String(mouseLog.length);
|
mouseCountSpan.textContent = String(mouseLog.length);
|
||||||
mouseLogContainer.scrollTop = mouseLogContainer.scrollHeight;
|
mouseLogContainer.scrollTop = mouseLogContainer.scrollHeight;
|
||||||
@@ -526,7 +561,7 @@
|
|||||||
window.addEventListener(type, handler, true);
|
window.addEventListener(type, handler, true);
|
||||||
mouseHandlers.push({ type: type, handler: handler });
|
mouseHandlers.push({ type: type, handler: handler });
|
||||||
});
|
});
|
||||||
startStopBtn.textContent = '■ Stop Capture';
|
setLocalized(startStopBtn, 'ui.stopCapture', null, '■ Stop Capture');
|
||||||
startStopBtn.setAttribute('data-mouse-capturing', 'true');
|
startStopBtn.setAttribute('data-mouse-capturing', 'true');
|
||||||
renderMouseLog();
|
renderMouseLog();
|
||||||
}
|
}
|
||||||
@@ -538,35 +573,35 @@
|
|||||||
window.removeEventListener(h.type, h.handler, true);
|
window.removeEventListener(h.type, h.handler, true);
|
||||||
});
|
});
|
||||||
mouseHandlers = [];
|
mouseHandlers = [];
|
||||||
startStopBtn.textContent = '▶ Start Capture';
|
setLocalized(startStopBtn, 'ui.startCapture', null, '▶ Start Capture');
|
||||||
startStopBtn.setAttribute('data-mouse-capturing', 'false');
|
startStopBtn.setAttribute('data-mouse-capturing', 'false');
|
||||||
}
|
}
|
||||||
|
|
||||||
var startStopBtn = el('button', {
|
var startStopBtn = localized('button', {
|
||||||
className: 'btn btn-primary',
|
className: 'btn btn-primary',
|
||||||
style: { marginRight: '0.5rem' },
|
style: { marginRight: '0.5rem' },
|
||||||
onClick: function () {
|
onClick: function () {
|
||||||
if (mouseCapturing) { stopMouseCapture(); } else { startMouseCapture(); }
|
if (mouseCapturing) { stopMouseCapture(); } else { startMouseCapture(); }
|
||||||
},
|
},
|
||||||
}, ['▶ Start Capture']);
|
}, 'ui.startCapture', '▶ Start Capture');
|
||||||
|
|
||||||
var clearBtn = el('button', {
|
var clearBtn = localized('button', {
|
||||||
className: 'btn btn-secondary',
|
className: 'btn btn-secondary',
|
||||||
style: { marginRight: '0.5rem' },
|
style: { marginRight: '0.5rem' },
|
||||||
onClick: function () {
|
onClick: function () {
|
||||||
mouseLog = [];
|
mouseLog = [];
|
||||||
renderMouseLog();
|
renderMouseLog();
|
||||||
},
|
},
|
||||||
}, ['Clear']);
|
}, 'ui.clear', 'Clear');
|
||||||
|
|
||||||
var copyBtn = el('button', {
|
var copyBtn = localized('button', {
|
||||||
className: 'btn btn-secondary',
|
className: 'btn btn-secondary',
|
||||||
onClick: function () {
|
onClick: function () {
|
||||||
var json = JSON.stringify(mouseLog, null, 2);
|
var json = JSON.stringify(mouseLog, null, 2);
|
||||||
if (navigator.clipboard) {
|
if (navigator.clipboard) {
|
||||||
navigator.clipboard.writeText(json).then(function () {
|
navigator.clipboard.writeText(json).then(function () {
|
||||||
copyBtn.textContent = '✓ Copied!';
|
setLocalized(copyBtn, 'ui.copied', null, '✓ Copied!');
|
||||||
setTimeout(function () { copyBtn.textContent = 'Copy JSON'; }, 1500);
|
setTimeout(function () { setLocalized(copyBtn, 'ui.copyJson', null, 'Copy JSON'); }, 1500);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
var ta = document.createElement('textarea');
|
var ta = document.createElement('textarea');
|
||||||
@@ -575,24 +610,22 @@
|
|||||||
ta.select();
|
ta.select();
|
||||||
document.execCommand('copy');
|
document.execCommand('copy');
|
||||||
document.body.removeChild(ta);
|
document.body.removeChild(ta);
|
||||||
copyBtn.textContent = '✓ Copied!';
|
setLocalized(copyBtn, 'ui.copied', null, '✓ Copied!');
|
||||||
setTimeout(function () { copyBtn.textContent = 'Copy JSON'; }, 1500);
|
setTimeout(function () { setLocalized(copyBtn, 'ui.copyJson', null, 'Copy JSON'); }, 1500);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}, ['Copy JSON']);
|
}, 'ui.copyJson', 'Copy JSON');
|
||||||
|
|
||||||
trackCleanup(function () { stopMouseCapture(); });
|
trackCleanup(function () { stopMouseCapture(); });
|
||||||
|
|
||||||
var mouseCard = div('pt-card', [
|
var mouseCard = div('pt-card', [
|
||||||
el('h3', { className: 'pt-card-title' }, ['Mouse Event Inspector']),
|
localized('h3', { className: 'pt-card-title' }, 'ui.mouseInspector', 'Mouse Event Inspector'),
|
||||||
el('p', { style: { margin: '0 0 0.5rem', color: '#a0a0b8', fontSize: '0.8rem' } }, [
|
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.'),
|
||||||
'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' } }, [
|
el('div', { style: { display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' } }, [
|
||||||
startStopBtn,
|
startStopBtn,
|
||||||
clearBtn,
|
clearBtn,
|
||||||
copyBtn,
|
copyBtn,
|
||||||
span('pt-list-label', ' Events:'),
|
localized('span', { className: 'pt-list-label' }, 'ui.eventsCount', 'Events:'),
|
||||||
mouseCountSpan,
|
mouseCountSpan,
|
||||||
]),
|
]),
|
||||||
mouseLogContainer,
|
mouseLogContainer,
|
||||||
@@ -667,6 +700,28 @@
|
|||||||
containerEl.innerHTML = '';
|
containerEl.innerHTML = '';
|
||||||
containerEl.className = 'pt-root';
|
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) ──────────────────── */
|
/* ── Counter state (local, not persisted) ──────────────────── */
|
||||||
var counterState = { value: 0 };
|
var counterState = { value: 0 };
|
||||||
|
|
||||||
@@ -674,77 +729,69 @@
|
|||||||
var header = div('pt-header', [
|
var header = div('pt-header', [
|
||||||
span('pt-icon', '⚙️'),
|
span('pt-icon', '⚙️'),
|
||||||
div('pt-title-group', [
|
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]),
|
el('p', { className: 'pt-plugin-id' }, [api.pluginId]),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/* ── Info card ──────────────────────────────────────────────── */
|
/* ── Info card ──────────────────────────────────────────────── */
|
||||||
var infoCard = div('pt-card', [
|
var infoCard = div('pt-card', [
|
||||||
el('p', { style: { margin: '0', color: '#a0a0b8', fontSize: '0.85rem' } }, [
|
localized('p', { style: { margin: '0', color: '#a0a0b8', fontSize: '0.85rem' } }, 'ui.settingsInfo', 'Settings panel loaded from the plugin frontend bundle through the VerstakPluginRegister contract.'),
|
||||||
'Settings panel loaded from plugin frontend bundle via ',
|
|
||||||
el('code', { style: { background: '#1a1a2e', padding: '0.1rem 0.3rem', borderRadius: '3px', color: '#4ecca3' } }, ['VerstakPluginRegister']),
|
|
||||||
' contract.',
|
|
||||||
]),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/* ── Counter section ────────────────────────────────────────── */
|
/* ── Counter section ────────────────────────────────────────── */
|
||||||
var counterDisplay = div('pt-counter', [
|
var counterDisplay = div('pt-counter', [
|
||||||
el('span', { className: 'pt-counter-value' }, [String(counterState.value)]),
|
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 () {
|
var incrementBtn = el('button', { className: 'btn btn-primary', onClick: function () {
|
||||||
counterState.value += 1;
|
counterState.value += 1;
|
||||||
counterDisplay.firstChild.textContent = String(counterState.value);
|
counterDisplay.firstChild.textContent = String(counterState.value);
|
||||||
}}, ['+ Increment']);
|
}}, [localized('span', {}, 'ui.increment', '+ Increment')]);
|
||||||
|
|
||||||
var decrementBtn = el('button', { className: 'btn btn-secondary', onClick: function () {
|
var decrementBtn = el('button', { className: 'btn btn-secondary', onClick: function () {
|
||||||
counterState.value = Math.max(0, counterState.value - 1);
|
counterState.value = Math.max(0, counterState.value - 1);
|
||||||
counterDisplay.firstChild.textContent = String(counterState.value);
|
counterDisplay.firstChild.textContent = String(counterState.value);
|
||||||
}}, ['− Decrement']);
|
}}, [localized('span', {}, 'ui.decrement', '− Decrement')]);
|
||||||
|
|
||||||
var resetBtn = el('button', { className: 'btn btn-secondary', onClick: function () {
|
var resetBtn = el('button', { className: 'btn btn-secondary', onClick: function () {
|
||||||
counterState.value = 0;
|
counterState.value = 0;
|
||||||
counterDisplay.firstChild.textContent = '0';
|
counterDisplay.firstChild.textContent = '0';
|
||||||
}}, ['↺ Reset']);
|
}}, [localized('span', {}, 'ui.reset', '↺ Reset')]);
|
||||||
|
|
||||||
var btnGroup = el('div', { style: { display: 'flex', gap: '0.5rem' } }, [
|
var btnGroup = el('div', { style: { display: 'flex', gap: '0.5rem' } }, [
|
||||||
incrementBtn, decrementBtn, resetBtn,
|
incrementBtn, decrementBtn, resetBtn,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
var counterCard = div('pt-card', [
|
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,
|
counterDisplay,
|
||||||
btnGroup,
|
btnGroup,
|
||||||
el('p', { style: { marginTop: '0.75rem', color: '#6c6c8a', fontSize: '0.7rem' } }, [
|
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.'),
|
||||||
'This counter is a local demo. State is not persisted — refreshing resets it.',
|
|
||||||
]),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/* ── Settings stub ─────────────────────────────────────────── */
|
/* ── Settings stub ─────────────────────────────────────────── */
|
||||||
var settingsDemoList = el('ul', { className: 'pt-list' });
|
var settingsDemoList = el('ul', { className: 'pt-list' });
|
||||||
var settingsItems = [
|
var settingsItems = [
|
||||||
{ label: 'Auto-run on load', value: 'true' },
|
{ key: 'autoRun', label: 'Auto-run on load', valueKey: 'true', value: 'true' },
|
||||||
{ label: 'Verbose logging', value: 'false' },
|
{ key: 'verbose', label: 'Verbose logging', valueKey: 'false', value: 'false' },
|
||||||
{ label: 'Theme override', value: 'inherit' },
|
{ key: 'theme', label: 'Theme override', valueKey: 'inherit', value: 'inherit' },
|
||||||
{ label: 'Notifications', value: 'enabled' },
|
{ key: 'notifications', label: 'Notifications', valueKey: 'enabled', value: 'enabled' },
|
||||||
];
|
];
|
||||||
settingsItems.forEach(function (s) {
|
settingsItems.forEach(function (s) {
|
||||||
settingsDemoList.appendChild(
|
settingsDemoList.appendChild(
|
||||||
el('li', { className: 'pt-list-item' }, [
|
el('li', { className: 'pt-list-item' }, [
|
||||||
span('pt-list-label', s.label),
|
localized('span', { className: 'pt-list-label' }, 'ui.setting.' + s.key, s.label),
|
||||||
span('pt-list-value', s.value),
|
localized('span', { className: 'pt-list-value' }, 'ui.value.' + s.valueKey, s.value),
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
var settingsCard = div('pt-card', [
|
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,
|
settingsDemoList,
|
||||||
el('p', { style: { marginTop: '0.5rem', color: '#6c6c8a', fontSize: '0.7rem' } }, [
|
localized('p', { style: { marginTop: '0.5rem', color: '#6c6c8a', fontSize: '0.7rem' } }, 'ui.settingsHint', 'Use api.settings.read() / api.settings.write() for persisted settings.'),
|
||||||
'Use api.settings.read() / api.settings.write() for persisted settings.',
|
|
||||||
]),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/* ── Assemble ──────────────────────────────────────────────── */
|
/* ── Assemble ──────────────────────────────────────────────── */
|
||||||
@@ -755,8 +802,10 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
unmount: function (containerEl) {
|
unmount: function (containerEl) {
|
||||||
|
if (typeof containerEl.__ptSettingsCleanup === 'function') containerEl.__ptSettingsCleanup();
|
||||||
containerEl.innerHTML = '';
|
containerEl.innerHTML = '';
|
||||||
containerEl.className = '';
|
containerEl.className = '';
|
||||||
|
delete containerEl.__ptSettingsCleanup;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -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()."
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"apiVersion": "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.",
|
"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",
|
"source": "official",
|
||||||
"icon": "flask",
|
"icon": "flask",
|
||||||
"provides": [
|
"provides": [
|
||||||
|
|||||||
@@ -277,7 +277,8 @@
|
|||||||
try {
|
try {
|
||||||
providers = await api.contributions.list('searchProviders');
|
providers = await api.contributions.list('searchProviders');
|
||||||
} catch (err) {
|
} 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;
|
return output;
|
||||||
}
|
}
|
||||||
providers = Array.isArray(providers) ? providers : [];
|
providers = Array.isArray(providers) ? providers : [];
|
||||||
@@ -294,7 +295,8 @@
|
|||||||
var normalized = normalizeProviderResults(provider, response && response.result);
|
var normalized = normalizeProviderResults(provider, response && response.result);
|
||||||
output.results = output.results.concat(normalized.slice(0, remaining - output.results.length));
|
output.results = output.results.concat(normalized.slice(0, remaining - output.results.length));
|
||||||
} catch (err) {
|
} 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;
|
return output;
|
||||||
@@ -315,6 +317,11 @@
|
|||||||
var statusEl = null;
|
var statusEl = null;
|
||||||
var alertEl = null;
|
var alertEl = null;
|
||||||
var resultsEl = 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() {
|
function ensureLayout() {
|
||||||
if (input) return;
|
if (input) return;
|
||||||
@@ -325,7 +332,7 @@
|
|||||||
input = el('input', {
|
input = el('input', {
|
||||||
className: 'search-input',
|
className: 'search-input',
|
||||||
type: 'search',
|
type: 'search',
|
||||||
placeholder: 'Search files, folders, text',
|
placeholder: tr('ui.placeholder', null, 'Search files, folders, text'),
|
||||||
value: state.query,
|
value: state.query,
|
||||||
'data-search-input': 'query',
|
'data-search-input': 'query',
|
||||||
onInput: function (event) {
|
onInput: function (event) {
|
||||||
@@ -341,7 +348,7 @@
|
|||||||
containerEl.appendChild(el('div', { className: 'search-toolbar' }, [
|
containerEl.appendChild(el('div', { className: 'search-toolbar' }, [
|
||||||
input,
|
input,
|
||||||
button,
|
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' });
|
statusEl = el('div', { className: 'search-status' });
|
||||||
@@ -357,7 +364,7 @@
|
|||||||
if (document.activeElement !== input && input.value !== state.query) {
|
if (document.activeElement !== input && input.value !== state.query) {
|
||||||
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;
|
button.disabled = !!state.searching;
|
||||||
statusEl.className = 'search-status' + (state.error ? ' error' : '');
|
statusEl.className = 'search-status' + (state.error ? ' error' : '');
|
||||||
statusEl.textContent = state.error || state.status;
|
statusEl.textContent = state.error || state.status;
|
||||||
@@ -365,15 +372,17 @@
|
|||||||
if (state.providerErrors && state.providerErrors.length) {
|
if (state.providerErrors && state.providerErrors.length) {
|
||||||
if (typeof alertEl.removeAttribute === 'function') alertEl.removeAttribute('hidden');
|
if (typeof alertEl.removeAttribute === 'function') alertEl.removeAttribute('hidden');
|
||||||
alertEl.appendChild(el('details', {}, [
|
alertEl.appendChild(el('details', {}, [
|
||||||
el('summary', {}, ['Some plugin search providers did not respond']),
|
el('summary', {}, [tr('ui.providersFailed', null, 'Some plugin search providers did not respond')]),
|
||||||
el('div', {}, [state.providerErrors.join('; ')])
|
el('div', {}, [state.providerErrors.map(function () {
|
||||||
|
return tr('ui.providerUnavailable', null, 'A search provider is unavailable.');
|
||||||
|
}).join(' ')])
|
||||||
]));
|
]));
|
||||||
} else if (typeof alertEl.setAttribute === 'function') {
|
} else if (typeof alertEl.setAttribute === 'function') {
|
||||||
alertEl.setAttribute('hidden', 'hidden');
|
alertEl.setAttribute('hidden', 'hidden');
|
||||||
}
|
}
|
||||||
resultsEl.innerHTML = '';
|
resultsEl.innerHTML = '';
|
||||||
if (!state.results.length) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
state.results.forEach(function (result) {
|
state.results.forEach(function (result) {
|
||||||
@@ -391,7 +400,7 @@
|
|||||||
]),
|
]),
|
||||||
result.openable ? el('button', {
|
result.openable ? el('button', {
|
||||||
className: 'search-btn search-open-btn',
|
className: 'search-btn search-open-btn',
|
||||||
textContent: 'Open',
|
textContent: tr('ui.open', null, 'Open'),
|
||||||
'data-search-open': result.path,
|
'data-search-open': result.path,
|
||||||
onClick: function () {
|
onClick: function () {
|
||||||
api.workbench.openResource({
|
api.workbench.openResource({
|
||||||
@@ -415,7 +424,7 @@
|
|||||||
if (query.length < 2) {
|
if (query.length < 2) {
|
||||||
state.searching = false;
|
state.searching = false;
|
||||||
state.results = [];
|
state.results = [];
|
||||||
state.status = 'Enter at least 2 characters.';
|
state.status = tr('ui.minChars', null, 'Enter at least 2 characters.');
|
||||||
state.error = '';
|
state.error = '';
|
||||||
state.providerErrors = [];
|
state.providerErrors = [];
|
||||||
render();
|
render();
|
||||||
@@ -432,7 +441,7 @@
|
|||||||
state.query = String(state.query || '').trim();
|
state.query = String(state.query || '').trim();
|
||||||
if (state.query.length < 2) {
|
if (state.query.length < 2) {
|
||||||
state.results = [];
|
state.results = [];
|
||||||
state.status = 'Enter at least 2 characters.';
|
state.status = tr('ui.minChars', null, 'Enter at least 2 characters.');
|
||||||
state.error = '';
|
state.error = '';
|
||||||
state.providerErrors = [];
|
state.providerErrors = [];
|
||||||
render();
|
render();
|
||||||
@@ -441,7 +450,7 @@
|
|||||||
state.searching = true;
|
state.searching = true;
|
||||||
state.error = '';
|
state.error = '';
|
||||||
state.providerErrors = [];
|
state.providerErrors = [];
|
||||||
state.status = 'Searching...';
|
state.status = tr('ui.searching', null, 'Searching...');
|
||||||
var seq = searchSeq + 1;
|
var seq = searchSeq + 1;
|
||||||
searchSeq = seq;
|
searchSeq = seq;
|
||||||
render();
|
render();
|
||||||
@@ -451,12 +460,13 @@
|
|||||||
var external = await runExternalProviders(api, rootPath, state.query, MAX_RESULTS - results.length);
|
var external = await runExternalProviders(api, rootPath, state.query, MAX_RESULTS - results.length);
|
||||||
if (seq !== searchSeq) return;
|
if (seq !== searchSeq) return;
|
||||||
state.results = results.concat(external.results);
|
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;
|
state.providerErrors = external.errors;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (seq !== searchSeq) return;
|
if (seq !== searchSeq) return;
|
||||||
|
console.warn('[verstak.search] search:', err);
|
||||||
state.results = [];
|
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 = [];
|
state.providerErrors = [];
|
||||||
} finally {
|
} finally {
|
||||||
if (seq !== searchSeq) return;
|
if (seq !== searchSeq) return;
|
||||||
@@ -507,6 +517,12 @@
|
|||||||
|
|
||||||
setupIntegrations();
|
setupIntegrations();
|
||||||
render();
|
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 () {
|
containerEl.__verstakSearchCleanup = function () {
|
||||||
if (searchTimer) clearTimeout(searchTimer);
|
if (searchTimer) clearTimeout(searchTimer);
|
||||||
searchSeq += 1;
|
searchSeq += 1;
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
}
|
||||||
@@ -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}"
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
"name": "Search",
|
"name": "Search",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"apiVersion": "0.1.0",
|
"apiVersion": "0.1.0",
|
||||||
"description": "Workspace-scoped vault text search provider.",
|
"description": "Deal-scoped vault text search provider.",
|
||||||
|
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
|
||||||
"source": "official",
|
"source": "official",
|
||||||
"icon": "search",
|
"icon": "search",
|
||||||
"provides": [
|
"provides": [
|
||||||
|
|||||||
@@ -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-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-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-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-title{font-weight:600;font-size:.88rem}',
|
||||||
'.secrets-count{color:var(--vt-color-text-muted,#7f8aa3);font-size:.76rem}',
|
'.secrets-count{color:var(--vt-color-text-muted,#7f8aa3);font-size:.76rem}',
|
||||||
'.secrets-spacer{flex:1}',
|
'.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-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-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-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-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-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-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-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-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{font-size:.78rem;color:var(--vt-color-text-muted,#7f8aa3);min-height:1rem}',
|
||||||
'.secrets-status.error{color:#ffc6ce}',
|
'.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 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 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}',
|
'.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');
|
].join('\n');
|
||||||
|
|
||||||
function el(tag, attrs, children) {
|
function el(tag, attrs, children) {
|
||||||
@@ -95,29 +102,30 @@
|
|||||||
|| (node && (node.rootPath || node.name || node.id)));
|
|| (node && (node.rootPath || node.name || node.id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function scopeLabel(record) {
|
function scopeLabel(record, translate) {
|
||||||
var scope = record && record.scope || {};
|
var scope = record && record.scope || {};
|
||||||
if (scope.kind === ScopeWorkspace) return cleanWorkspace(scope.workspaceRootPath) || 'Workspace';
|
if (scope.kind === ScopeWorkspace) return cleanWorkspace(scope.workspaceRootPath) || translate('ui.deal', null, 'Deal');
|
||||||
return 'Global';
|
return translate('ui.global', null, 'Global');
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedIDFromProps(props) {
|
function selectedIDFromProps(props) {
|
||||||
var resource = props && props.resource || {};
|
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));
|
if (path.indexOf('verstak-secret://') === 0) return decodeURIComponent(path.slice('verstak-secret://'.length));
|
||||||
return decodeURIComponent(path.replace(/^\/+/, ''));
|
return decodeURIComponent(path.replace(/^\/+/, ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupRecords(records) {
|
function groupRecords(records, translate) {
|
||||||
var groups = {};
|
var groups = {};
|
||||||
records.forEach(function (record) {
|
records.forEach(function (record) {
|
||||||
var label = scopeLabel(record);
|
var label = scopeLabel(record, translate);
|
||||||
groups[label] = groups[label] || [];
|
groups[label] = groups[label] || [];
|
||||||
groups[label].push(record);
|
groups[label].push(record);
|
||||||
});
|
});
|
||||||
return Object.keys(groups).sort(function (a, b) {
|
return Object.keys(groups).sort(function (a, b) {
|
||||||
if (a === 'Global') return -1;
|
if (a === translate('ui.global', null, 'Global')) return -1;
|
||||||
if (b === 'Global') return 1;
|
if (b === translate('ui.global', null, 'Global')) return 1;
|
||||||
return a.localeCompare(b);
|
return a.localeCompare(b);
|
||||||
}).map(function (label) {
|
}).map(function (label) {
|
||||||
groups[label].sort(function (a, b) {
|
groups[label].sort(function (a, b) {
|
||||||
@@ -144,12 +152,21 @@
|
|||||||
var workspaceRoot = workspaceFromProps(props || {});
|
var workspaceRoot = workspaceFromProps(props || {});
|
||||||
var selectedID = selectedIDFromProps(props || {});
|
var selectedID = selectedIDFromProps(props || {});
|
||||||
var records = [];
|
var records = [];
|
||||||
|
var workspaceOptions = [];
|
||||||
|
var scopeFilter = 'all';
|
||||||
|
var searchQuery = '';
|
||||||
var selectedRecord = null;
|
var selectedRecord = null;
|
||||||
var selectedValue = '';
|
var selectedValue = '';
|
||||||
|
var isValueVisible = false;
|
||||||
|
var deleteModal = null;
|
||||||
var initialized = false;
|
var initialized = false;
|
||||||
var unlocked = false;
|
var unlocked = false;
|
||||||
var statusText = '';
|
var statusText = '';
|
||||||
var statusError = false;
|
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) {
|
function setStatus(message, isError) {
|
||||||
statusText = message || '';
|
statusText = message || '';
|
||||||
@@ -157,18 +174,104 @@
|
|||||||
render();
|
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() {
|
function renderLocked() {
|
||||||
var passwordInput = el('input', {
|
var passwordInput = el('input', {
|
||||||
className: 'secrets-input',
|
className: 'secrets-input',
|
||||||
type: 'password',
|
type: 'password',
|
||||||
'data-secret-master-password': '',
|
'data-secret-master-password': '',
|
||||||
placeholder: 'Master password'
|
placeholder: tr('ui.masterPassword', null, 'Master password')
|
||||||
});
|
});
|
||||||
var confirmInput = initialized ? null : el('input', {
|
var confirmInput = initialized ? null : el('input', {
|
||||||
className: 'secrets-input',
|
className: 'secrets-input',
|
||||||
type: 'password',
|
type: 'password',
|
||||||
'data-secret-master-password-confirm': '',
|
'data-secret-master-password-confirm': '',
|
||||||
placeholder: 'Repeat master password'
|
placeholder: tr('ui.repeatPassword', null, 'Repeat master password')
|
||||||
});
|
});
|
||||||
var unlockBtn = el('button', {
|
var unlockBtn = el('button', {
|
||||||
className: 'secrets-btn primary',
|
className: 'secrets-btn primary',
|
||||||
@@ -176,7 +279,7 @@
|
|||||||
'data-secret-unlock': '',
|
'data-secret-unlock': '',
|
||||||
onClick: function () {
|
onClick: function () {
|
||||||
if (!initialized && passwordInput.value !== confirmInput.value) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
unlockBtn.disabled = true;
|
unlockBtn.disabled = true;
|
||||||
@@ -186,20 +289,20 @@
|
|||||||
return loadRecords();
|
return loadRecords();
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
unlockBtn.disabled = false;
|
unlockBtn.disabled = false;
|
||||||
setStatus((err && err.message) ? err.message : String(err), true);
|
setUserFacingError('unlock', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, ['Unlock']);
|
}, [tr('ui.unlock', null, 'Unlock')]);
|
||||||
if (!initialized) unlockBtn.textContent = 'Create master password';
|
if (!initialized) unlockBtn.textContent = tr('ui.createMaster', null, 'Create master password');
|
||||||
var rows = [
|
var rows = [
|
||||||
el('div', { className: 'secrets-row' }, [
|
el('div', { className: 'secrets-row' }, [
|
||||||
el('label', { className: 'secrets-label' }, ['Password']),
|
el('label', { className: 'secrets-label' }, [tr('ui.password', null, 'Password')]),
|
||||||
passwordInput
|
passwordInput
|
||||||
])
|
])
|
||||||
];
|
];
|
||||||
if (confirmInput) {
|
if (confirmInput) {
|
||||||
rows.push(el('div', { className: 'secrets-row' }, [
|
rows.push(el('div', { className: 'secrets-row' }, [
|
||||||
el('label', { className: 'secrets-label' }, ['Repeat']),
|
el('label', { className: 'secrets-label' }, [tr('ui.repeat', null, 'Repeat')]),
|
||||||
confirmInput
|
confirmInput
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
@@ -209,12 +312,12 @@
|
|||||||
containerEl.appendChild(el('div', { className: 'secrets-root' }, [
|
containerEl.appendChild(el('div', { className: 'secrets-root' }, [
|
||||||
el('div', { className: 'secrets-panel' }, [
|
el('div', { className: 'secrets-panel' }, [
|
||||||
el('div', { className: 'secrets-toolbar' }, [
|
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-main' }, [
|
||||||
el('div', { className: 'secrets-card' }, [
|
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)
|
el('div', { className: 'secrets-form' }, rows)
|
||||||
])
|
])
|
||||||
])
|
])
|
||||||
@@ -222,19 +325,48 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderList() {
|
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 = [
|
var children = [
|
||||||
el('div', { className: 'secrets-toolbar' }, [
|
el('div', { className: 'secrets-toolbar' }, [
|
||||||
el('span', { className: 'secrets-title' }, ['Secrets']),
|
el('span', { className: 'secrets-title' }, [tr('ui.title', null, 'Secrets')]),
|
||||||
el('span', { className: 'secrets-count' }, [String(records.length)]),
|
el('span', { className: 'secrets-count' }, [String(visibleRecords.length)]),
|
||||||
el('span', { className: 'secrets-spacer' }),
|
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) {
|
if (!visibleRecords.length) {
|
||||||
children.push(el('div', { className: 'secrets-empty' }, ['No secrets']));
|
children.push(el('div', { className: 'secrets-empty' }, [tr('ui.empty', null, 'No secrets')]));
|
||||||
return children;
|
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-group' }, [group.label]));
|
||||||
children.push(el('div', { className: 'secrets-list' }, group.records.map(function (record) {
|
children.push(el('div', { className: 'secrets-list' }, group.records.map(function (record) {
|
||||||
var active = selectedRecord && selectedRecord.id === record.id;
|
var active = selectedRecord && selectedRecord.id === record.id;
|
||||||
@@ -255,17 +387,20 @@
|
|||||||
|
|
||||||
function renderSelected() {
|
function renderSelected() {
|
||||||
if (!selectedRecord) return el('div', { className: 'secrets-card' }, [
|
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' }, [
|
return el('div', { className: 'secrets-card' }, [
|
||||||
el('h2', {}, [selectedRecord.title || selectedRecord.id]),
|
el('h2', {}, [selectedRecord.title || selectedRecord.id]),
|
||||||
el('table', { className: 'secrets-table' }, [
|
el('table', { className: 'secrets-table' }, [
|
||||||
el('tbody', {}, [
|
el('tbody', {}, [
|
||||||
fieldRow('Group', scopeLabel(selectedRecord)),
|
fieldRow(tr('ui.group', null, 'Group'), scopeLabel(selectedRecord, tr)),
|
||||||
fieldRow('ID', selectedRecord.id),
|
fieldRow('ID', selectedRecord.id),
|
||||||
fieldRow('Username', selectedRecord.username || ''),
|
fieldRow(tr('ui.username', null, 'Username'), selectedRecord.username || ''),
|
||||||
fieldRow('Password', selectedValue ? selectedValue : '••••••••••••', selectedValue ? '' : 'secrets-hidden-value'),
|
fieldRow(tr('ui.password', null, 'Password'), valueDisplay, isValueVisible && valueIsAvailable ? 'secrets-secret-value' : 'secrets-hidden-value'),
|
||||||
fieldRow('Updated', selectedRecord.updatedAt || '')
|
fieldRow(tr('ui.updated', null, 'Updated'), selectedRecord.updatedAt || '')
|
||||||
])
|
])
|
||||||
]),
|
]),
|
||||||
el('div', { className: 'secrets-actions' }, [
|
el('div', { className: 'secrets-actions' }, [
|
||||||
@@ -274,19 +409,37 @@
|
|||||||
type: 'button',
|
type: 'button',
|
||||||
'data-secret-copy-link': selectedRecord.id,
|
'data-secret-copy-link': selectedRecord.id,
|
||||||
onClick: function () { copySecretLink(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', {
|
el('button', {
|
||||||
className: 'secrets-btn',
|
className: 'secrets-btn',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
'data-secret-edit': selectedRecord.id,
|
'data-secret-edit': selectedRecord.id,
|
||||||
onClick: function () { showEditSecret(); }
|
onClick: function () { showEditSecret(); }
|
||||||
}, ['Edit']),
|
}, [tr('ui.edit', null, 'Edit')]),
|
||||||
el('button', {
|
el('button', {
|
||||||
className: 'secrets-btn danger',
|
className: 'secrets-btn danger',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
'data-secret-delete': selectedRecord.id,
|
'data-secret-delete': selectedRecord.id,
|
||||||
onClick: function () { deleteSecret(selectedRecord.id); }
|
onClick: function () { deleteSecret(selectedRecord.id); }
|
||||||
}, ['Delete'])
|
}, [tr('ui.delete', null, 'Delete')])
|
||||||
]),
|
]),
|
||||||
el('div', { className: statusError ? 'secrets-status error' : 'secrets-status' }, [statusText])
|
el('div', { className: statusError ? 'secrets-status error' : 'secrets-status' }, [statusText])
|
||||||
]);
|
]);
|
||||||
@@ -301,28 +454,46 @@
|
|||||||
|
|
||||||
function renderSecretForm(existing) {
|
function renderSecretForm(existing) {
|
||||||
var isEdit = !!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) : '';
|
title.value = existing ? text(existing.title) : '';
|
||||||
var id = el('input', { className: 'secrets-input', type: 'text', placeholder: 'stable.id' });
|
var id = el('input', { className: 'secrets-input', type: 'text', placeholder: 'stable.id' });
|
||||||
id.value = existing ? text(existing.id) : '';
|
id.value = existing ? text(existing.id) : '';
|
||||||
id.disabled = isEdit;
|
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) : '';
|
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 : '';
|
value.value = isEdit ? selectedValue : '';
|
||||||
var scope = el('select', { className: 'secrets-select' }, [
|
var scope = el('select', { className: 'secrets-select' }, [
|
||||||
el('option', { value: ScopeGlobal }, ['Global']),
|
el('option', { value: ScopeGlobal }, [tr('ui.global', null, 'Global')]),
|
||||||
el('option', { value: ScopeWorkspace }, [workspaceRoot || 'Workspace'])
|
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);
|
scope.value = existing && existing.scope && existing.scope.kind ? existing.scope.kind : (workspaceRoot ? ScopeWorkspace : ScopeGlobal);
|
||||||
|
updateWorkspaceVisibility();
|
||||||
return el('div', { className: 'secrets-card' }, [
|
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-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' }, ['ID']), id]),
|
||||||
el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, ['Username']), username]),
|
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' }, ['Scope']), scope]),
|
el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, [tr('ui.scope', null, 'Scope')]), scope]),
|
||||||
el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, ['Value']), value]),
|
workspaceRow,
|
||||||
|
el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, [tr('ui.value', null, 'Value')]), value]),
|
||||||
el('div', { className: 'secrets-actions' }, [
|
el('div', { className: 'secrets-actions' }, [
|
||||||
el('button', {
|
el('button', {
|
||||||
className: 'secrets-btn primary',
|
className: 'secrets-btn primary',
|
||||||
@@ -330,23 +501,28 @@
|
|||||||
'data-secret-save': '',
|
'data-secret-save': '',
|
||||||
onClick: function () {
|
onClick: function () {
|
||||||
var nextID = text(id.value).trim() || text(title.value).trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '.').replace(/^\.+|\.+$/g, '');
|
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({
|
api.secrets.write({
|
||||||
id: nextID,
|
id: nextID,
|
||||||
title: text(title.value).trim() || nextID,
|
title: text(title.value).trim() || nextID,
|
||||||
username: text(username.value).trim(),
|
username: text(username.value).trim(),
|
||||||
value: text(value.value),
|
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) {
|
}).then(function (record) {
|
||||||
selectedID = record.id;
|
selectedID = record.id;
|
||||||
selectedRecord = record;
|
selectedRecord = record;
|
||||||
selectedValue = '';
|
selectedValue = '';
|
||||||
return loadRecords();
|
return loadRecords();
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
setStatus((err && err.message) ? err.message : String(err), true);
|
setUserFacingError('save', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, ['Save']),
|
}, [tr('ui.save', null, 'Save')]),
|
||||||
el('button', { className: 'secrets-btn', type: 'button', onClick: function () { mode = 'selected'; render(); } }, ['Cancel'])
|
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])
|
el('div', { className: statusError ? 'secrets-status error' : 'secrets-status' }, [statusText])
|
||||||
])
|
])
|
||||||
@@ -377,11 +553,20 @@
|
|||||||
if (wanted) {
|
if (wanted) {
|
||||||
var found = records.find(function (record) { return record.id === wanted; });
|
var found = records.find(function (record) { return record.id === wanted; });
|
||||||
if (found) return selectRecord(found.id);
|
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;
|
selectedRecord = records[0] || null;
|
||||||
selectedValue = '';
|
selectedValue = '';
|
||||||
mode = 'selected';
|
mode = 'selected';
|
||||||
render();
|
render();
|
||||||
|
}).catch(function (err) {
|
||||||
|
setUserFacingError('load', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,6 +575,7 @@
|
|||||||
selectedID = id;
|
selectedID = id;
|
||||||
selectedRecord = records.find(function (record) { return record.id === id; }) || null;
|
selectedRecord = records.find(function (record) { return record.id === id; }) || null;
|
||||||
selectedValue = '';
|
selectedValue = '';
|
||||||
|
isValueVisible = false;
|
||||||
render();
|
render();
|
||||||
if (!id) return Promise.resolve();
|
if (!id) return Promise.resolve();
|
||||||
return api.secrets.read(id).then(function (record) {
|
return api.secrets.read(id).then(function (record) {
|
||||||
@@ -399,7 +585,7 @@
|
|||||||
render();
|
render();
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
setStatus((err && err.message) ? err.message : String(err), true);
|
setUserFacingError('read', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,40 +605,100 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function deleteSecret(id) {
|
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 () {
|
api.secrets.delete(id).then(function () {
|
||||||
selectedID = '';
|
selectedID = '';
|
||||||
selectedRecord = null;
|
selectedRecord = null;
|
||||||
selectedValue = '';
|
selectedValue = '';
|
||||||
|
isValueVisible = false;
|
||||||
return loadRecords();
|
return loadRecords();
|
||||||
}).catch(function (err) {
|
}).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) {
|
function copySecretLink(id) {
|
||||||
api.secrets.copyLink(id).then(function (link) {
|
api.secrets.copyLink(id).then(function (link) {
|
||||||
return writeClipboard(api, link).then(function () {
|
return writeClipboard(api, link).then(function () {
|
||||||
setStatus('Secret link copied', false);
|
setStatus(tr('ui.linkCopied', null, 'Secret link copied'), false);
|
||||||
});
|
});
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
setStatus((err && err.message) ? err.message : String(err), true);
|
setUserFacingError('copyLink', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
api.secrets.status().then(function (status) {
|
api.secrets.status().then(function (status) {
|
||||||
initialized = !!(status && status.initialized);
|
initialized = !!(status && status.initialized);
|
||||||
unlocked = !!(status && status.unlocked);
|
unlocked = !!(status && status.unlocked);
|
||||||
if (unlocked) return loadRecords();
|
if (unlocked) return loadWorkspaceOptions().then(loadRecords);
|
||||||
render();
|
render();
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
statusText = (err && err.message) ? err.message : String(err);
|
statusText = userFacingError('status', err);
|
||||||
statusError = true;
|
statusError = true;
|
||||||
renderLocked();
|
renderLocked();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var localeUnsubscribe = api.i18n && typeof api.i18n.onDidChangeLocale === 'function'
|
||||||
|
? api.i18n.onDidChangeLocale(render)
|
||||||
|
: null;
|
||||||
|
|
||||||
containerEl.__secretsCleanup = function () {
|
containerEl.__secretsCleanup = function () {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
removeDeleteModal();
|
||||||
|
if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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": "Значение секрета скопировано"
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
"name": "Secrets",
|
"name": "Secrets",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"apiVersion": "0.1.0",
|
"apiVersion": "0.1.0",
|
||||||
"description": "Encrypted global and workspace-scoped secret manager.",
|
"description": "Encrypted global and Deal-scoped secret manager.",
|
||||||
|
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
|
||||||
"source": "official",
|
"source": "official",
|
||||||
"icon": "key-round",
|
"icon": "key-round",
|
||||||
"provides": [
|
"provides": [
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
"secrets.write-ui"
|
"secrets.write-ui"
|
||||||
],
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
|
"files.read",
|
||||||
"secrets.read",
|
"secrets.read",
|
||||||
"secrets.write",
|
"secrets.write",
|
||||||
"ui.register"
|
"ui.register"
|
||||||
@@ -21,6 +23,23 @@
|
|||||||
"entry": "frontend/src/index.js"
|
"entry": "frontend/src/index.js"
|
||||||
},
|
},
|
||||||
"contributes": {
|
"contributes": {
|
||||||
|
"views": [
|
||||||
|
{
|
||||||
|
"id": "verstak.secrets.view",
|
||||||
|
"title": "Secrets",
|
||||||
|
"icon": "key-round",
|
||||||
|
"component": "SecretsView"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sidebarItems": [
|
||||||
|
{
|
||||||
|
"id": "verstak.secrets.sidebar",
|
||||||
|
"title": "Secrets",
|
||||||
|
"icon": "key-round",
|
||||||
|
"view": "verstak.secrets.view",
|
||||||
|
"position": 45
|
||||||
|
}
|
||||||
|
],
|
||||||
"openProviders": [
|
"openProviders": [
|
||||||
{
|
{
|
||||||
"id": "verstak.secrets.secret",
|
"id": "verstak.secrets.secret",
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { onDestroy, onMount } from 'svelte'
|
||||||
|
|
||||||
export let api = null
|
export let api = null
|
||||||
|
|
||||||
let settings = null
|
let settings = null
|
||||||
@@ -11,23 +13,32 @@
|
|||||||
let connectionOk = null
|
let connectionOk = null
|
||||||
|
|
||||||
let serverUrl = ''
|
let serverUrl = ''
|
||||||
|
let vaultId = ''
|
||||||
let username = ''
|
let username = ''
|
||||||
let password = ''
|
let password = ''
|
||||||
let syncInterval = 5
|
let syncInterval = 5
|
||||||
let autoSync = false
|
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_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;'
|
const INPUT_FOCUS_STYLE = INPUT_STYLE + 'outline:none;border-color:#4ecca3;'
|
||||||
|
|
||||||
function sanitizeError(msg) {
|
function reportError(key, fallback, error) {
|
||||||
if (!msg) return 'Unknown error'
|
console.warn('[verstak.sync] operation failed:', error)
|
||||||
let s = String(msg).replace(/<[^>]+>/g, '')
|
const code = String(error?.message || error || '').match(/sync-server:([a-z_]+)/)?.[1]
|
||||||
if (s.length > 200) s = s.substring(0, 200) + '...'
|
const localized = code ? tr(`ui.serverError.${code}`, null, '') : ''
|
||||||
return s
|
if (localized && localized !== `ui.serverError.${code}`) return localized
|
||||||
|
return tr(key, null, fallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncAPI() {
|
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
|
return api.sync
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,27 +48,35 @@
|
|||||||
const saved = await api.settings.read()
|
const saved = await api.settings.read()
|
||||||
if (saved) {
|
if (saved) {
|
||||||
serverUrl = saved.serverUrl || ''
|
serverUrl = saved.serverUrl || ''
|
||||||
|
vaultId = saved.vaultId || ''
|
||||||
username = saved.username || ''
|
username = saved.username || ''
|
||||||
autoSync = !!saved.autoSync
|
autoSync = !!saved.autoSync
|
||||||
syncInterval = saved.syncInterval || 5
|
syncInterval = saved.syncInterval || 5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (error) {
|
||||||
|
console.warn('[verstak.sync] settings load failed:', error)
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
settings = await syncAPI().status()
|
settings = await syncAPI().status()
|
||||||
if (settings) {
|
if (settings) {
|
||||||
if (settings.serverUrl) serverUrl = settings.serverUrl
|
if (settings.serverUrl) serverUrl = settings.serverUrl
|
||||||
|
if (settings.vaultId) vaultId = settings.vaultId
|
||||||
if (settings.syncInterval != null) syncInterval = settings.syncInterval
|
if (settings.syncInterval != null) syncInterval = settings.syncInterval
|
||||||
if (settings.syncInterval > 0) autoSync = true
|
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()
|
load()
|
||||||
|
|
||||||
async function saveSettings() {
|
async function saveSettings() {
|
||||||
if (syncInterval < 1 || syncInterval > 1440) {
|
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
|
return
|
||||||
}
|
}
|
||||||
loading = true
|
loading = true
|
||||||
@@ -65,19 +84,19 @@
|
|||||||
resultMsg = ''
|
resultMsg = ''
|
||||||
try {
|
try {
|
||||||
if (api?.settings?.writeAll) {
|
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)
|
await syncAPI().setInterval(autoSync ? syncInterval : 0)
|
||||||
resultMsg = 'Settings saved.'
|
resultMsg = tr('ui.settingsSaved', null, 'Settings saved.')
|
||||||
resultKind = ''
|
resultKind = ''
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorMsg = sanitizeError(e.message || e)
|
errorMsg = reportError('ui.saveFailed', 'Could not save sync settings. Please try again.', e)
|
||||||
}
|
}
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testConnection() {
|
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
|
loading = true
|
||||||
connectionResult = ''
|
connectionResult = ''
|
||||||
connectionOk = null
|
connectionOk = null
|
||||||
@@ -85,28 +104,28 @@
|
|||||||
try {
|
try {
|
||||||
await syncAPI().testConnection(serverUrl, username, password)
|
await syncAPI().testConnection(serverUrl, username, password)
|
||||||
connectionOk = true
|
connectionOk = true
|
||||||
connectionResult = 'Connection successful.'
|
connectionResult = tr('ui.connectionSuccessful', null, 'Connection successful.')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
connectionOk = false
|
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
|
loading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function configureSync() {
|
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
|
loading = true
|
||||||
errorMsg = ''
|
errorMsg = ''
|
||||||
connectionResult = ''
|
connectionResult = ''
|
||||||
try {
|
try {
|
||||||
await syncAPI().configure(serverUrl, username, password)
|
await syncAPI().configure(serverUrl, username, password, vaultId)
|
||||||
connectionResult = 'Connected successfully.'
|
connectionResult = tr('ui.connectedSuccessfully', null, 'Connected successfully.')
|
||||||
connectionOk = true
|
connectionOk = true
|
||||||
username = ''
|
username = ''
|
||||||
password = ''
|
password = ''
|
||||||
await load()
|
await load()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorMsg = sanitizeError(e.message || e)
|
errorMsg = reportError('ui.connectFailed', 'Could not connect this device. Please try again.', e)
|
||||||
}
|
}
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
@@ -115,28 +134,13 @@
|
|||||||
const conflicts = Array.isArray(result?.conflicts) ? result.conflicts : []
|
const conflicts = Array.isArray(result?.conflicts) ? result.conflicts : []
|
||||||
const applyErrors = Array.isArray(result?.applyErrors) ? result.applyErrors : []
|
const applyErrors = Array.isArray(result?.applyErrors) ? result.applyErrors : []
|
||||||
const parts = []
|
const parts = []
|
||||||
if (conflicts.length > 0) parts.push(conflicts.length + ' conflict(s)')
|
if (conflicts.length > 0) parts.push(tr('ui.conflictsCount', { count: conflicts.length }, '{count} conflict(s)'))
|
||||||
if (applyErrors.length > 0) parts.push(applyErrors.length + ' error(s)')
|
if (applyErrors.length > 0) parts.push(tr('ui.errorsCount', { count: applyErrors.length }, '{count} error(s)'))
|
||||||
return parts.join(' · ')
|
return parts.join(' · ')
|
||||||
}
|
}
|
||||||
|
|
||||||
function conflictField(conflict, keys) {
|
function formatSyncConflict() {
|
||||||
for (const key of keys) {
|
return tr('ui.syncConflictItem', null, 'A synchronization conflict needs attention.')
|
||||||
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(' · ')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runSyncNow() {
|
async function runSyncNow() {
|
||||||
@@ -146,7 +150,7 @@
|
|||||||
conflictDetails = []
|
conflictDetails = []
|
||||||
try {
|
try {
|
||||||
const r = await syncAPI().now()
|
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 warning = syncResultWarning(r)
|
||||||
const conflicts = Array.isArray(r?.conflicts) ? r.conflicts : []
|
const conflicts = Array.isArray(r?.conflicts) ? r.conflicts : []
|
||||||
conflictDetails = conflicts.slice(0, 5).map(formatSyncConflict)
|
conflictDetails = conflicts.slice(0, 5).map(formatSyncConflict)
|
||||||
@@ -154,7 +158,7 @@
|
|||||||
resultKind = warning ? 'warning' : ''
|
resultKind = warning ? 'warning' : ''
|
||||||
await load()
|
await load()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorMsg = sanitizeError(e.message || e)
|
errorMsg = reportError('ui.syncFailed', 'Could not synchronize. Please try again.', e)
|
||||||
}
|
}
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
@@ -167,7 +171,7 @@
|
|||||||
|
|
||||||
function saveInterval() {
|
function saveInterval() {
|
||||||
if (syncInterval < 1 || syncInterval > 1440) {
|
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
|
return
|
||||||
}
|
}
|
||||||
autoSync = syncInterval > 0
|
autoSync = syncInterval > 0
|
||||||
@@ -180,12 +184,12 @@
|
|||||||
resultMsg = ''
|
resultMsg = ''
|
||||||
try {
|
try {
|
||||||
await syncAPI().disconnect()
|
await syncAPI().disconnect()
|
||||||
resultMsg = 'Disconnected from server.'
|
resultMsg = tr('ui.disconnected', null, 'Disconnected from server.')
|
||||||
resultKind = ''
|
resultKind = ''
|
||||||
settings = null
|
settings = null
|
||||||
await load()
|
await load()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorMsg = sanitizeError(e.message || e)
|
errorMsg = reportError('ui.disconnectFailed', 'Could not disconnect from the server. Please try again.', e)
|
||||||
}
|
}
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
@@ -196,19 +200,27 @@
|
|||||||
resultMsg = ''
|
resultMsg = ''
|
||||||
try {
|
try {
|
||||||
await syncAPI().resetKey()
|
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 = ''
|
resultKind = ''
|
||||||
await load()
|
await load()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorMsg = sanitizeError(e.message || e)
|
errorMsg = reportError('ui.resetKeyFailed', 'Could not reset the sync key. Please try again.', e)
|
||||||
}
|
}
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
unsubscribeLocale = api?.i18n?.onDidChangeLocale?.((nextLocale) => {
|
||||||
|
locale = nextLocale
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onDestroy(() => unsubscribeLocale?.())
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div style="padding:1.5rem;max-width:500px;">
|
<div style="padding:1.5rem;max-width:500px;">
|
||||||
<h2 style="margin:0 0 0.25rem;color:#e0e0f0;font-size:1.2rem;">Sync</h2>
|
<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;">Synchronize your vault across devices.</p>
|
<p style="color:#a0a0b8;font-size:0.85rem;margin-bottom:1.25rem;">{tr('ui.description', null, 'Synchronize your vault across devices.')}</p>
|
||||||
|
|
||||||
{#if errorMsg}
|
{#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>
|
<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}
|
||||||
{#if conflictDetails.length > 0 && !errorMsg}
|
{#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="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}
|
{#each conflictDetails as detail}
|
||||||
<div>{detail}</div>
|
<div>{detail}</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -226,31 +238,42 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if settings && settings.lastError && !errorMsg}
|
{#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;">
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div style="background:#16213e;border:1px solid #0f3460;border-radius:8px;padding:1rem 1.25rem;margin-bottom:1rem;">
|
<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;">
|
<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" />
|
<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>
|
||||||
|
|
||||||
<div style="margin-bottom:0.75rem;">
|
<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} />
|
<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>
|
||||||
|
|
||||||
<div style="margin-bottom:0.75rem;">
|
<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} />
|
<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>
|
||||||
|
|
||||||
<div style="display:flex;gap:0.5rem;margin-top:1rem;">
|
<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:#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}>Connect</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>
|
</div>
|
||||||
|
|
||||||
{#if connectionResult}
|
{#if connectionResult}
|
||||||
@@ -259,34 +282,34 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="background:#16213e;border:1px solid #0f3460;border-radius:8px;padding:1rem 1.25rem;margin-bottom:1rem;">
|
<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;">
|
<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;" />
|
<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>
|
||||||
|
|
||||||
<div style="margin-bottom:0.75rem;">
|
<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;">
|
<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;" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if settings && settings.lastSyncAt}
|
{#if settings && settings.lastSyncAt}
|
||||||
<div style="color:#a0a0b8;font-size:0.85rem;">
|
<div style="color:#a0a0b8;font-size:0.85rem;">
|
||||||
Last sync: {settings.lastSyncAt}
|
{tr('ui.lastSync', { date: settings.lastSyncAt }, 'Last sync: {date}')}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;">
|
<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}
|
{#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={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}>Reset Key</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}>Disconnect</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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,13 @@
|
|||||||
|
|
||||||
let status = 'disabled'
|
let status = 'disabled'
|
||||||
let interval
|
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() {
|
async function loadStatus() {
|
||||||
try {
|
try {
|
||||||
@@ -32,11 +39,11 @@
|
|||||||
|
|
||||||
function statusText() {
|
function statusText() {
|
||||||
const labels = {
|
const labels = {
|
||||||
connected: 'Connected',
|
connected: tr('ui.status.connected', null, 'Connected'),
|
||||||
disconnected: 'Disconnected',
|
disconnected: tr('ui.status.disconnected', null, 'Disconnected'),
|
||||||
disabled: 'Not configured',
|
disabled: tr('ui.status.disabled', null, 'Not configured'),
|
||||||
error: 'Error',
|
error: tr('ui.status.error', null, 'Error'),
|
||||||
revoked: 'Revoked',
|
revoked: tr('ui.status.revoked', null, 'Revoked'),
|
||||||
}
|
}
|
||||||
return labels[status] || status
|
return labels[status] || status
|
||||||
}
|
}
|
||||||
@@ -50,14 +57,18 @@
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
loadStatus()
|
loadStatus()
|
||||||
interval = setInterval(loadStatus, 15000)
|
interval = setInterval(loadStatus, 15000)
|
||||||
|
unsubscribeLocale = api?.i18n?.onDidChangeLocale?.((nextLocale) => {
|
||||||
|
locale = nextLocale
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (interval) clearInterval(interval)
|
if (interval) clearInterval(interval)
|
||||||
|
unsubscribeLocale?.()
|
||||||
})
|
})
|
||||||
</script>
|
</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-dot" style="background: {statusColor()}"></span>
|
||||||
<span class="status-label">{statusText()}</span>
|
<span class="status-label">{statusText()}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -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": "Сервер отклонил этот запрос синхронизации."
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
"name": "Sync",
|
"name": "Sync",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"apiVersion": "0.1.0",
|
"apiVersion": "0.1.0",
|
||||||
|
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
|
||||||
"source": "official",
|
"source": "official",
|
||||||
"provides": ["verstak/sync/v1", "verstak/sync.status/v1"],
|
"provides": ["verstak/sync/v1", "verstak/sync.status/v1"],
|
||||||
"requires": ["verstak/core/files/v1"],
|
"requires": ["verstak/core/files/v1"],
|
||||||
|
|||||||
@@ -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-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-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-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,.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-select{max-width:12rem}',
|
'.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-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-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)}',
|
'.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 || {});
|
var workspaceRoot = workspaceFromProps(props || {});
|
||||||
return workspaceRoot
|
return workspaceRoot
|
||||||
? { mode: 'workspace', workspaceRoot: workspaceRoot, label: workspaceRoot }
|
? { mode: 'workspace', workspaceRoot: workspaceRoot, label: workspaceRoot }
|
||||||
: { mode: 'global', workspaceRoot: '', label: 'All workspaces' };
|
: { mode: 'global', workspaceRoot: '', label: 'All Deals' };
|
||||||
}
|
}
|
||||||
|
|
||||||
function now() {
|
function now() {
|
||||||
@@ -96,7 +96,28 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cleanDate(value) {
|
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) {
|
function cleanDateTime(value) {
|
||||||
@@ -106,6 +127,27 @@
|
|||||||
return isNaN(date.getTime()) ? '' : value;
|
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) {
|
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, '-');
|
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) {
|
function normalizeTodo(value) {
|
||||||
value = value || {};
|
value = value || {};
|
||||||
var status = cleanStatus(value.status);
|
var status = cleanStatus(value.status);
|
||||||
|
var reminderAt = cleanDateTime(value.reminderAt || value.reminderDateTime);
|
||||||
|
var reminder = splitReminderDateTime(reminderAt);
|
||||||
var createdAt = text(value.createdAt).trim() || now();
|
var createdAt = text(value.createdAt).trim() || now();
|
||||||
var completedAt = status === 'done' ? (text(value.completedAt).trim() || createdAt) : '';
|
var completedAt = status === 'done' ? (text(value.completedAt).trim() || createdAt) : '';
|
||||||
return {
|
return {
|
||||||
@@ -124,7 +168,8 @@
|
|||||||
status: status,
|
status: status,
|
||||||
priority: cleanPriority(value.priority),
|
priority: cleanPriority(value.priority),
|
||||||
dueAt: cleanDate(value.dueAt || value.dueDate),
|
dueAt: cleanDate(value.dueAt || value.dueDate),
|
||||||
reminderAt: cleanDateTime(value.reminderAt || value.reminderDateTime),
|
reminderDate: cleanDate(value.reminderDate) || reminder.date,
|
||||||
|
reminderAt: reminderAt,
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
updatedAt: text(value.updatedAt).trim() || createdAt,
|
updatedAt: text(value.updatedAt).trim() || createdAt,
|
||||||
completedAt: completedAt,
|
completedAt: completedAt,
|
||||||
@@ -153,6 +198,7 @@
|
|||||||
status: todo.status,
|
status: todo.status,
|
||||||
priority: todo.priority,
|
priority: todo.priority,
|
||||||
dueAt: todo.dueAt,
|
dueAt: todo.dueAt,
|
||||||
|
reminderDate: todo.reminderDate,
|
||||||
reminderAt: todo.reminderAt,
|
reminderAt: todo.reminderAt,
|
||||||
createdAt: todo.createdAt,
|
createdAt: todo.createdAt,
|
||||||
updatedAt: todo.updatedAt,
|
updatedAt: todo.updatedAt,
|
||||||
@@ -215,8 +261,13 @@
|
|||||||
var statusText = '';
|
var statusText = '';
|
||||||
var statusClass = '';
|
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 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 countEl = el('span', { className: 'todo-count' });
|
||||||
var statusEl = el('span', { className: 'todo-status' });
|
var statusEl = el('span', { className: 'todo-status' });
|
||||||
var filtersEl = el('div', { className: 'todo-filters' });
|
var filtersEl = el('div', { className: 'todo-filters' });
|
||||||
@@ -228,10 +279,10 @@
|
|||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
option('all', 'All statuses'),
|
option('all', tr('ui.allStatuses', null, 'All statuses')),
|
||||||
option('open', 'Open'),
|
option('open', tr('ui.status.open', null, 'Open')),
|
||||||
option('done', 'Done'),
|
option('done', tr('ui.status.done', null, 'Done')),
|
||||||
option('cancelled', 'Cancelled')
|
option('cancelled', tr('ui.status.cancelled', null, 'Cancelled'))
|
||||||
]);
|
]);
|
||||||
var workspaceFilterEl = el('select', {
|
var workspaceFilterEl = el('select', {
|
||||||
className: 'todo-select',
|
className: 'todo-select',
|
||||||
@@ -249,14 +300,14 @@
|
|||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
option('due', 'Sort by due date'),
|
option('due', tr('ui.sort.due', null, 'Sort by due date')),
|
||||||
option('reminder', 'Sort by reminder'),
|
option('reminder', tr('ui.sort.reminder', null, 'Sort by reminder')),
|
||||||
option('updated', 'Sort by updated')
|
option('updated', tr('ui.sort.updated', null, 'Sort by updated'))
|
||||||
]);
|
]);
|
||||||
var searchInput = el('input', {
|
var searchInput = el('input', {
|
||||||
className: 'todo-input search',
|
className: 'todo-input search',
|
||||||
type: 'search',
|
type: 'search',
|
||||||
placeholder: 'Search todos',
|
placeholder: tr('ui.search', null, 'Search todos'),
|
||||||
'data-todo-filter': 'search',
|
'data-todo-filter': 'search',
|
||||||
onInput: function (event) {
|
onInput: function (event) {
|
||||||
searchQuery = text(event.target.value).trim().toLowerCase();
|
searchQuery = text(event.target.value).trim().toLowerCase();
|
||||||
@@ -267,7 +318,7 @@
|
|||||||
className: 'todo-btn primary',
|
className: 'todo-btn primary',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
'data-todo-action': 'add',
|
'data-todo-action': 'add',
|
||||||
textContent: 'Add Todo',
|
textContent: tr('ui.add', null, 'Add Todo'),
|
||||||
onClick: function () { showTodoModal(null); }
|
onClick: function () { showTodoModal(null); }
|
||||||
});
|
});
|
||||||
var listEl = el('div', { className: 'todo-list' });
|
var listEl = el('div', { className: 'todo-list' });
|
||||||
@@ -309,8 +360,8 @@
|
|||||||
function renderWorkspaceFilterOptions() {
|
function renderWorkspaceFilterOptions() {
|
||||||
if (scope.mode !== 'global') return;
|
if (scope.mode !== 'global') return;
|
||||||
workspaceFilterEl.innerHTML = '';
|
workspaceFilterEl.innerHTML = '';
|
||||||
workspaceFilterEl.appendChild(option('', 'All workspaces'));
|
workspaceFilterEl.appendChild(option('', tr('ui.allWorkspaces', null, 'All Deals')));
|
||||||
workspaceFilterEl.appendChild(option('__unassigned__', 'Unassigned'));
|
workspaceFilterEl.appendChild(option('__unassigned__', tr('ui.unassigned', null, 'Unassigned')));
|
||||||
workspaceRoots().forEach(function (workspace) {
|
workspaceRoots().forEach(function (workspace) {
|
||||||
workspaceFilterEl.appendChild(option(workspace, 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() {
|
function persist() {
|
||||||
if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
|
if (!api || !api.settings || typeof api.settings.write !== 'function') return syncNotifications();
|
||||||
return api.settings.write(GLOBAL_KEY, storageTodos(sortTodos(todos))).catch(function (err) {
|
return api.settings.write(GLOBAL_KEY, storageTodos(sortTodos(todos))).then(function () {
|
||||||
statusText = 'Could not save todos: ' + (err && err.message ? err.message : String(err));
|
return syncNotifications();
|
||||||
statusClass = 'error';
|
}).catch(function (err) {
|
||||||
|
reportError('ui.saveError', 'Could not save tasks. Please try again.', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadStored() {
|
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) {
|
return api.settings.read().then(function (settings) {
|
||||||
todos = sortTodos(normalizeTodos((settings || {})[GLOBAL_KEY]));
|
todos = sortTodos(normalizeTodos((settings || {})[GLOBAL_KEY]));
|
||||||
|
return syncNotifications();
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
statusText = 'Could not load todos: ' + (err && err.message ? err.message : String(err));
|
reportError('ui.loadError', 'Could not load tasks. Please try again.', err);
|
||||||
statusClass = 'error';
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,18 +458,20 @@
|
|||||||
|
|
||||||
function showTodoModal(existingTodo) {
|
function showTodoModal(existingTodo) {
|
||||||
var editing = !!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 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: 'Optional description', 'data-todo-input': 'description' });
|
var descriptionInput = el('textarea', { className: 'todo-input textarea', placeholder: tr('ui.descriptionPlaceholder', null, 'Optional description'), 'data-todo-input': 'description' });
|
||||||
descriptionInput.value = editing ? existingTodo.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';
|
priorityInput.value = editing ? existingTodo.priority : 'normal';
|
||||||
var dueInput = el('input', { className: 'todo-input', type: 'date', value: editing ? existingTodo.dueAt : '', 'data-todo-input': 'dueAt' });
|
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 workspaceInput = null;
|
||||||
var workspace = editing ? existingTodo.workspaceRootPath : scope.workspaceRoot;
|
var workspace = editing ? existingTodo.workspaceRootPath : scope.workspaceRoot;
|
||||||
if (scope.mode === 'global') {
|
if (scope.mode === 'global') {
|
||||||
workspaceInput = el('select', { className: 'todo-select', 'data-todo-input': 'workspaceRootPath' });
|
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) {
|
workspaceRoots().forEach(function (workspaceRoot) {
|
||||||
workspaceInput.appendChild(option(workspaceRoot, workspaceRoot));
|
workspaceInput.appendChild(option(workspaceRoot, workspaceRoot));
|
||||||
});
|
});
|
||||||
@@ -396,12 +481,26 @@
|
|||||||
function saveTodo() {
|
function saveTodo() {
|
||||||
var title = text(titleInput.value).trim();
|
var title = text(titleInput.value).trim();
|
||||||
if (!title) {
|
if (!title) {
|
||||||
statusText = 'Title is required';
|
statusText = tr('ui.titleRequired', null, 'Title is required');
|
||||||
statusClass = 'error';
|
statusClass = 'error';
|
||||||
render();
|
render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var workspaceRoot = scope.mode === 'workspace' ? scope.workspaceRoot : cleanWorkspace(workspaceInput && workspaceInput.value);
|
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 timestamp = now();
|
||||||
var next = normalizeTodo({
|
var next = normalizeTodo({
|
||||||
id: editing ? existingTodo.id : todoId(workspaceRoot, title),
|
id: editing ? existingTodo.id : todoId(workspaceRoot, title),
|
||||||
@@ -412,7 +511,8 @@
|
|||||||
status: editing ? existingTodo.status : 'open',
|
status: editing ? existingTodo.status : 'open',
|
||||||
priority: priorityInput.value,
|
priority: priorityInput.value,
|
||||||
dueAt: dueInput.value,
|
dueAt: dueInput.value,
|
||||||
reminderAt: reminderInput.value,
|
reminderDate: reminderDate,
|
||||||
|
reminderAt: joinReminderDateTime(reminderDate, reminderTime),
|
||||||
createdAt: editing ? existingTodo.createdAt : timestamp,
|
createdAt: editing ? existingTodo.createdAt : timestamp,
|
||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
completedAt: editing ? existingTodo.completedAt : '',
|
completedAt: editing ? existingTodo.completedAt : '',
|
||||||
@@ -427,20 +527,21 @@
|
|||||||
todos = sortTodos(todos);
|
todos = sortTodos(todos);
|
||||||
if (workspaceRoot && workspaceOptions.indexOf(workspaceRoot) === -1) workspaceOptions.push(workspaceRoot);
|
if (workspaceRoot && workspaceOptions.indexOf(workspaceRoot) === -1) workspaceOptions.push(workspaceRoot);
|
||||||
closeTodoModal();
|
closeTodoModal();
|
||||||
statusText = editing ? 'Todo updated' : 'Todo added';
|
statusText = editing ? tr('ui.updated', null, 'Todo updated') : tr('ui.added', null, 'Todo added');
|
||||||
statusClass = '';
|
statusClass = '';
|
||||||
persist().then(render);
|
persist().then(render);
|
||||||
}
|
}
|
||||||
|
|
||||||
var fields = [
|
var fields = [
|
||||||
el('label', { className: 'todo-field wide' }, ['Title', titleInput]),
|
el('label', { className: 'todo-field wide' }, [tr('ui.field.title', null, 'Title'), titleInput]),
|
||||||
el('label', { className: 'todo-field wide' }, ['Description', descriptionInput]),
|
el('label', { className: 'todo-field wide' }, [tr('ui.field.description', null, 'Description'), descriptionInput]),
|
||||||
el('label', { className: 'todo-field' }, ['Priority', priorityInput]),
|
el('label', { className: 'todo-field' }, [tr('ui.field.priority', null, 'Priority'), priorityInput]),
|
||||||
el('label', { className: 'todo-field' }, ['Due date', dueInput]),
|
el('label', { className: 'todo-field' }, [tr('ui.field.due', null, 'Due date'), dueInput]),
|
||||||
el('label', { className: 'todo-field' }, ['Reminder', reminderInput])
|
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]));
|
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: 'Workspace: ' + scope.workspaceRoot }));
|
else fields.push(el('div', { className: 'todo-field', textContent: tr('ui.workspaceValue', { workspace: scope.workspaceRoot }, 'Deal: ' + scope.workspaceRoot) }));
|
||||||
|
|
||||||
modalHost.innerHTML = '';
|
modalHost.innerHTML = '';
|
||||||
if (typeof modalHost.removeAttribute === 'function') modalHost.removeAttribute('hidden');
|
if (typeof modalHost.removeAttribute === 'function') modalHost.removeAttribute('hidden');
|
||||||
@@ -448,11 +549,11 @@
|
|||||||
if (event.target === event.currentTarget) closeTodoModal();
|
if (event.target === event.currentTarget) closeTodoModal();
|
||||||
} }, [
|
} }, [
|
||||||
el('div', { className: 'todo-modal' }, [
|
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-form-grid' }, fields),
|
||||||
el('div', { className: 'todo-modal-actions' }, [
|
el('div', { className: 'todo-modal-actions' }, [
|
||||||
el('button', { className: 'todo-btn', type: 'button', textContent: 'Cancel', onClick: closeTodoModal }),
|
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 ? 'Save changes' : 'Add Todo', onClick: saveTodo })
|
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
|
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 = '';
|
statusClass = '';
|
||||||
persist().then(render);
|
persist().then(render);
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteTodo(todo) {
|
function deleteTodo(todo) {
|
||||||
todos = todos.filter(function (item) { return item.id !== todo.id; });
|
todos = todos.filter(function (item) { return item.id !== todo.id; });
|
||||||
statusText = 'Todo deleted';
|
statusText = tr('ui.deleted', null, 'Todo deleted');
|
||||||
statusClass = '';
|
statusClass = '';
|
||||||
persist().then(render);
|
persist().then(render);
|
||||||
}
|
}
|
||||||
@@ -513,11 +614,12 @@
|
|||||||
var workspace = cleanWorkspace(todo.workspaceRootPath);
|
var workspace = cleanWorkspace(todo.workspaceRootPath);
|
||||||
var due = dueState(todo);
|
var due = dueState(todo);
|
||||||
var reminderDue = reminderIsDue(todo);
|
var reminderDue = reminderIsDue(todo);
|
||||||
if (scope.mode === 'global') meta.push(el('span', { className: 'todo-badge', textContent: workspace || 'Unassigned' }));
|
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: todo.priority + ' priority' }));
|
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: todo.status }));
|
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: (due === 'overdue' ? 'Overdue · ' : (due === 'due-soon' ? 'Due soon · ' : '')) + 'Due ' + formatDate(todo.dueAt) }));
|
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: (reminderDue ? 'Reminder due ' : 'Reminder ') + formatDate(todo.reminderAt) }));
|
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);
|
return el('div', { className: 'todo-row-meta' }, meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,30 +629,30 @@
|
|||||||
if (!visible.length) {
|
if (!visible.length) {
|
||||||
listEl.appendChild(el('div', {
|
listEl.appendChild(el('div', {
|
||||||
className: 'todo-empty',
|
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;
|
return;
|
||||||
}
|
}
|
||||||
visible.forEach(function (todo) {
|
visible.forEach(function (todo) {
|
||||||
var actionButtons = [];
|
var actionButtons = [];
|
||||||
if (scope.mode === 'global' && todo.workspaceRootPath) {
|
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') {
|
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': '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: 'Cancel', onClick: function () { setTodoStatus(todo, 'cancelled'); } }));
|
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 {
|
} 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') {
|
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', 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: 'Delete', onClick: function () { deleteTodo(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 }, [
|
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-main' }, [
|
||||||
el('div', { className: 'todo-row-head' }, [
|
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,
|
todo.description ? el('div', { className: 'todo-row-description', textContent: todo.description }) : null,
|
||||||
renderTodoMeta(todo)
|
renderTodoMeta(todo)
|
||||||
@@ -563,8 +665,8 @@
|
|||||||
function render() {
|
function render() {
|
||||||
var visible = visibleTodos();
|
var visible = visibleTodos();
|
||||||
countEl.textContent = visible.length === todos.length
|
countEl.textContent = visible.length === todos.length
|
||||||
? todos.length + ' todo' + (todos.length === 1 ? '' : 's')
|
? tr('ui.count', { count: todos.length }, todos.length + ' todo' + (todos.length === 1 ? '' : 's'))
|
||||||
: visible.length + ' of ' + todos.length + ' todos';
|
: tr('ui.filteredCount', { visible: visible.length, count: todos.length }, visible.length + ' of ' + todos.length + ' todos');
|
||||||
statusFilterEl.value = statusFilter;
|
statusFilterEl.value = statusFilter;
|
||||||
sortEl.value = sortMode;
|
sortEl.value = sortMode;
|
||||||
searchInput.value = searchQuery;
|
searchInput.value = searchQuery;
|
||||||
@@ -578,6 +680,15 @@
|
|||||||
Promise.all([loadStored(), loadWorkspaceOptions()]).then(function () {
|
Promise.all([loadStored(), loadWorkspaceOptions()]).then(function () {
|
||||||
render();
|
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) {
|
TodoView.unmount = function (containerEl) {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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}"
|
||||||
|
}
|
||||||
@@ -4,17 +4,22 @@
|
|||||||
"name": "Todos",
|
"name": "Todos",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"apiVersion": "0.1.0",
|
"apiVersion": "0.1.0",
|
||||||
"description": "Global and workspace todo tracking with due and reminder metadata.",
|
"description": "Global and Deal-scoped todo tracking with due and reminder metadata.",
|
||||||
|
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
|
||||||
"source": "official",
|
"source": "official",
|
||||||
"icon": "list-todo",
|
"icon": "list-todo",
|
||||||
"provides": [
|
"provides": [
|
||||||
"todo.list",
|
"todo.list",
|
||||||
"todo.workspace"
|
"todo.workspace"
|
||||||
],
|
],
|
||||||
|
"requires": [
|
||||||
|
"verstak/core/notifications/v1"
|
||||||
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"files.read",
|
"files.read",
|
||||||
"storage.namespace",
|
"storage.namespace",
|
||||||
"ui.register"
|
"ui.register",
|
||||||
|
"notifications.schedule"
|
||||||
],
|
],
|
||||||
"frontend": {
|
"frontend": {
|
||||||
"entry": "frontend/src/index.js"
|
"entry": "frontend/src/index.js"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
'.trash-spacer{flex:1}',
|
'.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-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-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{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:hover:not(:disabled){border-color:var(--vt-color-accent,#4ecca3);color:var(--vt-color-text-primary,#f4f7fb)}',
|
||||||
'.trash-btn:disabled{opacity:.48;cursor:default}',
|
'.trash-btn:disabled{opacity:.48;cursor:default}',
|
||||||
@@ -152,6 +152,10 @@
|
|||||||
statusError: false,
|
statusError: false,
|
||||||
disposed: 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() {
|
function workspaceOptions() {
|
||||||
var values = {};
|
var values = {};
|
||||||
@@ -170,18 +174,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function statusText(entries) {
|
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;
|
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() {
|
function renderConfirmation() {
|
||||||
var entry = state.entries.find(function (item) { return item.trashId === state.confirmingId; });
|
var entry = state.entries.find(function (item) { return item.trashId === state.confirmingId; });
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
return el('div', { className: 'trash-confirm-backdrop', 'data-trash-confirm': entry.trashId }, [
|
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('section', { className: 'trash-confirm', role: 'dialog', 'aria-modal': 'true', 'aria-label': tr('ui.deletePermanently', null, 'Delete permanently') }, [
|
||||||
el('h3', {}, ['Delete permanently?']),
|
el('h3', {}, [tr('ui.deleteQuestion', null, 'Delete permanently?')]),
|
||||||
el('p', {}, ['This cannot be undone.']),
|
el('p', {}, [tr('ui.cannotUndo', null, 'This cannot be undone.')]),
|
||||||
el('p', { className: 'trash-confirm-path' }, [entry.originalPath || nameFor(entry)]),
|
el('p', { className: 'trash-confirm-path' }, [entry.originalPath || nameFor(entry)]),
|
||||||
el('div', { className: 'trash-confirm-actions' }, [
|
el('div', { className: 'trash-confirm-actions' }, [
|
||||||
el('button', {
|
el('button', {
|
||||||
@@ -189,14 +193,14 @@
|
|||||||
type: 'button',
|
type: 'button',
|
||||||
'data-trash-confirm-cancel': entry.trashId,
|
'data-trash-confirm-cancel': entry.trashId,
|
||||||
onClick: function () { state.confirmingId = ''; render(); }
|
onClick: function () { state.confirmingId = ''; render(); }
|
||||||
}, ['Cancel']),
|
}, [tr('ui.cancel', null, 'Cancel')]),
|
||||||
el('button', {
|
el('button', {
|
||||||
className: 'trash-btn trash-btn-danger',
|
className: 'trash-btn trash-btn-danger',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
disabled: state.busyId === entry.trashId,
|
disabled: state.busyId === entry.trashId,
|
||||||
'data-trash-confirm-delete': entry.trashId,
|
'data-trash-confirm-delete': entry.trashId,
|
||||||
onClick: function () { deletePermanently(entry); }
|
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,
|
value: state.workspace,
|
||||||
'data-trash-filter-workspace': '',
|
'data-trash-filter-workspace': '',
|
||||||
onChange: function (event) { state.workspace = event.target.value; render(); }
|
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) {
|
workspaceOptions().forEach(function (workspace) {
|
||||||
workspaceSelect.appendChild(el('option', { value: workspace }, [workspace]));
|
workspaceSelect.appendChild(el('option', { value: workspace }, [workspace]));
|
||||||
});
|
});
|
||||||
@@ -224,9 +228,9 @@
|
|||||||
'data-trash-filter-type': '',
|
'data-trash-filter-type': '',
|
||||||
onChange: function (event) { state.type = event.target.value; render(); }
|
onChange: function (event) { state.type = event.target.value; render(); }
|
||||||
}, [
|
}, [
|
||||||
el('option', { value: '' }, ['All types']),
|
el('option', { value: '' }, [tr('ui.allTypes', null, 'All types')]),
|
||||||
el('option', { value: 'file' }, ['Files']),
|
el('option', { value: 'file' }, [tr('ui.files', null, 'Files')]),
|
||||||
el('option', { value: 'folder' }, ['Folders'])
|
el('option', { value: 'folder' }, [tr('ui.folders', null, 'Folders')])
|
||||||
]);
|
]);
|
||||||
|
|
||||||
var sortSelect = el('select', {
|
var sortSelect = el('select', {
|
||||||
@@ -235,28 +239,28 @@
|
|||||||
'data-trash-sort': '',
|
'data-trash-sort': '',
|
||||||
onChange: function (event) { state.sort = event.target.value; render(); }
|
onChange: function (event) { state.sort = event.target.value; render(); }
|
||||||
}, [
|
}, [
|
||||||
el('option', { value: 'date-desc' }, ['Deleted: newest']),
|
el('option', { value: 'date-desc' }, [tr('ui.newest', null, 'Deleted: newest')]),
|
||||||
el('option', { value: 'date-asc' }, ['Deleted: oldest']),
|
el('option', { value: 'date-asc' }, [tr('ui.oldest', null, 'Deleted: oldest')]),
|
||||||
el('option', { value: 'name-asc' }, ['Name']),
|
el('option', { value: 'name-asc' }, [tr('ui.name', null, 'Name')]),
|
||||||
el('option', { value: 'type-asc' }, ['Type'])
|
el('option', { value: 'type-asc' }, [tr('ui.type', null, 'Type')])
|
||||||
]);
|
]);
|
||||||
|
|
||||||
containerEl.appendChild(el('div', { className: 'trash-toolbar' }, [
|
containerEl.appendChild(el('div', { className: 'trash-toolbar' }, [
|
||||||
el('span', { className: 'trash-title' }, ['Trash']),
|
el('span', { className: 'trash-title' }, [tr('ui.title', null, 'Trash')]),
|
||||||
el('span', { className: 'trash-count' }, [state.entries.length + ' total']),
|
el('span', { className: 'trash-count' }, [tr('ui.total', { count: state.entries.length }, state.entries.length + ' total')]),
|
||||||
el('span', { className: 'trash-spacer' }),
|
el('span', { className: 'trash-spacer' }),
|
||||||
el('input', {
|
el('input', {
|
||||||
className: 'trash-control trash-search',
|
className: 'trash-control trash-search',
|
||||||
type: 'search',
|
type: 'search',
|
||||||
value: state.query,
|
value: state.query,
|
||||||
placeholder: 'Filter name or path',
|
placeholder: tr('ui.filter', null, 'Filter name or path'),
|
||||||
'data-trash-filter-search': '',
|
'data-trash-filter-search': '',
|
||||||
onInput: function (event) { state.query = event.target.value; render(); }
|
onInput: function (event) { state.query = event.target.value; render(); }
|
||||||
}),
|
}),
|
||||||
workspaceSelect,
|
workspaceSelect,
|
||||||
typeSelect,
|
typeSelect,
|
||||||
sortSelect,
|
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', {
|
containerEl.appendChild(el('div', {
|
||||||
className: 'trash-status' + (state.statusError ? ' error' : ''),
|
className: 'trash-status' + (state.statusError ? ' error' : ''),
|
||||||
@@ -265,17 +269,17 @@
|
|||||||
|
|
||||||
var list = el('div', { className: 'trash-list', 'data-trash-list': '' });
|
var list = el('div', { className: 'trash-list', 'data-trash-list': '' });
|
||||||
list.appendChild(el('div', { className: 'trash-header' }, [
|
list.appendChild(el('div', { className: 'trash-header' }, [
|
||||||
el('span', {}, ['Name']),
|
el('span', {}, [tr('ui.name', null, 'Name')]),
|
||||||
el('span', {}, ['Workspace']),
|
el('span', {}, [tr('ui.workspace', null, 'Deal')]),
|
||||||
el('span', {}, ['Original path']),
|
el('span', {}, [tr('ui.originalPath', null, 'Original path')]),
|
||||||
el('span', {}, ['Deleted']),
|
el('span', {}, [tr('ui.deleted', null, 'Deleted')]),
|
||||||
el('span', {}, ['Type / size']),
|
el('span', {}, [tr('ui.typeSize', null, 'Type / size')]),
|
||||||
el('span', {}, ['Actions'])
|
el('span', {}, [tr('ui.actions', null, 'Actions')])
|
||||||
]));
|
]));
|
||||||
if (state.loading) {
|
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) {
|
} 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 {
|
} else {
|
||||||
rows.forEach(function (entry) {
|
rows.forEach(function (entry) {
|
||||||
var size = formatSize(entry.size);
|
var size = formatSize(entry.size);
|
||||||
@@ -296,14 +300,14 @@
|
|||||||
disabled: state.busyId === entry.trashId,
|
disabled: state.busyId === entry.trashId,
|
||||||
'data-trash-restore': entry.trashId,
|
'data-trash-restore': entry.trashId,
|
||||||
onClick: function () { restoreEntry(entry); }
|
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', {
|
el('button', {
|
||||||
className: 'trash-btn trash-btn-danger',
|
className: 'trash-btn trash-btn-danger',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
disabled: state.busyId === entry.trashId,
|
disabled: state.busyId === entry.trashId,
|
||||||
'data-trash-delete': entry.trashId,
|
'data-trash-delete': entry.trashId,
|
||||||
onClick: function () { state.confirmingId = entry.trashId; render(); }
|
onClick: function () { state.confirmingId = entry.trashId; render(); }
|
||||||
}, ['Delete permanently'])
|
}, [tr('ui.deletePermanently', null, 'Delete permanently')])
|
||||||
])
|
])
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
@@ -386,8 +390,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadEntries();
|
loadEntries();
|
||||||
|
var localeUnsubscribe = api.i18n && typeof api.i18n.onDidChangeLocale === 'function'
|
||||||
|
? api.i18n.onDidChangeLocale(render)
|
||||||
|
: null;
|
||||||
containerEl.__trashCleanup = function () {
|
containerEl.__trashCleanup = function () {
|
||||||
state.disposed = true;
|
state.disposed = true;
|
||||||
|
if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
|
||||||
containerEl.innerHTML = '';
|
containerEl.innerHTML = '';
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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": "Восстановить"
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"apiVersion": "0.1.0",
|
"apiVersion": "0.1.0",
|
||||||
"description": "Global vault trash manager for restoring or permanently deleting deleted files and folders.",
|
"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",
|
"source": "official",
|
||||||
"icon": "trash",
|
"icon": "trash",
|
||||||
"provides": [
|
"provides": [
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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"
|
||||||
|
}
|
||||||
@@ -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": "Выбрать цвет"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
Executable
+52
@@ -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
@@ -79,7 +79,14 @@ with open(path, 'w', encoding='utf-8') as f:
|
|||||||
fi
|
fi
|
||||||
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
|
if [ -d "$plugin_dir/backend" ]; then
|
||||||
# Find the compiled binary (same name as plugin directory)
|
# Find the compiled binary (same name as plugin directory)
|
||||||
local bin_name="$plugin_name"
|
local bin_name="$plugin_name"
|
||||||
@@ -91,7 +98,7 @@ with open(path, 'w', encoding='utf-8') as f:
|
|||||||
fi
|
fi
|
||||||
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
|
if [ ! -f "$dist_dir/plugin.json" ]; then
|
||||||
echo " ❌ dist package missing plugin.json"
|
echo " ❌ dist package missing plugin.json"
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -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');
|
||||||
@@ -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');
|
||||||
@@ -76,6 +76,43 @@ else
|
|||||||
echo " ℹ️ python3 not available — skipping manifest validation"
|
echo " ℹ️ python3 not available — skipping manifest validation"
|
||||||
fi
|
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 ""
|
echo ""
|
||||||
# Guard official plugins against bypassing the v2 plugin API for note features.
|
# Guard official plugins against bypassing the v2 plugin API for note features.
|
||||||
echo "[frontend API boundary]"
|
echo "[frontend API boundary]"
|
||||||
|
|||||||
Executable
+37
@@ -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"
|
||||||
Executable
+91
@@ -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
|
||||||
Executable
+15
@@ -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"
|
||||||
@@ -116,8 +116,9 @@ function loadComponent(document) {
|
|||||||
return component;
|
return component;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeApi(initialSettings = {}) {
|
function makeApi(initialSettings = {}, initialData = {}) {
|
||||||
const settings = { ...initialSettings };
|
const settings = { ...initialSettings };
|
||||||
|
const data = { ...initialData };
|
||||||
const handlers = {};
|
const handlers = {};
|
||||||
const commandHandlers = new Map();
|
const commandHandlers = new Map();
|
||||||
const unsubscribed = [];
|
const unsubscribed = [];
|
||||||
@@ -132,6 +133,14 @@ function makeApi(initialSettings = {}) {
|
|||||||
return { ...settings };
|
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: {
|
events: {
|
||||||
subscribe: async (name, handler) => {
|
subscribe: async (name, handler) => {
|
||||||
handlers[name] = handler;
|
handlers[name] = handler;
|
||||||
@@ -150,11 +159,14 @@ function makeApi(initialSettings = {}) {
|
|||||||
storedEvents(key = 'events') {
|
storedEvents(key = 'events') {
|
||||||
return settings[key] || [];
|
return settings[key] || [];
|
||||||
},
|
},
|
||||||
|
storedData(name) {
|
||||||
|
return Array.isArray(data[name]) ? data[name] : [];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function flush() {
|
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()) {
|
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 (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('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('Selection captured')) throw new Error('browser capture label was not rendered');
|
||||||
if (!container.textContent.includes('browser.capture.converted')) throw new Error('conversion event type 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('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');
|
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'));
|
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) 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('Deal: Project')) throw new Error('candidate Deal was not rendered');
|
||||||
if (!candidateNode.textContent.includes('Estimated duration: 30 min')) throw new Error('candidate duration 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 (!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') === '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');
|
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.candidateId) throw new Error('candidate id is missing');
|
||||||
if (candidate.workspaceRootPath !== 'Project') throw new Error('candidate workspace mismatch');
|
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.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.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 (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');
|
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');
|
if (!clearButton) throw new Error('clear activity button not found');
|
||||||
clearButton.click();
|
clearButton.click();
|
||||||
await flush();
|
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(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');
|
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);
|
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({
|
const persistedApi = makeApi({
|
||||||
'events:workspace:Project': [{
|
'events:workspace:Project': [{
|
||||||
@@ -449,6 +471,86 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
|
|||||||
}
|
}
|
||||||
component.unmount && component.unmount(sessionView.container);
|
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');
|
console.log('activity plugin smoke passed');
|
||||||
})().catch((err) => {
|
})().catch((err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ const vm = require('vm');
|
|||||||
const root = path.resolve(__dirname, '..');
|
const root = path.resolve(__dirname, '..');
|
||||||
const sourcePath = path.join(root, 'plugins', 'browser-inbox', 'frontend', 'src', 'index.js');
|
const sourcePath = path.join(root, 'plugins', 'browser-inbox', 'frontend', 'src', 'index.js');
|
||||||
const source = fs.readFileSync(sourcePath, 'utf8');
|
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 {
|
class FakeNode {
|
||||||
constructor(tagName) {
|
constructor(tagName) {
|
||||||
@@ -113,7 +118,15 @@ function makeDocument() {
|
|||||||
function loadComponents(document) {
|
function loadComponents(document) {
|
||||||
const registry = {};
|
const registry = {};
|
||||||
const sandbox = {
|
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,
|
Date,
|
||||||
document,
|
document,
|
||||||
window: {
|
window: {
|
||||||
@@ -136,12 +149,13 @@ function loadComponent(document) {
|
|||||||
return component;
|
return component;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeApi(initialSettings = {}) {
|
function makeApi(initialSettings = {}, locale = 'en') {
|
||||||
const settings = { ...initialSettings };
|
const settings = { ...initialSettings };
|
||||||
const handlers = {};
|
const handlers = {};
|
||||||
const unsubscribed = [];
|
const unsubscribed = [];
|
||||||
const fileWrites = [];
|
const fileWrites = [];
|
||||||
const fileByteWrites = [];
|
const fileByteWrites = [];
|
||||||
|
const openedURLs = [];
|
||||||
const publishedEvents = [];
|
const publishedEvents = [];
|
||||||
const workspaceEntries = [
|
const workspaceEntries = [
|
||||||
{ name: 'ClientA', relativePath: 'ClientA', type: 'folder' },
|
{ name: 'ClientA', relativePath: 'ClientA', type: 'folder' },
|
||||||
@@ -152,22 +166,83 @@ function makeApi(initialSettings = {}) {
|
|||||||
receiverToken: 'initial-browser-token',
|
receiverToken: 'initial-browser-token',
|
||||||
};
|
};
|
||||||
let nextWriteError = null;
|
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 {
|
return {
|
||||||
settings,
|
settings,
|
||||||
handlers,
|
handlers,
|
||||||
unsubscribed,
|
unsubscribed,
|
||||||
fileWrites,
|
fileWrites,
|
||||||
fileByteWrites,
|
fileByteWrites,
|
||||||
|
openedURLs,
|
||||||
publishedEvents,
|
publishedEvents,
|
||||||
|
i18n: {
|
||||||
|
getLocale: () => locale,
|
||||||
|
t: translate,
|
||||||
|
onDidChangeLocale: () => () => {},
|
||||||
|
},
|
||||||
failNextWrite(message) {
|
failNextWrite(message) {
|
||||||
nextWriteError = new Error(message || 'write failed');
|
nextWriteError = new Error(message || 'write failed');
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
publish: async (name, payload) => {
|
publish: async (name, payload) => {
|
||||||
publishedEvents.push({ name, payload });
|
publishedEvents.push({ name, payload });
|
||||||
|
if (name === 'browser-inbox.storage.mutate') backendMutate(payload || {});
|
||||||
},
|
},
|
||||||
subscribe: async (name, handler) => {
|
subscribe: async (name, handler) => {
|
||||||
handlers[name] = handler;
|
handlers[name] = async (event) => {
|
||||||
|
if (name.startsWith('browser.capture.')) backendAppend(event);
|
||||||
|
return handler(event);
|
||||||
|
};
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribed.push(name);
|
unsubscribed.push(name);
|
||||||
delete handlers[name];
|
delete handlers[name];
|
||||||
@@ -199,6 +274,9 @@ function makeApi(initialSettings = {}) {
|
|||||||
}
|
}
|
||||||
fileByteWrites.push({ relativePath, dataBase64, options });
|
fileByteWrites.push({ relativePath, dataBase64, options });
|
||||||
},
|
},
|
||||||
|
openURL: async (url) => {
|
||||||
|
openedURLs.push(url);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
browserReceiver: {
|
browserReceiver: {
|
||||||
pairing: async () => ({ ...receiverPairing }),
|
pairing: async () => ({ ...receiverPairing }),
|
||||||
@@ -214,7 +292,7 @@ function makeApi(initialSettings = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function flush() {
|
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()) {
|
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());
|
const settingsComponents = loadComponents(makeDocument());
|
||||||
if (!settingsComponents.BrowserInboxSettings) throw new Error('BrowserInboxSettings was not registered');
|
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 api = makeApi();
|
||||||
const settingsView = await mountSettingsWithApi(makeApi());
|
const settingsView = await mountSettingsWithApi(makeApi());
|
||||||
const receiverURLInput = walk(settingsView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-pairing-url') === '');
|
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');
|
if (!clearButton) throw new Error('clear button not found');
|
||||||
clearButton.click();
|
clearButton.click();
|
||||||
await flush();
|
await flush();
|
||||||
if (api.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'capture-1')) {
|
if (!api.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'capture-1' && capture.globalState === 'archived')) {
|
||||||
throw new Error('workspace clear action did not remove the Project capture');
|
throw new Error('workspace clear action did not archive the Project capture');
|
||||||
}
|
}
|
||||||
if (!api.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'capture-2')) {
|
if (!api.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'capture-2')) {
|
||||||
throw new Error('workspace clear action removed a capture from another workspace');
|
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')) {
|
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');
|
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);
|
component.unmount && component.unmount(legacyGlobal.container);
|
||||||
|
|
||||||
const legacyProject = await mountWithApi(legacyApi, { workspaceNode: { name: 'Project' }, workspaceRootPath: 'Project' });
|
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)) {
|
if (!assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned' && capture.processed === false)) {
|
||||||
throw new Error('mark unprocessed did not persist state');
|
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();
|
deleteButton.click();
|
||||||
await flush();
|
await flush();
|
||||||
if (assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned')) {
|
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);
|
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({
|
const conversionApi = makeApi({
|
||||||
'captures:workspace:Project': [{
|
'captures:workspace:Project': [{
|
||||||
captureId: 'convert-selection',
|
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('# 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('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 (!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')) {
|
if (!conversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-selection' && capture.globalState === 'archived')) {
|
||||||
throw new Error('converted capture was not removed from queue');
|
throw new Error('converted capture was not archived');
|
||||||
}
|
}
|
||||||
const convertedEvent = conversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted');
|
const convertedEvent = conversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted');
|
||||||
if (!convertedEvent) throw new Error('browser.capture.converted event was not published');
|
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');
|
failedConversionApi.failNextWrite('file already exists');
|
||||||
failedCreateNoteButton.click();
|
failedCreateNoteButton.click();
|
||||||
await flush();
|
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');
|
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');
|
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')) {
|
if (failedConversionApi.publishedEvents.some((event) => event.name === 'browser.capture.converted')) {
|
||||||
throw new Error('failed conversion published converted event');
|
throw new Error('failed conversion published converted event');
|
||||||
}
|
}
|
||||||
@@ -665,6 +801,11 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
|
|||||||
const linkConversionView = await mountWithApi(linkConversionApi);
|
const linkConversionView = await mountWithApi(linkConversionApi);
|
||||||
const createLinkButton = walk(linkConversionView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'create-link');
|
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');
|
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();
|
createLinkButton.click();
|
||||||
await flush();
|
await flush();
|
||||||
if (linkConversionApi.fileWrites.length !== 1) throw new Error(`expected one link write, got ${linkConversionApi.fileWrites.length}`);
|
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('[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 (!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')) {
|
if (!linkConversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-link' && capture.globalState === 'archived')) {
|
||||||
throw new Error('converted link capture was not removed from queue');
|
throw new Error('converted link capture was not archived');
|
||||||
}
|
}
|
||||||
const convertedLinkEvent = linkConversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted');
|
const convertedLinkEvent = linkConversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted');
|
||||||
if (!convertedLinkEvent) throw new Error('browser.capture.converted link event was not published');
|
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');
|
if (convertedLinkEvent.payload.linkPath !== 'Project/Links/Example_Article.url') throw new Error('converted link event linkPath mismatch');
|
||||||
component.unmount && component.unmount(linkConversionView.container);
|
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({
|
const failedLinkApi = makeApi({
|
||||||
'captures:workspace:Project': [{
|
'captures:workspace:Project': [{
|
||||||
captureId: 'convert-link-conflict',
|
captureId: 'convert-link-conflict',
|
||||||
@@ -704,10 +864,10 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
|
|||||||
failedLinkApi.failNextWrite('link already exists');
|
failedLinkApi.failNextWrite('link already exists');
|
||||||
failedCreateLinkButton.click();
|
failedCreateLinkButton.click();
|
||||||
await flush();
|
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');
|
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');
|
throw new Error('failed link conversion did not render an error status');
|
||||||
}
|
}
|
||||||
if (failedLinkApi.publishedEvents.some((event) => event.name === 'browser.capture.converted')) {
|
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) {
|
if (fileWrite.options.createIfMissing !== true || fileWrite.options.overwrite !== false) {
|
||||||
throw new Error(`file write options mismatch: ${JSON.stringify(fileWrite.options)}`);
|
throw new Error(`file write options mismatch: ${JSON.stringify(fileWrite.options)}`);
|
||||||
}
|
}
|
||||||
if (fileConversionApi.getStoredCaptures(projectKey).some((capture) => capture.captureId === 'convert-file')) {
|
if (!fileConversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-file' && capture.globalState === 'archived')) {
|
||||||
throw new Error('converted file capture was not removed from queue');
|
throw new Error('converted file capture was not archived');
|
||||||
}
|
}
|
||||||
const convertedFileEvent = fileConversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted');
|
const convertedFileEvent = fileConversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted');
|
||||||
if (!convertedFileEvent) throw new Error('browser.capture.converted file event was not published');
|
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');
|
failedFileApi.failNextWrite('file already exists');
|
||||||
failedCreateFileButton.click();
|
failedCreateFileButton.click();
|
||||||
await flush();
|
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');
|
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');
|
throw new Error('failed file conversion did not render an error status');
|
||||||
}
|
}
|
||||||
if (failedFileApi.publishedEvents.some((event) => event.name === 'browser.capture.converted')) {
|
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);
|
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');
|
console.log('browser inbox plugin smoke passed');
|
||||||
})().catch((err) => {
|
})().catch((err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ async function flush() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mountEditor(secretProviderEnabled) {
|
async function mountEditor(secretProviderEnabled, translations) {
|
||||||
const document = makeDocument();
|
const document = makeDocument();
|
||||||
const component = loadComponent(document);
|
const component = loadComponent(document);
|
||||||
const opened = [];
|
const opened = [];
|
||||||
@@ -146,6 +146,15 @@ async function mountEditor(secretProviderEnabled) {
|
|||||||
return { status: 'opened', request };
|
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');
|
const container = document.createElement('div');
|
||||||
component.mount(container, {
|
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');
|
if (disabledPreview.innerHTML.includes('data-secret-id')) throw new Error('secret link rendered without secrets provider');
|
||||||
|
|
||||||
const enabled = await mountEditor(true);
|
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');
|
const preview = walk(enabled.container, (node) => node.className === 'de-preview');
|
||||||
if (!preview) throw new Error('enabled preview missing');
|
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');
|
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');
|
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');
|
console.log('default editor smoke passed');
|
||||||
})().catch((err) => {
|
})().catch((err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|||||||
@@ -195,9 +195,11 @@ function loadFilesComponent(document) {
|
|||||||
return { component, clipboard: sandbox.navigator.clipboard };
|
return { component, clipboard: sandbox.navigator.clipboard };
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeApi() {
|
function makeApi(options = {}) {
|
||||||
const externalCalls = [];
|
const externalCalls = [];
|
||||||
const contributionCalls = [];
|
const contributionCalls = [];
|
||||||
|
const created = [];
|
||||||
|
const moved = [];
|
||||||
const eventHandlers = {};
|
const eventHandlers = {};
|
||||||
let restored = false;
|
let restored = false;
|
||||||
let externalVisible = false;
|
let externalVisible = false;
|
||||||
@@ -212,6 +214,8 @@ function makeApi() {
|
|||||||
return {
|
return {
|
||||||
externalCalls,
|
externalCalls,
|
||||||
contributionCalls,
|
contributionCalls,
|
||||||
|
created,
|
||||||
|
moved,
|
||||||
emitFileChanged(payload) {
|
emitFileChanged(payload) {
|
||||||
(eventHandlers['file.changed'] || []).forEach((handler) => handler({
|
(eventHandlers['file.changed'] || []).forEach((handler) => handler({
|
||||||
name: 'file.changed',
|
name: 'file.changed',
|
||||||
@@ -229,6 +233,15 @@ function makeApi() {
|
|||||||
size: 12,
|
size: 12,
|
||||||
modifiedAt: '2026-06-27T00:00:00Z',
|
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) {
|
if (restored) {
|
||||||
entries.push({
|
entries.push({
|
||||||
name: 'deleted.md',
|
name: 'deleted.md',
|
||||||
@@ -253,9 +266,15 @@ function makeApi() {
|
|||||||
},
|
},
|
||||||
metadata: async () => { throw new Error('not-found'); },
|
metadata: async () => { throw new Error('not-found'); },
|
||||||
readText: async () => '# Readme\n',
|
readText: async () => '# Readme\n',
|
||||||
writeText: async () => undefined,
|
writeText: async (relativePath, content, options) => {
|
||||||
createFolder: async () => undefined,
|
created.push({ type: 'file', relativePath, content, options });
|
||||||
move: async () => undefined,
|
},
|
||||||
|
createFolder: async (relativePath) => {
|
||||||
|
created.push({ type: 'folder', relativePath });
|
||||||
|
},
|
||||||
|
move: async (fromRelativePath, toRelativePath) => {
|
||||||
|
moved.push({ fromRelativePath, toRelativePath });
|
||||||
|
},
|
||||||
trash: async () => undefined,
|
trash: async () => undefined,
|
||||||
listTrash: async () => trashEntries.slice(),
|
listTrash: async () => trashEntries.slice(),
|
||||||
restoreTrash: async (trashId) => {
|
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() {
|
showExternalFile() {
|
||||||
externalVisible = true;
|
externalVisible = true;
|
||||||
},
|
},
|
||||||
@@ -328,6 +356,62 @@ async function flush() {
|
|||||||
const row = walk(container, (node) => node.getAttribute && node.getAttribute('data-file-path') === 'Docs/readme.md');
|
const row = walk(container, (node) => node.getAttribute && node.getAttribute('data-file-path') === 'Docs/readme.md');
|
||||||
if (!row) throw new Error('file row not rendered');
|
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);
|
const list = walk(container, (node) => node.getAttribute && node.getAttribute('data-files-list') !== undefined);
|
||||||
if (!list) throw new Error('files list not rendered');
|
if (!list) throw new Error('files list not rendered');
|
||||||
list.dispatchEvent('contextmenu', { target: row, clientX: 20, clientY: 20 });
|
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}`);
|
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');
|
console.log('files frontend smoke passed');
|
||||||
})().catch((err) => {
|
})().catch((err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ const vm = require('vm');
|
|||||||
const root = path.resolve(__dirname, '..');
|
const root = path.resolve(__dirname, '..');
|
||||||
const manifestPath = path.join(root, 'plugins', 'journal', 'plugin.json');
|
const manifestPath = path.join(root, 'plugins', 'journal', 'plugin.json');
|
||||||
const sourcePath = path.join(root, 'plugins', 'journal', 'frontend', 'src', 'index.js');
|
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(manifestPath)) throw new Error('journal plugin manifest missing');
|
||||||
if (!fs.existsSync(sourcePath)) throw new Error('journal frontend entry missing');
|
if (!fs.existsSync(sourcePath)) throw new Error('journal frontend entry missing');
|
||||||
|
|
||||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||||
const source = fs.readFileSync(sourcePath, 'utf8');
|
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||||
|
const russianLocale = JSON.parse(fs.readFileSync(russianLocalePath, 'utf8'));
|
||||||
|
|
||||||
class FakeNode {
|
class FakeNode {
|
||||||
constructor(tagName) {
|
constructor(tagName) {
|
||||||
@@ -131,9 +133,11 @@ function loadComponent(document) {
|
|||||||
return component;
|
return component;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeApi(initialSettings = {}) {
|
function makeApi(initialSettings = {}, locale = null) {
|
||||||
const settings = { ...initialSettings };
|
const settings = { ...initialSettings };
|
||||||
|
const publishedEvents = [];
|
||||||
return {
|
return {
|
||||||
|
publishedEvents,
|
||||||
settings: {
|
settings: {
|
||||||
read: async (key) => (key ? settings[key] : { ...settings }),
|
read: async (key) => (key ? settings[key] : { ...settings }),
|
||||||
write: async (key, value) => {
|
write: async (key, value) => {
|
||||||
@@ -141,6 +145,22 @@ function makeApi(initialSettings = {}) {
|
|||||||
return { ...settings };
|
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) {
|
storedEntries(key) {
|
||||||
return settings[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.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('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.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.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');
|
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 = {
|
const candidate = {
|
||||||
candidateId: 'work-session:Project:capture-1:note-1',
|
candidateId: 'work-session:Project:capture-1:note-1',
|
||||||
|
sessionId: 'session-journal-1',
|
||||||
|
handledThrough: '2026-06-27T11:03:00.000Z',
|
||||||
workspaceRootPath: 'Project',
|
workspaceRootPath: 'Project',
|
||||||
startedAt: '2026-06-27T10:12:00.000Z',
|
startedAt: '2026-06-27T10:12:00.000Z',
|
||||||
endedAt: '2026-06-27T11:03:00.000Z',
|
endedAt: '2026-06-27T11:03:00.000Z',
|
||||||
@@ -229,8 +252,11 @@ function byData(container, attr, value) {
|
|||||||
toolRequest: { type: 'work-session-candidate', candidate },
|
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('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('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', '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', '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');
|
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');
|
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');
|
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')) {
|
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');
|
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();
|
byData(candidateView.container, 'data-journal-action', 'delete').click();
|
||||||
await flush();
|
await flush();
|
||||||
if (api.storedEntries(projectKey).length !== 1) throw new Error('journal entry was not deleted');
|
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')) {
|
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');
|
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(container);
|
||||||
component.unmount && component.unmount(candidateView.container);
|
component.unmount && component.unmount(candidateView.container);
|
||||||
|
component.unmount && component.unmount(russianCandidateView.container);
|
||||||
component.unmount && component.unmount(todoView.container);
|
component.unmount && component.unmount(todoView.container);
|
||||||
component.unmount && component.unmount(duplicateTodoView.container);
|
component.unmount && component.unmount(duplicateTodoView.container);
|
||||||
component.unmount && component.unmount(globalView.container);
|
component.unmount && component.unmount(globalView.container);
|
||||||
|
|||||||
@@ -264,12 +264,19 @@ async function mountNotes(api) {
|
|||||||
const createButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-action') === 'create');
|
const createButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-action') === 'create');
|
||||||
if (!createButton) throw new Error('create button not found');
|
if (!createButton) throw new Error('create button not found');
|
||||||
createButton.click();
|
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');
|
if (!input) throw new Error('create input not found');
|
||||||
input.value = 'First Note';
|
const confirm = walk(createModal, (node) => node.tagName === 'BUTTON' && node.textContent === 'Create');
|
||||||
const confirm = walk(container, (node) => node.tagName === 'BUTTON' && node.textContent === 'Create');
|
|
||||||
if (!confirm) throw new Error('create confirm button not found');
|
if (!confirm) throw new Error('create confirm button not found');
|
||||||
confirm.click();
|
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();
|
await flush();
|
||||||
|
|
||||||
const created = createApi.entries.get('Project/Notes/First_Note.md');
|
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');
|
const renameButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-note-action') === 'rename');
|
||||||
if (!renameButton) throw new Error('rename note button not found');
|
if (!renameButton) throw new Error('rename note button not found');
|
||||||
renameButton.click();
|
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');
|
if (!renameInput) throw new Error('rename input not found');
|
||||||
renameInput.value = 'Second Note';
|
renameInput.value = 'Second Note';
|
||||||
const renameConfirm = walk(container, (node) => node.tagName === 'BUTTON' && node.textContent === 'Rename');
|
renameInput.dispatchEvent('keydown', { key: 'Enter' });
|
||||||
if (!renameConfirm) throw new Error('rename confirm button not found');
|
|
||||||
renameConfirm.click();
|
|
||||||
await flush();
|
await flush();
|
||||||
const conflictModal = walk(document.body, (node) => node.className === 'notes-modal-msg');
|
const renameError = walk(renameModal, (node) => node.getAttribute && node.getAttribute('data-notes-rename-error') !== undefined);
|
||||||
if (!conflictModal || !conflictModal.textContent.includes('Project/Notes/Second_Note.md')) {
|
if (!renameError || !renameError.textContent.includes('Project/Notes/Second_Note.md')) {
|
||||||
throw new Error(`rename conflict modal should include existing path, got ${conflictModal && conflictModal.textContent}`);
|
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 (walk(document.body, (node) => node.className === 'notes-modal-msg')) {
|
||||||
if (!conflictOk) throw new Error('rename conflict OK button not found');
|
throw new Error('rename conflict opened a second modal instead of reporting in the form');
|
||||||
conflictOk.click();
|
}
|
||||||
|
renameInput.dispatchEvent('keydown', { key: 'Escape' });
|
||||||
await flush();
|
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');
|
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');
|
if (!providerAction) throw new Error('provider note action button not found');
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ const vm = require('vm');
|
|||||||
const root = path.resolve(__dirname, '..');
|
const root = path.resolve(__dirname, '..');
|
||||||
const bundlePath = path.join(root, 'plugins', 'platform-test', 'frontend', 'src', 'index.js');
|
const bundlePath = path.join(root, 'plugins', 'platform-test', 'frontend', 'src', 'index.js');
|
||||||
const source = fs.readFileSync(bundlePath, 'utf8');
|
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 {
|
class FakeNode {
|
||||||
constructor(tagName) {
|
constructor(tagName) {
|
||||||
@@ -37,6 +41,19 @@ class FakeNode {
|
|||||||
addEventListener() {}
|
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() {
|
function makeDocument() {
|
||||||
return {
|
return {
|
||||||
head: new FakeNode('head'),
|
head: new FakeNode('head'),
|
||||||
@@ -76,6 +93,22 @@ if (!components) {
|
|||||||
|
|
||||||
const api = {
|
const api = {
|
||||||
pluginId: 'verstak.platform-test',
|
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: {
|
settings: {
|
||||||
read: async () => 'initial value',
|
read: async () => 'initial value',
|
||||||
write: async () => undefined,
|
write: async () => undefined,
|
||||||
@@ -179,9 +212,35 @@ const api = {
|
|||||||
if (container.children.length === 0) {
|
if (container.children.length === 0) {
|
||||||
throw new Error(`${name} mounted no DOM nodes`);
|
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') {
|
if (typeof component.unmount === 'function') {
|
||||||
component.unmount(container);
|
component.unmount(container);
|
||||||
}
|
}
|
||||||
|
api.i18n.setLocale('en');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('platform-test frontend smoke passed');
|
console.log('platform-test frontend smoke passed');
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const sourcePath = path.join(root, 'plugins', 'search', 'frontend', 'src', 'inde
|
|||||||
const manifestPath = path.join(root, 'plugins', 'search', 'plugin.json');
|
const manifestPath = path.join(root, 'plugins', 'search', 'plugin.json');
|
||||||
const source = fs.readFileSync(sourcePath, 'utf8');
|
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||||
|
const technicalErrors = [];
|
||||||
|
|
||||||
class FakeNode {
|
class FakeNode {
|
||||||
constructor(tagName) {
|
constructor(tagName) {
|
||||||
@@ -100,7 +101,15 @@ function makeDocument() {
|
|||||||
function loadComponent(document) {
|
function loadComponent(document) {
|
||||||
const registry = {};
|
const registry = {};
|
||||||
vm.runInNewContext(source, {
|
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,
|
document,
|
||||||
window: {
|
window: {
|
||||||
VerstakPluginRegister(pluginId, bundle) {
|
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/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('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('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('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 (!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 (!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 (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 = queryInput();
|
||||||
input.value = 'image';
|
input.value = 'image';
|
||||||
|
|||||||
@@ -28,6 +28,16 @@ class FakeNode {
|
|||||||
return node;
|
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) {
|
setAttribute(name, value) {
|
||||||
this.attributes[name] = String(value);
|
this.attributes[name] = String(value);
|
||||||
}
|
}
|
||||||
@@ -96,10 +106,18 @@ function makeDocument() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadComponent(document) {
|
function loadComponent(document, errorLog) {
|
||||||
const registry = {};
|
const registry = {};
|
||||||
vm.runInNewContext(source, {
|
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,
|
document,
|
||||||
window: {
|
window: {
|
||||||
VerstakPluginRegister(pluginId, bundle) {
|
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.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.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('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.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.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.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');
|
if (!(manifest.contributes.settingsPanels || []).some((item) => item.component === 'SecretsView')) throw new Error('secrets settings panel missing');
|
||||||
|
|
||||||
const document = makeDocument();
|
const document = makeDocument();
|
||||||
const component = loadComponent(document);
|
const errorLog = [];
|
||||||
|
const component = loadComponent(document, errorLog);
|
||||||
const records = [
|
const records = [
|
||||||
{ id: 'global.server', title: 'Global Server', username: 'root', scope: { kind: 'global' }, updatedAt: '2026-06-29T00:00:00Z' },
|
{ 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' },
|
{ 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 initialized = false;
|
||||||
let unlocked = false;
|
let unlocked = false;
|
||||||
|
let unlockError = '';
|
||||||
const readCalls = [];
|
const readCalls = [];
|
||||||
const copied = [];
|
const copied = [];
|
||||||
const deleted = [];
|
const deleted = [];
|
||||||
@@ -149,6 +172,7 @@ async function flush() {
|
|||||||
secrets: {
|
secrets: {
|
||||||
status: async () => ({ initialized, unlocked }),
|
status: async () => ({ initialized, unlocked }),
|
||||||
unlock: async (password) => {
|
unlock: async (password) => {
|
||||||
|
if (unlockError) throw new Error(unlockError);
|
||||||
if (password !== 'master-password') throw new Error('bad password');
|
if (password !== 'master-password') throw new Error('bad password');
|
||||||
initialized = true;
|
initialized = true;
|
||||||
unlocked = true;
|
unlocked = true;
|
||||||
@@ -177,6 +201,12 @@ async function flush() {
|
|||||||
clipboard: {
|
clipboard: {
|
||||||
writeText: async (text) => copied.push(text),
|
writeText: async (text) => copied.push(text),
|
||||||
},
|
},
|
||||||
|
files: {
|
||||||
|
list: async () => [
|
||||||
|
{ type: 'folder', relativePath: 'ClientA' },
|
||||||
|
{ type: 'folder', relativePath: 'ClientB' },
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const container = document.createElement('div');
|
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 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') === '');
|
const unlockButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-unlock') === '');
|
||||||
if (!passwordInput || !confirmInput || !unlockButton) throw new Error('setup controls missing');
|
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';
|
passwordInput.value = 'master-password';
|
||||||
confirmInput.value = 'master-password';
|
confirmInput.value = 'master-password';
|
||||||
unlockButton.click();
|
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('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('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('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');
|
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');
|
if (!copyButton) throw new Error('copy link button missing');
|
||||||
@@ -226,8 +281,69 @@ async function flush() {
|
|||||||
if (!deleteButton) throw new Error('delete button missing');
|
if (!deleteButton) throw new Error('delete button missing');
|
||||||
deleteButton.click();
|
deleteButton.click();
|
||||||
await flush();
|
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');
|
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');
|
console.log('secrets plugin smoke passed');
|
||||||
})().catch((err) => {
|
})().catch((err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|||||||
@@ -9,14 +9,17 @@ const source = fs.readFileSync(sourcePath, 'utf8');
|
|||||||
if (!source.includes('settings.lastError')) {
|
if (!source.includes('settings.lastError')) {
|
||||||
throw new Error('SyncSettings must render persisted settings.lastError');
|
throw new Error('SyncSettings must render persisted settings.lastError');
|
||||||
}
|
}
|
||||||
if (!source.includes('sanitizeError(settings.lastError)')) {
|
if (!source.includes('function reportError')) {
|
||||||
throw new Error('SyncSettings must sanitize persisted sync errors before rendering');
|
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');
|
throw new Error('SyncSettings must label the persisted sync error');
|
||||||
}
|
}
|
||||||
if (!source.includes('function formatSyncConflict')) {
|
if (!source.includes("tr('ui.syncConflictItem'")) {
|
||||||
throw new Error('SyncSettings must format individual sync conflicts');
|
throw new Error('SyncSettings must hide technical conflict identifiers behind a user-facing summary');
|
||||||
}
|
}
|
||||||
if (!source.includes('conflictDetails')) {
|
if (!source.includes('conflictDetails')) {
|
||||||
throw new Error('SyncSettings must store sync conflict details after Sync Now');
|
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')) {
|
if (!source.includes('Sync conflicts')) {
|
||||||
throw new Error('SyncSettings must label the conflict details section');
|
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');
|
console.log('sync plugin smoke passed');
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ const sourcePath = path.join(root, 'plugins', 'todo', 'frontend', 'src', 'index.
|
|||||||
const manifestPath = path.join(root, 'plugins', 'todo', 'plugin.json');
|
const manifestPath = path.join(root, 'plugins', 'todo', 'plugin.json');
|
||||||
const source = fs.readFileSync(sourcePath, 'utf8');
|
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, '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 {
|
class FakeNode {
|
||||||
constructor(tagName) {
|
constructor(tagName) {
|
||||||
@@ -140,10 +144,14 @@ function loadComponent(document, emittedEvents) {
|
|||||||
return component;
|
return component;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeApi(initialSettings = {}) {
|
function makeApi(initialSettings = {}, initialLocale = 'en') {
|
||||||
const settings = { ...initialSettings };
|
const settings = { ...initialSettings };
|
||||||
|
const notificationCalls = [];
|
||||||
|
let locale = initialLocale;
|
||||||
|
const localeListeners = [];
|
||||||
return {
|
return {
|
||||||
settings,
|
settings,
|
||||||
|
notificationCalls,
|
||||||
settingsApi: {
|
settingsApi: {
|
||||||
read: async (key) => (key ? settings[key] : { ...settings }),
|
read: async (key) => (key ? settings[key] : { ...settings }),
|
||||||
write: async (key, value) => {
|
write: async (key, value) => {
|
||||||
@@ -157,8 +165,37 @@ function makeApi(initialSettings = {}) {
|
|||||||
{ name: 'Project', relativePath: 'Project', type: 'folder' },
|
{ 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() {
|
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.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('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('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.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');
|
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', 'title').value = 'Prepare project review';
|
||||||
byData(container, 'data-todo-input', 'description').value = 'Collect factual review notes.';
|
byData(container, 'data-todo-input', 'description').value = 'Collect factual review notes.';
|
||||||
byData(container, 'data-todo-input', 'priority').value = 'high';
|
byData(container, 'data-todo-input', 'priority').value = 'high';
|
||||||
byData(container, 'data-todo-input', 'dueAt').value = '2000-01-01';
|
byData(container, 'data-todo-input', 'dueAt').value = '01/02/2000';
|
||||||
byData(container, 'data-todo-input', 'reminderAt').value = '2000-01-01T09:00';
|
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();
|
byData(container, 'data-todo-action', 'save').click();
|
||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
@@ -202,20 +246,56 @@ async function mountWithApi(apiState, props, emittedEvents = [], document = make
|
|||||||
const createdTodo = storedAfterCreate[0];
|
const createdTodo = storedAfterCreate[0];
|
||||||
if (createdTodo.workspaceRootPath !== 'Project') throw new Error('workspace Todo did not keep the Project root path');
|
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.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');
|
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-action', 'edit').click();
|
||||||
byData(container, 'data-todo-input', 'title').value = 'Prepare project review updated';
|
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();
|
byData(container, 'data-todo-action', 'save').click();
|
||||||
await flush();
|
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].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();
|
byData(container, 'data-todo-action', 'mark-done').click();
|
||||||
await flush();
|
await flush();
|
||||||
if (apiState.settings['todos:global'][0].status !== 'done' || !apiState.settings['todos:global'][0].completedAt) {
|
if (apiState.settings['todos:global'][0].status !== 'done' || !apiState.settings['todos:global'][0].completedAt) {
|
||||||
throw new Error('mark done did not persist completed state');
|
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', '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');
|
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')) {
|
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');
|
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');
|
const workspaceFilter = byData(globalView.container, 'data-todo-filter', 'workspace');
|
||||||
workspaceFilter.value = 'ClientA';
|
workspaceFilter.value = 'ClientA';
|
||||||
workspaceFilter.dispatchEvent('change');
|
workspaceFilter.dispatchEvent('change');
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ const vm = require('vm');
|
|||||||
const root = path.resolve(__dirname, '..');
|
const root = path.resolve(__dirname, '..');
|
||||||
const sourcePath = path.join(root, 'plugins', 'trash', 'frontend', 'src', 'index.js');
|
const sourcePath = path.join(root, 'plugins', 'trash', 'frontend', 'src', 'index.js');
|
||||||
const manifestPath = path.join(root, 'plugins', 'trash', 'plugin.json');
|
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 source = fs.readFileSync(sourcePath, 'utf8');
|
||||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||||
|
const russianLocale = JSON.parse(fs.readFileSync(russianLocalePath, 'utf8'));
|
||||||
|
|
||||||
class FakeClassList {
|
class FakeClassList {
|
||||||
constructor(node) {
|
constructor(node) {
|
||||||
@@ -159,7 +161,7 @@ function loadTrashComponent(document) {
|
|||||||
return component;
|
return component;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeApi() {
|
function makeApi(locale = null) {
|
||||||
let entries = [
|
let entries = [
|
||||||
{
|
{
|
||||||
trashId: 'old-project-file',
|
trashId: 'old-project-file',
|
||||||
@@ -194,6 +196,11 @@ function makeApi() {
|
|||||||
return {
|
return {
|
||||||
restoreCalls,
|
restoreCalls,
|
||||||
deleteCalls,
|
deleteCalls,
|
||||||
|
i18n: locale ? {
|
||||||
|
t(key, params, fallback) {
|
||||||
|
return String(locale[key] || fallback || key).replace(/\{(\w+)\}/g, (_match, name) => String((params || {})[name] ?? ''));
|
||||||
|
},
|
||||||
|
} : null,
|
||||||
files: {
|
files: {
|
||||||
listTrash: async () => entries.slice(),
|
listTrash: async () => entries.slice(),
|
||||||
restoreTrash: async (trashId, options) => {
|
restoreTrash: async (trashId, options) => {
|
||||||
@@ -237,6 +244,10 @@ function findByData(rootNode, name, value) {
|
|||||||
component.mount(container, {}, api);
|
component.mount(container, {}, api);
|
||||||
await flush();
|
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');
|
const latestRow = findByData(container, 'data-trash-row', 'latest-client-report');
|
||||||
if (!latestRow || !latestRow.textContent.includes('ClientA') || !latestRow.textContent.includes('ClientA/Archive/report.pdf')) {
|
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}`);
|
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);
|
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');
|
console.log('trash frontend smoke passed');
|
||||||
})().catch((err) => {
|
})().catch((err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|||||||
Executable
+19
@@ -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"
|
||||||
Executable
+22
@@ -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"
|
||||||
Executable
+104
@@ -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"
|
||||||
Reference in New Issue
Block a user