Author SHA1 Message Date
mirivlad 484d599a9f docs: use Deal terminology in official plugins 2026-07-14 23:50:59 +08:00
mirivlad 5ddc63bb8d docs: describe search as Deal scoped 2026-07-14 23:20:08 +08:00
mirivlad f456287c2e fix: use Deal terminology in trash 2026-07-14 23:19:11 +08:00
mirivlad c8b8045da1 fix: use Deal terminology in todos 2026-07-14 23:17:08 +08:00
mirivlad 3a1920d104 fix: localize journal candidate review 2026-07-14 23:12:29 +08:00
mirivlad 713b95b5a8 fix: localize activity event labels 2026-07-14 23:08:00 +08:00
mirivlad b449e9c616 fix: simplify markdown editor chrome 2026-07-14 22:56:40 +08:00
mirivlad 90ad8e8467 fix: protect secrets values and deletion 2026-07-14 22:53:37 +08:00
mirivlad aa7956dec4 fix: localize files context menu 2026-07-14 22:49:59 +08:00
mirivlad 44b39ca051 fix: use modal forms for files 2026-07-14 22:46:14 +08:00
mirivlad c83c15bced fix: use modal forms for notes 2026-07-14 22:41:17 +08:00
mirivlad 99172944a1 fix: localize Browser materials workflow 2026-07-14 22:28:53 +08:00
mirivlad a1b3c31d0d fix: localize plugin operation errors 2026-07-14 22:00:24 +08:00
mirivlad 1f03226378 fix: localize Secrets operation errors 2026-07-14 20:56:25 +08:00
mirivlad bee365ad22 fix: support typed Todo reminder times 2026-07-14 20:50:14 +08:00
mirivlad dfc53f1a5f feat: add global secrets view 2026-07-14 20:41:40 +08:00
mirivlad dd75335727 fix: add entries from global journal 2026-07-14 19:43:21 +08:00
mirivlad f23fb6e993 fix: make Todo reminder time selectable 2026-07-14 19:38:00 +08:00
mirivlad 0cd2c2a6ab test: cover activity deletion confirmation 2026-07-14 19:38:00 +08:00
mirivlad 304620952f fix: confirm activity deletion 2026-07-14 19:19:06 +08:00
mirivlad 112fb4cbec fix: apply markdown blocks to full lines 2026-07-14 19:13:56 +08:00
mirivlad 78a9dd31a3 fix: open requested secrets from workbench 2026-07-14 19:13:56 +08:00
mirivlad eb0f468463 fix: make Todo reminders and selects usable 2026-07-14 08:00:33 +08:00
mirivlad 7687f410c4 feat: schedule Todo native reminders 2026-07-14 02:47:05 +08:00
mirivlad 7e34a73835 build: publish portable plugin releases 2026-07-14 01:58:30 +08:00
mirivlad 47c1db9979 build: add portable plugin archives 2026-07-13 22:29:46 +08:00
mirivlad 22c842d5e3 build: add Windows plugin packages 2026-07-13 21:46:40 +08:00
mirivlad 1a15d57033 build: publish plugin releases to GitHub 2026-07-13 05:01:42 +08:00
mirivlad 483b6ba46e build: ignore generated plugin releases 2026-07-12 21:34:57 +08:00
mirivlad 226464f082 docs: prepare official plugins alpha release 2026-07-12 21:22:24 +08:00
mirivlad e93584c37c feat: stabilize activity sessions for journal review 2026-07-12 20:49:13 +08:00
mirivlad 50c4fc5e68 feat: open browser links and resolve filename collisions 2026-07-12 20:39:09 +08:00
mirivlad b45a56b09d feat: add browser inbox archive flow 2026-07-12 17:43:17 +08:00
mirivlad 0db9f896ce feat: read activity from append-only storage 2026-07-12 17:29:13 +08:00
mirivlad 69a1938ff3 fix: make browser inbox mutations durable 2026-07-11 23:30:43 +08:00
mirivlad fe97c5ddcc fix: fully localize platform diagnostics 2026-07-11 12:56:40 +08:00
mirivlad 1bc5ecfd16 fix: keep plugin localization live and complete 2026-07-11 12:48:15 +08:00
mirivlad 3dd0ec9f1f feat: localize official plugins in Russian 2026-07-11 12:42:37 +08:00
82 changed files with 5138 additions and 1080 deletions
+2
View File
@@ -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
+17
View File
@@ -0,0 +1,17 @@
Verstak Official Plugins
Copyright (C) 2026 Verstak contributors
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU Affero General Public License as published by the Free
Software Foundation, either version 3 of the License, or (at your option) any
later version.
This program is distributed in the hope that it will be useful, but WITHOUT
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
details.
The complete license text is available at:
https://www.gnu.org/licenses/agpl-3.0.html
SPDX-License-Identifier: AGPL-3.0-or-later
+72 -3
View File
@@ -1,5 +1,74 @@
# verstak-official-plugins # Verstak Official Plugins
Official Verstak plugins monorepo — files, trash, notes, markdown-editor, preview, activity, journal, todos, browser-inbox, search, secrets, templates + shared packages. The first-party plugin set for Verstak Desktop: Files, Trash, Notes, editor and
preview providers, Browser Inbox, Activity, Journal, Todo, Search, Secrets and
Templates. Plugin bundles are built into independent directories that the
desktop host loads from its `plugins/` folder.
`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. > **Alpha software.** Build this repository together with compatible
> `verstak-desktop` and `verstak-sdk` checkouts.
## 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).
+12 -12
View File
@@ -4,22 +4,22 @@ Browser Inbox is one global queue of browser captures. New and changed records a
stored in `captures:global`; previous `captures` and `captures:workspace:*` keys 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
View File
@@ -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.
+388 -142
View File
@@ -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;
+62
View File
@@ -0,0 +1,62 @@
{
"manifest.name": "Activity",
"manifest.description": "Deal-scoped activity log for public plugin events.",
"contributions.views.verstak.activity.view.title": "Activity",
"contributions.sidebarItems.verstak.activity.sidebar.title": "Activity",
"contributions.workspaceItems.verstak.activity.workspace.title": "Activity",
"contributions.commands.verstak.activity.suggestWorklog.title": "List Possible Journal Entries",
"ui.loading": "Loading activity...",
"ui.title": "Activity",
"ui.workspaceTitle": "Activity · {workspace}",
"ui.clear": "Clear",
"ui.clearConfirmTitle": "Clear activity?",
"ui.clearGlobalWarning": "This permanently deletes all recorded activity and journal suggestions in every Deal.",
"ui.clearWorkspaceWarning": "This permanently deletes recorded activity for {workspace}. Activity in other Deals remains.",
"ui.confirmClear": "Clear activity",
"ui.cancel": "Cancel",
"ui.saveError": "Could not save activity. Please try again.",
"ui.cleared": "Activity cleared",
"ui.clearError": "Could not clear activity. Please try again.",
"ui.dismissError": "Could not dismiss the suggestion. Please try again.",
"ui.loadError": "Could not load activity. Please try again.",
"ui.commandsUnavailable": "Activity actions are unavailable. Please try again.",
"ui.subscriptionsUnavailable": "Activity updates are unavailable. Please try again.",
"ui.empty": "No activity events yet",
"ui.emptyHint": "File changes, browser captures, and conversions will appear here.",
"ui.details": "Details",
"ui.eventSource": "Event: {event}{source}",
"ui.dismissed": "Candidate dismissed",
"ui.candidates": "Possible journal entries",
"ui.candidate": "Possible journal entry",
"ui.review": "Review",
"ui.dismiss": "Dismiss",
"ui.event.workspaceSelected": "Deal selected",
"ui.event.fileOpened": "File opened",
"ui.event.fileChanged": "File changed",
"ui.event.noteEdited": "Note edited",
"ui.event.workSessionDetected": "Work session detected",
"ui.event.browserCaptureReceived": "Browser capture received",
"ui.event.pageCaptured": "Page captured",
"ui.event.selectionCaptured": "Selection captured",
"ui.event.linkCaptured": "Link captured",
"ui.event.fileCaptured": "File captured",
"ui.event.captureConverted": "Capture converted",
"ui.event.browserDomainActivity": "Browser domain activity",
"ui.event.activity": "Activity event",
"ui.kind.capture": "Capture",
"ui.kind.file": "File",
"ui.kind.note": "Note",
"ui.kind.deal": "Deal",
"ui.kind.work": "Work",
"ui.kind.activity": "Activity",
"ui.candidateDeal": "Deal: {deal}",
"ui.candidateTime": "Time: {time}",
"ui.candidateDuration": "Estimated duration: {minutes} min",
"ui.candidateActivities": "Activities: {count}",
"ui.includedActivities": "Included activities ({count})",
"ui.minutes": "{count} min",
"ui.eventCount.one": "{count} event",
"ui.eventCount.many": "{count} events",
"ui.listeningGlobal": "Listening for all activity",
"ui.listeningDeal": "Listening for Deal activity"
}
+62
View File
@@ -0,0 +1,62 @@
{
"manifest.name": "Активность",
"manifest.description": "Журнал публичных событий плагинов в Деле.",
"contributions.views.verstak.activity.view.title": "Активность",
"contributions.sidebarItems.verstak.activity.sidebar.title": "Активность",
"contributions.workspaceItems.verstak.activity.workspace.title": "Активность",
"contributions.commands.verstak.activity.suggestWorklog.title": "Показать возможные записи журнала",
"ui.loading": "Загрузка активности...",
"ui.title": "Активность",
"ui.workspaceTitle": "Активность · {workspace}",
"ui.clear": "Очистить",
"ui.clearConfirmTitle": "Очистить активность?",
"ui.clearGlobalWarning": "Будут безвозвратно удалены вся записанная активность и предложения для журнала во всех Делах.",
"ui.clearWorkspaceWarning": "Будет безвозвратно удалена записанная активность Дела «{workspace}». Активность других Дел останется.",
"ui.confirmClear": "Очистить активность",
"ui.cancel": "Отмена",
"ui.saveError": "Не удалось сохранить активность. Повторите попытку.",
"ui.cleared": "Активность очищена",
"ui.clearError": "Не удалось очистить активность. Повторите попытку.",
"ui.dismissError": "Не удалось отклонить предложение. Повторите попытку.",
"ui.loadError": "Не удалось загрузить активность. Повторите попытку.",
"ui.commandsUnavailable": "Действия с активностью недоступны. Повторите попытку.",
"ui.subscriptionsUnavailable": "Обновления активности недоступны. Повторите попытку.",
"ui.empty": "Событий активности пока нет",
"ui.emptyHint": "Здесь появятся изменения файлов, материалы из браузера и преобразования.",
"ui.details": "Подробнее",
"ui.eventSource": "Событие: {event}{source}",
"ui.dismissed": "Кандидат скрыт",
"ui.candidates": "Возможные записи журнала",
"ui.candidate": "Возможная запись журнала",
"ui.review": "Проверить",
"ui.dismiss": "Скрыть",
"ui.event.workspaceSelected": "Выбрано Дело",
"ui.event.fileOpened": "Открыт файл",
"ui.event.fileChanged": "Изменён файл",
"ui.event.noteEdited": "Изменена заметка",
"ui.event.workSessionDetected": "Обнаружена рабочая сессия",
"ui.event.browserCaptureReceived": "Получено сохранённое из браузера",
"ui.event.pageCaptured": "Сохранена страница",
"ui.event.selectionCaptured": "Сохранено выделение",
"ui.event.linkCaptured": "Сохранена ссылка",
"ui.event.fileCaptured": "Сохранён файл",
"ui.event.captureConverted": "Сохранённое преобразовано",
"ui.event.browserDomainActivity": "Активность на домене в браузере",
"ui.event.activity": "Событие активности",
"ui.kind.capture": "Сохранённое",
"ui.kind.file": "Файл",
"ui.kind.note": "Заметка",
"ui.kind.deal": "Дело",
"ui.kind.work": "Работа",
"ui.kind.activity": "Активность",
"ui.candidateDeal": "Дело: {deal}",
"ui.candidateTime": "Время: {time}",
"ui.candidateDuration": "Оценка длительности: {minutes} мин.",
"ui.candidateActivities": "Активностей: {count}",
"ui.includedActivities": "Учтённые активности ({count})",
"ui.minutes": "{count} мин.",
"ui.eventCount.one": "Событий: {count}",
"ui.eventCount.many": "Событий: {count}",
"ui.listeningGlobal": "Учёт всей активности включён",
"ui.listeningDeal": "Учёт активности Дела включён"
}
+2 -1
View File
@@ -4,7 +4,8 @@
"name": "Activity", "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": [
+376 -164
View File
@@ -1,5 +1,5 @@
/* =========================================================== /* ===========================================================
Browser Inbox Plugin — Verstak v2 Frontend Bundle Browser Plugin — Verstak v2 Frontend Bundle
Contract: window.VerstakPluginRegister(id, { components }) 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 = '';
+90
View File
@@ -0,0 +1,90 @@
{
"manifest.name": "Browser",
"manifest.description": "Global browser materials with explicit Deal assignment delivered through the local receiver event protocol.",
"contributions.views.verstak.browser-inbox.view.title": "Browser",
"contributions.sidebarItems.verstak.browser-inbox.sidebar.title": "Browser",
"contributions.workspaceItems.verstak.browser-inbox.workspace.title": "Browser",
"contributions.settingsPanels.verstak.browser-inbox.settings.title": "Browser",
"ui.connecting": "Connecting to receiver events...",
"ui.title": "Browser",
"ui.workspaceTitle": "Browser · {workspace}",
"ui.allCaptures": "All captures",
"ui.allDeals": "All Deals",
"ui.unassigned": "Unassigned",
"ui.unprocessed": "Unprocessed",
"ui.processed": "Processed",
"ui.search": "Search captures",
"ui.clear": "Clear",
"ui.assignedHere": "Assigned to this Deal",
"ui.selectCapture": "Select a capture to inspect it.",
"ui.kind": "Kind",
"ui.domain": "Domain",
"ui.captured": "Captured",
"ui.workspace": "Deal",
"ui.status": "Status",
"ui.createNote": "Create Note",
"ui.createLink": "Create Link",
"ui.createFile": "Create File",
"ui.delete": "Delete",
"ui.copyUrl": "Copy URL",
"ui.copyToken": "Copy Token",
"ui.rotateToken": "Rotate Token",
"ui.receiverUrl": "Receiver URL",
"ui.pairingToken": "Pairing Token",
"ui.loading": "Loading...",
"ui.clipboardUnavailable": "Clipboard unavailable",
"ui.copied": "{label} copied",
"ui.rotateConfirm": "Rotate pairing token?",
"ui.rotating": "Rotating...",
"ui.tokenRotated": "Token rotated",
"ui.saveError": "Could not update browser materials. Please try again.",
"ui.createNoteError": "Could not create the note. Please try again.",
"ui.createLinkError": "Could not create the link. Please try again.",
"ui.openLinkError": "Could not open the link. Please try again.",
"ui.createFileError": "Could not create the file. Please try again.",
"ui.loadError": "Could not load browser materials. Please try again.",
"ui.receiverError": "The browser receiver is unavailable. Please try again.",
"ui.pairingLoadError": "Could not load browser connection settings. Please try again.",
"ui.clipboardError": "Could not copy to the clipboard. Please try again.",
"ui.tokenRotateError": "Could not rotate the pairing token. Please try again.",
"ui.statusFilter": "Material status filter",
"ui.workspaceFilter": "Deal filter",
"ui.assignment": "Assign to Deal",
"ui.clearAssignment": "Clear assignment",
"ui.empty": "No browser materials yet. Send a page, selection, or link from the extension.",
"ui.emptyFiltered": "No materials match the current filters.",
"ui.untitledCapture": "Untitled material",
"ui.url": "URL",
"ui.browser": "Browser",
"ui.assignBeforeCreation": "Assign a Deal before creating a note, link, or file.",
"ui.markProcessed": "Mark processed",
"ui.markUnprocessed": "Mark unprocessed",
"ui.openLink": "Open link",
"ui.restore": "Restore to Inbox",
"ui.archive": "Archive",
"ui.deletePermanently": "Delete permanently",
"ui.captureReceived": "Material received",
"ui.captureLoadError": "Could not load the received material.",
"ui.inboxArchived": "Inbox archived",
"ui.workspaceCapturesArchived": "Deal materials archived",
"ui.assignedToWorkspace": "Material assigned to {workspace}",
"ui.captureUnassigned": "Material is unassigned",
"ui.captureArchived": "Material archived",
"ui.captureRestored": "Material restored to Inbox",
"ui.captureDeleted": "Material permanently deleted",
"ui.captureProcessed": "Material marked processed",
"ui.captureUnprocessed": "Material marked unprocessed",
"ui.creatingNote": "Creating note...",
"ui.noteCreated": "Note created: {path}",
"ui.creatingLink": "Creating link...",
"ui.linkCreated": "Link created",
"ui.creatingFile": "Creating file...",
"ui.fileCreated": "File created: {path}",
"ui.receiverReadyAll": "Receiver ready for all Deals",
"ui.receiverReadyWorkspace": "Receiver ready for this Deal",
"ui.items.one": "{count} item",
"ui.items.few": "{count} items",
"ui.items.many": "{count} items",
"ui.items.other": "{count} items",
"ui.items.filtered": "{visible} of {total}"
}
+90
View File
@@ -0,0 +1,90 @@
{
"manifest.name": "Браузер",
"manifest.description": "Общие материалы из браузера с явным назначением Дела через локальный протокол приёма.",
"contributions.views.verstak.browser-inbox.view.title": "Браузер",
"contributions.sidebarItems.verstak.browser-inbox.sidebar.title": "Браузер",
"contributions.workspaceItems.verstak.browser-inbox.workspace.title": "Браузер",
"contributions.settingsPanels.verstak.browser-inbox.settings.title": "Браузер",
"ui.connecting": "Подключение к событиям приёмника...",
"ui.title": "Браузер",
"ui.workspaceTitle": "Браузер · {workspace}",
"ui.allCaptures": "Все материалы",
"ui.allDeals": "Все Дела",
"ui.unassigned": "Не назначено",
"ui.unprocessed": "Не обработано",
"ui.processed": "Обработано",
"ui.search": "Поиск материалов",
"ui.clear": "Очистить",
"ui.assignedHere": "Назначено этому Делу",
"ui.selectCapture": "Выберите материал для просмотра.",
"ui.kind": "Тип",
"ui.domain": "Домен",
"ui.captured": "Получено",
"ui.workspace": "Дело",
"ui.status": "Состояние",
"ui.createNote": "Создать заметку",
"ui.createLink": "Создать ссылку",
"ui.createFile": "Создать файл",
"ui.delete": "Удалить",
"ui.copyUrl": "Копировать URL",
"ui.copyToken": "Копировать токен",
"ui.rotateToken": "Сменить токен",
"ui.receiverUrl": "URL приёмника",
"ui.pairingToken": "Токен сопряжения",
"ui.loading": "Загрузка...",
"ui.clipboardUnavailable": "Буфер обмена недоступен",
"ui.copied": "{label} скопирован",
"ui.rotateConfirm": "Сменить токен сопряжения?",
"ui.rotating": "Смена токена...",
"ui.tokenRotated": "Токен изменён",
"ui.saveError": "Не удалось обновить материалы браузера. Повторите попытку.",
"ui.createNoteError": "Не удалось создать заметку. Повторите попытку.",
"ui.createLinkError": "Не удалось создать ссылку. Повторите попытку.",
"ui.openLinkError": "Не удалось открыть ссылку. Повторите попытку.",
"ui.createFileError": "Не удалось создать файл. Повторите попытку.",
"ui.loadError": "Не удалось загрузить материалы браузера. Повторите попытку.",
"ui.receiverError": "Приёмник браузера недоступен. Повторите попытку.",
"ui.pairingLoadError": "Не удалось загрузить параметры подключения браузера. Повторите попытку.",
"ui.clipboardError": "Не удалось скопировать в буфер обмена. Повторите попытку.",
"ui.tokenRotateError": "Не удалось сменить токен сопряжения. Повторите попытку.",
"ui.statusFilter": "Фильтр состояния материала",
"ui.workspaceFilter": "Фильтр Дела",
"ui.assignment": "Назначить Делу",
"ui.clearAssignment": "Снять назначение",
"ui.empty": "Пока нет материалов из браузера. Отправьте страницу, выделенный текст или ссылку с помощью расширения.",
"ui.emptyFiltered": "Нет материалов по текущим фильтрам.",
"ui.untitledCapture": "Материал без названия",
"ui.url": "URL",
"ui.browser": "Браузер",
"ui.assignBeforeCreation": "Назначьте Дело перед созданием заметки, ссылки или файла.",
"ui.markProcessed": "Отметить обработанным",
"ui.markUnprocessed": "Отметить необработанным",
"ui.openLink": "Открыть ссылку",
"ui.restore": "Вернуть во входящие",
"ui.archive": "Архивировать",
"ui.deletePermanently": "Удалить навсегда",
"ui.captureReceived": "Материал получен",
"ui.captureLoadError": "Не удалось загрузить полученный материал.",
"ui.inboxArchived": "Входящие архивированы",
"ui.workspaceCapturesArchived": "Материалы Дела архивированы",
"ui.assignedToWorkspace": "Материал назначен Делу «{workspace}»",
"ui.captureUnassigned": "Материал не назначен",
"ui.captureArchived": "Материал архивирован",
"ui.captureRestored": "Материал возвращён во входящие",
"ui.captureDeleted": "Материал удалён навсегда",
"ui.captureProcessed": "Материал отмечен обработанным",
"ui.captureUnprocessed": "Материал отмечен необработанным",
"ui.creatingNote": "Создание заметки...",
"ui.noteCreated": "Заметка создана: {path}",
"ui.creatingLink": "Создание ссылки...",
"ui.linkCreated": "Ссылка создана",
"ui.creatingFile": "Создание файла...",
"ui.fileCreated": "Файл создан: {path}",
"ui.receiverReadyAll": "Приёмник готов для всех Дел",
"ui.receiverReadyWorkspace": "Приёмник готов для этого Дела",
"ui.items.one": "{count} материал",
"ui.items.few": "{count} материала",
"ui.items.many": "{count} материалов",
"ui.items.other": "{count} материала",
"ui.items.filtered": "{visible} из {total}"
}
+7 -6
View File
@@ -1,10 +1,11 @@
{ {
"schemaVersion": 1, "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"
} }
] ]
+62 -37
View File
@@ -18,7 +18,6 @@
'.de-root{display:flex;flex-direction:column;height:100%;min-height:0;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;color:#e0e0e0;background:#0d0d1a}', '.de-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);
}; };
}, },
+33
View File
@@ -0,0 +1,33 @@
{
"manifest.name": "Default Editor",
"manifest.description": "Built-in text and markdown editor/viewer for Verstak. Provides openProviders for generic text, generic markdown, and notes-context markdown files.",
"contributions.openProviders.verstak.default-editor.text.title": "Default Text Editor",
"contributions.openProviders.verstak.default-editor.markdown.title": "Default Markdown Editor",
"contributions.openProviders.verstak.default-editor.notes-markdown.title": "Default Notes Markdown Editor",
"ui.notesContext": "notes context",
"ui.note": "Note",
"ui.edit": "Edit",
"ui.preview": "Preview",
"ui.split": "Split",
"ui.reload": "Reload",
"ui.save": "Save",
"ui.notesInfo": "Notes context active. Note actions, backlinks, and graph tools are reserved for the future Notes plugin.",
"ui.saving": "Saving...",
"ui.saveError": "Error saving",
"ui.modified": "Modified",
"ui.savedAt": "Saved {time}",
"ui.saved": "Saved",
"ui.discardConfirm": "Discard unsaved changes and reload from disk?",
"ui.loading": "Loading...",
"ui.loadFailed": "Could not load the file. Please try again.",
"ui.md.heading": "Heading",
"ui.md.bold": "Bold",
"ui.md.italic": "Italic",
"ui.md.link": "Link",
"ui.md.inlineCode": "Inline code",
"ui.md.codeBlock": "Code block",
"ui.md.bulletList": "Bullet list",
"ui.md.numberedList": "Numbered list",
"ui.md.quote": "Quote",
"ui.md.taskItem": "Task item"
}
+33
View File
@@ -0,0 +1,33 @@
{
"manifest.name": "Стандартный редактор",
"manifest.description": "Встроенный редактор и просмотрщик текста и Markdown для Верстака.",
"contributions.openProviders.verstak.default-editor.text.title": "Стандартный текстовый редактор",
"contributions.openProviders.verstak.default-editor.markdown.title": "Стандартный редактор Markdown",
"contributions.openProviders.verstak.default-editor.notes-markdown.title": "Стандартный редактор Markdown-заметок",
"ui.notesContext": "контекст заметки",
"ui.note": "Заметка",
"ui.edit": "Редактировать",
"ui.preview": "Просмотр",
"ui.split": "Разделить",
"ui.reload": "Перезагрузить",
"ui.save": "Сохранить",
"ui.notesInfo": "Активен контекст заметки. Действия, обратные ссылки и граф будут развиваться в плагине заметок.",
"ui.saving": "Сохранение...",
"ui.saveError": "Ошибка сохранения",
"ui.modified": "Изменён",
"ui.savedAt": "Сохранено {time}",
"ui.saved": "Сохранено",
"ui.discardConfirm": "Отменить несохранённые изменения и перечитать файл с диска?",
"ui.loading": "Загрузка...",
"ui.loadFailed": "Не удалось загрузить файл. Повторите попытку.",
"ui.md.heading": "Заголовок",
"ui.md.bold": "Жирный текст",
"ui.md.italic": "Курсив",
"ui.md.link": "Ссылка",
"ui.md.inlineCode": "Встроенный код",
"ui.md.codeBlock": "Блок кода",
"ui.md.bulletList": "Маркированный список",
"ui.md.numberedList": "Нумерованный список",
"ui.md.quote": "Цитата",
"ui.md.taskItem": "Пункт задачи"
}
+1
View File
@@ -5,6 +5,7 @@
"version": "0.1.0", "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": [
+14 -4
View File
@@ -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 = '';
+9
View File
@@ -0,0 +1,9 @@
{
"manifest.name": "File Preview",
"manifest.description": "Read-only inline image preview provider for vault files.",
"contributions.openProviders.verstak.file-preview.image.title": "Image Preview",
"ui.openExternal": "Open External",
"ui.preview": "Preview",
"ui.loading": "Loading...",
"ui.error": "Could not preview this file. Please try again."
}
+9
View File
@@ -0,0 +1,9 @@
{
"manifest.name": "Просмотр файлов",
"manifest.description": "Встроенный просмотр изображений из хранилища без редактирования.",
"contributions.openProviders.verstak.file-preview.image.title": "Просмотр изображения",
"ui.openExternal": "Открыть во внешнем приложении",
"ui.preview": "Просмотр",
"ui.loading": "Загрузка...",
"ui.error": "Не удалось открыть предварительный просмотр файла. Повторите попытку."
}
+1
View File
@@ -5,6 +5,7 @@
"version": "0.1.0", "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": [
+262 -128
View File
@@ -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}',
@@ -320,15 +322,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 +349,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 +361,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 +378,18 @@
} }
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 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 +411,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 +447,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;
@@ -598,27 +593,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 +628,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);
@@ -687,9 +682,9 @@
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 +696,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 +704,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 +803,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 +949,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 +1006,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 +1027,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 +1060,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 +1089,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 +1151,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 +1264,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 +1590,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 +1623,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);
+59
View File
@@ -0,0 +1,59 @@
{
"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.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."
}
+59
View File
@@ -0,0 +1,59 @@
{
"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.create": "Создать",
"ui.cancel": "Отмена",
"ui.createFolderTitle": "Создать папку",
"ui.createMarkdownTitle": "Создать Markdown-файл",
"ui.createTextTitle": "Создать текстовый файл",
"ui.renameTitle": "Переименовать {name}",
"ui.folderName": "Название папки",
"ui.markdownFileName": "Название Markdown-файла",
"ui.textFileName": "Название текстового файла",
"ui.fileName": "Название файла или папки",
"ui.nameRequired": "Введите название.",
"ui.invalidCharacters": "В названии есть недопустимые символы.",
"ui.invalidName": "Введите корректное название.",
"ui.nameDiffersOnlyByCase": "Название отличается только регистром букв.",
"ui.nameConflict": "Элемент с таким названием уже существует.",
"ui.externalFailed": "Не удалось выполнить действие «{action}».",
"ui.vaultRelativePath": "Путь относительно хранилища:",
"ui.copyPath": "Скопировать путь",
"ui.close": "Закрыть",
"ui.trashConfirm": "Переместить «{name}» в корзину?",
"ui.trashManyConfirm": "Переместить в корзину {count} элементов?",
"ui.clipboardDifferentDeal": "Элементы буфера обмена принадлежат другому Делу.",
"ui.emptyFolder": "Папка пуста",
"ui.noMatches": "Совпадений нет",
"ui.clearFilter": "Сбросить фильтр",
"ui.loading": "Загрузка...",
"ui.loadFailed": "Не удалось загрузить файлы. Повторите попытку.",
"ui.createError": "Не удалось создать этот элемент. Повторите попытку.",
"ui.renameError": "Не удалось переименовать этот элемент. Повторите попытку.",
"ui.trashError": "Не удалось переместить элемент в корзину. Повторите попытку."
}
+2 -1
View File
@@ -4,7 +4,8 @@
"name": "Files", "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": [
+136 -53
View File
@@ -31,6 +31,7 @@
'.journal-status.error{display:inline-flex;border:1px solid rgba(233,69,96,.45);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-danger-muted,rgba(233,69,96,.14));color:#ffc6ce;padding:.18rem .4rem}', '.journal-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) {
+63
View File
@@ -0,0 +1,63 @@
{
"manifest.name": "Journal",
"manifest.description": "Deal-scoped journal with user-authored entries and optional Activity links.",
"contributions.views.verstak.journal.view.title": "Journal",
"contributions.sidebarItems.verstak.journal.sidebar.title": "Journal",
"contributions.workspaceItems.verstak.journal.workspace.title": "Journal",
"ui.loading": "Loading journal...",
"ui.title": "Journal",
"ui.workspaceTitle": "Journal · {workspace}",
"ui.add": "Add",
"ui.saveError": "Could not save journal. Please try again.",
"ui.aggregating": "Aggregating worklogs",
"ui.loadError": "Could not load journal. Please try again.",
"ui.ready": "Ready",
"ui.workItem": "Work item",
"ui.body": "Body",
"ui.editEntry": "Edit journal entry",
"ui.reviewCandidate": "Review possible journal entry",
"ui.fromTodo": "Create journal entry from completed todo",
"ui.addEntry": "Add journal entry",
"ui.date": "Date",
"ui.minutes": "Minutes",
"ui.fieldTitle": "Title",
"ui.billable": "Billable",
"ui.cancel": "Cancel",
"ui.saveChanges": "Save changes",
"ui.addEntryShort": "Add entry",
"ui.titleRequired": "Title is required",
"ui.deleted": "Entry deleted",
"ui.empty": "No worklog entries yet.",
"ui.edit": "Edit",
"ui.delete": "Delete",
"ui.entryCount.one": "{count} entry",
"ui.entryCount.many": "{count} entries",
"ui.candidate.title": "Possible journal entry",
"ui.candidate.deal": "Deal: {deal}",
"ui.candidate.time": "Time: {start} – {end}",
"ui.candidate.duration": "Estimated duration: {minutes} min",
"ui.candidate.activities": "Activities: {count}",
"ui.candidate.linkedActivities": "Linked activities",
"ui.candidate.duplicate": "A journal entry already references this candidate",
"ui.todo.title": "Completed todo",
"ui.todo.completed": "Completed: {time}",
"ui.todo.duplicate": "A journal entry already references this todo",
"ui.activity.captureLink": "Link captured",
"ui.activity.captureSelection": "Selection captured",
"ui.activity.capturePage": "Page captured",
"ui.activity.fileCreated": "File created",
"ui.activity.fileUpdated": "File updated",
"ui.activity.fileDeleted": "File deleted",
"ui.activity.noteSaved": "Note saved",
"ui.activity.dealCreated": "Deal created",
"ui.activity.dealRenamed": "Deal renamed",
"ui.activity.generic": "Activity",
"ui.updated": "Entry updated",
"ui.added": "Entry added",
"ui.minutesValue": "{minutes} min",
"ui.meta.billable": "billable",
"ui.meta.nonBillable": "non-billable",
"ui.meta.activities.one": "{count} linked activity",
"ui.meta.activities.many": "{count} linked activities",
"ui.meta.todo": "linked todo"
}
+63
View File
@@ -0,0 +1,63 @@
{
"manifest.name": "Журнал",
"manifest.description": "Журнал Дела с ручными записями и необязательными связями с активностью.",
"contributions.views.verstak.journal.view.title": "Журнал",
"contributions.sidebarItems.verstak.journal.sidebar.title": "Журнал",
"contributions.workspaceItems.verstak.journal.workspace.title": "Журнал",
"ui.loading": "Загрузка журнала...",
"ui.title": "Журнал",
"ui.workspaceTitle": "Журнал · {workspace}",
"ui.add": "Добавить",
"ui.saveError": "Не удалось сохранить журнал. Повторите попытку.",
"ui.aggregating": "Сбор записей о работе",
"ui.loadError": "Не удалось загрузить журнал. Повторите попытку.",
"ui.ready": "Готово",
"ui.workItem": "Выполненная работа",
"ui.body": "Описание",
"ui.editEntry": "Изменить запись журнала",
"ui.reviewCandidate": "Проверить возможную запись журнала",
"ui.fromTodo": "Создать запись журнала из выполненной задачи",
"ui.addEntry": "Добавить запись журнала",
"ui.date": "Дата",
"ui.minutes": "Минуты",
"ui.fieldTitle": "Название",
"ui.billable": "Оплачиваемая работа",
"ui.cancel": "Отмена",
"ui.saveChanges": "Сохранить изменения",
"ui.addEntryShort": "Добавить запись",
"ui.titleRequired": "Введите название",
"ui.deleted": "Запись удалена",
"ui.empty": "Записей о работе пока нет.",
"ui.edit": "Изменить",
"ui.delete": "Удалить",
"ui.entryCount.one": "{count} запись",
"ui.entryCount.many": "Записей: {count}",
"ui.candidate.title": "Возможная запись журнала",
"ui.candidate.deal": "Дело: {deal}",
"ui.candidate.time": "Время: {start} – {end}",
"ui.candidate.duration": "Расчётная длительность: {minutes} мин",
"ui.candidate.activities": "Активностей: {count}",
"ui.candidate.linkedActivities": "Связанные активности",
"ui.candidate.duplicate": "Запись журнала уже ссылается на этого кандидата",
"ui.todo.title": "Выполненная задача",
"ui.todo.completed": "Выполнена: {time}",
"ui.todo.duplicate": "Запись журнала уже ссылается на эту задачу",
"ui.activity.captureLink": "Сохранена ссылка",
"ui.activity.captureSelection": "Захвачено выделение",
"ui.activity.capturePage": "Сохранена страница",
"ui.activity.fileCreated": "Создан файл",
"ui.activity.fileUpdated": "Изменён файл",
"ui.activity.fileDeleted": "Удалён файл",
"ui.activity.noteSaved": "Сохранена заметка",
"ui.activity.dealCreated": "Создано Дело",
"ui.activity.dealRenamed": "Переименовано Дело",
"ui.activity.generic": "Активность",
"ui.updated": "Запись обновлена",
"ui.added": "Запись добавлена",
"ui.minutesValue": "{minutes} мин",
"ui.meta.billable": "оплачиваемая",
"ui.meta.nonBillable": "неоплачиваемая",
"ui.meta.activities.one": "{count} связанная активность",
"ui.meta.activities.many": "Связанных активностей: {count}",
"ui.meta.todo": "связанная задача"
}
+4 -1
View File
@@ -4,7 +4,8 @@
"name": "Journal", "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"
], ],
+199 -96
View File
@@ -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();
}; };
}, },
+40
View File
@@ -0,0 +1,40 @@
{
"manifest.name": "Notes",
"manifest.description": "Deal-scoped notes manager for Markdown files in canonical Notes folders with create, rename, trash, and Workbench integration.",
"contributions.workspaceItems.verstak.notes.workspace.title": "Notes",
"ui.newNote": "New Note",
"ui.filter": "Filter notes",
"ui.title": "Notes in {workspace}",
"ui.noteTitle": "Note title",
"ui.createTitle": "Create note",
"ui.renameTitle": "Rename note",
"ui.titleRequired": "Enter a note title.",
"ui.create": "Create",
"ui.cancel": "Cancel",
"ui.newTitle": "New title",
"ui.rename": "Rename",
"ui.loading": "Loading...",
"ui.empty": "No notes yet",
"ui.noMatches": "No matching notes",
"ui.clearFilterHint": "Clear the filter to show all notes",
"ui.open": "Open",
"ui.trash": "Move to Trash",
"ui.emptyHint": "Click \"New Note\" to create one",
"ui.creating": "Creating note...",
"ui.created": "Note created",
"ui.renaming": "Renaming...",
"ui.renamed": "Note renamed",
"ui.trashing": "Moving note to trash...",
"ui.trashed": "Note moved to trash",
"ui.conflictTitle": "Name Conflict",
"ui.conflictMessage": "A note with the title \"{title}\" already exists.",
"ui.existingFile": " Existing file: {path}.",
"ui.chooseDifferent": " Please choose a different title.",
"ui.trashTitle": "Move Note to Trash",
"ui.trashConfirm": "Move \"{title}\" to trash?",
"ui.loadError": "Could not load notes. Please try again.",
"ui.actionError": "Could not complete this note action. Please try again.",
"ui.createError": "Could not create the note. Please try again.",
"ui.renameError": "Could not rename the note. Please try again.",
"ui.trashError": "Could not move the note to trash. Please try again."
}
+40
View File
@@ -0,0 +1,40 @@
{
"manifest.name": "Заметки",
"manifest.description": "Менеджер Markdown-заметок Дела с созданием, переименованием и корзиной.",
"contributions.workspaceItems.verstak.notes.workspace.title": "Заметки",
"ui.newNote": "Новая заметка",
"ui.filter": "Фильтр заметок",
"ui.title": "Заметки · {workspace}",
"ui.noteTitle": "Название заметки",
"ui.createTitle": "Создать заметку",
"ui.renameTitle": "Переименовать заметку",
"ui.titleRequired": "Введите название заметки.",
"ui.create": "Создать",
"ui.cancel": "Отмена",
"ui.newTitle": "Новое название",
"ui.rename": "Переименовать",
"ui.loading": "Загрузка...",
"ui.empty": "Заметок пока нет",
"ui.noMatches": "Подходящих заметок нет",
"ui.clearFilterHint": "Сбросьте фильтр, чтобы показать все заметки",
"ui.open": "Открыть",
"ui.trash": "Переместить в корзину",
"ui.emptyHint": "Нажмите «Новая заметка», чтобы создать первую",
"ui.creating": "Создание заметки...",
"ui.created": "Заметка создана",
"ui.renaming": "Переименование...",
"ui.renamed": "Заметка переименована",
"ui.trashing": "Перемещение заметки в корзину...",
"ui.trashed": "Заметка перемещена в корзину",
"ui.conflictTitle": "Конфликт названия",
"ui.conflictMessage": "Заметка с названием «{title}» уже существует.",
"ui.existingFile": " Существующий файл: {path}.",
"ui.chooseDifferent": " Выберите другое название.",
"ui.trashTitle": "Переместить заметку в корзину",
"ui.trashConfirm": "Переместить «{title}» в корзину?",
"ui.loadError": "Не удалось загрузить заметки. Повторите попытку.",
"ui.actionError": "Не удалось выполнить действие с заметкой. Повторите попытку.",
"ui.createError": "Не удалось создать заметку. Повторите попытку.",
"ui.renameError": "Не удалось переименовать заметку. Повторите попытку.",
"ui.trashError": "Не удалось переместить заметку в корзину. Повторите попытку."
}
+2 -1
View File
@@ -4,7 +4,8 @@
"name": "Notes", "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": [
+164 -115
View File
@@ -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;
}, },
}; };
+111
View File
@@ -0,0 +1,111 @@
{
"manifest.name": "Platform Test",
"manifest.description": "Runtime test plugin for verifying the Verstak platform: manifest loading, capability registration, contribution injection, event bus, and UI rendering.",
"contributions.views.verstak.platform-test.diagnostics.title": "Platform Diagnostics",
"contributions.commands.verstak.platform-test.run-tests.title": "Run Platform Tests",
"contributions.commands.verstak.platform-test.show-version.title": "Show Version Info",
"contributions.sidebarItems.verstak.platform-test.sidebar.title": "Platform Test",
"contributions.statusBarItems.verstak.platform-test.status.label": "[OK] All Tests Pass",
"contributions.settingsPanels.verstak.platform-test.settings.title": "Platform Test Settings",
"contributions.openProviders.verstak.platform-test.markdown-diagnostic.title": "Platform Test Markdown Diagnostic",
"ui.diagnostics": "Platform Diagnostics",
"ui.bundleLoaded": "Plugin Bundle Loaded",
"ui.apiBridge": "API Bridge",
"ui.testResults": "Test Results",
"ui.capabilities": "Granted Capabilities",
"ui.pluginInfo": "Plugin Info",
"ui.hostMethods": "Host Methods",
"ui.mouseInspector": "Mouse Event Inspector",
"ui.settings": "Platform Test Settings",
"ui.increment": "+ Increment",
"ui.decrement": "− Decrement",
"ui.reset": "Reset",
"ui.counter": "Interactive Counter",
"ui.demoSettings": "Demo Settings",
"ui.savedLoading": "Saved setting: loading...",
"ui.capabilitiesLoading": "Capabilities: loading...",
"ui.commandRegistering": "Command: registering...",
"ui.eventSubscribing": "Event: subscribing...",
"ui.filesRunning": "Files: running...",
"ui.filesPathChecking": "Files error path: checking...",
"ui.workbenchReady": "Workbench: ready",
"ui.workbenchOpening": "Workbench: opening...",
"ui.workbenchOpened": "Workbench: opened {path} with {provider}",
"ui.workbenchError": "Workbench error: {error}",
"ui.openTextDiagnostic": "Open Text Diagnostic",
"ui.openMarkdownDiagnostic": "Open Markdown Diagnostic",
"ui.openNotesDiagnostic": "Open Notes Diagnostic",
"ui.savedSetting": "Saved setting",
"ui.saving": "Saving...",
"ui.savedSettingValue": "Saved setting: {value}",
"ui.saved": "Saved",
"ui.errorValue": "Error: {error}",
"ui.saveSetting": "Save Setting",
"ui.settingsError": "Settings error: {error}",
"ui.capabilitiesAvailable": "Capabilities: {count} available",
"ui.capabilitiesError": "Capabilities error: {error}",
"ui.bundleCapabilityAvailable": "Frontend Bundle Loaded | capability available",
"ui.bundleCapabilityMissing": "Frontend Bundle Loaded | capability missing",
"ui.capabilityError": "Capability error: {error}",
"ui.commandResult": "Command: {status} {version} from {source}",
"ui.commandError": "Command error: {error}",
"ui.eventReceived": "Event: received {message}",
"ui.eventError": "Event error: {error}",
"ui.filesPassed": "Files: wrote/read/listed/moved/trashed",
"ui.filesError": "Files error: {error}",
"ui.filesPathAllowed": "Files error path: unexpectedly allowed",
"ui.filesPathWrongError": "Files error path: wrong error {error}",
"ui.filesPathRejected": "Files error path: rejected reserved-path",
"ui.persistedSetting": "Persisted setting",
"ui.newValue": "New value",
"ui.capabilitiesLabel": "Capabilities",
"ui.commandRuntime": "Command runtime",
"ui.eventRuntime": "Event runtime",
"ui.filesRuntime": "Files runtime",
"ui.filesReservedPath": "Files reserved path",
"ui.workbenchRouting": "Workbench routing",
"ui.passed": "Passed",
"ui.failed": "Failed",
"ui.total": "Total",
"ui.successRate": "Success Rate",
"ui.checking": "Checking...",
"ui.available": "✓ Available",
"ui.unavailable": "— Unavailable",
"ui.error": "Error",
"ui.ready": "✓ Ready",
"ui.missing": "✗ Missing",
"ui.loaded": "Loaded ✓",
"ui.info.pluginId": "Plugin ID",
"ui.info.bundleStatus": "Bundle Status",
"ui.info.registration": "Registration Scheme",
"ui.info.components": "Components",
"ui.info.container": "Container",
"ui.test.registration": "Plugin Registration",
"ui.test.platformCapability": "Capability: verstak/platform-test/v1",
"ui.test.diagnosticsCapability": "Capability: verstak/diagnostics/v1",
"ui.test.filesCapability": "Capability: verstak/core/files/v1",
"ui.test.workbenchCapability": "Capability: verstak/core/workbench/v1",
"ui.test.apiContract": "API Contract Compliance",
"ui.pass": "✓ PASS",
"ui.fail": "✗ FAIL",
"ui.noMouseEvents": "(no events captured)",
"ui.stopCapture": "■ Stop Capture",
"ui.startCapture": "▶ Start Capture",
"ui.clear": "Clear",
"ui.copyJson": "Copy JSON",
"ui.copied": "✓ Copied!",
"ui.mouseHint": "Captures ALL mouse/pointer events on window. Press back/forward buttons to see what WebKitGTK reports.",
"ui.eventsCount": "Events:",
"ui.settingsInfo": "Settings panel loaded from the plugin frontend bundle through the VerstakPluginRegister contract.",
"ui.counterClicks": "clicks (session only, no persistence)",
"ui.counterHint": "This counter is a local demo. State is not persisted — refreshing resets it.",
"ui.setting.autoRun": "Auto-run on load",
"ui.setting.verbose": "Verbose logging",
"ui.setting.theme": "Theme override",
"ui.setting.notifications": "Notifications",
"ui.value.true": "true",
"ui.value.false": "false",
"ui.value.inherit": "inherit",
"ui.value.enabled": "enabled",
"ui.settingsHint": "Use api.settings.read() / api.settings.write() for persisted settings."
}
+111
View File
@@ -0,0 +1,111 @@
{
"manifest.name": "Тест платформы",
"manifest.description": "Диагностический плагин для проверки загрузки manifest, capabilities, contributions, шины событий и интерфейса Верстака.",
"contributions.views.verstak.platform-test.diagnostics.title": "Диагностика платформы",
"contributions.commands.verstak.platform-test.run-tests.title": "Запустить тесты платформы",
"contributions.commands.verstak.platform-test.show-version.title": "Показать сведения о версии",
"contributions.sidebarItems.verstak.platform-test.sidebar.title": "Тест платформы",
"contributions.statusBarItems.verstak.platform-test.status.label": "[OK] Все тесты пройдены",
"contributions.settingsPanels.verstak.platform-test.settings.title": "Настройки теста платформы",
"contributions.openProviders.verstak.platform-test.markdown-diagnostic.title": "Диагностика Markdown платформы",
"ui.diagnostics": "Диагностика платформы",
"ui.bundleLoaded": "Пакет плагина загружен",
"ui.apiBridge": "Мост API",
"ui.testResults": "Результаты тестов",
"ui.capabilities": "Предоставленные возможности",
"ui.pluginInfo": "Сведения о плагине",
"ui.hostMethods": "Методы хоста",
"ui.mouseInspector": "Инспектор событий мыши",
"ui.settings": "Настройки теста платформы",
"ui.increment": "+ Увеличить",
"ui.decrement": "− Уменьшить",
"ui.reset": "Сбросить",
"ui.counter": "Интерактивный счётчик",
"ui.demoSettings": "Демонстрационные настройки",
"ui.savedLoading": "Загрузка сохранённой настройки...",
"ui.capabilitiesLoading": "Загрузка возможностей...",
"ui.commandRegistering": "Регистрация команды...",
"ui.eventSubscribing": "Подписка на событие...",
"ui.filesRunning": "Проверка файлов...",
"ui.filesPathChecking": "Проверка запрещённого пути...",
"ui.workbenchReady": "Workbench готов",
"ui.workbenchOpening": "Открытие Workbench...",
"ui.workbenchOpened": "Workbench открыл {path} через {provider}",
"ui.workbenchError": "Ошибка Workbench: {error}",
"ui.openTextDiagnostic": "Открыть текстовую диагностику",
"ui.openMarkdownDiagnostic": "Открыть диагностику Markdown",
"ui.openNotesDiagnostic": "Открыть диагностику заметок",
"ui.savedSetting": "Сохранённая настройка",
"ui.saving": "Сохранение...",
"ui.savedSettingValue": "Сохранённая настройка: {value}",
"ui.saved": "Сохранено",
"ui.errorValue": "Ошибка: {error}",
"ui.saveSetting": "Сохранить настройку",
"ui.settingsError": "Ошибка настроек: {error}",
"ui.capabilitiesAvailable": "Доступно возможностей: {count}",
"ui.capabilitiesError": "Ошибка возможностей: {error}",
"ui.bundleCapabilityAvailable": "Frontend bundle загружен | capability доступна",
"ui.bundleCapabilityMissing": "Frontend bundle загружен | capability отсутствует",
"ui.capabilityError": "Ошибка capability: {error}",
"ui.commandResult": "Команда: {status} {version}, источник: {source}",
"ui.commandError": "Ошибка команды: {error}",
"ui.eventReceived": "Получено событие: {message}",
"ui.eventError": "Ошибка события: {error}",
"ui.filesPassed": "Файлы: запись/чтение/список/перемещение/удаление выполнены",
"ui.filesError": "Ошибка файлов: {error}",
"ui.filesPathAllowed": "Ошибка: запрещённый путь неожиданно разрешён",
"ui.filesPathWrongError": "Неверная ошибка запрещённого пути: {error}",
"ui.filesPathRejected": "Зарезервированный путь отклонён",
"ui.persistedSetting": "Сохранённая настройка",
"ui.newValue": "Новое значение",
"ui.capabilitiesLabel": "Возможности",
"ui.commandRuntime": "Выполнение команд",
"ui.eventRuntime": "Обработка событий",
"ui.filesRuntime": "Работа с файлами",
"ui.filesReservedPath": "Зарезервированный путь",
"ui.workbenchRouting": "Маршрутизация Workbench",
"ui.passed": "Пройдено",
"ui.failed": "Ошибок",
"ui.total": "Всего",
"ui.successRate": "Успешность",
"ui.checking": "Проверка...",
"ui.available": "✓ Доступно",
"ui.unavailable": "— Недоступно",
"ui.error": "Ошибка",
"ui.ready": "✓ Готово",
"ui.missing": "✗ Отсутствует",
"ui.loaded": "Загружено ✓",
"ui.info.pluginId": "ID плагина",
"ui.info.bundleStatus": "Состояние bundle",
"ui.info.registration": "Схема регистрации",
"ui.info.components": "Компоненты",
"ui.info.container": "Контейнер",
"ui.test.registration": "Регистрация плагина",
"ui.test.platformCapability": "Capability: verstak/platform-test/v1",
"ui.test.diagnosticsCapability": "Capability: verstak/diagnostics/v1",
"ui.test.filesCapability": "Capability: verstak/core/files/v1",
"ui.test.workbenchCapability": "Capability: verstak/core/workbench/v1",
"ui.test.apiContract": "Соответствие контракту API",
"ui.pass": "✓ ПРОЙДЕНО",
"ui.fail": "✗ ОШИБКА",
"ui.noMouseEvents": "(события не зафиксированы)",
"ui.stopCapture": "■ Остановить запись",
"ui.startCapture": "▶ Начать запись",
"ui.clear": "Очистить",
"ui.copyJson": "Копировать JSON",
"ui.copied": "✓ Скопировано!",
"ui.mouseHint": "Записывает все события мыши и указателя в окне. Нажмите кнопки назад/вперёд, чтобы увидеть события WebKitGTK.",
"ui.eventsCount": "События:",
"ui.settingsInfo": "Панель настроек загружена из frontend bundle плагина через контракт VerstakPluginRegister.",
"ui.counterClicks": "нажатий (только в текущем сеансе)",
"ui.counterHint": "Это локальный демонстрационный счётчик. После обновления его значение сбрасывается.",
"ui.setting.autoRun": "Автозапуск при загрузке",
"ui.setting.verbose": "Подробный журнал",
"ui.setting.theme": "Переопределение темы",
"ui.setting.notifications": "Уведомления",
"ui.value.true": "да",
"ui.value.false": "нет",
"ui.value.inherit": "наследовать",
"ui.value.enabled": "включены",
"ui.settingsHint": "Для постоянных настроек используйте api.settings.read() / api.settings.write()."
}
+1
View File
@@ -5,6 +5,7 @@
"version": "0.1.0", "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": [
+30 -14
View File
@@ -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;
+18
View File
@@ -0,0 +1,18 @@
{
"manifest.name": "Search",
"manifest.description": "Deal-scoped vault text search provider.",
"contributions.workspaceItems.verstak.search.workspace.title": "Search",
"contributions.commands.verstak.search.searchVaultText.title": "Search Vault Text",
"contributions.searchProviders.verstak.search.vault-text.label": "Vault Text Search",
"ui.minChars": "Enter at least 2 characters.",
"ui.placeholder": "Search files, folders, text",
"ui.vault": "Vault",
"ui.searching": "Searching...",
"ui.search": "Search",
"ui.providersFailed": "Some plugin search providers did not respond",
"ui.providerUnavailable": "A search provider is unavailable.",
"ui.searchError": "Could not search the vault. Please try again.",
"ui.noResults": "No results",
"ui.open": "Open",
"ui.count": "{count} result(s)"
}
+18
View File
@@ -0,0 +1,18 @@
{
"manifest.name": "Поиск",
"manifest.description": "Поиск текста в хранилище в пределах Дела.",
"contributions.workspaceItems.verstak.search.workspace.title": "Поиск",
"contributions.commands.verstak.search.searchVaultText.title": "Искать текст в хранилище",
"contributions.searchProviders.verstak.search.vault-text.label": "Поиск по тексту хранилища",
"ui.minChars": "Введите не менее 2 символов.",
"ui.placeholder": "Поиск файлов, папок и текста",
"ui.vault": "Хранилище",
"ui.searching": "Поиск...",
"ui.search": "Искать",
"ui.providersFailed": "Некоторые поставщики поиска не ответили",
"ui.providerUnavailable": "Один из источников поиска недоступен.",
"ui.searchError": "Не удалось выполнить поиск в хранилище. Повторите попытку.",
"ui.noResults": "Ничего не найдено",
"ui.open": "Открыть",
"ui.count": "Результатов: {count}"
}
+2 -1
View File
@@ -4,7 +4,8 @@
"name": "Search", "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": [
+304 -58
View File
@@ -23,6 +23,7 @@
'.secrets-panel{min-height:0;overflow:auto;border-right:1px solid var(--vt-color-border,#202b46);background:var(--vt-color-surface-muted,#111629)}', '.secrets-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();
}; };
}, },
+62
View File
@@ -0,0 +1,62 @@
{
"manifest.name": "Secrets",
"manifest.description": "Encrypted global and Deal-scoped secret manager.",
"contributions.views.verstak.secrets.view.title": "Secrets",
"contributions.sidebarItems.verstak.secrets.sidebar.title": "Secrets",
"contributions.openProviders.verstak.secrets.secret.title": "Secrets",
"contributions.settingsPanels.verstak.secrets.settings.title": "Secrets",
"contributions.workspaceItems.verstak.secrets.workspace.title": "Secrets",
"ui.masterPassword": "Master password",
"ui.repeatPassword": "Repeat master password",
"ui.passwordMismatch": "Master passwords do not match",
"ui.masterPasswordMinLength": "Master password must be at least {count} characters.",
"ui.masterPasswordRequired": "Enter a master password.",
"ui.masterPasswordInvalid": "The master password is incorrect.",
"ui.unlock": "Unlock",
"ui.createMaster": "Create master password",
"ui.password": "Password",
"ui.repeat": "Repeat",
"ui.title": "Secrets",
"ui.unlockSecrets": "Unlock secrets",
"ui.new": "New",
"ui.empty": "No secrets",
"ui.select": "Select a secret",
"ui.requestedUnavailable": "The requested secret is unavailable.",
"ui.group": "Group",
"ui.username": "Username",
"ui.updated": "Updated",
"ui.copyLink": "Copy secret link",
"ui.edit": "Edit",
"ui.delete": "Delete",
"ui.copyValue": "Copy value",
"ui.showValue": "Show value",
"ui.hideValue": "Hide value",
"ui.fieldTitle": "Title",
"ui.optionalUsername": "optional username",
"ui.secretValue": "Secret value",
"ui.global": "Global",
"ui.workspace": "Deal",
"ui.editSecret": "Edit secret",
"ui.newSecret": "New secret",
"ui.scope": "Scope",
"ui.deal": "Deal",
"ui.scopeAll": "All scopes",
"ui.search": "Search secrets",
"ui.chooseWorkspace": "Choose a Deal",
"ui.workspaceRequired": "Choose a Deal for this secret.",
"ui.statusError": "Could not check the secret storage. Please try again.",
"ui.loadError": "Could not load secrets. Please try again.",
"ui.readError": "Could not open this secret. Please try again.",
"ui.saveError": "Could not save the secret. Please try again.",
"ui.deleteError": "Could not delete the secret. Please try again.",
"ui.copyValueError": "Could not copy the secret value. Please try again.",
"ui.copyLinkError": "Could not copy the secret link. Please try again.",
"ui.unlockError": "Could not unlock secrets. Check the master password and try again.",
"ui.value": "Value",
"ui.save": "Save",
"ui.cancel": "Cancel",
"ui.deleteSecretTitle": "Delete secret",
"ui.deleteConfirm": "Delete \"{title}\"? This cannot be undone.",
"ui.linkCopied": "Secret link copied",
"ui.valueCopied": "Secret value copied"
}
+62
View File
@@ -0,0 +1,62 @@
{
"manifest.name": "Секреты",
"manifest.description": "Зашифрованное хранилище общих секретов и секретов Дел.",
"contributions.views.verstak.secrets.view.title": "Секреты",
"contributions.sidebarItems.verstak.secrets.sidebar.title": "Секреты",
"contributions.openProviders.verstak.secrets.secret.title": "Секреты",
"contributions.settingsPanels.verstak.secrets.settings.title": "Секреты",
"contributions.workspaceItems.verstak.secrets.workspace.title": "Секреты",
"ui.masterPassword": "Мастер-пароль",
"ui.repeatPassword": "Повторите мастер-пароль",
"ui.passwordMismatch": "Мастер-пароли не совпадают",
"ui.masterPasswordMinLength": "Мастер-пароль должен содержать не менее {count} символов.",
"ui.masterPasswordRequired": "Введите мастер-пароль.",
"ui.masterPasswordInvalid": "Мастер-пароль указан неверно.",
"ui.unlock": "Разблокировать",
"ui.createMaster": "Создать мастер-пароль",
"ui.password": "Пароль",
"ui.repeat": "Повтор",
"ui.title": "Секреты",
"ui.unlockSecrets": "Разблокировать секреты",
"ui.new": "Новый",
"ui.empty": "Секретов нет",
"ui.select": "Выберите секрет",
"ui.requestedUnavailable": "Запрошенный секрет недоступен.",
"ui.group": "Группа",
"ui.username": "Имя пользователя",
"ui.updated": "Обновлён",
"ui.copyLink": "Копировать ссылку на секрет",
"ui.edit": "Изменить",
"ui.delete": "Удалить",
"ui.copyValue": "Копировать значение",
"ui.showValue": "Показать значение",
"ui.hideValue": "Скрыть значение",
"ui.fieldTitle": "Название",
"ui.optionalUsername": "необязательное имя пользователя",
"ui.secretValue": "Значение секрета",
"ui.global": "Общий",
"ui.workspace": "Дело",
"ui.editSecret": "Изменить секрет",
"ui.newSecret": "Новый секрет",
"ui.scope": "Область",
"ui.deal": "Дело",
"ui.scopeAll": "Все области",
"ui.search": "Поиск секретов",
"ui.chooseWorkspace": "Выберите дело",
"ui.workspaceRequired": "Выберите дело для этого секрета.",
"ui.statusError": "Не удалось проверить хранилище секретов. Повторите попытку.",
"ui.loadError": "Не удалось загрузить секреты. Повторите попытку.",
"ui.readError": "Не удалось открыть секрет. Повторите попытку.",
"ui.saveError": "Не удалось сохранить секрет. Повторите попытку.",
"ui.deleteError": "Не удалось удалить секрет. Повторите попытку.",
"ui.copyValueError": "Не удалось скопировать значение секрета. Повторите попытку.",
"ui.copyLinkError": "Не удалось скопировать ссылку на секрет. Повторите попытку.",
"ui.unlockError": "Не удалось разблокировать секреты. Проверьте мастер-пароль и повторите попытку.",
"ui.value": "Значение",
"ui.save": "Сохранить",
"ui.cancel": "Отмена",
"ui.deleteSecretTitle": "Удалить секрет",
"ui.deleteConfirm": "Удалить «{title}»? Это действие нельзя отменить.",
"ui.linkCopied": "Ссылка на секрет скопирована",
"ui.valueCopied": "Значение секрета скопировано"
}
+20 -1
View File
@@ -4,7 +4,8 @@
"name": "Secrets", "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",
+68 -62
View File
@@ -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
@@ -15,19 +17,24 @@
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, '') return tr(key, null, fallback)
if (s.length > 200) s = s.substring(0, 200) + '...'
return s
} }
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
} }
@@ -42,22 +49,28 @@
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.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
@@ -68,16 +81,16 @@
await api.settings.writeAll({ serverUrl, username, autoSync, syncInterval }) await api.settings.writeAll({ serverUrl, 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 +98,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)
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 +128,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 +144,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 +152,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 +165,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 +178,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 +194,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 +224,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 +232,31 @@
{/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> </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-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 +265,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>
+17 -6
View File
@@ -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>
+48
View File
@@ -0,0 +1,48 @@
{
"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.syncConflictItem": "A synchronization conflict needs attention.",
"ui.server": "Server",
"ui.serverUrl": "Server URL",
"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"
}
+48
View File
@@ -0,0 +1,48 @@
{
"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.syncConflictItem": "Конфликт синхронизации требует внимания.",
"ui.server": "Сервер",
"ui.serverUrl": "URL сервера",
"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": "Доступ отозван"
}
+1
View File
@@ -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"],
+171 -60
View File
@@ -17,8 +17,8 @@
'.todo-toolbar{display:flex;align-items:center;gap:.5rem;min-height:2.75rem;padding:.5rem .75rem;border-bottom:1px solid var(--vt-color-border,#202b46);background:var(--vt-color-surface-muted,#111629);flex-wrap:wrap;flex-shrink:0}', '.todo-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) {
+69
View File
@@ -0,0 +1,69 @@
{
"manifest.name": "Todos",
"manifest.description": "Global and Deal-scoped todo tracking with due and reminder metadata.",
"contributions.views.verstak.todo.view.title": "Todos",
"contributions.sidebarItems.verstak.todo.sidebar.title": "Todos",
"contributions.workspaceItems.verstak.todo.workspace.title": "Todos",
"ui.title": "Todos",
"ui.workspaceTitle": "Todos · {workspace}",
"ui.allStatuses": "All statuses",
"ui.status.open": "Open",
"ui.status.done": "Done",
"ui.status.cancelled": "Cancelled",
"ui.sort.due": "Sort by due date",
"ui.sort.reminder": "Sort by reminder",
"ui.sort.updated": "Sort by updated",
"ui.search": "Search todos",
"ui.add": "Add Todo",
"ui.allWorkspaces": "All Deals",
"ui.unassigned": "Unassigned",
"ui.saveError": "Could not save tasks. Please try again.",
"ui.loadError": "Could not load tasks. Please try again.",
"ui.notificationError": "Could not schedule reminders. Please try again.",
"ui.notificationTitle": "Todo reminder",
"ui.notificationBody": "{title}",
"ui.titlePlaceholder": "Todo title",
"ui.descriptionPlaceholder": "Optional description",
"ui.priority.low": "Low",
"ui.priority.normal": "Normal",
"ui.priority.high": "High",
"ui.titleRequired": "Title is required",
"ui.updated": "Todo updated",
"ui.added": "Todo added",
"ui.field.title": "Title",
"ui.field.description": "Description",
"ui.field.priority": "Priority",
"ui.field.due": "Due date",
"ui.field.reminder": "Reminder",
"ui.field.reminderDate": "Reminder date",
"ui.field.reminderTime": "Reminder time",
"ui.reminderTimePlaceholder": "14:30",
"ui.reminderTimeInvalid": "Enter a valid reminder time (HH:MM)",
"ui.reminderDateRequired": "Choose a reminder date before setting its time",
"ui.field.workspace": "Deal",
"ui.workspaceValue": "Deal: {workspace}",
"ui.edit": "Edit Todo",
"ui.cancel": "Cancel",
"ui.saveChanges": "Save changes",
"ui.markedDone": "Todo marked done",
"ui.cancelled": "Todo cancelled",
"ui.reopened": "Todo reopened",
"ui.deleted": "Todo deleted",
"ui.priorityValue": "{priority} priority",
"ui.overduePrefix": "Overdue · ",
"ui.dueSoonPrefix": "Due soon · ",
"ui.dueValue": "{prefix}Due {date}",
"ui.reminderDueValue": "Reminder due {date}",
"ui.reminderValue": "Reminder {date}",
"ui.reminderDateValue": "Reminder date {date}",
"ui.noMatches": "No todos match the current filters.",
"ui.empty": "No todos yet.",
"ui.openWorkspace": "Open Deal",
"ui.reopen": "Reopen",
"ui.createJournal": "Create Journal Entry",
"ui.editAction": "Edit",
"ui.delete": "Delete",
"ui.untitled": "Untitled todo",
"ui.count": "{count} todo(s)",
"ui.filteredCount": "{visible} of {count} todos"
}
+69
View File
@@ -0,0 +1,69 @@
{
"manifest.name": "Задачи",
"manifest.description": "Общие задачи и задачи Дел со сроками и напоминаниями.",
"contributions.views.verstak.todo.view.title": "Задачи",
"contributions.sidebarItems.verstak.todo.sidebar.title": "Задачи",
"contributions.workspaceItems.verstak.todo.workspace.title": "Задачи",
"ui.title": "Задачи",
"ui.workspaceTitle": "Задачи · {workspace}",
"ui.allStatuses": "Все состояния",
"ui.status.open": "Открыта",
"ui.status.done": "Выполнена",
"ui.status.cancelled": "Отменена",
"ui.sort.due": "Сортировать по сроку",
"ui.sort.reminder": "Сортировать по напоминанию",
"ui.sort.updated": "Сортировать по обновлению",
"ui.search": "Поиск задач",
"ui.add": "Добавить задачу",
"ui.allWorkspaces": "Все Дела",
"ui.unassigned": "Не назначено",
"ui.saveError": "Не удалось сохранить задачи. Повторите попытку.",
"ui.loadError": "Не удалось загрузить задачи. Повторите попытку.",
"ui.notificationError": "Не удалось запланировать напоминания. Повторите попытку.",
"ui.notificationTitle": "Напоминание о задаче",
"ui.notificationBody": "{title}",
"ui.titlePlaceholder": "Название задачи",
"ui.descriptionPlaceholder": "Необязательное описание",
"ui.priority.low": "Низкий",
"ui.priority.normal": "Обычный",
"ui.priority.high": "Высокий",
"ui.titleRequired": "Введите название",
"ui.updated": "Задача обновлена",
"ui.added": "Задача добавлена",
"ui.field.title": "Название",
"ui.field.description": "Описание",
"ui.field.priority": "Приоритет",
"ui.field.due": "Срок",
"ui.field.reminder": "Напоминание",
"ui.field.reminderDate": "Дата напоминания",
"ui.field.reminderTime": "Время напоминания",
"ui.reminderTimePlaceholder": "14:30",
"ui.reminderTimeInvalid": "Введите корректное время напоминания (ЧЧ:ММ)",
"ui.reminderDateRequired": "Сначала выберите дату напоминания",
"ui.field.workspace": "Дело",
"ui.workspaceValue": "Дело: {workspace}",
"ui.edit": "Изменить задачу",
"ui.cancel": "Отмена",
"ui.saveChanges": "Сохранить изменения",
"ui.markedDone": "Задача выполнена",
"ui.cancelled": "Задача отменена",
"ui.reopened": "Задача открыта снова",
"ui.deleted": "Задача удалена",
"ui.priorityValue": "Приоритет: {priority}",
"ui.overduePrefix": "Просрочено · ",
"ui.dueSoonPrefix": "Скоро срок · ",
"ui.dueValue": "{prefix}Срок: {date}",
"ui.reminderDueValue": "Пора напомнить: {date}",
"ui.reminderValue": "Напоминание: {date}",
"ui.reminderDateValue": "Дата напоминания: {date}",
"ui.noMatches": "Нет задач, соответствующих фильтрам.",
"ui.empty": "Задач пока нет.",
"ui.openWorkspace": "Открыть Дело",
"ui.reopen": "Открыть снова",
"ui.createJournal": "Создать запись журнала",
"ui.editAction": "Изменить",
"ui.delete": "Удалить",
"ui.untitled": "Задача без названия",
"ui.count": "Задач: {count}",
"ui.filteredCount": "Показано {visible} из {count}"
}
+7 -2
View File
@@ -4,17 +4,22 @@
"name": "Todos", "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"
+38 -30
View File
@@ -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 = '';
}; };
}, },
+34
View File
@@ -0,0 +1,34 @@
{
"manifest.name": "Trash",
"manifest.description": "Global vault trash manager for restoring or permanently deleting deleted files and folders.",
"contributions.views.verstak.trash.view.title": "Trash",
"contributions.sidebarItems.verstak.trash.sidebar.title": "Trash",
"ui.loading": "Loading deleted items...",
"ui.count": "{count} deleted item(s)",
"ui.deletePermanently": "Delete permanently",
"ui.deleteQuestion": "Delete permanently?",
"ui.cannotUndo": "This cannot be undone.",
"ui.cancel": "Cancel",
"ui.deleting": "Deleting...",
"ui.allWorkspaces": "All Deals",
"ui.allTypes": "All types",
"ui.files": "Files",
"ui.folders": "Folders",
"ui.newest": "Deleted: newest",
"ui.oldest": "Deleted: oldest",
"ui.name": "Name",
"ui.type": "Type",
"ui.title": "Trash",
"ui.total": "{count} total",
"ui.filter": "Filter name or path",
"ui.refresh": "Refresh",
"ui.workspace": "Deal",
"ui.originalPath": "Original path",
"ui.deleted": "Deleted",
"ui.typeSize": "Type / size",
"ui.actions": "Actions",
"ui.noMatches": "No deleted items match the current filters.",
"ui.empty": "Trash is empty.",
"ui.restoring": "Restoring...",
"ui.restore": "Restore"
}
+34
View File
@@ -0,0 +1,34 @@
{
"manifest.name": "Корзина",
"manifest.description": "Общая корзина хранилища для восстановления или окончательного удаления файлов и папок.",
"contributions.views.verstak.trash.view.title": "Корзина",
"contributions.sidebarItems.verstak.trash.sidebar.title": "Корзина",
"ui.loading": "Загрузка удалённых элементов...",
"ui.count": "Удалённых элементов: {count}",
"ui.deletePermanently": "Удалить навсегда",
"ui.deleteQuestion": "Удалить навсегда?",
"ui.cannotUndo": "Это действие нельзя отменить.",
"ui.cancel": "Отмена",
"ui.deleting": "Удаление...",
"ui.allWorkspaces": "Все Дела",
"ui.allTypes": "Все типы",
"ui.files": "Файлы",
"ui.folders": "Папки",
"ui.newest": "Сначала недавно удалённые",
"ui.oldest": "Сначала давно удалённые",
"ui.name": "Имя",
"ui.type": "Тип",
"ui.title": "Корзина",
"ui.total": "Всего: {count}",
"ui.filter": "Фильтр по имени или пути",
"ui.refresh": "Обновить",
"ui.workspace": "Дело",
"ui.originalPath": "Исходный путь",
"ui.deleted": "Удалён",
"ui.typeSize": "Тип / размер",
"ui.actions": "Действия",
"ui.noMatches": "Нет удалённых элементов, соответствующих фильтрам.",
"ui.empty": "Корзина пуста.",
"ui.restoring": "Восстановление...",
"ui.restore": "Восстановить"
}
+1
View File
@@ -5,6 +5,7 @@
"version": "0.1.0", "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": [
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WINDOWS_DIST="${VERSTAK_WINDOWS_PLUGIN_DIST:-$ROOT/dist-windows}"
WINDOWS_CC="${VERSTAK_WINDOWS_CC:-x86_64-w64-mingw32-gcc}"
GO_BIN="${GO_BIN:-go}"
if ! command -v "$WINDOWS_CC" >/dev/null; then
echo "Windows cross-compiler not found: $WINDOWS_CC" >&2
echo "Install x86_64-w64-mingw32-gcc (for example: sudo apt install gcc-mingw-w64-x86-64)." >&2
exit 1
fi
if ! command -v "$GO_BIN" >/dev/null; then
echo "go is required to build Windows plugin backends" >&2
exit 1
fi
cd "$ROOT"
echo "=== verstak official plugins Windows amd64 build ==="
# Reuse the canonical frontend and manifest packager, then replace every Go
# sidecar with its Windows executable in a separate dist-windows tree.
./scripts/build.sh
rm -rf "$WINDOWS_DIST"
mkdir -p "$WINDOWS_DIST"
cp -R "$ROOT/dist/." "$WINDOWS_DIST/"
backend_count=0
for plugin_dir in "$ROOT"/plugins/*/; do
[[ -d "$plugin_dir" ]] || continue
plugin_name="$(basename "$plugin_dir")"
backend_dir="$plugin_dir/backend"
[[ -d "$backend_dir" ]] || continue
if [[ ! -f "$backend_dir/go.mod" && ! -f "$backend_dir/main.go" ]]; then
continue
fi
package_backend="$WINDOWS_DIST/$plugin_name/backend"
rm -rf "$package_backend"
mkdir -p "$package_backend"
echo " → Windows backend: $plugin_name.exe"
(
cd "$backend_dir"
GOOS=windows GOARCH=amd64 CGO_ENABLED=1 CC="$WINDOWS_CC" \
"$GO_BIN" build -o "$package_backend/$plugin_name.exe" .
)
backend_count=$((backend_count + 1))
done
echo "Windows plugin packages: $WINDOWS_DIST"
echo "Windows backends built: $backend_count"
+9 -2
View File
@@ -79,7 +79,14 @@ with open(path, 'w', encoding='utf-8') as f:
fi fi
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
+81
View File
@@ -0,0 +1,81 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const pluginsRoot = path.join(root, 'plugins');
const contributionFields = {
views: 'title',
commands: 'title',
settingsPanels: 'title',
sidebarItems: 'title',
fileActions: 'label',
noteActions: 'label',
contextMenuEntries: 'label',
searchProviders: 'label',
statusBarItems: 'label',
openProviders: 'title',
workspaceItems: 'title',
};
const problems = [];
for (const entry of fs.readdirSync(pluginsRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const pluginRoot = path.join(pluginsRoot, entry.name);
const manifestPath = path.join(pluginRoot, 'plugin.json');
if (!fs.existsSync(manifestPath)) continue;
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const localization = manifest.localization;
if (!localization || localization.defaultLocale !== 'en') {
problems.push(`${manifest.id}: localization.defaultLocale must be en`);
continue;
}
if (!localization.locales || !localization.locales.en || !localization.locales.ru) {
problems.push(`${manifest.id}: en and ru catalogs must be declared`);
continue;
}
const catalogs = {};
for (const locale of ['en', 'ru']) {
const relative = localization.locales[locale];
const catalogPath = path.resolve(pluginRoot, relative);
if (!catalogPath.startsWith(pluginRoot + path.sep) || !fs.existsSync(catalogPath)) {
problems.push(`${manifest.id}: missing safe ${locale} catalog at ${relative}`);
continue;
}
try {
catalogs[locale] = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
} catch (error) {
problems.push(`${manifest.id}: invalid ${locale} catalog: ${error.message}`);
continue;
}
for (const [key, value] of Object.entries(catalogs[locale])) {
if (typeof value !== 'string') problems.push(`${manifest.id}: ${locale}.${key} must be a string`);
}
}
if (!catalogs.en || !catalogs.ru) continue;
const enKeys = Object.keys(catalogs.en).sort();
const ruKeys = Object.keys(catalogs.ru).sort();
if (JSON.stringify(enKeys) !== JSON.stringify(ruKeys)) {
problems.push(`${manifest.id}: en/ru catalog keys differ`);
}
const required = ['manifest.name'];
if (manifest.description) required.push('manifest.description');
for (const [point, field] of Object.entries(contributionFields)) {
for (const item of manifest.contributes?.[point] || []) {
required.push(`contributions.${point}.${item.id}.${field}`);
}
}
for (const key of required) {
if (!Object.prototype.hasOwnProperty.call(catalogs.en, key)) problems.push(`${manifest.id}: en catalog missing ${key}`);
if (!Object.prototype.hasOwnProperty.call(catalogs.ru, key)) problems.push(`${manifest.id}: ru catalog missing ${key}`);
}
}
if (problems.length > 0) {
problems.forEach((problem) => console.error(` FAIL ${problem}`));
process.exit(1);
}
console.log(' OK official plugin locale catalogs are complete and aligned');
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
const checks = [
['plugins/todo/frontend/src/index.js', '.todo-select'],
['plugins/files/frontend/src/index.js', '.files-sort'],
['plugins/notes/frontend/src/index.js', '.notes-sort'],
['plugins/browser-inbox/frontend/src/index.js', '.browser-inbox-select'],
['plugins/journal/frontend/src/index.js', '.journal-input.journal-select'],
['plugins/trash/frontend/src/index.js', '.trash-select'],
['plugins/secrets/frontend/src/index.js', '.secrets-select'],
];
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
let failed = false;
for (const [relativePath, selector] of checks) {
const source = fs.readFileSync(path.join(root, relativePath), 'utf8');
const escaped = escapeRegExp(selector);
if (!new RegExp(`${escaped}\\{[^}]*appearance:none`).test(source)) {
console.error(`${relativePath}: ${selector} must hide the native select arrow`);
failed = true;
}
if (!new RegExp(`${escaped} option\\{[^}]*background`).test(source)) {
console.error(`${relativePath}: ${selector} options must use the application surface`);
failed = true;
}
}
if (failed) process.exit(1);
console.log('official plugin select styles are complete');
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
function frontendSources(dir) {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const target = path.join(dir, entry.name);
if (entry.isDirectory()) return frontendSources(target);
return /\.(?:js|svelte)$/.test(entry.name) ? [target] : [];
});
}
const files = fs.readdirSync(path.join(root, 'plugins'))
.flatMap((plugin) => frontendSources(path.join(root, 'plugins', plugin, 'frontend', 'src')));
const renderPatterns = [
/statusText\s*=/,
/state\.error\s*=/,
/errorMsg\s*=/,
/setStatus\(/,
/setCreateError\(/,
/setRenameError\(/,
/renderEmpty\(/,
/output\.errors\.push\(/,
/window\.alert\(/,
/showExternalFallback\(/,
/de-error-msg/,
/files-error-msg/,
/body\.textContent\s*=/
];
const violations = [];
for (const file of files) {
const lines = fs.readFileSync(file, 'utf8').split('\n');
lines.forEach((line, index) => {
if (!/(?:err(?:or)?\.message|String\(err(?:or)?\b)/.test(line)) return;
if (!renderPatterns.some((pattern) => pattern.test(line))) return;
violations.push(`${path.relative(root, file)}:${index + 1}: technical error text reaches a user-facing UI sink`);
});
}
if (violations.length) {
console.error('User-facing UI must not render raw backend/plugin errors:');
violations.forEach((violation) => console.error(` ${violation}`));
process.exit(1);
}
console.log('user-facing error messages do not expose raw backend details');
+37
View File
@@ -76,6 +76,43 @@ else
echo " ℹ️ python3 not available — skipping manifest validation" 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]"
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VERSION="${1:-}"
if [[ -z "$VERSION" || ! "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
echo "usage: $0 <version>" >&2
echo "example: $0 v0.1.0-alpha.1" >&2
exit 2
fi
if ! command -v zip >/dev/null; then
echo "zip is required to create the Windows plugin archive" >&2
exit 1
fi
echo "=== verstak official portable plugin packages $VERSION ==="
"$ROOT/scripts/build.sh"
"$ROOT/scripts/build-windows.sh"
RELEASE_ROOT="$ROOT/release"
LINUX_ARCHIVE="$RELEASE_ROOT/verstak-official-plugins-linux-amd64-$VERSION.tar.gz"
WINDOWS_ARCHIVE="$RELEASE_ROOT/verstak-official-plugins-windows-amd64-$VERSION.zip"
rm -rf "$RELEASE_ROOT"
mkdir -p "$RELEASE_ROOT"
# Each archive expands directly into the desktop application's plugins/
# directory. Frontends and manifests are shared; platform-test's native
# sidecar is built for the target operating system.
tar -C "$ROOT/dist" -czf "$LINUX_ARCHIVE" .
(cd "$ROOT/dist-windows" && zip -qr "$WINDOWS_ARCHIVE" .)
(cd "$RELEASE_ROOT" && sha256sum "$(basename "$LINUX_ARCHIVE")" "$(basename "$WINDOWS_ARCHIVE")" > SHA256SUMS)
echo "Linux plugins: $LINUX_ARCHIVE"
echo "Windows plugins: $WINDOWS_ARCHIVE"
echo "checksums: $RELEASE_ROOT/SHA256SUMS"
+80
View File
@@ -0,0 +1,80 @@
#!/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}"
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
RELEASE_OPTIONS=(--generate-notes --verify-tag)
if [[ "$VERSION" == *-* ]]; then
RELEASE_OPTIONS+=(--prerelease)
else
RELEASE_OPTIONS+=(--latest)
fi
"$GH_BIN" release create "$VERSION" "${ASSETS[@]}" \
--repo "$REPOSITORY" \
--title "Verstak Official Plugins $VERSION" \
"${RELEASE_OPTIONS[@]}"
fi
echo "GitHub release:"
"$GH_BIN" release view "$VERSION" --repo "$REPOSITORY" --json url --jq .url
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VERSION="${1:-}"
if [[ -z "$VERSION" || ! "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
echo "usage: $0 <version>" >&2
echo "example: $0 v0.1.0-alpha.1" >&2
exit 2
fi
echo "=== verstak official plugins release $VERSION ==="
"$ROOT/scripts/check.sh"
"$ROOT/scripts/package-portable.sh" "$VERSION"
+110 -8
View File
@@ -116,8 +116,9 @@ function loadComponent(document) {
return component; 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);
+183 -19
View File
@@ -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);
+22 -1
View File
@@ -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);
+104 -4
View File
@@ -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',
@@ -253,9 +257,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 +317,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 +347,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 +453,31 @@ 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({
translations: {
'ui.open': 'Открыть',
'ui.openExternal': 'Открыть во внешнем приложении',
'ui.showInFolder': 'Показать в папке',
'ui.rename': 'Переименовать',
'ui.duplicate': 'Создать копию',
'ui.cut': 'Вырезать',
'ui.copy': 'Копировать',
'ui.trash': 'Переместить в корзину',
},
});
const russianContainer = new FakeNode('div');
russianComponent.mount(russianContainer, {}, russianApi);
await flush();
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');
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);
+58 -2
View File
@@ -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);
+24 -13
View File
@@ -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');
+59
View File
@@ -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');
+13 -2
View File
@@ -8,6 +8,7 @@ const sourcePath = path.join(root, 'plugins', 'search', 'frontend', 'src', 'inde
const manifestPath = path.join(root, 'plugins', 'search', 'plugin.json'); const 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';
+120 -4
View File
@@ -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);
+8 -5
View File
@@ -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');
+88 -5
View File
@@ -8,6 +8,10 @@ const sourcePath = path.join(root, 'plugins', 'todo', 'frontend', 'src', 'index.
const manifestPath = path.join(root, 'plugins', 'todo', 'plugin.json'); const 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');
+20 -1
View File
@@ -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);
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BUILDER="$ROOT/scripts/build-windows.sh"
if [[ ! -x "$BUILDER" ]]; then
echo "Windows plugin builder is missing or not executable: $BUILDER" >&2
exit 1
fi
bash -n "$BUILDER"
grep -Fq 'GOOS=windows' "$BUILDER"
grep -Fq 'GOARCH=amd64' "$BUILDER"
grep -Fq 'dist-windows' "$BUILDER"
grep -Fq '.exe' "$BUILDER"
grep -Fq 'x86_64-w64-mingw32-gcc' "$BUILDER"
echo "Windows plugin build script contract passed"
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PACKAGER="$ROOT/scripts/package-portable.sh"
if [[ ! -x "$PACKAGER" ]]; then
echo "portable plugin packager is missing or not executable: $PACKAGER" >&2
exit 1
fi
bash -n "$PACKAGER"
grep -Fq 'scripts/build.sh' "$PACKAGER"
grep -Fq 'scripts/build-windows.sh' "$PACKAGER"
grep -Fq 'verstak-official-plugins-linux-amd64-' "$PACKAGER"
grep -Fq 'verstak-official-plugins-windows-amd64-' "$PACKAGER"
grep -Fq 'tar -C' "$PACKAGER"
grep -Fq 'zip -qr' "$PACKAGER"
grep -Fq 'SHA256SUMS' "$PACKAGER"
grep -Fq 'package-portable.sh' "$ROOT/scripts/release.sh"
echo "portable plugin package script contract passed"
+98
View File
@@ -0,0 +1,98 @@
#!/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/state"
LOG="$WORK/log"
export LOG
cat > "$WORK/release.sh" <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
printf 'release:%s\n' "$1" >> "$LOG"
printf '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
;;
tag) touch "$TEST_STATE/tag"; printf 'tag:%s\n' "${3:-}" >> "$LOG" ;;
push) printf 'push:%s:%s\n' "${2:-}" "${3:-}" >> "$LOG" ;;
*) echo "unexpected git invocation: $*" >&2; exit 1 ;;
esac
SCRIPT
chmod +x "$WORK/bin/git"
cat > "$WORK/bin/gh" <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
case "${1:-}:${2:-}" in
auth:status) printf 'auth\n' >> "$LOG" ;;
release:view)
if [[ -f "$TEST_STATE/release" ]]; then echo https://github.example/release; else exit 1; fi
;;
release:create) touch "$TEST_STATE/release"; printf 'create:%s\n' "$*" >> "$LOG" ;;
release:upload) printf 'upload:%s\n' "$*" >> "$LOG" ;;
*) echo "unexpected gh invocation: $*" >&2; exit 1 ;;
esac
SCRIPT
chmod +x "$WORK/bin/gh"
run_publisher() {
VERSTAK_RELEASE_SCRIPT="$WORK/release.sh" \
VERSTAK_RELEASE_DIR="$WORK/release" \
GIT_BIN="$WORK/bin/git" \
GH_BIN="$WORK/bin/gh" \
EXPECTED_ROOT="$ROOT" \
TEST_STATE="$WORK/state" \
"$PUBLISHER" "$VERSION"
}
run_publisher
grep -Fqx "release:$VERSION" "$LOG"
grep -Fqx "tag:$VERSION" "$LOG"
grep -Fqx "push:origin:refs/tags/$VERSION" "$LOG"
grep -F "release create $VERSION" "$LOG" >/dev/null
for asset in "${ASSET_NAMES[@]}"; do
grep -F "$asset" "$LOG" >/dev/null
done
grep -F "SHA256SUMS" "$LOG" >/dev/null
grep -F -- "--prerelease" "$LOG" >/dev/null
run_publisher
grep -F "release upload $VERSION" "$LOG" >/dev/null
echo "GitHub release publisher test passed"