Compare commits

..

No commits in common. "main" and "codex/alpha-product-ux" have entirely different histories.

63 changed files with 590 additions and 2399 deletions

1
.gitignore vendored
View File

@ -1,5 +1,4 @@
dist/ dist/
dist-windows/
release/ release/
node_modules/ node_modules/
# compiled backend binaries (built from source) # compiled backend binaries (built from source)

View File

@ -28,22 +28,6 @@ cd ../verstak-desktop
./scripts/install-dev-plugins.sh ./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 ## Alpha behaviour
- Browser Inbox archives processed captures and can restore them; permanent - Browser Inbox archives processed captures and can restore them; permanent
@ -54,19 +38,14 @@ create a GitHub Release.
explicit unassigned scope. Journal creation is always a user action. explicit unassigned scope. Journal creation is always a user action.
- Todo is optional: Overview uses it only when the Todo capability is present. - Todo is optional: Overview uses it only when the Todo capability is present.
## Publish a GitHub Release ## Release package
```bash ```bash
./scripts/publish-github-release.sh v0.1.0-alpha.1 ./scripts/release.sh v0.1.0-alpha.1
``` ```
The publisher requires an authenticated [`gh`](https://cli.github.com/) CLI The command rebuilds all packages and writes
and a clean, up-to-date `main`. It runs the local release command, creates and `release/verstak-official-plugins-<version>.tar.gz` plus `SHA256SUMS`.
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 ## License

View File

@ -37,19 +37,19 @@
'activity.session.handled' 'activity.session.handled'
]; ];
var EVENT_LABELS = { var EVENT_LABELS = {
'workspace.selected': { key: 'ui.event.workspaceSelected', fallback: 'Deal selected' }, 'workspace.selected': 'Workspace selected',
'case.selected': { key: 'ui.event.workspaceSelected', fallback: 'Deal selected' }, 'case.selected': 'Workspace selected',
'file.opened': { key: 'ui.event.fileOpened', fallback: 'File opened' }, 'file.opened': 'File opened',
'file.changed': { key: 'ui.event.fileChanged', fallback: 'File changed' }, 'file.changed': 'File changed',
'note.saved': { key: 'ui.event.noteEdited', fallback: 'Note edited' }, 'note.saved': 'Note edited',
'action.started': { key: 'ui.event.workSessionDetected', fallback: 'Work session detected' }, 'action.started': 'Work session detected',
'browser.capture.received': { key: 'ui.event.browserCaptureReceived', fallback: 'Browser capture received' }, 'browser.capture.received': 'Browser capture received',
'browser.capture.page': { key: 'ui.event.pageCaptured', fallback: 'Page captured' }, 'browser.capture.page': 'Page captured',
'browser.capture.selection': { key: 'ui.event.selectionCaptured', fallback: 'Selection captured' }, 'browser.capture.selection': 'Selection captured',
'browser.capture.link': { key: 'ui.event.linkCaptured', fallback: 'Link captured' }, 'browser.capture.link': 'Link captured',
'browser.capture.file': { key: 'ui.event.fileCaptured', fallback: 'File captured' }, 'browser.capture.file': 'File captured',
'browser.capture.converted': { key: 'ui.event.captureConverted', fallback: 'Capture converted' }, 'browser.capture.converted': 'Capture converted',
'browser.activity.domain': { key: 'ui.event.browserDomainActivity', fallback: 'Browser domain activity' } 'browser.activity.domain': 'Browser domain activity'
}; };
var LOW_VALUE_EVENT_TYPES = { var LOW_VALUE_EVENT_TYPES = {
'workspace.selected': true, 'workspace.selected': true,
@ -79,7 +79,6 @@
'.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)}',
@ -412,27 +411,25 @@
}).slice(0, MAX_CANDIDATES); }).slice(0, MAX_CANDIDATES);
} }
function humanEventType(type, translate) { function humanEventType(type) {
var label = EVENT_LABELS[text(type).toLowerCase()]; return EVENT_LABELS[text(type).toLowerCase()] || '';
return label ? translate(label.key, null, label.fallback) : '';
} }
function humanEventTitle(activity, translate) { function humanEventTitle(activity) {
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();
var label = humanEventType(type, translate); if (explicit && explicit.toLowerCase() !== type.toLowerCase()) return explicit;
if (explicit && explicit.toLowerCase() !== type.toLowerCase()) return label ? label + ' — ' + explicit : explicit; return humanEventType(type) || text(activity && (activity.summary || activity.activityId)).trim() || 'Activity event';
return label || text(activity && (activity.summary || activity.activityId)).trim() || translate('ui.event.activity', null, 'Activity event');
} }
function eventKind(activity, translate) { function eventKind(activity) {
var type = text(activity && activity.type).toLowerCase(); var type = text(activity && activity.type).toLowerCase();
if (type.indexOf('browser.capture') === 0) return translate('ui.kind.capture', null, 'Capture'); if (type.indexOf('browser.capture') === 0) return 'Capture';
if (type.indexOf('file.') === 0) return translate('ui.kind.file', null, 'File'); if (type.indexOf('file.') === 0) return 'File';
if (type.indexOf('note.') === 0) return translate('ui.kind.note', null, 'Note'); if (type.indexOf('note.') === 0) return 'Note';
if (type.indexOf('workspace') !== -1 || type.indexOf('case.') === 0) return translate('ui.kind.deal', null, 'Deal'); if (type.indexOf('workspace') !== -1 || type.indexOf('case.') === 0) return 'Workspace';
if (type.indexOf('action.') === 0) return translate('ui.kind.work', null, 'Work'); if (type.indexOf('action.') === 0) return 'Work';
return translate('ui.kind.activity', null, 'Activity'); return 'Activity';
} }
function globalEventKeys(settings) { function globalEventKeys(settings) {
@ -565,14 +562,6 @@
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' ? tr('ui.title', null, 'Activity') : tr('ui.workspaceTitle', { workspace: scope.label }, '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' });
@ -581,7 +570,18 @@
className: 'activity-btn danger', className: 'activity-btn danger',
'data-activity-action': 'clear', 'data-activity-action': 'clear',
textContent: tr('ui.clear', null, 'Clear'), textContent: tr('ui.clear', null, 'Clear'),
onClick: showClearConfirmation onClick: function () {
if (scope.mode === 'global') {
clearGlobal().then(render);
return;
}
events = [];
candidateSourceEvents = candidateSourceEvents.filter(function (activity) {
return candidateWorkspace(activity) !== scope.workspaceRoot;
});
updateCandidates();
clearWorkspaceRaw(scope.workspaceRoot).then(persist).then(render);
}
}); });
toolbar.appendChild(titleEl); toolbar.appendChild(titleEl);
toolbar.appendChild(countEl); toolbar.appendChild(countEl);
@ -594,11 +594,9 @@
'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, sessionRegistry, dismissedByWorkspace, handledSessions); return visibleCandidates(candidateSourceEvents, workspaceRoot, sessionRegistry, dismissedByWorkspace, handledSessions);
@ -644,7 +642,8 @@
? 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(persistSessionRegistry).then(persistCandidateCaches).catch(function (err) { return api.settings.write(scope.key, storageEvents(toStore)).then(persistSessionRegistry).then(persistCandidateCaches).catch(function (err) {
reportError('ui.saveError', 'Could not save activity. Please try again.', err); statusText = tr('ui.saveError', { error: err && err.message ? err.message : String(err) }, 'Could not save activity: ' + (err && err.message ? err.message : String(err)));
statusClass = 'error';
}); });
} }
@ -677,61 +676,11 @@
statusText = tr('ui.cleared', null, 'Activity cleared'); statusText = tr('ui.cleared', null, 'Activity cleared');
statusClass = ''; statusClass = '';
}).catch(function (err) { }).catch(function (err) {
reportError('ui.clearError', 'Could not clear activity. Please try again.', err); statusText = tr('ui.clearError', { error: err && err.message ? err.message : String(err) }, 'Could not clear activity: ' + (err && err.message ? err.message : String(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) { function clearWorkspaceRaw(workspaceRoot) {
if (!api || !api.storage || !api.storage.data || typeof api.storage.data.readNDJSON !== 'function' || typeof api.storage.data.writeNDJSON !== 'function') { if (!api || !api.storage || !api.storage.data || typeof api.storage.data.readNDJSON !== 'function' || typeof api.storage.data.writeNDJSON !== 'function') {
return Promise.resolve(); return Promise.resolve();
@ -762,10 +711,14 @@
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, tr) }), el('span', { className: 'activity-type', textContent: eventKind(activity) }),
el('span', { className: 'activity-title-text', textContent: humanEventTitle(activity, tr) }) el('span', { className: 'activity-title-text', textContent: humanEventTitle(activity) })
]), ]),
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', {}, [tr('ui.details', null, 'Details')]),
el('div', { className: 'activity-source', textContent: tr('ui.eventSource', { event: activity.type, source: activity.sourcePluginId ? ' · ' + activity.sourcePluginId : '' }, 'Event: ' + activity.type + (activity.sourcePluginId ? ' · Source: ' + activity.sourcePluginId : '')) })
])
]) ])
])); ]));
}); });
@ -801,7 +754,8 @@
statusText = tr('ui.dismissed', null, 'Candidate dismissed'); statusText = tr('ui.dismissed', null, 'Candidate dismissed');
statusClass = ''; statusClass = '';
}).catch(function (err) { }).catch(function (err) {
reportError('ui.dismissError', 'Could not dismiss the suggestion. Please try again.', err); statusText = 'Could not dismiss candidate: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
}).then(render); }).then(render);
} }
@ -822,19 +776,19 @@
el('div', {}, [ el('div', {}, [
el('div', { className: 'activity-candidate-title', textContent: tr('ui.candidate', null, '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: tr('ui.candidateDeal', { deal: candidate.workspaceRootPath }, 'Deal: ' + candidate.workspaceRootPath) }), el('div', { textContent: 'Workspace: ' + candidate.workspaceRootPath }),
el('div', { textContent: tr('ui.candidateTime', { time: candidateTimeRange(candidate) }, 'Time: ' + candidateTimeRange(candidate)) }), el('div', { textContent: 'Time: ' + candidateTimeRange(candidate) }),
el('div', { textContent: tr('ui.candidateDuration', { minutes: candidate.estimatedMinutes }, 'Estimated duration: ' + candidate.estimatedMinutes + ' min') }), el('div', { textContent: 'Estimated duration: ' + candidate.estimatedMinutes + ' min' }),
el('div', { textContent: tr('ui.candidateActivities', { count: candidate.activityCount }, 'Activities: ' + candidate.activityCount) }) el('div', { textContent: 'Activities: ' + candidate.activityCount })
]), ]),
el('details', { className: 'activity-candidate-activities' }, [ el('details', { className: 'activity-candidate-activities' }, [
el('summary', { textContent: tr('ui.includedActivities', { count: candidate.activityCount }, 'Included activities (' + candidate.activityCount + ')') }) el('summary', { textContent: '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 + ' · ' + humanEventTitle(activity, tr) }); return el('div', { className: 'activity-candidate-activity', textContent: activity.occurredAt + ' · ' + activity.type + ' · ' + activity.activityId });
}))) })))
]), ]),
el('div', { className: 'activity-candidate-actions' }, [ el('div', { className: 'activity-candidate-actions' }, [
el('div', { className: 'activity-candidate-duration', textContent: tr('ui.minutes', { count: candidate.estimatedMinutes }, candidate.estimatedMinutes + ' min') }), el('div', { className: 'activity-candidate-duration', textContent: candidate.estimatedMinutes + ' min' }),
el('button', { className: 'activity-btn', type: 'button', 'data-work-session-action': 'review', textContent: tr('ui.review', null, 'Review'), onClick: function () { reviewCandidate(candidate); } }), el('button', { className: 'activity-btn', type: 'button', 'data-work-session-action': 'review', textContent: tr('ui.review', null, 'Review'), onClick: function () { reviewCandidate(candidate); } }),
el('button', { className: 'activity-btn', type: 'button', 'data-work-session-action': 'dismiss', textContent: tr('ui.dismiss', null, 'Dismiss'), onClick: function () { dismissCandidate(candidate); } }) el('button', { className: 'activity-btn', type: 'button', 'data-work-session-action': 'dismiss', textContent: tr('ui.dismiss', null, 'Dismiss'), onClick: function () { dismissCandidate(candidate); } })
]) ])
@ -843,7 +797,7 @@
} }
function render() { function render() {
countEl.textContent = tr(events.length === 1 ? 'ui.eventCount.one' : 'ui.eventCount.many', { count: events.length }, events.length + ' event' + (events.length === 1 ? '' : 's')); countEl.textContent = 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 : '');
@ -873,7 +827,8 @@
updateCandidates(); updateCandidates();
return persistSessionRegistry().then(persistCandidateCaches); return persistSessionRegistry().then(persistCandidateCaches);
}).catch(function (err) { }).catch(function (err) {
reportError('ui.loadError', 'Could not load activity. Please try again.', err); statusText = 'Could not load activity: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
}); });
} }
@ -900,7 +855,8 @@
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) {
reportError('ui.commandsUnavailable', 'Activity actions are unavailable. Please try again.', err); statusText = 'Activity commands unavailable: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
}); });
} }
@ -915,12 +871,11 @@
if (typeof unsubscribe === 'function') unsubscribers.push(unsubscribe); if (typeof unsubscribe === 'function') unsubscribers.push(unsubscribe);
}); });
})).then(function () { })).then(function () {
statusText = scope.mode === 'global' statusText = scope.mode === 'global' ? 'Listening for all activity' : 'Listening for workspace activity';
? tr('ui.listeningGlobal', null, 'Listening for all activity')
: tr('ui.listeningDeal', null, 'Listening for Deal activity');
statusClass = ''; statusClass = '';
}).catch(function (err) { }).catch(function (err) {
reportError('ui.subscriptionsUnavailable', 'Activity updates are unavailable. Please try again.', err); statusText = 'Activity subscriptions unavailable: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
}); });
} }

View File

@ -1,6 +1,6 @@
{ {
"manifest.name": "Activity", "manifest.name": "Activity",
"manifest.description": "Deal-scoped activity log for public plugin events.", "manifest.description": "Workspace-scoped activity log for public plugin events.",
"contributions.views.verstak.activity.view.title": "Activity", "contributions.views.verstak.activity.view.title": "Activity",
"contributions.sidebarItems.verstak.activity.sidebar.title": "Activity", "contributions.sidebarItems.verstak.activity.sidebar.title": "Activity",
"contributions.workspaceItems.verstak.activity.workspace.title": "Activity", "contributions.workspaceItems.verstak.activity.workspace.title": "Activity",
@ -9,18 +9,9 @@
"ui.title": "Activity", "ui.title": "Activity",
"ui.workspaceTitle": "Activity · {workspace}", "ui.workspaceTitle": "Activity · {workspace}",
"ui.clear": "Clear", "ui.clear": "Clear",
"ui.clearConfirmTitle": "Clear activity?", "ui.saveError": "Could not save activity: {error}",
"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.cleared": "Activity cleared",
"ui.clearError": "Could not clear activity. Please try again.", "ui.clearError": "Could not clear activity: {error}",
"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.empty": "No activity events yet",
"ui.emptyHint": "File changes, browser captures, and conversions will appear here.", "ui.emptyHint": "File changes, browser captures, and conversions will appear here.",
"ui.details": "Details", "ui.details": "Details",
@ -29,34 +20,5 @@
"ui.candidates": "Possible journal entries", "ui.candidates": "Possible journal entries",
"ui.candidate": "Possible journal entry", "ui.candidate": "Possible journal entry",
"ui.review": "Review", "ui.review": "Review",
"ui.dismiss": "Dismiss", "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"
} }

View File

@ -1,6 +1,6 @@
{ {
"manifest.name": "Активность", "manifest.name": "Активность",
"manifest.description": "Журнал публичных событий плагинов в Деле.", "manifest.description": "Журнал публичных событий плагинов в рабочем пространстве.",
"contributions.views.verstak.activity.view.title": "Активность", "contributions.views.verstak.activity.view.title": "Активность",
"contributions.sidebarItems.verstak.activity.sidebar.title": "Активность", "contributions.sidebarItems.verstak.activity.sidebar.title": "Активность",
"contributions.workspaceItems.verstak.activity.workspace.title": "Активность", "contributions.workspaceItems.verstak.activity.workspace.title": "Активность",
@ -9,18 +9,9 @@
"ui.title": "Активность", "ui.title": "Активность",
"ui.workspaceTitle": "Активность · {workspace}", "ui.workspaceTitle": "Активность · {workspace}",
"ui.clear": "Очистить", "ui.clear": "Очистить",
"ui.clearConfirmTitle": "Очистить активность?", "ui.saveError": "Не удалось сохранить активность: {error}",
"ui.clearGlobalWarning": "Будут безвозвратно удалены вся записанная активность и предложения для журнала во всех Делах.",
"ui.clearWorkspaceWarning": "Будет безвозвратно удалена записанная активность Дела «{workspace}». Активность других Дел останется.",
"ui.confirmClear": "Очистить активность",
"ui.cancel": "Отмена",
"ui.saveError": "Не удалось сохранить активность. Повторите попытку.",
"ui.cleared": "Активность очищена", "ui.cleared": "Активность очищена",
"ui.clearError": "Не удалось очистить активность. Повторите попытку.", "ui.clearError": "Не удалось очистить активность: {error}",
"ui.dismissError": "Не удалось отклонить предложение. Повторите попытку.",
"ui.loadError": "Не удалось загрузить активность. Повторите попытку.",
"ui.commandsUnavailable": "Действия с активностью недоступны. Повторите попытку.",
"ui.subscriptionsUnavailable": "Обновления активности недоступны. Повторите попытку.",
"ui.empty": "Событий активности пока нет", "ui.empty": "Событий активности пока нет",
"ui.emptyHint": "Здесь появятся изменения файлов, материалы из браузера и преобразования.", "ui.emptyHint": "Здесь появятся изменения файлов, материалы из браузера и преобразования.",
"ui.details": "Подробнее", "ui.details": "Подробнее",
@ -29,34 +20,5 @@
"ui.candidates": "Возможные записи журнала", "ui.candidates": "Возможные записи журнала",
"ui.candidate": "Возможная запись журнала", "ui.candidate": "Возможная запись журнала",
"ui.review": "Проверить", "ui.review": "Проверить",
"ui.dismiss": "Скрыть", "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": "Учёт активности Дела включён"
} }

View File

@ -4,7 +4,7 @@
"name": "Activity", "name": "Activity",
"version": "0.1.0", "version": "0.1.0",
"apiVersion": "0.1.0", "apiVersion": "0.1.0",
"description": "Deal-scoped activity log for public plugin events.", "description": "Workspace-scoped activity log for public plugin events.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } }, "localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official", "source": "official",
"icon": "activity", "icon": "activity",

View File

@ -1,5 +1,5 @@
/* =========================================================== /* ===========================================================
Browser Plugin Verstak v2 Frontend Bundle Browser Inbox Plugin Verstak v2 Frontend Bundle
Contract: window.VerstakPluginRegister(id, { components }) Contract: window.VerstakPluginRegister(id, { components })
=========================================================== */ =========================================================== */
@ -146,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: '', workspaceRoot: '' }; return { mode: 'global', key: GLOBAL_KEY, label: 'All workspaces', workspaceRoot: '' };
} }
return { return {
mode: 'workspace', mode: 'workspace',
@ -161,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, fallbackTitle) { function displayTitle(capture) {
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 || fallbackTitle || ''; return capture.title || capture.url || capture.captureId || 'Untitled capture';
} }
function noteTitle(capture, fallbackTitle) { function noteTitle(capture) {
return text((capture && (capture.title || capture.domain || capture.captureId)) || fallbackTitle).trim() || text(fallbackTitle).trim(); return text((capture && (capture.title || capture.domain || capture.captureId)) || 'Browser Capture').trim() || 'Browser Capture';
} }
function safeNoteFilename(title) { function safeNoteFilename(title) {
@ -199,8 +199,8 @@
return base; return base;
} }
function captureToMarkdown(capture, fallbackTitle) { function captureToMarkdown(capture) {
var title = noteTitle(capture, fallbackTitle); var title = noteTitle(capture);
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);
@ -363,7 +363,7 @@
var scope = scopeFromProps(props || {}); var scope = scopeFromProps(props || {});
var captures = []; var captures = [];
var selectedId = ''; var selectedId = '';
var statusText = ''; var statusText = 'Connecting to receiver events...';
var statusClass = ''; var statusClass = '';
var disposed = false; var disposed = false;
var unsubscribers = []; var unsubscribers = [];
@ -376,39 +376,17 @@
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback); if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key; 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...'); 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' ? tr('ui.title', null, 'Browser') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Browser · ' + scope.label) }); var titleEl = el('span', { className: 'browser-inbox-title', textContent: scope.mode === 'global' ? tr('ui.title', null, 'Browser Inbox') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Browser Inbox · ' + 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': tr('ui.statusFilter', null, 'Material status filter'), 'aria-label': 'Capture 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 = '';
@ -424,7 +402,7 @@
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': tr('ui.workspaceFilter', null, 'Deal filter'), 'aria-label': 'Workspace 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 = '';
@ -436,7 +414,7 @@
type: 'search', type: 'search',
placeholder: tr('ui.search', null, 'Search captures'), placeholder: tr('ui.search', null, 'Search captures'),
'data-browser-inbox-filter': 'search', 'data-browser-inbox-filter': 'search',
'aria-label': tr('ui.search', null, 'Search captures'), 'aria-label': '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 = '';
@ -494,7 +472,7 @@
function renderWorkspaceFilterOptions() { function renderWorkspaceFilterOptions() {
if (scope.mode !== 'global') return; if (scope.mode !== 'global') return;
workspaceFilterEl.innerHTML = ''; workspaceFilterEl.innerHTML = '';
workspaceFilterEl.appendChild(option('', tr('ui.allDeals', null, 'All Deals'))); workspaceFilterEl.appendChild(option('', 'All workspaces'));
workspaceRoots().forEach(function (root) { workspaceRoots().forEach(function (root) {
workspaceFilterEl.appendChild(option(root, root)); workspaceFilterEl.appendChild(option(root, root));
}); });
@ -518,7 +496,9 @@
function publishMutation(action, payload, verify, verifySettings) { function publishMutation(action, payload, verify, verifySettings) {
if (!api || !api.events || typeof api.events.publish !== 'function') { if (!api || !api.events || typeof api.events.publish !== 'function') {
reportError('ui.saveError', 'Could not update browser materials. Please try again.'); statusText = 'Could not save inbox: events API unavailable';
statusClass = 'error';
render();
return Promise.resolve(false); return Promise.resolve(false);
} }
return api.events.publish(MUTATION_EVENT, Object.assign({ action: action }, payload || {})).then(function () { return api.events.publish(MUTATION_EVENT, Object.assign({ action: action }, payload || {})).then(function () {
@ -536,7 +516,9 @@
} }
return true; return true;
}).catch(function (err) { }).catch(function (err) {
reportError('ui.saveError', 'Could not update browser materials. Please try again.', err); statusText = 'Could not save inbox: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
render();
return false; return false;
}); });
} }
@ -563,9 +545,7 @@
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 archiveCaptures(ids, scope.mode === 'global' return archiveCaptures(ids, scope.mode === 'global' ? 'Inbox archived' : 'Workspace captures archived');
? tr('ui.inboxArchived', null, 'Inbox archived')
: tr('ui.workspaceCapturesArchived', null, 'Deal materials archived'));
} }
function selectedCapture() { function selectedCapture() {
@ -583,8 +563,10 @@
}); });
if (existing) return Promise.resolve(); if (existing) return Promise.resolve();
selectedId = capture.captureId; selectedId = capture.captureId;
statusText = tr('ui.captureReceived', null, 'Material received'); statusText = 'Capture received';
statusClass = ''; statusClass = '';
statusText = 'Could not load received capture from storage';
statusClass = 'error';
render(); render();
return Promise.resolve(); return Promise.resolve();
} }
@ -610,16 +592,14 @@
}).then(function (saved) { }).then(function (saved) {
if (!saved) return; if (!saved) return;
if (workspaceRoot && workspaceOptions.indexOf(workspaceRoot) === -1) workspaceOptions.push(workspaceRoot); if (workspaceRoot && workspaceOptions.indexOf(workspaceRoot) === -1) workspaceOptions.push(workspaceRoot);
statusText = workspaceRoot statusText = workspaceRoot ? 'Capture assigned to ' + workspaceRoot : 'Capture is unassigned';
? tr('ui.assignedToWorkspace', { workspace: workspaceRoot }, 'Material assigned to ' + workspaceRoot)
: tr('ui.captureUnassigned', null, 'Material is unassigned');
statusClass = ''; statusClass = '';
render(); render();
}); });
} }
function archiveCapture(captureId) { function archiveCapture(captureId) {
return archiveCaptures([captureId], tr('ui.captureArchived', null, 'Material archived')); return archiveCaptures([captureId], 'Capture archived');
} }
function restoreCapture(captureId) { function restoreCapture(captureId) {
@ -629,7 +609,7 @@
}); });
}).then(function (saved) { }).then(function (saved) {
if (!saved) return; if (!saved) return;
statusText = tr('ui.captureRestored', null, 'Material restored to Inbox'); statusText = 'Capture restored to Inbox';
statusClass = ''; statusClass = '';
render(); render();
}); });
@ -641,7 +621,7 @@
}).then(function (saved) { }).then(function (saved) {
if (!saved) return; if (!saved) return;
if (selectedId === captureId) selectedId = ''; if (selectedId === captureId) selectedId = '';
statusText = tr('ui.captureDeleted', null, 'Material permanently deleted'); statusText = 'Capture permanently deleted';
statusClass = ''; statusClass = '';
render(); render();
}); });
@ -657,9 +637,7 @@
}); });
}).then(function (saved) { }).then(function (saved) {
if (!saved) return; if (!saved) return;
statusText = processed statusText = processed ? 'Capture marked processed' : 'Capture marked unprocessed';
? tr('ui.captureProcessed', null, 'Material marked processed')
: tr('ui.captureUnprocessed', null, 'Material marked unprocessed');
statusClass = ''; statusClass = '';
render(); render();
}); });
@ -668,15 +646,17 @@
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') {
reportError('ui.createNoteError', 'Could not create the note. Please try again.'); statusText = 'Could not create note: files API unavailable';
statusClass = 'error';
render();
return Promise.resolve(); return Promise.resolve();
} }
var title = noteTitle(capture, tr('ui.untitledCapture', null, 'Untitled material')); var title = noteTitle(capture);
var notePath = capture.workspaceRootPath + '/Notes/' + safeNoteFilename(title); var notePath = capture.workspaceRootPath + '/Notes/' + safeNoteFilename(title);
statusText = tr('ui.creatingNote', null, 'Creating note...'); statusText = 'Creating note...';
statusClass = ''; statusClass = '';
render(); render();
return api.files.writeText(notePath, captureToMarkdown(capture, tr('ui.untitledCapture', null, 'Untitled material')), { return api.files.writeText(notePath, captureToMarkdown(capture), {
createIfMissing: true, createIfMissing: true,
overwrite: false overwrite: false
}).then(function () { }).then(function () {
@ -693,22 +673,26 @@
} }
return undefined; return undefined;
}).then(function () { }).then(function () {
statusText = tr('ui.noteCreated', { path: notePath }, 'Note created: ' + notePath); statusText = 'Created note: ' + notePath;
statusClass = ''; statusClass = '';
return archiveCapture(capture.captureId); return archiveCapture(capture.captureId);
}).catch(function (err) { }).catch(function (err) {
reportError('ui.createNoteError', 'Could not create the note. Please try again.', err); statusText = 'Could not create note: ' + (err && err.message ? err.message : String(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') {
reportError('ui.createLinkError', 'Could not create the link. Please try again.'); statusText = 'Could not create link: files API unavailable';
statusClass = 'error';
render();
return Promise.resolve(); return Promise.resolve();
} }
var title = noteTitle(capture, tr('ui.untitledCapture', null, 'Untitled material')); var title = noteTitle(capture);
statusText = tr('ui.creatingLink', null, 'Creating link...'); statusText = 'Creating link...';
statusClass = ''; statusClass = '';
render(); render();
function writeLink(number) { function writeLink(number) {
@ -741,30 +725,36 @@
} }
return linkPath; return linkPath;
}).then(function () { }).then(function () {
statusText = tr('ui.linkCreated', null, 'Link created'); statusText = 'Created link';
statusClass = ''; statusClass = '';
return archiveCapture(capture.captureId); return archiveCapture(capture.captureId);
}).catch(function (err) { }).catch(function (err) {
reportError('ui.createLinkError', 'Could not create the link. Please try again.', err); statusText = 'Could not create link: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
render();
}); });
} }
function openCaptureURL(capture) { function openCaptureURL(capture) {
if (!capture || !capture.url || !api || !api.files || typeof api.files.openURL !== 'function') return Promise.resolve(); if (!capture || !capture.url || !api || !api.files || typeof api.files.openURL !== 'function') return Promise.resolve();
return api.files.openURL(capture.url).catch(function (err) { return api.files.openURL(capture.url).catch(function (err) {
reportError('ui.openLinkError', 'Could not open the link. Please try again.', err); statusText = 'Could not open link: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
render();
}); });
} }
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')) {
reportError('ui.createFileError', 'Could not create the file. Please try again.'); statusText = 'Could not create file: files API unavailable';
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 = tr('ui.creatingFile', null, 'Creating file...'); statusText = 'Creating file...';
statusClass = ''; statusClass = '';
render(); render();
var writeOptions = { var writeOptions = {
@ -791,11 +781,13 @@
} }
return undefined; return undefined;
}).then(function () { }).then(function () {
statusText = tr('ui.fileCreated', { path: filePath }, 'File created: ' + filePath); statusText = 'Created file: ' + filePath;
statusClass = ''; statusClass = '';
return archiveCapture(capture.captureId); return archiveCapture(capture.captureId);
}).catch(function (err) { }).catch(function (err) {
reportError('ui.createFileError', 'Could not create the file. Please try again.', err); statusText = 'Could not create file: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
render();
}); });
} }
@ -804,8 +796,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
? tr('ui.empty', null, 'No browser materials yet. Send a page, selection, or link from the extension.') ? 'No browser captures yet. Keep this view open, then send a page, selection, or link from the extension.'
: tr('ui.emptyFiltered', null, 'No materials match the current filters.'); : 'No captures 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;
} }
@ -821,22 +813,20 @@
}, [ }, [
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, tr('ui.untitledCapture', null, 'Untitled material')) }) el('span', { className: 'browser-inbox-row-title', textContent: displayTitle(capture) })
]), ]),
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 || tr('ui.unassigned', null, 'Unassigned') textContent: workspaceRoot || 'Unassigned'
}), }),
el('span', { el('span', {
className: 'browser-inbox-badge' + (capture.processed ? ' processed' : ''), className: 'browser-inbox-badge' + (capture.processed ? ' processed' : ''),
textContent: capture.processed textContent: capture.processed ? 'Processed' : 'Unprocessed'
? 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 capture.globalState === 'archived' ? el('span', { className: 'browser-inbox-badge', textContent: 'Archived' }) : 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 }));
@ -853,47 +843,45 @@
return; return;
} }
selectedId = capture.captureId; selectedId = capture.captureId;
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-detail-title', textContent: displayTitle(capture) }));
detailEl.appendChild(el('div', { className: 'browser-inbox-meta' }, [ detailEl.appendChild(el('div', { className: 'browser-inbox-meta' }, [
el('div', { className: 'browser-inbox-meta-label', textContent: tr('ui.kind', null, '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: tr('ui.url', null, 'URL') }), el('div', { className: 'browser-inbox-meta-label', textContent: '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: tr('ui.domain', null, '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: tr('ui.captured', null, '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: tr('ui.browser', null, 'Browser') }), el('div', { className: 'browser-inbox-meta-label', textContent: 'Browser' }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.browserName || capture.source || '-' }), el('div', { className: 'browser-inbox-meta-value', textContent: capture.browserName || capture.source || '-' }),
el('div', { className: 'browser-inbox-meta-label', textContent: tr('ui.workspace', null, 'Workspace') }), el('div', { className: 'browser-inbox-meta-label', textContent: tr('ui.workspace', null, 'Workspace') }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.workspaceRootPath || tr('ui.unassigned', null, 'Unassigned') }), el('div', { className: 'browser-inbox-meta-value', textContent: capture.workspaceRootPath || 'Unassigned' }),
el('div', { className: 'browser-inbox-meta-label', textContent: tr('ui.status', null, 'Status') }), el('div', { className: 'browser-inbox-meta-label', textContent: tr('ui.status', null, 'Status') }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.processed el('div', { className: 'browser-inbox-meta-value', textContent: capture.processed ? 'Processed' : 'Unprocessed' })
? 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': tr('ui.assignment', null, 'Assign to Deal'), 'aria-label': 'Assign capture workspace',
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('', tr('ui.unassigned', null, 'Unassigned'))); assignmentSelect.appendChild(option('', '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: tr('ui.assignment', null, 'Assign to Deal') }), el('span', { className: 'browser-inbox-meta-label', textContent: 'Assign workspace' }),
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: tr('ui.clearAssignment', null, 'Clear assignment'), textContent: 'Clear assignment',
onClick: function () { onClick: function () {
assignWorkspace(capture.captureId, ''); assignWorkspace(capture.captureId, '');
} }
@ -901,7 +889,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: tr('ui.assignBeforeCreation', null, 'Assign a Deal before creating a note, link, or file.') })); detailEl.appendChild(el('div', { className: 'browser-inbox-detail-note', textContent: 'Assign a workspace 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 }));
@ -913,9 +901,7 @@
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 textContent: capture.processed ? 'Mark Unprocessed' : 'Mark 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);
} }
@ -924,7 +910,7 @@
actionButtons.push(el('button', { actionButtons.push(el('button', {
className: 'browser-inbox-btn', className: 'browser-inbox-btn',
'data-browser-inbox-action': 'open-link', 'data-browser-inbox-action': 'open-link',
textContent: tr('ui.openLink', null, 'Open link'), textContent: 'Open link',
onClick: function () { onClick: function () {
openCaptureURL(capture); openCaptureURL(capture);
} }
@ -964,7 +950,7 @@
actionButtons.push(el('button', { actionButtons.push(el('button', {
className: 'browser-inbox-btn', className: 'browser-inbox-btn',
'data-browser-inbox-action': 'restore', 'data-browser-inbox-action': 'restore',
textContent: tr('ui.restore', null, 'Restore to Inbox'), textContent: 'Restore to Inbox',
onClick: function () { onClick: function () {
restoreCapture(capture.captureId); restoreCapture(capture.captureId);
} }
@ -973,7 +959,7 @@
actionButtons.push(el('button', { actionButtons.push(el('button', {
className: 'browser-inbox-btn', className: 'browser-inbox-btn',
'data-browser-inbox-action': 'archive', 'data-browser-inbox-action': 'archive',
textContent: tr('ui.archive', null, 'Archive'), textContent: 'Archive',
onClick: function () { onClick: function () {
archiveCapture(capture.captureId); archiveCapture(capture.captureId);
} }
@ -982,7 +968,7 @@
actionButtons.push(el('button', { actionButtons.push(el('button', {
className: 'browser-inbox-btn danger', className: 'browser-inbox-btn danger',
'data-browser-inbox-action': 'delete-permanently', 'data-browser-inbox-action': 'delete-permanently',
textContent: tr('ui.deletePermanently', null, 'Delete permanently'), textContent: 'Delete permanently',
onClick: function () { onClick: function () {
permanentlyDeleteCapture(capture.captureId); permanentlyDeleteCapture(capture.captureId);
} }
@ -994,11 +980,8 @@
var visibleCount = visibleCaptures().length; var visibleCount = visibleCaptures().length;
var total = captures.length; var total = captures.length;
countEl.textContent = visibleCount === total countEl.textContent = visibleCount === total
? localizedItemCount(total) ? total + ' item' + (total === 1 ? '' : 's')
: tr('ui.items.filtered', { : visibleCount + ' of ' + total + ' items';
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;
@ -1051,7 +1034,8 @@
}); });
}); });
}).catch(function (err) { }).catch(function (err) {
reportError('ui.loadError', 'Could not load browser materials. Please try again.', err); statusText = 'Could not load inbox: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
}); });
} }
@ -1091,13 +1075,13 @@
}).then(function (saved) { }).then(function (saved) {
if (!saved) return; if (!saved) return;
selectedId = received.captureId; selectedId = received.captureId;
statusText = tr('ui.captureReceived', null, 'Material received'); statusText = 'Capture received';
statusClass = ''; statusClass = '';
render(); render();
}); });
} }
selectedId = received.captureId; selectedId = received.captureId;
statusText = tr('ui.captureReceived', null, 'Material received'); statusText = 'Capture received';
statusClass = ''; statusClass = '';
render(); render();
return undefined; return undefined;
@ -1106,12 +1090,11 @@
if (typeof unsubscribe === 'function') unsubscribers.push(unsubscribe); if (typeof unsubscribe === 'function') unsubscribers.push(unsubscribe);
}); });
})).then(function () { })).then(function () {
statusText = scope.mode === 'global' statusText = scope.mode === 'global' ? 'Receiver ready for all workspaces' : 'Receiver ready for workspace';
? 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) {
reportError('ui.receiverError', 'The browser receiver is unavailable. Please try again.', err); statusText = 'Receiver unavailable: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
}); });
} }
@ -1125,11 +1108,8 @@
}); });
if (api && api.i18n && typeof api.i18n.onDidChangeLocale === 'function') { if (api && api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
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); titleEl.textContent = scope.mode === 'global' ? tr('ui.title', null, 'Browser Inbox') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Browser Inbox · ' + scope.label);
searchInput.setAttribute('placeholder', tr('ui.search', null, 'Search captures')); 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'); clearBtn.textContent = tr('ui.clear', null, 'Clear');
render(); render();
}); });
@ -1221,13 +1201,6 @@
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;
@ -1253,7 +1226,7 @@
applyPairing(pairing); applyPairing(pairing);
setStatus('', false); setStatus('', false);
}).catch(function (err) { }).catch(function (err) {
reportError('ui.pairingLoadError', 'Could not load browser connection settings. Please try again.', err); setStatus(text(err && err.message ? err.message : err), true);
}).then(function () { }).then(function () {
setBusy(false); setBusy(false);
}); });
@ -1268,7 +1241,7 @@
navigator.clipboard.writeText(value).then(function () { navigator.clipboard.writeText(value).then(function () {
setStatus(tr('ui.copied', { label: label }, '{label} copied'), false); setStatus(tr('ui.copied', { label: label }, '{label} copied'), false);
}).catch(function (err) { }).catch(function (err) {
reportError('ui.clipboardError', 'Could not copy to the clipboard. Please try again.', err); setStatus(text(err && err.message ? err.message : err), true);
}); });
} }
@ -1288,7 +1261,7 @@
applyPairing(pairing); applyPairing(pairing);
setStatus(tr('ui.tokenRotated', null, 'Token rotated'), false); setStatus(tr('ui.tokenRotated', null, 'Token rotated'), false);
}).catch(function (err) { }).catch(function (err) {
reportError('ui.tokenRotateError', 'Could not rotate the pairing token. Please try again.', err); setStatus(text(err && err.message ? err.message : err), true);
}).then(function () { }).then(function () {
setBusy(false); setBusy(false);
}); });

View File

@ -1,26 +1,25 @@
{ {
"manifest.name": "Browser", "manifest.name": "Browser Inbox",
"manifest.description": "Global browser materials with explicit Deal assignment delivered through the local receiver event protocol.", "manifest.description": "Global browser capture queue with explicit workspace assignment delivered through the local receiver event protocol.",
"contributions.views.verstak.browser-inbox.view.title": "Browser", "contributions.views.verstak.browser-inbox.view.title": "Browser Inbox",
"contributions.sidebarItems.verstak.browser-inbox.sidebar.title": "Browser", "contributions.sidebarItems.verstak.browser-inbox.sidebar.title": "Browser Inbox",
"contributions.workspaceItems.verstak.browser-inbox.workspace.title": "Browser", "contributions.workspaceItems.verstak.browser-inbox.workspace.title": "Browser Inbox",
"contributions.settingsPanels.verstak.browser-inbox.settings.title": "Browser", "contributions.settingsPanels.verstak.browser-inbox.settings.title": "Browser Inbox",
"ui.connecting": "Connecting to receiver events...", "ui.connecting": "Connecting to receiver events...",
"ui.title": "Browser", "ui.title": "Browser Inbox",
"ui.workspaceTitle": "Browser · {workspace}", "ui.workspaceTitle": "Browser Inbox · {workspace}",
"ui.allCaptures": "All captures", "ui.allCaptures": "All captures",
"ui.allDeals": "All Deals",
"ui.unassigned": "Unassigned", "ui.unassigned": "Unassigned",
"ui.unprocessed": "Unprocessed", "ui.unprocessed": "Unprocessed",
"ui.processed": "Processed", "ui.processed": "Processed",
"ui.search": "Search captures", "ui.search": "Search captures",
"ui.clear": "Clear", "ui.clear": "Clear",
"ui.assignedHere": "Assigned to this Deal", "ui.assignedHere": "Assigned to this workspace",
"ui.selectCapture": "Select a capture to inspect it.", "ui.selectCapture": "Select a capture to inspect it.",
"ui.kind": "Kind", "ui.kind": "Kind",
"ui.domain": "Domain", "ui.domain": "Domain",
"ui.captured": "Captured", "ui.captured": "Captured",
"ui.workspace": "Deal", "ui.workspace": "Workspace",
"ui.status": "Status", "ui.status": "Status",
"ui.createNote": "Create Note", "ui.createNote": "Create Note",
"ui.createLink": "Create Link", "ui.createLink": "Create Link",
@ -36,55 +35,5 @@
"ui.copied": "{label} copied", "ui.copied": "{label} copied",
"ui.rotateConfirm": "Rotate pairing token?", "ui.rotateConfirm": "Rotate pairing token?",
"ui.rotating": "Rotating...", "ui.rotating": "Rotating...",
"ui.tokenRotated": "Token rotated", "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}"
} }

View File

@ -1,26 +1,25 @@
{ {
"manifest.name": "Браузер", "manifest.name": "Входящие из браузера",
"manifest.description": "Общие материалы из браузера с явным назначением Дела через локальный протокол приёма.", "manifest.description": "Общая очередь материалов из браузера с явным назначением рабочего пространства через локальный протокол приёма.",
"contributions.views.verstak.browser-inbox.view.title": "Браузер", "contributions.views.verstak.browser-inbox.view.title": "Входящие из браузера",
"contributions.sidebarItems.verstak.browser-inbox.sidebar.title": "Браузер", "contributions.sidebarItems.verstak.browser-inbox.sidebar.title": "Входящие из браузера",
"contributions.workspaceItems.verstak.browser-inbox.workspace.title": "Браузер", "contributions.workspaceItems.verstak.browser-inbox.workspace.title": "Входящие из браузера",
"contributions.settingsPanels.verstak.browser-inbox.settings.title": "Браузер", "contributions.settingsPanels.verstak.browser-inbox.settings.title": "Входящие из браузера",
"ui.connecting": "Подключение к событиям приёмника...", "ui.connecting": "Подключение к событиям приёмника...",
"ui.title": "Браузер", "ui.title": "Входящие из браузера",
"ui.workspaceTitle": "Браузер · {workspace}", "ui.workspaceTitle": "Входящие из браузера · {workspace}",
"ui.allCaptures": "Все материалы", "ui.allCaptures": "Все материалы",
"ui.allDeals": "Все Дела",
"ui.unassigned": "Не назначено", "ui.unassigned": "Не назначено",
"ui.unprocessed": "Не обработано", "ui.unprocessed": "Не обработано",
"ui.processed": "Обработано", "ui.processed": "Обработано",
"ui.search": "Поиск материалов", "ui.search": "Поиск материалов",
"ui.clear": "Очистить", "ui.clear": "Очистить",
"ui.assignedHere": "Назначено этому Делу", "ui.assignedHere": "Назначено этому рабочему пространству",
"ui.selectCapture": "Выберите материал для просмотра.", "ui.selectCapture": "Выберите материал для просмотра.",
"ui.kind": "Тип", "ui.kind": "Тип",
"ui.domain": "Домен", "ui.domain": "Домен",
"ui.captured": "Получено", "ui.captured": "Получено",
"ui.workspace": "Дело", "ui.workspace": "Рабочее пространство",
"ui.status": "Состояние", "ui.status": "Состояние",
"ui.createNote": "Создать заметку", "ui.createNote": "Создать заметку",
"ui.createLink": "Создать ссылку", "ui.createLink": "Создать ссылку",
@ -36,55 +35,5 @@
"ui.copied": "{label} скопирован", "ui.copied": "{label} скопирован",
"ui.rotateConfirm": "Сменить токен сопряжения?", "ui.rotateConfirm": "Сменить токен сопряжения?",
"ui.rotating": "Смена токена...", "ui.rotating": "Смена токена...",
"ui.tokenRotated": "Токен изменён", "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}"
} }

View File

@ -1,10 +1,10 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"id": "verstak.browser-inbox", "id": "verstak.browser-inbox",
"name": "Browser", "name": "Browser Inbox",
"version": "0.1.0", "version": "0.1.0",
"apiVersion": "0.1.0", "apiVersion": "0.1.0",
"description": "Global browser materials with explicit Deal assignment delivered through the local receiver event protocol.", "description": "Global browser capture queue with explicit workspace assignment delivered through the local receiver event protocol.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } }, "localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official", "source": "official",
"icon": "inbox", "icon": "inbox",
@ -29,7 +29,7 @@
"views": [ "views": [
{ {
"id": "verstak.browser-inbox.view", "id": "verstak.browser-inbox.view",
"title": "Browser", "title": "Browser Inbox",
"icon": "inbox", "icon": "inbox",
"component": "BrowserInboxView" "component": "BrowserInboxView"
} }
@ -37,7 +37,7 @@
"sidebarItems": [ "sidebarItems": [
{ {
"id": "verstak.browser-inbox.sidebar", "id": "verstak.browser-inbox.sidebar",
"title": "Browser", "title": "Browser Inbox",
"icon": "inbox", "icon": "inbox",
"view": "verstak.browser-inbox.view", "view": "verstak.browser-inbox.view",
"position": 30 "position": 30
@ -46,7 +46,7 @@
"workspaceItems": [ "workspaceItems": [
{ {
"id": "verstak.browser-inbox.workspace", "id": "verstak.browser-inbox.workspace",
"title": "Browser", "title": "Browser Inbox",
"icon": "inbox", "icon": "inbox",
"component": "BrowserInboxView" "component": "BrowserInboxView"
} }
@ -54,7 +54,7 @@
"settingsPanels": [ "settingsPanels": [
{ {
"id": "verstak.browser-inbox.settings", "id": "verstak.browser-inbox.settings",
"title": "Browser", "title": "Browser Inbox",
"component": "BrowserInboxSettings" "component": "BrowserInboxSettings"
} }
] ]

View File

@ -18,6 +18,7 @@
'.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}',
@ -44,6 +45,7 @@
'.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');
@ -257,14 +259,11 @@
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 lineStart = value.lastIndexOf('\n', Math.max(0, start - 1)) + 1; var selected = value.slice(start, end) || placeholder || '';
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, lineStart) + replacement + value.slice(lineEnd); textarea.value = value.slice(0, start) + replacement + value.slice(end);
textarea.selectionStart = lineStart; textarea.selectionStart = start;
textarea.selectionEnd = lineStart + replacement.length; textarea.selectionEnd = start + replacement.length;
textarea.dispatchEvent(new Event('input', { bubbles: true })); textarea.dispatchEvent(new Event('input', { bubbles: true }));
textarea.focus(); textarea.focus();
} }
@ -301,8 +300,9 @@
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': '' }, [tr('ui.note', null, 'Note')]) : null; var notesBadge = editorMode === 'notes-markdown' ? el('span', { className: 'de-notes-badge', 'data-notes-badge': '' }, [tr('ui.notesContext', null, 'notes context')]) : 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' }, [tr('ui.edit', null, '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' }, [tr('ui.preview', null, 'Preview')]) : null; var previewBtn = isMarkdown ? el('button', { className: 'de-toolbar-btn', 'data-editor-mode-button': 'preview' }, [tr('ui.preview', null, 'Preview')]) : null;
@ -310,7 +310,7 @@
var reloadBtn = el('button', { className: 'de-toolbar-btn', 'data-editor-action': 'reload' }, [tr('ui.reload', null, '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' }, [tr('ui.save', null, '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 = [contextLabel]; var toolbarChildren = [modeLabel, 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); });
@ -320,18 +320,18 @@
if (isMarkdown) { if (isMarkdown) {
mdToolbar = el('div', { className: 'de-md-toolbar' }); mdToolbar = el('div', { className: 'de-md-toolbar' });
[ [
['heading', 'H', 'ui.md.heading', 'Heading'], ['heading', 'H', 'Heading'],
['bold', 'B', 'ui.md.bold', 'Bold'], ['bold', 'B', 'Bold'],
['italic', 'I', 'ui.md.italic', 'Italic'], ['italic', 'I', 'Italic'],
['link', 'Link', 'ui.md.link', 'Link'], ['link', 'Link', 'Link'],
['code', 'Code', 'ui.md.inlineCode', 'Inline code'], ['code', 'Code', 'Inline code'],
['code-block', '```', 'ui.md.codeBlock', 'Code block'], ['code-block', '```', 'Code block'],
['bullet', '• List', 'ui.md.bulletList', 'Bullet list'], ['bullet', '• List', 'Bullet list'],
['numbered', '1. List', 'ui.md.numberedList', 'Numbered list'], ['numbered', '1. List', 'Numbered list'],
['quote', 'Quote', 'ui.md.quote', 'Quote'], ['quote', 'Quote', 'Quote'],
['task', 'Task', 'ui.md.taskItem', 'Task item'] ['task', 'Task', 'Task item']
].forEach(function (item) { ].forEach(function (item) {
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]])); mdToolbar.appendChild(el('button', { className: 'de-md-btn', 'data-md-action': item[0], title: item[2] }, [item[1]]));
}); });
containerEl.appendChild(mdToolbar); containerEl.appendChild(mdToolbar);
} }
@ -339,6 +339,10 @@
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' }, [tr('ui.notesInfo', null, '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;
@ -488,10 +492,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', {}, [tr('ui.loadFailed', null, 'Could not load the file. Please try again.')]) el('div', {}, [tr('ui.loadFailed', null, 'Failed to load file')]),
el('div', { className: 'de-error-msg' }, [(err && err.message) ? err.message : String(err)])
])); ]));
}); });
} }
@ -536,25 +540,12 @@
reloadFromDisk(); reloadFromDisk();
var localeUnsubscribe = api.i18n && typeof api.i18n.onDidChangeLocale === 'function' var localeUnsubscribe = api.i18n && typeof api.i18n.onDidChangeLocale === 'function'
? api.i18n.onDidChangeLocale(function () { ? api.i18n.onDidChangeLocale(function () {
if (notesBadge) notesBadge.textContent = tr('ui.note', null, 'Note'); if (notesBadge) notesBadge.textContent = tr('ui.notesContext', null, 'notes context');
if (editBtn) editBtn.textContent = tr('ui.edit', null, 'Edit'); if (editBtn) editBtn.textContent = tr('ui.edit', null, 'Edit');
if (previewBtn) previewBtn.textContent = tr('ui.preview', null, 'Preview'); if (previewBtn) previewBtn.textContent = tr('ui.preview', null, 'Preview');
if (splitBtn) splitBtn.textContent = tr('ui.split', null, 'Split'); if (splitBtn) splitBtn.textContent = tr('ui.split', null, 'Split');
reloadBtn.textContent = tr('ui.reload', null, 'Reload'); reloadBtn.textContent = tr('ui.reload', null, 'Reload');
saveBtn.textContent = tr('ui.save', null, 'Save'); 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(); updateStatus();
}) })
: null; : null;

View File

@ -5,7 +5,6 @@
"contributions.openProviders.verstak.default-editor.markdown.title": "Default Markdown Editor", "contributions.openProviders.verstak.default-editor.markdown.title": "Default Markdown Editor",
"contributions.openProviders.verstak.default-editor.notes-markdown.title": "Default Notes Markdown Editor", "contributions.openProviders.verstak.default-editor.notes-markdown.title": "Default Notes Markdown Editor",
"ui.notesContext": "notes context", "ui.notesContext": "notes context",
"ui.note": "Note",
"ui.edit": "Edit", "ui.edit": "Edit",
"ui.preview": "Preview", "ui.preview": "Preview",
"ui.split": "Split", "ui.split": "Split",
@ -19,15 +18,5 @@
"ui.saved": "Saved", "ui.saved": "Saved",
"ui.discardConfirm": "Discard unsaved changes and reload from disk?", "ui.discardConfirm": "Discard unsaved changes and reload from disk?",
"ui.loading": "Loading...", "ui.loading": "Loading...",
"ui.loadFailed": "Could not load the file. Please try again.", "ui.loadFailed": "Failed to load file"
"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"
} }

View File

@ -5,7 +5,6 @@
"contributions.openProviders.verstak.default-editor.markdown.title": "Стандартный редактор Markdown", "contributions.openProviders.verstak.default-editor.markdown.title": "Стандартный редактор Markdown",
"contributions.openProviders.verstak.default-editor.notes-markdown.title": "Стандартный редактор Markdown-заметок", "contributions.openProviders.verstak.default-editor.notes-markdown.title": "Стандартный редактор Markdown-заметок",
"ui.notesContext": "контекст заметки", "ui.notesContext": "контекст заметки",
"ui.note": "Заметка",
"ui.edit": "Редактировать", "ui.edit": "Редактировать",
"ui.preview": "Просмотр", "ui.preview": "Просмотр",
"ui.split": "Разделить", "ui.split": "Разделить",
@ -19,15 +18,5 @@
"ui.saved": "Сохранено", "ui.saved": "Сохранено",
"ui.discardConfirm": "Отменить несохранённые изменения и перечитать файл с диска?", "ui.discardConfirm": "Отменить несохранённые изменения и перечитать файл с диска?",
"ui.loading": "Загрузка...", "ui.loading": "Загрузка...",
"ui.loadFailed": "Не удалось загрузить файл. Повторите попытку.", "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": "Пункт задачи"
} }

View File

@ -143,9 +143,8 @@
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 = tr('ui.error', null, 'Could not preview this file. Please try again.'); body.textContent = tr('ui.error', { error: err && err.message ? err.message : String(err) }, 'Preview error: ' + (err && err.message ? err.message : String(err)));
}); });
if (api.i18n && typeof api.i18n.onDidChangeLocale === 'function') { if (api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
api.i18n.onDidChangeLocale(function () { api.i18n.onDidChangeLocale(function () {

View File

@ -5,5 +5,5 @@
"ui.openExternal": "Open External", "ui.openExternal": "Open External",
"ui.preview": "Preview", "ui.preview": "Preview",
"ui.loading": "Loading...", "ui.loading": "Loading...",
"ui.error": "Could not preview this file. Please try again." "ui.error": "Preview error: {error}"
} }

View File

@ -5,5 +5,5 @@
"ui.openExternal": "Открыть во внешнем приложении", "ui.openExternal": "Открыть во внешнем приложении",
"ui.preview": "Просмотр", "ui.preview": "Просмотр",
"ui.loading": "Загрузка...", "ui.loading": "Загрузка...",
"ui.error": "Не удалось открыть предварительный просмотр файла. Повторите попытку." "ui.error": "Ошибка просмотра: {error}"
} }

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{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,.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{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 option{background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb)}', '.files-sort{width:9.5rem;appearance:none;background-color:#0f1424;background-image:linear-gradient(45deg,transparent 50%,#8b8ba8 50%),linear-gradient(135deg,#8b8ba8 50%,transparent 50%);background-position:calc(100% - 14px) 50%,calc(100% - 9px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:1.6rem}',
'.files-filter:focus,.files-sort:focus{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,.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-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,6 +60,10 @@
'.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}',
@ -71,12 +75,6 @@
'.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}',
@ -322,16 +320,15 @@
return Promise.reject(new Error('clipboard unavailable')); return Promise.reject(new Error('clipboard unavailable'));
} }
function showExternalFallback(entry, mode, labels) { function showExternalFallback(entry, mode, reason) {
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' ? (labels.showInFolder || 'Show in folder') : (labels.openExternal || 'Open externally'); var title = mode === 'explorer' ? 'Show in Explorer' : 'Open External';
var message = (labels.externalFailed || '{action} failed.').replace('{action}', title) + '\n' + (labels.vaultRelativePath || 'Vault-relative path:') + '\n' + pathToShow; var message = title + ' failed.\n' + (reason ? String(reason) + '\n' : '') + 'Vault-relative path:\n' + pathToShow;
confirmModal(message, { confirmText: labels.copyPath || 'Copy path', cancelText: labels.close || 'Close' }).then(function (copy) { confirmModal(message, { confirmText: 'Copy Path', cancelText: '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);
@ -349,7 +346,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)) || ''; var workspaceName = workspaceRoot || (workspaceNode && (workspaceNode.name || workspaceNode.title || workspaceNode.id)) || 'Workspace';
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: '' };
@ -361,12 +358,6 @@
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 = [];
@ -383,13 +374,6 @@
return fallback || key; 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;
@ -447,6 +431,32 @@
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': '' }, [tr('ui.create', null, 'Create')]);
var createCancel = el('button', { className: 'files-toolbar-btn' }, [tr('ui.cancel', null, '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': '' }, [tr('ui.rename', null, 'Rename')]);
var renameCancel = el('button', { className: 'files-toolbar-btn' }, [tr('ui.cancel', null, '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;
@ -704,10 +714,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', {}, [tr('ui.loadFailed', null, 'Could not load files. Please try again.')]) el('div', {}, [tr('ui.loadFailed', null, 'Failed to load files')]),
el('div', { className: 'files-error-msg' }, [(err && err.message) ? err.message : String(err)])
])); ]));
}); });
} }
@ -803,131 +813,35 @@
}).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;
createModal = showFileFormModal('create', { createInput.value = '';
title: mode === 'folder' createInput.placeholder = mode === 'folder' ? 'Folder name' : (mode === 'markdown' ? 'Markdown file name' : 'Text file name');
? tr('ui.createFolderTitle', null, 'Create folder') setCreateError('');
: (mode === 'markdown' createPanel.style.display = 'flex';
? tr('ui.createMarkdownTitle', null, 'Create Markdown file') createInput.focus();
: 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 tr('ui.nameRequired', null, 'Enter a name.'); if (!name) return 'Name is required';
if (/[\\/:*?"<>|\x00-\x1f]/.test(name)) return tr('ui.invalidCharacters', null, 'The name contains invalid characters.'); if (/[\\/:*?"<>|\x00-\x1f]/.test(name)) return 'Invalid characters in name';
if (name === '.' || name === '..' || name[0] === ' ' || name[name.length - 1] === ' ' || name[name.length - 1] === '.') return tr('ui.invalidName', null, 'Enter a valid name.'); if (name === '.' || name === '..' || name[0] === ' ' || name[name.length - 1] === ' ' || name[name.length - 1] === '.') return 'Invalid name';
return ''; return '';
} }
function cancelCreate() { function cancelCreate() {
createMode = ''; createMode = '';
if (createModal && typeof createModal.remove === 'function') createModal.remove(); setCreateError('');
else if (createModal && createModal.parentNode) createModal.parentNode.removeChild(createModal); createPanel.style.display = 'none';
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) {
@ -949,56 +863,46 @@
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(reportError('ui.createError', 'Could not create this item. Please try again.', err)); setCreateError('Error: ' + ((err && err.message) ? err.message : String(err)));
}); });
} }
function beginRename(entry) { function beginRename(entry) {
entry = entry || selectedEntry(); entry = entry || selectedEntry();
if (!entry) return; if (!entry) return;
cancelRename();
renameTarget = entry; renameTarget = entry;
renameModal = showFileFormModal('rename', { renameInput.value = entry.name;
title: tr('ui.renameTitle', { name: entry.name }, 'Rename ' + entry.name), setRenameError('');
placeholder: tr('ui.fileName', null, 'File or folder name'), renamePanel.style.display = 'flex';
value: entry.name, renameInput.focus();
confirmLabel: tr('ui.rename', null, 'Rename') renameInput.select();
});
} }
function cancelRename() { function cancelRename() {
renameTarget = null; renameTarget = null;
if (renameModal && typeof renameModal.remove === 'function') renameModal.remove(); setRenameError('');
else if (renameModal && renameModal.parentNode) renameModal.parentNode.removeChild(renameModal); renamePanel.style.display = 'none';
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 || !renameInput) return; if (!renameTarget) return;
var newName = renameInput.value.trim(); var newName = renameInput.value.trim();
if (!newName) { if (!newName || newName === renameTarget.name) {
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(tr('ui.invalidCharacters', null, 'The name contains invalid characters.')); setRenameError('Invalid characters in name');
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(tr('ui.invalidName', null, 'Enter a valid name.')); setRenameError('Invalid name');
return; return;
} }
var from = renameTarget.relativePath; var from = renameTarget.relativePath;
@ -1006,20 +910,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(tr('ui.nameDiffersOnlyByCase', null, 'The name differs only by letter case.')); setRenameError('Name differs only by case');
return; return;
} }
setRenameError(tr('ui.nameConflict', null, 'An item with that name already exists.')); setRenameError('A file 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(tr('ui.nameConflict', null, 'An item with that name already exists.')); setRenameError('A file with that name already exists');
return; return;
} }
setRenameError(reportError('ui.renameError', 'Could not rename this item. Please try again.', err)); setRenameError('Error: ' + ((err && err.message) ? err.message : String(err)));
}); });
}); });
} }
@ -1027,24 +931,14 @@
function trashEntry(entry) { function trashEntry(entry) {
var count = selectedCount(); var count = selectedCount();
if (entry) { if (entry) {
confirmModal(tr('ui.trashConfirm', { name: entry.name }, 'Move "' + entry.name + '" to trash?'), { confirmModal('Move "' + entry.name + '" to trash?', { danger: true }).then(function (ok) {
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) { }).catch(function (err) { window.alert((err && err.message) ? err.message : String(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(tr('ui.trashManyConfirm', { count: count }, 'Move ' + count + ' items to trash?'), { confirmModal('Move ' + count + ' items to trash?', { danger: true }).then(function (ok) {
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 () {
@ -1060,6 +954,14 @@
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);
@ -1089,23 +991,14 @@
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, fallbackLabels); showExternalFallback(entry, mode, 'files external-open API is unavailable.');
return; return;
} }
action(entry.relativePath).catch(function (err) { action(entry.relativePath).catch(function (err) {
console.warn('[verstak.files] external open error:', err); showExternalFallback(entry, mode, err && err.message ? err.message : err);
showExternalFallback(entry, mode, fallbackLabels);
}); });
} }
@ -1151,27 +1044,27 @@
renderList(); renderList();
} }
var isFolder = entry.type === 'folder'; var isFolder = entry.type === 'folder';
ctxMenu.appendChild(ctxItem(isFolder ? tr('ui.openFolder', null, 'Open folder') : tr('ui.open', null, 'Open'), '', function () { openEntry(entry); }, 'open', 'open')); ctxMenu.appendChild(ctxItem(isFolder ? 'Open Folder' : 'Open', '', function () { openEntry(entry); }, 'open', 'open'));
ctxMenu.appendChild(ctxItem(tr('ui.openExternal', null, 'Open externally'), '', function () { openExternalEntry(entry, 'external'); }, 'open-external', 'external')); ctxMenu.appendChild(ctxItem('Open External', '', function () { openExternalEntry(entry, 'external'); }, 'open-external', 'external'));
ctxMenu.appendChild(ctxItem(tr('ui.showInFolder', null, 'Show in folder'), '', function () { openExternalEntry(entry, 'explorer'); }, 'show-in-explorer', 'explorer')); ctxMenu.appendChild(ctxItem('Show in Explorer', '', function () { openExternalEntry(entry, 'explorer'); }, 'show-in-explorer', 'explorer'));
appendContributionMenuItems(entry); appendContributionMenuItems(entry);
ctxMenu.appendChild(ctxSep()); ctxMenu.appendChild(ctxSep());
ctxMenu.appendChild(ctxItem(tr('ui.rename', null, 'Rename'), '', function () { beginRename(entry); }, 'rename', 'rename')); ctxMenu.appendChild(ctxItem('Rename', '', function () { beginRename(entry); }, 'rename', 'rename'));
if (entry.type !== 'folder') { if (entry.type !== 'folder') {
ctxMenu.appendChild(ctxItem(tr('ui.duplicate', null, 'Duplicate'), '', function () { duplicateEntry(entry); }, 'duplicate', 'duplicate')); ctxMenu.appendChild(ctxItem('Duplicate', '', function () { duplicateEntry(entry); }, 'duplicate', 'duplicate'));
} }
ctxMenu.appendChild(ctxSep()); ctxMenu.appendChild(ctxSep());
ctxMenu.appendChild(ctxItem(tr('ui.cut', null, 'Cut'), '', function () { cutSelection(); }, 'cut', 'cut')); ctxMenu.appendChild(ctxItem('Cut', '', function () { cutSelection(); }, 'cut', 'cut'));
ctxMenu.appendChild(ctxItem(tr('ui.copy', null, 'Copy'), '', function () { copySelection(); }, 'copy', 'copy')); ctxMenu.appendChild(ctxItem('Copy', '', function () { copySelection(); }, 'copy', 'copy'));
ctxMenu.appendChild(ctxSep()); ctxMenu.appendChild(ctxSep());
ctxMenu.appendChild(ctxItem(tr('ui.trash', null, 'Move to trash'), 'danger', function () { trashEntry(); }, 'trash', 'trash')); ctxMenu.appendChild(ctxItem('Move to Trash', 'danger', function () { trashEntry(); }, 'trash', 'trash'));
} else { } else {
ctxMenu.appendChild(ctxItem(tr('ui.newFolder', null, 'New folder'), '', function () { startCreate('folder'); }, 'new-folder', 'folderAdd')); ctxMenu.appendChild(ctxItem('New Folder', '', function () { startCreate('folder'); }, 'new-folder', 'folderAdd'));
ctxMenu.appendChild(ctxItem(tr('ui.newMarkdown', null, 'New Markdown file'), '', function () { startCreate('markdown'); }, 'new-markdown', 'markdownAdd')); ctxMenu.appendChild(ctxItem('New Markdown', '', function () { startCreate('markdown'); }, 'new-markdown', 'markdownAdd'));
ctxMenu.appendChild(ctxItem(tr('ui.newText', null, 'New text file'), '', function () { startCreate('text'); }, 'new-text', 'textAdd')); ctxMenu.appendChild(ctxItem('New Text', '', 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(tr('ui.paste', null, 'Paste'), '', function () { pasteEntry(); }, 'paste', 'paste')); ctxMenu.appendChild(ctxItem('Paste', '', function () { pasteEntry(); }, 'paste', 'paste'));
} }
} }
ctxMenu.style.display = 'block'; ctxMenu.style.display = 'block';
@ -1264,7 +1157,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(tr('ui.clipboardDifferentDeal', null, 'Clipboard items belong to another Deal.')); window.alert('Clipboard items belong to another workspace.');
return; return;
} }
var destinationDir = scopedPath(currentPath); var destinationDir = scopedPath(currentPath);
@ -1605,6 +1498,10 @@
item[0].setAttribute('aria-label', label); item[0].setAttribute('aria-label', label);
}); });
filterInput.setAttribute('placeholder', tr('ui.filter', null, 'Filter current folder')); filterInput.setAttribute('placeholder', tr('ui.filter', null, 'Filter current folder'));
createConfirm.textContent = tr('ui.create', null, 'Create');
createCancel.textContent = tr('ui.cancel', null, 'Cancel');
renameConfirm.textContent = tr('ui.rename', null, 'Rename');
renameCancel.textContent = tr('ui.cancel', null, 'Cancel');
renderList(); renderList();
}); });
} }
@ -1623,8 +1520,6 @@
containerEl.__filesCleanup = function () { containerEl.__filesCleanup = function () {
disposed = true; disposed = true;
cancelCreate();
cancelRename();
if (typeof localeUnsubscribe === 'function') localeUnsubscribe(); if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
if (typeof fileChangedUnsubscribe === 'function') fileChangedUnsubscribe(); if (typeof fileChangedUnsubscribe === 'function') fileChangedUnsubscribe();
document.removeEventListener('click', onDocClick); document.removeEventListener('click', onDocClick);

View File

@ -1,6 +1,6 @@
{ {
"manifest.name": "Files", "manifest.name": "Files",
"manifest.description": "Deal-scoped vault file explorer with create, rename, trash, filtering, sorting, and Workbench openResource integration.", "manifest.description": "Workspace-scoped vault file explorer with create, rename, trash, filtering, sorting, and Workbench openResource integration.",
"contributions.workspaceItems.verstak.files.workspace.title": "Files", "contributions.workspaceItems.verstak.files.workspace.title": "Files",
"ui.back": "Back", "ui.back": "Back",
"ui.forward": "Forward", "ui.forward": "Forward",
@ -10,11 +10,7 @@
"ui.newMarkdown": "New markdown file", "ui.newMarkdown": "New markdown file",
"ui.newText": "New text file", "ui.newText": "New text file",
"ui.open": "Open", "ui.open": "Open",
"ui.openFolder": "Open folder",
"ui.openExternal": "Open externally",
"ui.showInFolder": "Show in folder",
"ui.rename": "Rename", "ui.rename": "Rename",
"ui.duplicate": "Duplicate",
"ui.trash": "Move to trash", "ui.trash": "Move to trash",
"ui.cut": "Cut", "ui.cut": "Cut",
"ui.copy": "Copy", "ui.copy": "Copy",
@ -28,32 +24,9 @@
"ui.column.actions": "Actions", "ui.column.actions": "Actions",
"ui.create": "Create", "ui.create": "Create",
"ui.cancel": "Cancel", "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.emptyFolder": "Empty folder",
"ui.noMatches": "No matches", "ui.noMatches": "No matches",
"ui.clearFilter": "Clear filter", "ui.clearFilter": "Clear filter",
"ui.loading": "Loading...", "ui.loading": "Loading...",
"ui.loadFailed": "Could not load files. Please try again.", "ui.loadFailed": "Failed to load files"
"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."
} }

View File

@ -1,6 +1,6 @@
{ {
"manifest.name": "Файлы", "manifest.name": "Файлы",
"manifest.description": "Файловый менеджер Дела с созданием, переименованием, корзиной, фильтрацией и сортировкой.", "manifest.description": "Файловый менеджер рабочего пространства с созданием, переименованием, корзиной, фильтрацией и сортировкой.",
"contributions.workspaceItems.verstak.files.workspace.title": "Файлы", "contributions.workspaceItems.verstak.files.workspace.title": "Файлы",
"ui.back": "Назад", "ui.back": "Назад",
"ui.forward": "Вперёд", "ui.forward": "Вперёд",
@ -10,11 +10,7 @@
"ui.newMarkdown": "Новый Markdown-файл", "ui.newMarkdown": "Новый Markdown-файл",
"ui.newText": "Новый текстовый файл", "ui.newText": "Новый текстовый файл",
"ui.open": "Открыть", "ui.open": "Открыть",
"ui.openFolder": "Открыть папку",
"ui.openExternal": "Открыть во внешнем приложении",
"ui.showInFolder": "Показать в папке",
"ui.rename": "Переименовать", "ui.rename": "Переименовать",
"ui.duplicate": "Создать копию",
"ui.trash": "Переместить в корзину", "ui.trash": "Переместить в корзину",
"ui.cut": "Вырезать", "ui.cut": "Вырезать",
"ui.copy": "Копировать", "ui.copy": "Копировать",
@ -28,32 +24,9 @@
"ui.column.actions": "Действия", "ui.column.actions": "Действия",
"ui.create": "Создать", "ui.create": "Создать",
"ui.cancel": "Отмена", "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.emptyFolder": "Папка пуста",
"ui.noMatches": "Совпадений нет", "ui.noMatches": "Совпадений нет",
"ui.clearFilter": "Сбросить фильтр", "ui.clearFilter": "Сбросить фильтр",
"ui.loading": "Загрузка...", "ui.loading": "Загрузка...",
"ui.loadFailed": "Не удалось загрузить файлы. Повторите попытку.", "ui.loadFailed": "Не удалось загрузить файлы"
"ui.createError": "Не удалось создать этот элемент. Повторите попытку.",
"ui.renameError": "Не удалось переименовать этот элемент. Повторите попытку.",
"ui.trashError": "Не удалось переместить элемент в корзину. Повторите попытку."
} }

View File

@ -4,7 +4,7 @@
"name": "Files", "name": "Files",
"version": "0.1.0", "version": "0.1.0",
"apiVersion": "0.1.0", "apiVersion": "0.1.0",
"description": "Deal-scoped vault file explorer with create, rename, trash, filtering, sorting, and Workbench openResource integration.", "description": "Workspace-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" } }, "localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official", "source": "official",
"icon": "folder", "icon": "folder",

View File

@ -31,7 +31,6 @@
'.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)}',
@ -276,7 +275,6 @@
var scope = scopeFromProps(props || {}); var scope = scopeFromProps(props || {});
var entries = []; var entries = [];
var workspaceOptions = [];
function tr(key, params, fallback) { function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback); if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key; return fallback || key;
@ -285,14 +283,6 @@
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' ? tr('ui.title', null, 'Journal') : tr('ui.workspaceTitle', { workspace: scope.label }, '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' });
@ -314,23 +304,12 @@
containerEl.appendChild(listEl); containerEl.appendChild(listEl);
containerEl.appendChild(modalHost); containerEl.appendChild(modalHost);
function persist(workspaceRoot, values) { function persist() {
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();
var target = cleanWorkspace(workspaceRoot || scope.workspaceRoot); return api.settings.write(scope.key, storageEntries(entries)).catch(function (err) {
if (!target) return Promise.resolve(); statusText = tr('ui.saveError', { error: err && err.message ? err.message : String(err) }, 'Could not save journal: ' + (err && err.message ? err.message : String(err)));
return api.settings.write(WORKLOG_PREFIX + encodeKey(target), storageEntries(values || entries)).catch(function (err) { statusClass = 'error';
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;
});
}); });
} }
@ -346,7 +325,8 @@
statusText = tr('ui.aggregating', null, 'Aggregating worklogs'); statusText = tr('ui.aggregating', null, 'Aggregating worklogs');
statusClass = ''; statusClass = '';
}).catch(function (err) { }).catch(function (err) {
reportError('ui.loadError', 'Could not load journal. Please try again.', err); statusText = tr('ui.loadError', { error: err && err.message ? err.message : String(err) }, 'Could not load journal: ' + (err && err.message ? err.message : String(err)));
statusClass = 'error';
}); });
} }
return api.settings.read(scope.key).then(function (stored) { return api.settings.read(scope.key).then(function (stored) {
@ -354,7 +334,8 @@
statusText = tr('ui.ready', null, 'Ready'); statusText = tr('ui.ready', null, 'Ready');
statusClass = ''; statusClass = '';
}).catch(function (err) { }).catch(function (err) {
reportError('ui.loadError', 'Could not load journal. Please try again.', err); statusText = tr('ui.loadError', { error: err && err.message ? err.message : String(err) }, 'Could not load journal: ' + (err && err.message ? err.message : String(err)));
statusClass = 'error';
}); });
} }
@ -364,6 +345,7 @@
} }
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;
@ -374,13 +356,6 @@
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;
@ -394,7 +369,6 @@
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 : '', sessionId: reviewingCandidate ? candidate.sessionId : '',
handledThrough: reviewingCandidate ? candidate.handledThrough : '', handledThrough: reviewingCandidate ? candidate.handledThrough : '',
@ -437,7 +411,6 @@
el('div', { className: 'journal-modal-grid' }, [ el('div', { className: 'journal-modal-grid' }, [
el('label', { className: 'journal-field' }, [tr('ui.date', null, 'Date'), dateInput]), el('label', { className: 'journal-field' }, [tr('ui.date', null, 'Date'), dateInput]),
el('label', { className: 'journal-field' }, [tr('ui.minutes', null, 'Minutes'), minutesInput]), el('label', { className: 'journal-field' }, [tr('ui.minutes', null, 'Minutes'), minutesInput]),
workspaceInput ? el('label', { className: 'journal-field wide' }, [tr('ui.workspace', null, 'Deal'), workspaceInput]) : null,
el('label', { className: 'journal-field wide' }, [tr('ui.fieldTitle', null, 'Title'), titleInput]), el('label', { className: 'journal-field wide' }, [tr('ui.fieldTitle', null, 'Title'), titleInput]),
el('label', { className: 'journal-field wide' }, [tr('ui.body', null, 'Body'), summaryInput]), el('label', { className: 'journal-field wide' }, [tr('ui.body', null, 'Body'), summaryInput]),
el('label', { className: 'journal-billable' }, [billableInput, tr('ui.billable', null, 'Billable')]) el('label', { className: 'journal-billable' }, [billableInput, tr('ui.billable', null, 'Billable')])
@ -453,6 +426,7 @@
} }
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 = tr('ui.titleRequired', null, 'Title is required'); statusText = tr('ui.titleRequired', null, 'Title is required');
@ -460,8 +434,6 @@
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 sessionID = text(formValue && formValue.sessionId).trim();
var handledThrough = text(formValue && formValue.handledThrough).trim(); var handledThrough = text(formValue && formValue.handledThrough).trim();
@ -479,8 +451,8 @@
return; return;
} }
var entry = normalizeEntry({ var entry = normalizeEntry({
entryId: existingEntry ? existingEntry.entryId : entryId(workspaceRoot, formValue.date || today(), title), entryId: existingEntry ? existingEntry.entryId : entryId(scope.workspaceRoot, formValue.date || today(), title),
workspaceRootPath: workspaceRoot, workspaceRootPath: scope.workspaceRoot,
date: formValue.date || today(), date: formValue.date || today(),
title: title, title: title,
summary: formValue.summary, summary: formValue.summary,
@ -498,11 +470,10 @@
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 ? 'Entry updated' : 'Entry added';
statusClass = ''; statusClass = '';
persist(workspaceRoot, targetEntries).then(function () { persist().then(function () {
if (!sessionID || !handledThrough || !api || !api.events || typeof api.events.publish !== 'function') return undefined; if (!sessionID || !handledThrough || !api || !api.events || typeof api.events.publish !== 'function') return undefined;
return api.events.publish('activity.session.handled', { return api.events.publish('activity.session.handled', {
sessionId: sessionID, sessionId: sessionID,
@ -554,12 +525,12 @@
); );
statusEl.textContent = statusText; statusEl.textContent = statusText;
statusEl.className = 'journal-status' + (statusClass ? ' ' + statusClass : ''); statusEl.className = 'journal-status' + (statusClass ? ' ' + statusClass : '');
addBtn.disabled = false; addBtn.disabled = scope.mode !== 'workspace';
renderList(); renderList();
} }
render(); render();
Promise.all([loadStored(), loadWorkspaceOptions()]).then(function () { loadStored().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);

View File

@ -8,9 +8,9 @@
"ui.title": "Journal", "ui.title": "Journal",
"ui.workspaceTitle": "Journal · {workspace}", "ui.workspaceTitle": "Journal · {workspace}",
"ui.add": "Add", "ui.add": "Add",
"ui.saveError": "Could not save journal. Please try again.", "ui.saveError": "Could not save journal: {error}",
"ui.aggregating": "Aggregating worklogs", "ui.aggregating": "Aggregating worklogs",
"ui.loadError": "Could not load journal. Please try again.", "ui.loadError": "Could not load journal: {error}",
"ui.ready": "Ready", "ui.ready": "Ready",
"ui.workItem": "Work item", "ui.workItem": "Work item",
"ui.body": "Body", "ui.body": "Body",

View File

@ -8,9 +8,9 @@
"ui.title": "Журнал", "ui.title": "Журнал",
"ui.workspaceTitle": "Журнал · {workspace}", "ui.workspaceTitle": "Журнал · {workspace}",
"ui.add": "Добавить", "ui.add": "Добавить",
"ui.saveError": "Не удалось сохранить журнал. Повторите попытку.", "ui.saveError": "Не удалось сохранить журнал: {error}",
"ui.aggregating": "Сбор записей о работе", "ui.aggregating": "Сбор записей о работе",
"ui.loadError": "Не удалось загрузить журнал. Повторите попытку.", "ui.loadError": "Не удалось загрузить журнал: {error}",
"ui.ready": "Готово", "ui.ready": "Готово",
"ui.workItem": "Выполненная работа", "ui.workItem": "Выполненная работа",
"ui.body": "Описание", "ui.body": "Описание",

View File

@ -15,7 +15,6 @@
], ],
"permissions": [ "permissions": [
"events.publish", "events.publish",
"files.read",
"storage.namespace", "storage.namespace",
"ui.register" "ui.register"
], ],

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 option{background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb)}', '.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-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,16 +43,14 @@
'.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}',
@ -173,7 +171,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)) || ''; var workspaceName = workspaceRoot || (workspaceNode && (workspaceNode.name || workspaceNode.title)) || 'Workspace';
var notes = []; var notes = [];
var selectedPath = ''; var selectedPath = '';
@ -289,12 +287,23 @@
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 createModal = null; var createPanel = el('div', { className: 'notes-panel', style: { display: 'none' } });
var createInput = null; var createInput = el('input', { className: 'notes-input', 'data-notes-create-input': '', placeholder: tr('ui.noteTitle', null, 'Note title') });
var createError = null; var createConfirm = el('button', { className: 'notes-btn', textContent: tr('ui.create', null, 'Create') });
var renameModal = null; var createCancel = el('button', { className: 'notes-btn', textContent: tr('ui.cancel', null, 'Cancel') });
var renameInput = null; createPanel.appendChild(createInput);
var renameError = null; createPanel.appendChild(createConfirm);
createPanel.appendChild(createCancel);
containerEl.appendChild(createPanel);
var renamePanel = el('div', { className: 'notes-panel', style: { display: 'none' } });
var renameInput = el('input', { className: 'notes-input', 'data-notes-rename-input': '', placeholder: tr('ui.newTitle', null, 'New title') });
var renameConfirm = el('button', { className: 'notes-btn', textContent: tr('ui.rename', null, 'Rename') });
var renameCancel = el('button', { className: 'notes-btn', textContent: tr('ui.cancel', null, 'Cancel') });
renamePanel.appendChild(renameInput);
renamePanel.appendChild(renameConfirm);
renamePanel.appendChild(renameCancel);
containerEl.appendChild(renamePanel);
// ─── Core Functions ───────────────────────────────────── // ─── Core Functions ─────────────────────────────────────
@ -315,13 +324,6 @@
} }
} }
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' }, [tr('ui.loading', null, 'Loading...')])); listContainer.appendChild(el('div', { className: 'notes-empty' }, [tr('ui.loading', null, 'Loading...')]));
@ -333,7 +335,7 @@
renderList(); renderList();
}).catch(function (err) { }).catch(function (err) {
if (disposed) return; if (disposed) return;
renderEmpty(userFacingError('ui.loadError', 'Could not load notes. Please try again.', err)); renderEmpty('Error: ' + (err.message || err));
}); });
} }
@ -377,7 +379,8 @@
}).then(function () { }).then(function () {
if (!disposed) setStatus(action.label + ' complete', 'success'); if (!disposed) setStatus(action.label + ' complete', 'success');
}).catch(function (err) { }).catch(function (err) {
if (!disposed) setStatus(userFacingError('ui.actionError', 'Could not complete this note action. Please try again.', err), 'error'); console.error('[notes] contribution action failed:', err);
if (!disposed) setStatus('Error: ' + (err && err.message ? err.message : err), 'error');
}); });
} }
@ -486,134 +489,28 @@
}).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() {
hideCreate(); createInput.value = '';
createModal = showNoteFormModal('create'); createPanel.style.display = 'flex';
createInput.focus();
} }
function hideCreate() { function hideCreate() {
if (createModal) createModal.remove(); createPanel.style.display = 'none';
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) { if (!title) return;
setCreateError(tr('ui.titleRequired', null, 'Enter a note title.'));
createInput.focus();
return;
}
setStatus(tr('ui.creating', null, 'Creating note...'), 'loading'); 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) {
setCreateError(conflictMessage(title, data.path)); showConflictModal(title, data.path, createInput);
createInput.focus();
return; return;
} }
hideCreate(); hideCreate();
@ -636,55 +533,42 @@
}).catch(function () {}); }).catch(function () {});
} }
}).catch(function (err) { }).catch(function (err) {
setCreateError(userFacingError('ui.createError', 'Could not create the note. Please try again.', err)); setStatus('Error: ' + (err.message || err), 'error');
}); });
} }
// ─── Rename ───────────────────────────────────────────── // ─── Rename ─────────────────────────────────────────────
function beginRename(note) { function beginRename(note) {
hideRename();
renameTarget = note; renameTarget = note;
renameModal = showNoteFormModal('rename', note); renameInput.value = note.title || fileName(note.path);
renamePanel.style.display = 'flex';
renameInput.focus();
renameInput.select();
} }
function hideRename() { function hideRename() {
renameTarget = null; renameTarget = null;
if (renameModal) renameModal.remove(); renamePanel.style.display = 'none';
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 || !renameInput) return; if (!renameTarget) return;
var newTitle = renameInput.value.trim(); var newTitle = renameInput.value.trim();
if (!newTitle) { if (!newTitle) return;
setRenameError(tr('ui.titleRequired', null, 'Enter a note title.'));
renameInput.focus();
return;
}
setStatus(tr('ui.renaming', null, 'Renaming...'), 'loading'); 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) {
setRenameError(conflictMessage(newTitle, data.path)); showConflictModal(newTitle, data.path, renameInput);
renameInput.focus();
return; return;
} }
hideRename(); hideRename();
setStatus(tr('ui.renamed', null, 'Note renamed'), 'success'); setStatus(tr('ui.renamed', null, 'Note renamed'), 'success');
loadNotes(); loadNotes();
}).catch(function (err) { }).catch(function (err) {
setRenameError(userFacingError('ui.renameError', 'Could not rename the note. Please try again.', err)); setStatus('Error: ' + (err.message || err), 'error');
}); });
} }
@ -701,11 +585,30 @@
setStatus(tr('ui.trashed', null, 'Note moved to trash'), 'success'); setStatus(tr('ui.trashed', null, 'Note moved to trash'), 'success');
loadNotes(); loadNotes();
}).catch(function (err) { }).catch(function (err) {
setStatus(userFacingError('ui.trashError', 'Could not move the note to trash. Please try again.', err), 'error'); setStatus('Error: ' + (err.message || 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' }, [tr('ui.conflictTitle', null, 'Name Conflict')]),
el('div', { className: 'notes-modal-msg' }, [
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('')),
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' });
@ -739,6 +642,18 @@
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 ───────────────────────────────────────────────
@ -751,6 +666,12 @@
createBtn.innerHTML = iconSvg('add') + ' ' + tr('ui.newNote', null, 'New Note'); createBtn.innerHTML = iconSvg('add') + ' ' + tr('ui.newNote', null, 'New Note');
filterInput.setAttribute('placeholder', tr('ui.filter', null, 'Filter notes')); filterInput.setAttribute('placeholder', tr('ui.filter', null, 'Filter notes'));
titleBar.textContent = tr('ui.title', { workspace: workspaceName }, 'Notes in ' + workspaceName); titleBar.textContent = tr('ui.title', { workspace: workspaceName }, 'Notes in ' + workspaceName);
createInput.setAttribute('placeholder', tr('ui.noteTitle', null, 'Note title'));
renameInput.setAttribute('placeholder', tr('ui.newTitle', null, 'New title'));
createConfirm.textContent = tr('ui.create', null, 'Create');
createCancel.textContent = tr('ui.cancel', null, 'Cancel');
renameConfirm.textContent = tr('ui.rename', null, 'Rename');
renameCancel.textContent = tr('ui.cancel', null, 'Cancel');
renderList(); renderList();
}); });
} }
@ -769,8 +690,6 @@
containerEl.__notesCleanup = function () { containerEl.__notesCleanup = function () {
disposed = true; disposed = true;
hideCreate();
hideRename();
if (typeof localeUnsubscribe === 'function') localeUnsubscribe(); if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
if (typeof fileChangedUnsubscribe === 'function') fileChangedUnsubscribe(); if (typeof fileChangedUnsubscribe === 'function') fileChangedUnsubscribe();
}; };

View File

@ -1,14 +1,11 @@
{ {
"manifest.name": "Notes", "manifest.name": "Notes",
"manifest.description": "Deal-scoped notes manager for Markdown files in canonical Notes folders with create, rename, trash, and Workbench integration.", "manifest.description": "Workspace-scoped notes manager for Markdown files in canonical Notes folders with create, rename, trash, and Workbench integration.",
"contributions.workspaceItems.verstak.notes.workspace.title": "Notes", "contributions.workspaceItems.verstak.notes.workspace.title": "Notes",
"ui.newNote": "New Note", "ui.newNote": "New Note",
"ui.filter": "Filter notes", "ui.filter": "Filter notes",
"ui.title": "Notes in {workspace}", "ui.title": "Notes in {workspace}",
"ui.noteTitle": "Note title", "ui.noteTitle": "Note title",
"ui.createTitle": "Create note",
"ui.renameTitle": "Rename note",
"ui.titleRequired": "Enter a note title.",
"ui.create": "Create", "ui.create": "Create",
"ui.cancel": "Cancel", "ui.cancel": "Cancel",
"ui.newTitle": "New title", "ui.newTitle": "New title",
@ -31,10 +28,5 @@
"ui.existingFile": " Existing file: {path}.", "ui.existingFile": " Existing file: {path}.",
"ui.chooseDifferent": " Please choose a different title.", "ui.chooseDifferent": " Please choose a different title.",
"ui.trashTitle": "Move Note to Trash", "ui.trashTitle": "Move Note to Trash",
"ui.trashConfirm": "Move \"{title}\" 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."
} }

View File

@ -1,14 +1,11 @@
{ {
"manifest.name": "Заметки", "manifest.name": "Заметки",
"manifest.description": "Менеджер Markdown-заметок Дела с созданием, переименованием и корзиной.", "manifest.description": "Менеджер Markdown-заметок рабочего пространства с созданием, переименованием и корзиной.",
"contributions.workspaceItems.verstak.notes.workspace.title": "Заметки", "contributions.workspaceItems.verstak.notes.workspace.title": "Заметки",
"ui.newNote": "Новая заметка", "ui.newNote": "Новая заметка",
"ui.filter": "Фильтр заметок", "ui.filter": "Фильтр заметок",
"ui.title": "Заметки · {workspace}", "ui.title": "Заметки · {workspace}",
"ui.noteTitle": "Название заметки", "ui.noteTitle": "Название заметки",
"ui.createTitle": "Создать заметку",
"ui.renameTitle": "Переименовать заметку",
"ui.titleRequired": "Введите название заметки.",
"ui.create": "Создать", "ui.create": "Создать",
"ui.cancel": "Отмена", "ui.cancel": "Отмена",
"ui.newTitle": "Новое название", "ui.newTitle": "Новое название",
@ -31,10 +28,5 @@
"ui.existingFile": " Существующий файл: {path}.", "ui.existingFile": " Существующий файл: {path}.",
"ui.chooseDifferent": " Выберите другое название.", "ui.chooseDifferent": " Выберите другое название.",
"ui.trashTitle": "Переместить заметку в корзину", "ui.trashTitle": "Переместить заметку в корзину",
"ui.trashConfirm": "Переместить «{title}» в корзину?", "ui.trashConfirm": "Переместить «{title}» в корзину?"
"ui.loadError": "Не удалось загрузить заметки. Повторите попытку.",
"ui.actionError": "Не удалось выполнить действие с заметкой. Повторите попытку.",
"ui.createError": "Не удалось создать заметку. Повторите попытку.",
"ui.renameError": "Не удалось переименовать заметку. Повторите попытку.",
"ui.trashError": "Не удалось переместить заметку в корзину. Повторите попытку."
} }

View File

@ -4,7 +4,7 @@
"name": "Notes", "name": "Notes",
"version": "0.1.0", "version": "0.1.0",
"apiVersion": "0.1.0", "apiVersion": "0.1.0",
"description": "Deal-scoped notes manager for Markdown files in canonical Notes folders with create, rename, trash, and Workbench integration.", "description": "Workspace-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" } }, "localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official", "source": "official",
"icon": "edit", "icon": "edit",

View File

@ -277,8 +277,7 @@
try { try {
providers = await api.contributions.list('searchProviders'); providers = await api.contributions.list('searchProviders');
} catch (err) { } catch (err) {
console.warn('[verstak.search] list search providers:', err); output.errors.push(err && err.message ? err.message : String(err));
output.errors.push(true);
return output; return output;
} }
providers = Array.isArray(providers) ? providers : []; providers = Array.isArray(providers) ? providers : [];
@ -295,8 +294,7 @@
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) {
console.warn('[verstak.search] provider search:', err); output.errors.push(err && err.message ? err.message : String(err));
output.errors.push(true);
} }
} }
return output; return output;
@ -373,9 +371,7 @@
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', {}, [tr('ui.providersFailed', null, 'Some plugin search providers did not respond')]), el('summary', {}, [tr('ui.providersFailed', null, 'Some plugin search providers did not respond')]),
el('div', {}, [state.providerErrors.map(function () { el('div', {}, [state.providerErrors.join('; ')])
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');
@ -464,9 +460,8 @@
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 = tr('ui.searchError', null, 'Could not search the vault. Please try again.'); state.error = err && err.message ? err.message : String(err);
state.providerErrors = []; state.providerErrors = [];
} finally { } finally {
if (seq !== searchSeq) return; if (seq !== searchSeq) return;

View File

@ -10,8 +10,6 @@
"ui.searching": "Searching...", "ui.searching": "Searching...",
"ui.search": "Search", "ui.search": "Search",
"ui.providersFailed": "Some plugin search providers did not respond", "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.noResults": "No results",
"ui.open": "Open", "ui.open": "Open",
"ui.count": "{count} result(s)" "ui.count": "{count} result(s)"

View File

@ -10,8 +10,6 @@
"ui.searching": "Поиск...", "ui.searching": "Поиск...",
"ui.search": "Искать", "ui.search": "Искать",
"ui.providersFailed": "Некоторые поставщики поиска не ответили", "ui.providersFailed": "Некоторые поставщики поиска не ответили",
"ui.providerUnavailable": "Один из источников поиска недоступен.",
"ui.searchError": "Не удалось выполнить поиск в хранилище. Повторите попытку.",
"ui.noResults": "Ничего не найдено", "ui.noResults": "Ничего не найдено",
"ui.open": "Открыть", "ui.open": "Открыть",
"ui.count": "Результатов: {count}" "ui.count": "Результатов: {count}"

View File

@ -23,7 +23,6 @@
'.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}',
@ -45,11 +44,10 @@
'.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,.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-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-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{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,.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-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-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}',
@ -60,12 +58,7 @@
'.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}',
'.secrets-modal-overlay{position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.6)}', '@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{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) {
@ -102,30 +95,29 @@
|| (node && (node.rootPath || node.name || node.id))); || (node && (node.rootPath || node.name || node.id)));
} }
function scopeLabel(record, translate) { function scopeLabel(record) {
var scope = record && record.scope || {}; var scope = record && record.scope || {};
if (scope.kind === ScopeWorkspace) return cleanWorkspace(scope.workspaceRootPath) || translate('ui.deal', null, 'Deal'); if (scope.kind === ScopeWorkspace) return cleanWorkspace(scope.workspaceRootPath) || 'Workspace';
return translate('ui.global', null, 'Global'); return 'Global';
} }
function selectedIDFromProps(props) { function selectedIDFromProps(props) {
var resource = props && props.resource || {}; var resource = props && props.resource || {};
var request = props && props.request || {}; var path = text(resource.path || props && props.secretId);
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, translate) { function groupRecords(records) {
var groups = {}; var groups = {};
records.forEach(function (record) { records.forEach(function (record) {
var label = scopeLabel(record, translate); var label = scopeLabel(record);
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 === translate('ui.global', null, 'Global')) return -1; if (a === 'Global') return -1;
if (b === translate('ui.global', null, 'Global')) return 1; if (b === '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) {
@ -152,13 +144,8 @@
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 = '';
@ -174,92 +161,6 @@
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',
@ -289,7 +190,7 @@
return loadRecords(); return loadRecords();
}).catch(function (err) { }).catch(function (err) {
unlockBtn.disabled = false; unlockBtn.disabled = false;
setUserFacingError('unlock', err); setStatus((err && err.message) ? err.message : String(err), true);
}); });
} }
}, [tr('ui.unlock', null, 'Unlock')]); }, [tr('ui.unlock', null, 'Unlock')]);
@ -325,48 +226,19 @@
} }
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' }, [tr('ui.title', null, 'Secrets')]), el('span', { className: 'secrets-title' }, [tr('ui.title', null, 'Secrets')]),
el('span', { className: 'secrets-count' }, [String(visibleRecords.length)]), el('span', { className: 'secrets-count' }, [String(records.length)]),
el('span', { className: 'secrets-spacer' }), el('span', { className: 'secrets-spacer' }),
el('button', { className: 'secrets-btn', type: 'button', onClick: showNewSecret }, [tr('ui.new', null, 'New')]) el('button', { className: 'secrets-btn', type: 'button', onClick: showNewSecret }, [tr('ui.new', null, 'New')])
]), ])
el('div', { className: 'secrets-filters' }, [searchInput, scopeSelect])
]; ];
if (!visibleRecords.length) { if (!records.length) {
children.push(el('div', { className: 'secrets-empty' }, [tr('ui.empty', null, 'No secrets')])); children.push(el('div', { className: 'secrets-empty' }, [tr('ui.empty', null, 'No secrets')]));
return children; return children;
} }
groupRecords(visibleRecords, tr).forEach(function (group) { groupRecords(records).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;
@ -387,19 +259,16 @@
function renderSelected() { function renderSelected() {
if (!selectedRecord) return el('div', { className: 'secrets-card' }, [ if (!selectedRecord) return el('div', { className: 'secrets-card' }, [
el('h2', {}, [tr('ui.select', null, '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(tr('ui.group', null, 'Group'), scopeLabel(selectedRecord, tr)), fieldRow(tr('ui.group', null, 'Group'), scopeLabel(selectedRecord)),
fieldRow('ID', selectedRecord.id), fieldRow('ID', selectedRecord.id),
fieldRow(tr('ui.username', null, 'Username'), selectedRecord.username || ''), fieldRow(tr('ui.username', null, 'Username'), selectedRecord.username || ''),
fieldRow(tr('ui.password', null, 'Password'), valueDisplay, isValueVisible && valueIsAvailable ? 'secrets-secret-value' : 'secrets-hidden-value'), fieldRow(tr('ui.password', null, 'Password'), selectedValue ? selectedValue : '••••••••••••', selectedValue ? '' : 'secrets-hidden-value'),
fieldRow(tr('ui.updated', null, 'Updated'), selectedRecord.updatedAt || '') fieldRow(tr('ui.updated', null, 'Updated'), selectedRecord.updatedAt || '')
]) ])
]), ]),
@ -410,24 +279,6 @@
'data-secret-copy-link': selectedRecord.id, 'data-secret-copy-link': selectedRecord.id,
onClick: function () { copySecretLink(selectedRecord.id); } onClick: function () { copySecretLink(selectedRecord.id); }
}, [tr('ui.copyLink', null, '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',
@ -465,26 +316,9 @@
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 }, [tr('ui.global', null, 'Global')]), el('option', { value: ScopeGlobal }, [tr('ui.global', null, 'Global')]),
el('option', { value: ScopeWorkspace }, [tr('ui.deal', null, 'Deal')]) el('option', { value: ScopeWorkspace }, [workspaceRoot || tr('ui.workspace', null, 'Workspace')])
]); ]);
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 ? tr('ui.editSecret', null, 'Edit secret') : tr('ui.newSecret', null, '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' }, [
@ -492,7 +326,6 @@
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' }, [tr('ui.username', null, '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' }, [tr('ui.scope', null, 'Scope')]), scope]), el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, [tr('ui.scope', null, 'Scope')]), scope]),
workspaceRow,
el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, [tr('ui.value', null, 'Value')]), value]), el('div', { className: 'secrets-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', {
@ -501,24 +334,19 @@
'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: targetWorkspace } : { kind: ScopeGlobal } scope: scope.value === ScopeWorkspace ? { kind: ScopeWorkspace, workspaceRootPath: workspaceRoot } : { 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) {
setUserFacingError('save', err); setStatus((err && err.message) ? err.message : String(err), true);
}); });
} }
}, [tr('ui.save', null, 'Save')]), }, [tr('ui.save', null, 'Save')]),
@ -553,20 +381,11 @@
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);
}); });
} }
@ -575,7 +394,6 @@
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) {
@ -585,7 +403,7 @@
render(); render();
}).catch(function (err) { }).catch(function (err) {
if (disposed) return; if (disposed) return;
setUserFacingError('read', err); setStatus((err && err.message) ? err.message : String(err), true);
}); });
} }
@ -605,68 +423,14 @@
} }
function deleteSecret(id) { function deleteSecret(id) {
if (!id) return; if (!id || !window.confirm(tr('ui.deleteConfirm', null, 'Delete this secret?'))) 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) {
setUserFacingError('delete', err); setStatus((err && err.message) ? err.message : String(err), true);
});
}
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);
}); });
} }
@ -676,17 +440,17 @@
setStatus(tr('ui.linkCopied', null, 'Secret link copied'), false); setStatus(tr('ui.linkCopied', null, 'Secret link copied'), false);
}); });
}).catch(function (err) { }).catch(function (err) {
setUserFacingError('copyLink', err); setStatus((err && err.message) ? err.message : String(err), true);
}); });
} }
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 loadWorkspaceOptions().then(loadRecords); if (unlocked) return loadRecords();
render(); render();
}).catch(function (err) { }).catch(function (err) {
statusText = userFacingError('status', err); statusText = (err && err.message) ? err.message : String(err);
statusError = true; statusError = true;
renderLocked(); renderLocked();
}); });
@ -697,7 +461,6 @@
containerEl.__secretsCleanup = function () { containerEl.__secretsCleanup = function () {
disposed = true; disposed = true;
removeDeleteModal();
if (typeof localeUnsubscribe === 'function') localeUnsubscribe(); if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
}; };
}, },

View File

@ -1,17 +1,12 @@
{ {
"manifest.name": "Secrets", "manifest.name": "Secrets",
"manifest.description": "Encrypted global and Deal-scoped secret manager.", "manifest.description": "Encrypted global and workspace-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.openProviders.verstak.secrets.secret.title": "Secrets",
"contributions.settingsPanels.verstak.secrets.settings.title": "Secrets", "contributions.settingsPanels.verstak.secrets.settings.title": "Secrets",
"contributions.workspaceItems.verstak.secrets.workspace.title": "Secrets", "contributions.workspaceItems.verstak.secrets.workspace.title": "Secrets",
"ui.masterPassword": "Master password", "ui.masterPassword": "Master password",
"ui.repeatPassword": "Repeat master password", "ui.repeatPassword": "Repeat master password",
"ui.passwordMismatch": "Master passwords do not match", "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.unlock": "Unlock",
"ui.createMaster": "Create master password", "ui.createMaster": "Create master password",
"ui.password": "Password", "ui.password": "Password",
@ -21,42 +16,23 @@
"ui.new": "New", "ui.new": "New",
"ui.empty": "No secrets", "ui.empty": "No secrets",
"ui.select": "Select a secret", "ui.select": "Select a secret",
"ui.requestedUnavailable": "The requested secret is unavailable.",
"ui.group": "Group", "ui.group": "Group",
"ui.username": "Username", "ui.username": "Username",
"ui.updated": "Updated", "ui.updated": "Updated",
"ui.copyLink": "Copy secret link", "ui.copyLink": "Copy secret link",
"ui.edit": "Edit", "ui.edit": "Edit",
"ui.delete": "Delete", "ui.delete": "Delete",
"ui.copyValue": "Copy value",
"ui.showValue": "Show value",
"ui.hideValue": "Hide value",
"ui.fieldTitle": "Title", "ui.fieldTitle": "Title",
"ui.optionalUsername": "optional username", "ui.optionalUsername": "optional username",
"ui.secretValue": "Secret value", "ui.secretValue": "Secret value",
"ui.global": "Global", "ui.global": "Global",
"ui.workspace": "Deal", "ui.workspace": "Workspace",
"ui.editSecret": "Edit secret", "ui.editSecret": "Edit secret",
"ui.newSecret": "New secret", "ui.newSecret": "New secret",
"ui.scope": "Scope", "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.value": "Value",
"ui.save": "Save", "ui.save": "Save",
"ui.cancel": "Cancel", "ui.cancel": "Cancel",
"ui.deleteSecretTitle": "Delete secret", "ui.deleteConfirm": "Delete this secret?",
"ui.deleteConfirm": "Delete \"{title}\"? This cannot be undone.", "ui.linkCopied": "Secret link copied"
"ui.linkCopied": "Secret link copied",
"ui.valueCopied": "Secret value copied"
} }

View File

@ -1,17 +1,12 @@
{ {
"manifest.name": "Секреты", "manifest.name": "Секреты",
"manifest.description": "Зашифрованное хранилище общих секретов и секретов Дел.", "manifest.description": "Зашифрованное хранилище общих секретов и секретов рабочих пространств.",
"contributions.views.verstak.secrets.view.title": "Секреты",
"contributions.sidebarItems.verstak.secrets.sidebar.title": "Секреты",
"contributions.openProviders.verstak.secrets.secret.title": "Секреты", "contributions.openProviders.verstak.secrets.secret.title": "Секреты",
"contributions.settingsPanels.verstak.secrets.settings.title": "Секреты", "contributions.settingsPanels.verstak.secrets.settings.title": "Секреты",
"contributions.workspaceItems.verstak.secrets.workspace.title": "Секреты", "contributions.workspaceItems.verstak.secrets.workspace.title": "Секреты",
"ui.masterPassword": "Мастер-пароль", "ui.masterPassword": "Мастер-пароль",
"ui.repeatPassword": "Повторите мастер-пароль", "ui.repeatPassword": "Повторите мастер-пароль",
"ui.passwordMismatch": "Мастер-пароли не совпадают", "ui.passwordMismatch": "Мастер-пароли не совпадают",
"ui.masterPasswordMinLength": "Мастер-пароль должен содержать не менее {count} символов.",
"ui.masterPasswordRequired": "Введите мастер-пароль.",
"ui.masterPasswordInvalid": "Мастер-пароль указан неверно.",
"ui.unlock": "Разблокировать", "ui.unlock": "Разблокировать",
"ui.createMaster": "Создать мастер-пароль", "ui.createMaster": "Создать мастер-пароль",
"ui.password": "Пароль", "ui.password": "Пароль",
@ -21,42 +16,23 @@
"ui.new": "Новый", "ui.new": "Новый",
"ui.empty": "Секретов нет", "ui.empty": "Секретов нет",
"ui.select": "Выберите секрет", "ui.select": "Выберите секрет",
"ui.requestedUnavailable": "Запрошенный секрет недоступен.",
"ui.group": "Группа", "ui.group": "Группа",
"ui.username": "Имя пользователя", "ui.username": "Имя пользователя",
"ui.updated": "Обновлён", "ui.updated": "Обновлён",
"ui.copyLink": "Копировать ссылку на секрет", "ui.copyLink": "Копировать ссылку на секрет",
"ui.edit": "Изменить", "ui.edit": "Изменить",
"ui.delete": "Удалить", "ui.delete": "Удалить",
"ui.copyValue": "Копировать значение",
"ui.showValue": "Показать значение",
"ui.hideValue": "Скрыть значение",
"ui.fieldTitle": "Название", "ui.fieldTitle": "Название",
"ui.optionalUsername": "необязательное имя пользователя", "ui.optionalUsername": "необязательное имя пользователя",
"ui.secretValue": "Значение секрета", "ui.secretValue": "Значение секрета",
"ui.global": "Общий", "ui.global": "Общий",
"ui.workspace": "Дело", "ui.workspace": "Рабочее пространство",
"ui.editSecret": "Изменить секрет", "ui.editSecret": "Изменить секрет",
"ui.newSecret": "Новый секрет", "ui.newSecret": "Новый секрет",
"ui.scope": "Область", "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.value": "Значение",
"ui.save": "Сохранить", "ui.save": "Сохранить",
"ui.cancel": "Отмена", "ui.cancel": "Отмена",
"ui.deleteSecretTitle": "Удалить секрет", "ui.deleteConfirm": "Удалить этот секрет?",
"ui.deleteConfirm": "Удалить «{title}»? Это действие нельзя отменить.", "ui.linkCopied": "Ссылка на секрет скопирована"
"ui.linkCopied": "Ссылка на секрет скопирована",
"ui.valueCopied": "Значение секрета скопировано"
} }

View File

@ -4,7 +4,7 @@
"name": "Secrets", "name": "Secrets",
"version": "0.1.0", "version": "0.1.0",
"apiVersion": "0.1.0", "apiVersion": "0.1.0",
"description": "Encrypted global and Deal-scoped secret manager.", "description": "Encrypted global and workspace-scoped secret manager.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } }, "localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official", "source": "official",
"icon": "key-round", "icon": "key-round",
@ -14,7 +14,6 @@
"secrets.write-ui" "secrets.write-ui"
], ],
"permissions": [ "permissions": [
"files.read",
"secrets.read", "secrets.read",
"secrets.write", "secrets.write",
"ui.register" "ui.register"
@ -23,23 +22,6 @@
"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",

View File

@ -28,9 +28,11 @@
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 reportError(key, fallback, error) { function sanitizeError(msg) {
console.warn('[verstak.sync] operation failed:', error) if (!msg) return tr('ui.unknownError', null, 'Unknown error')
return tr(key, null, fallback) let s = String(msg).replace(/<[^>]+>/g, '')
if (s.length > 200) s = s.substring(0, 200) + '...'
return s
} }
function syncAPI() { function syncAPI() {
@ -49,21 +51,15 @@
syncInterval = saved.syncInterval || 5 syncInterval = saved.syncInterval || 5
} }
} }
} catch (error) { } catch (_) {}
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 (error) {
console.warn('[verstak.sync] status load failed:', error)
settings = null
} }
} catch (_) { settings = null }
} }
load() load()
@ -84,7 +80,7 @@
resultMsg = tr('ui.settingsSaved', null, 'Settings saved.') resultMsg = tr('ui.settingsSaved', null, 'Settings saved.')
resultKind = '' resultKind = ''
} catch (e) { } catch (e) {
errorMsg = reportError('ui.saveFailed', 'Could not save sync settings. Please try again.', e) errorMsg = sanitizeError(e.message || e)
} }
loading = false loading = false
} }
@ -101,7 +97,7 @@
connectionResult = tr('ui.connectionSuccessful', null, 'Connection successful.') connectionResult = tr('ui.connectionSuccessful', null, 'Connection successful.')
} catch (e) { } catch (e) {
connectionOk = false connectionOk = false
connectionResult = reportError('ui.connectionFailed', 'Could not connect. Check the server address and credentials.', e) connectionResult = tr('ui.connectionFailed', { error: sanitizeError(e.message || e) }, 'Connection failed: {error}')
} }
loading = false loading = false
} }
@ -119,7 +115,7 @@
password = '' password = ''
await load() await load()
} catch (e) { } catch (e) {
errorMsg = reportError('ui.connectFailed', 'Could not connect this device. Please try again.', e) errorMsg = sanitizeError(e.message || e)
} }
loading = false loading = false
} }
@ -133,8 +129,23 @@
return parts.join(' · ') return parts.join(' · ')
} }
function formatSyncConflict() { function conflictField(conflict, keys) {
return tr('ui.syncConflictItem', null, 'A synchronization conflict needs attention.') for (const key of keys) {
const value = conflict && conflict[key]
if (value != null && String(value).trim()) return String(value)
}
return ''
}
function formatSyncConflict(conflict) {
const entityType = conflictField(conflict, ['entity_type', 'entityType']) || 'item'
const entityId = conflictField(conflict, ['entity_id', 'entityId', 'path']) || 'unknown'
const opId = conflictField(conflict, ['op_id', 'opId'])
const reason = conflictField(conflict, ['reason', 'message'])
const parts = [entityType + ': ' + entityId]
if (opId) parts.push('op ' + opId)
if (reason) parts.push(reason)
return parts.join(' · ')
} }
async function runSyncNow() { async function runSyncNow() {
@ -152,7 +163,7 @@
resultKind = warning ? 'warning' : '' resultKind = warning ? 'warning' : ''
await load() await load()
} catch (e) { } catch (e) {
errorMsg = reportError('ui.syncFailed', 'Could not synchronize. Please try again.', e) errorMsg = sanitizeError(e.message || e)
} }
loading = false loading = false
} }
@ -183,7 +194,7 @@
settings = null settings = null
await load() await load()
} catch (e) { } catch (e) {
errorMsg = reportError('ui.disconnectFailed', 'Could not disconnect from the server. Please try again.', e) errorMsg = sanitizeError(e.message || e)
} }
loading = false loading = false
} }
@ -198,7 +209,7 @@
resultKind = '' resultKind = ''
await load() await load()
} catch (e) { } catch (e) {
errorMsg = reportError('ui.resetKeyFailed', 'Could not reset the sync key. Please try again.', e) errorMsg = sanitizeError(e.message || e)
} }
loading = false loading = false
} }
@ -232,7 +243,7 @@
{/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;">
{tr('ui.lastSyncError', null, 'The last synchronization did not finish. Try again.')} {tr('ui.lastSyncError', { error: sanitizeError(settings.lastError) }, 'Last sync error: {error}')}
</div> </div>
{/if} {/if}

View File

@ -5,17 +5,12 @@
"ui.title": "Sync", "ui.title": "Sync",
"ui.description": "Synchronize your vault across devices.", "ui.description": "Synchronize your vault across devices.",
"ui.unknownError": "Unknown error", "ui.unknownError": "Unknown error",
"ui.apiUnavailable": "Synchronization is unavailable right now.", "ui.apiUnavailable": "Plugin API sync namespace not available",
"ui.intervalError": "Sync interval must be between 1 and 1440 minutes.", "ui.intervalError": "Sync interval must be between 1 and 1440 minutes.",
"ui.settingsSaved": "Settings saved.", "ui.settingsSaved": "Settings saved.",
"ui.serverRequired": "Server URL is required.", "ui.serverRequired": "Server URL is required.",
"ui.connectionSuccessful": "Connection successful.", "ui.connectionSuccessful": "Connection successful.",
"ui.connectionFailed": "Could not connect. Check the server address and credentials.", "ui.connectionFailed": "Connection failed: {error}",
"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.connectedSuccessfully": "Connected successfully.",
"ui.conflictsCount": "{count} conflict(s)", "ui.conflictsCount": "{count} conflict(s)",
"ui.errorsCount": "{count} error(s)", "ui.errorsCount": "{count} error(s)",
@ -23,8 +18,7 @@
"ui.disconnected": "Disconnected from server.", "ui.disconnected": "Disconnected from server.",
"ui.keyReset": "Sync key reset. Connect again to pair this device.", "ui.keyReset": "Sync key reset. Connect again to pair this device.",
"ui.syncConflicts": "Sync conflicts", "ui.syncConflicts": "Sync conflicts",
"ui.lastSyncError": "The last synchronization did not finish. Try again.", "ui.lastSyncError": "Last sync error: {error}",
"ui.syncConflictItem": "A synchronization conflict needs attention.",
"ui.server": "Server", "ui.server": "Server",
"ui.serverUrl": "Server URL", "ui.serverUrl": "Server URL",
"ui.username": "Username", "ui.username": "Username",

View File

@ -5,17 +5,12 @@
"ui.title": "Синхронизация", "ui.title": "Синхронизация",
"ui.description": "Синхронизируйте хранилище между устройствами.", "ui.description": "Синхронизируйте хранилище между устройствами.",
"ui.unknownError": "Неизвестная ошибка", "ui.unknownError": "Неизвестная ошибка",
"ui.apiUnavailable": "Синхронизация сейчас недоступна.", "ui.apiUnavailable": "API синхронизации плагина недоступен",
"ui.intervalError": "Интервал синхронизации должен быть от 1 до 1440 минут.", "ui.intervalError": "Интервал синхронизации должен быть от 1 до 1440 минут.",
"ui.settingsSaved": "Настройки сохранены.", "ui.settingsSaved": "Настройки сохранены.",
"ui.serverRequired": "Укажите URL сервера.", "ui.serverRequired": "Укажите URL сервера.",
"ui.connectionSuccessful": "Подключение успешно.", "ui.connectionSuccessful": "Подключение успешно.",
"ui.connectionFailed": "Не удалось подключиться. Проверьте адрес сервера и учётные данные.", "ui.connectionFailed": "Не удалось подключиться: {error}",
"ui.saveFailed": "Не удалось сохранить настройки синхронизации. Повторите попытку.",
"ui.connectFailed": "Не удалось подключить это устройство. Повторите попытку.",
"ui.syncFailed": "Не удалось синхронизировать данные. Повторите попытку.",
"ui.disconnectFailed": "Не удалось отключиться от сервера. Повторите попытку.",
"ui.resetKeyFailed": "Не удалось сбросить ключ синхронизации. Повторите попытку.",
"ui.connectedSuccessfully": "Подключение установлено.", "ui.connectedSuccessfully": "Подключение установлено.",
"ui.conflictsCount": "Конфликтов: {count}", "ui.conflictsCount": "Конфликтов: {count}",
"ui.errorsCount": "Ошибок: {count}", "ui.errorsCount": "Ошибок: {count}",
@ -23,8 +18,7 @@
"ui.disconnected": "Сервер отключён.", "ui.disconnected": "Сервер отключён.",
"ui.keyReset": "Ключ синхронизации сброшен. Подключитесь снова, чтобы связать устройство.", "ui.keyReset": "Ключ синхронизации сброшен. Подключитесь снова, чтобы связать устройство.",
"ui.syncConflicts": "Конфликты синхронизации", "ui.syncConflicts": "Конфликты синхронизации",
"ui.lastSyncError": "Последняя синхронизация не завершилась. Повторите попытку.", "ui.lastSyncError": "Ошибка последней синхронизации: {error}",
"ui.syncConflictItem": "Конфликт синхронизации требует внимания.",
"ui.server": "Сервер", "ui.server": "Сервер",
"ui.serverUrl": "URL сервера", "ui.serverUrl": "URL сервера",
"ui.username": "Имя пользователя", "ui.username": "Имя пользователя",

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);color-scheme:dark;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);font:inherit;font-size:.78rem;padding:.32rem .45rem}',
'.todo-input.search{width:min(15rem,100%)}.todo-input.textarea{min-height:6.5rem;resize:vertical;line-height:1.4}.todo-input.todo-time-input{width:100%;font-variant-numeric:tabular-nums}.todo-select{max-width:12rem;appearance:none;background-color:var(--vt-color-surface,#15152c);background-image:linear-gradient(45deg,transparent 50%,var(--vt-color-text-muted,#7f8aa3) 50%),linear-gradient(135deg,var(--vt-color-text-muted,#7f8aa3) 50%,transparent 50%);background-position:calc(100% - 14px) 50%,calc(100% - 9px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:1.7rem}.todo-select option{background:var(--vt-color-surface,#15152c);color:var(--vt-color-text-primary,#f4f7fb)}', '.todo-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-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)}',
@ -96,28 +96,7 @@
} }
function cleanDate(value) { function cleanDate(value) {
value = text(value).trim(); return /^\d{4}-\d{2}-\d{2}$/.test(text(value).trim()) ? 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) {
@ -127,27 +106,6 @@
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, '-');
} }
@ -155,8 +113,6 @@
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 {
@ -168,8 +124,7 @@
status: status, status: status,
priority: cleanPriority(value.priority), priority: cleanPriority(value.priority),
dueAt: cleanDate(value.dueAt || value.dueDate), dueAt: cleanDate(value.dueAt || value.dueDate),
reminderDate: cleanDate(value.reminderDate) || reminder.date, reminderAt: cleanDateTime(value.reminderAt || value.reminderDateTime),
reminderAt: reminderAt,
createdAt: createdAt, createdAt: createdAt,
updatedAt: text(value.updatedAt).trim() || createdAt, updatedAt: text(value.updatedAt).trim() || createdAt,
completedAt: completedAt, completedAt: completedAt,
@ -198,7 +153,6 @@
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,
@ -386,53 +340,21 @@
}); });
} }
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 syncNotifications(); if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
return api.settings.write(GLOBAL_KEY, storageTodos(sortTodos(todos))).then(function () { return api.settings.write(GLOBAL_KEY, storageTodos(sortTodos(todos))).catch(function (err) {
return syncNotifications(); statusText = tr('ui.saveError', { error: err && err.message ? err.message : String(err) }, 'Could not save todos: ' + (err && err.message ? err.message : String(err)));
}).catch(function (err) { statusClass = 'error';
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 syncNotifications(); 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) {
todos = sortTodos(normalizeTodos((settings || {})[GLOBAL_KEY])); todos = sortTodos(normalizeTodos((settings || {})[GLOBAL_KEY]));
return syncNotifications();
}).catch(function (err) { }).catch(function (err) {
reportError('ui.loadError', 'Could not load tasks. Please try again.', err); statusText = tr('ui.loadError', { error: err && err.message ? err.message : String(err) }, 'Could not load todos: ' + (err && err.message ? err.message : String(err)));
statusClass = 'error';
}); });
} }
@ -464,9 +386,7 @@
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'))]); 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 reminder = splitReminderDateTime(editing ? existingTodo.reminderAt : ''); var reminderInput = el('input', { className: 'todo-input', type: 'datetime-local', value: editing ? existingTodo.reminderAt : '', 'data-todo-input': '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') {
@ -487,20 +407,6 @@
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),
@ -511,8 +417,7 @@
status: editing ? existingTodo.status : 'open', status: editing ? existingTodo.status : 'open',
priority: priorityInput.value, priority: priorityInput.value,
dueAt: dueInput.value, dueAt: dueInput.value,
reminderDate: reminderDate, reminderAt: reminderInput.value,
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 : '',
@ -537,8 +442,7 @@
el('label', { className: 'todo-field wide' }, [tr('ui.field.description', null, 'Description'), descriptionInput]), el('label', { className: 'todo-field wide' }, [tr('ui.field.description', null, 'Description'), descriptionInput]),
el('label', { className: 'todo-field' }, [tr('ui.field.priority', null, 'Priority'), priorityInput]), el('label', { className: 'todo-field' }, [tr('ui.field.priority', null, 'Priority'), priorityInput]),
el('label', { className: 'todo-field' }, [tr('ui.field.due', null, 'Due date'), dueInput]), el('label', { className: 'todo-field' }, [tr('ui.field.due', null, 'Due date'), dueInput]),
el('label', { className: 'todo-field' }, [tr('ui.field.reminderDate', null, 'Reminder date'), reminderDateInput]), el('label', { className: 'todo-field' }, [tr('ui.field.reminder', null, 'Reminder'), reminderInput])
el('label', { className: 'todo-field' }, [tr('ui.field.reminderTime', null, 'Reminder time'), reminderTimeInput])
]; ];
if (workspaceInput) fields.push(el('label', { className: 'todo-field' }, [tr('ui.field.workspace', null, 'Workspace'), workspaceInput])); if (workspaceInput) fields.push(el('label', { className: 'todo-field' }, [tr('ui.field.workspace', null, 'Workspace'), workspaceInput]));
else fields.push(el('div', { className: 'todo-field', textContent: tr('ui.workspaceValue', { workspace: scope.workspaceRoot }, 'Workspace: ' + scope.workspaceRoot) })); else fields.push(el('div', { className: 'todo-field', textContent: tr('ui.workspaceValue', { workspace: scope.workspaceRoot }, 'Workspace: ' + scope.workspaceRoot) }));
@ -619,7 +523,6 @@
meta.push(el('span', { className: 'todo-badge', textContent: tr('ui.status.' + todo.status, null, 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: 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.dueAt) meta.push(el('span', { className: 'todo-badge ' + due, textContent: tr('ui.dueValue', { prefix: due === 'overdue' ? tr('ui.overduePrefix', null, 'Overdue · ') : (due === 'due-soon' ? tr('ui.dueSoonPrefix', null, 'Due soon · ') : ''), date: formatDate(todo.dueAt) }, (due === 'overdue' ? 'Overdue · ' : (due === 'due-soon' ? 'Due soon · ' : '')) + 'Due ' + formatDate(todo.dueAt)) }));
if (todo.reminderAt) meta.push(el('span', { className: 'todo-badge ' + (reminderDue ? 'reminder-due' : ''), textContent: tr(reminderDue ? 'ui.reminderDueValue' : 'ui.reminderValue', { date: formatDate(todo.reminderAt) }, (reminderDue ? 'Reminder due ' : 'Reminder ') + formatDate(todo.reminderAt)) })); 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);
} }
@ -686,7 +589,6 @@
searchInput.setAttribute('placeholder', tr('ui.search', null, 'Search todos')); searchInput.setAttribute('placeholder', tr('ui.search', null, 'Search todos'));
addBtn.textContent = tr('ui.add', null, 'Add Todo'); addBtn.textContent = tr('ui.add', null, 'Add Todo');
render(); render();
syncNotifications().then(render);
}); });
} }
}; };

View File

@ -17,11 +17,8 @@
"ui.add": "Add Todo", "ui.add": "Add Todo",
"ui.allWorkspaces": "All workspaces", "ui.allWorkspaces": "All workspaces",
"ui.unassigned": "Unassigned", "ui.unassigned": "Unassigned",
"ui.saveError": "Could not save tasks. Please try again.", "ui.saveError": "Could not save todos: {error}",
"ui.loadError": "Could not load tasks. Please try again.", "ui.loadError": "Could not load todos: {error}",
"ui.notificationError": "Could not schedule reminders. Please try again.",
"ui.notificationTitle": "Todo reminder",
"ui.notificationBody": "{title}",
"ui.titlePlaceholder": "Todo title", "ui.titlePlaceholder": "Todo title",
"ui.descriptionPlaceholder": "Optional description", "ui.descriptionPlaceholder": "Optional description",
"ui.priority.low": "Low", "ui.priority.low": "Low",
@ -35,11 +32,6 @@
"ui.field.priority": "Priority", "ui.field.priority": "Priority",
"ui.field.due": "Due date", "ui.field.due": "Due date",
"ui.field.reminder": "Reminder", "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": "Workspace", "ui.field.workspace": "Workspace",
"ui.workspaceValue": "Workspace: {workspace}", "ui.workspaceValue": "Workspace: {workspace}",
"ui.edit": "Edit Todo", "ui.edit": "Edit Todo",
@ -55,7 +47,6 @@
"ui.dueValue": "{prefix}Due {date}", "ui.dueValue": "{prefix}Due {date}",
"ui.reminderDueValue": "Reminder due {date}", "ui.reminderDueValue": "Reminder due {date}",
"ui.reminderValue": "Reminder {date}", "ui.reminderValue": "Reminder {date}",
"ui.reminderDateValue": "Reminder date {date}",
"ui.noMatches": "No todos match the current filters.", "ui.noMatches": "No todos match the current filters.",
"ui.empty": "No todos yet.", "ui.empty": "No todos yet.",
"ui.openWorkspace": "Open workspace", "ui.openWorkspace": "Open workspace",

View File

@ -17,11 +17,8 @@
"ui.add": "Добавить задачу", "ui.add": "Добавить задачу",
"ui.allWorkspaces": "Все рабочие пространства", "ui.allWorkspaces": "Все рабочие пространства",
"ui.unassigned": "Не назначено", "ui.unassigned": "Не назначено",
"ui.saveError": "Не удалось сохранить задачи. Повторите попытку.", "ui.saveError": "Не удалось сохранить задачи: {error}",
"ui.loadError": "Не удалось загрузить задачи. Повторите попытку.", "ui.loadError": "Не удалось загрузить задачи: {error}",
"ui.notificationError": "Не удалось запланировать напоминания. Повторите попытку.",
"ui.notificationTitle": "Напоминание о задаче",
"ui.notificationBody": "{title}",
"ui.titlePlaceholder": "Название задачи", "ui.titlePlaceholder": "Название задачи",
"ui.descriptionPlaceholder": "Необязательное описание", "ui.descriptionPlaceholder": "Необязательное описание",
"ui.priority.low": "Низкий", "ui.priority.low": "Низкий",
@ -35,11 +32,6 @@
"ui.field.priority": "Приоритет", "ui.field.priority": "Приоритет",
"ui.field.due": "Срок", "ui.field.due": "Срок",
"ui.field.reminder": "Напоминание", "ui.field.reminder": "Напоминание",
"ui.field.reminderDate": "Дата напоминания",
"ui.field.reminderTime": "Время напоминания",
"ui.reminderTimePlaceholder": "14:30",
"ui.reminderTimeInvalid": "Введите корректное время напоминания (ЧЧ:ММ)",
"ui.reminderDateRequired": "Сначала выберите дату напоминания",
"ui.field.workspace": "Рабочее пространство", "ui.field.workspace": "Рабочее пространство",
"ui.workspaceValue": "Рабочее пространство: {workspace}", "ui.workspaceValue": "Рабочее пространство: {workspace}",
"ui.edit": "Изменить задачу", "ui.edit": "Изменить задачу",
@ -55,7 +47,6 @@
"ui.dueValue": "{prefix}Срок: {date}", "ui.dueValue": "{prefix}Срок: {date}",
"ui.reminderDueValue": "Пора напомнить: {date}", "ui.reminderDueValue": "Пора напомнить: {date}",
"ui.reminderValue": "Напоминание: {date}", "ui.reminderValue": "Напоминание: {date}",
"ui.reminderDateValue": "Дата напоминания: {date}",
"ui.noMatches": "Нет задач, соответствующих фильтрам.", "ui.noMatches": "Нет задач, соответствующих фильтрам.",
"ui.empty": "Задач пока нет.", "ui.empty": "Задач пока нет.",
"ui.openWorkspace": "Открыть рабочее пространство", "ui.openWorkspace": "Открыть рабочее пространство",

View File

@ -12,14 +12,10 @@
"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"

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;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-select{max-width:11rem}',
'.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}',

View File

@ -1,52 +0,0 @@
#!/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"

View File

@ -1,35 +0,0 @@
#!/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');

View File

@ -1,51 +0,0 @@
#!/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');

View File

@ -89,30 +89,6 @@ else
echo " ⚠️ node not available — skipping localization catalog validation" echo " ⚠️ node not available — skipping localization catalog validation"
fi 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]"

View File

@ -1,37 +0,0 @@
#!/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"

View File

@ -1,80 +0,0 @@
#!/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

View File

@ -12,4 +12,18 @@ fi
echo "=== verstak official plugins release $VERSION ===" echo "=== verstak official plugins release $VERSION ==="
"$ROOT/scripts/check.sh" "$ROOT/scripts/check.sh"
"$ROOT/scripts/package-portable.sh" "$VERSION" "$ROOT/scripts/build.sh"
RELEASE_ROOT="$ROOT/release"
STAGING="$RELEASE_ROOT/verstak-official-plugins-$VERSION"
ARCHIVE="$RELEASE_ROOT/verstak-official-plugins-$VERSION.tar.gz"
rm -rf "$STAGING" "$ARCHIVE"
mkdir -p "$STAGING"
cp "$ROOT/README.md" "$ROOT/LICENSE" "$STAGING/"
cp -R "$ROOT/dist" "$STAGING/dist"
tar -C "$RELEASE_ROOT" -czf "$ARCHIVE" "$(basename "$STAGING")"
(cd "$RELEASE_ROOT" && sha256sum "$(basename "$ARCHIVE")" > SHA256SUMS)
echo "release archive: $ARCHIVE"
echo "checksums: $RELEASE_ROOT/SHA256SUMS"

View File

@ -280,16 +280,13 @@ 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('Selection captured')) throw new Error('browser capture label was not rendered'); if (!container.textContent.includes('browser.capture.selection')) throw new Error('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.converted')) throw new Error('conversion event type 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('Deal: Project')) throw new Error('candidate Deal was not rendered'); if (!candidateNode.textContent.includes('Workspace: Project')) throw new Error('candidate workspace was not rendered');
if (!candidateNode.textContent.includes('Estimated duration: 20 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');
@ -364,13 +361,6 @@ 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');
@ -541,13 +531,6 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
const rawClear = walk(rawView.container, (node) => node.getAttribute && node.getAttribute('data-activity-action') === 'clear'); const rawClear = walk(rawView.container, (node) => node.getAttribute && node.getAttribute('data-activity-action') === 'clear');
rawClear.click(); rawClear.click();
await flush(); 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'); if (rawApi.storedData('activity-events').length !== 0) throw new Error('clear activity did not replace append-only data');
component.unmount && component.unmount(rawView.container); component.unmount && component.unmount(rawView.container);

View File

@ -6,11 +6,6 @@ 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) {
@ -118,15 +113,7 @@ 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: {
@ -149,7 +136,7 @@ function loadComponent(document) {
return component; return component;
} }
function makeApi(initialSettings = {}, locale = 'en') { function makeApi(initialSettings = {}) {
const settings = { ...initialSettings }; const settings = { ...initialSettings };
const handlers = {}; const handlers = {};
const unsubscribed = []; const unsubscribed = [];
@ -166,13 +153,6 @@ function makeApi(initialSettings = {}, locale = 'en') {
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() { function backendCaptures() {
const keys = ['captures:global', 'captures', ...Object.keys(settings).filter((key) => key.startsWith('captures:workspace:'))]; const keys = ['captures:global', 'captures', ...Object.keys(settings).filter((key) => key.startsWith('captures:workspace:'))];
const seen = new Set(); const seen = new Set();
@ -225,11 +205,6 @@ function makeApi(initialSettings = {}, locale = 'en') {
fileByteWrites, fileByteWrites,
openedURLs, 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');
}, },
@ -323,18 +298,6 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
} }
styledView.component.unmount && styledView.component.unmount(styledView.container); 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') === '');
@ -775,12 +738,9 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
if (!failedConversionApi.getStoredCaptures(globalKey).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 the note. Please try again.')) { if (!failedConversionView.container.textContent.includes('Could not create note')) {
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');
} }
@ -867,7 +827,7 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
if (!failedLinkApi.getStoredCaptures(globalKey).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 the link. Please try again.')) { if (!failedLinkView.container.textContent.includes('Could not create link')) {
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')) {
@ -974,7 +934,7 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
if (!failedFileApi.getStoredCaptures(globalKey).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 the file. Please try again.')) { if (!failedFileView.container.textContent.includes('Could not create file')) {
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')) {
@ -982,10 +942,6 @@ 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);

View File

@ -120,7 +120,7 @@ async function flush() {
} }
} }
async function mountEditor(secretProviderEnabled, translations) { async function mountEditor(secretProviderEnabled) {
const document = makeDocument(); const document = makeDocument();
const component = loadComponent(document); const component = loadComponent(document);
const opened = []; const opened = [];
@ -146,15 +146,6 @@ async function mountEditor(secretProviderEnabled, translations) {
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, {
@ -171,12 +162,6 @@ async function mountEditor(secretProviderEnabled, translations) {
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');
@ -197,12 +182,6 @@ async function mountEditor(secretProviderEnabled, translations) {
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);

View File

@ -195,11 +195,9 @@ function loadFilesComponent(document) {
return { component, clipboard: sandbox.navigator.clipboard }; return { component, clipboard: sandbox.navigator.clipboard };
} }
function makeApi(options = {}) { function makeApi() {
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;
@ -214,8 +212,6 @@ function makeApi(options = {}) {
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',
@ -257,15 +253,9 @@ function makeApi(options = {}) {
}, },
metadata: async () => { throw new Error('not-found'); }, metadata: async () => { throw new Error('not-found'); },
readText: async () => '# Readme\n', readText: async () => '# Readme\n',
writeText: async (relativePath, content, options) => { writeText: async () => undefined,
created.push({ type: 'file', relativePath, content, options }); createFolder: async () => undefined,
}, 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) => {
@ -317,15 +307,6 @@ function makeApi(options = {}) {
}; };
}, },
}, },
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;
}, },
@ -347,62 +328,6 @@ 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 });
@ -453,31 +378,6 @@ 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);

View File

@ -148,12 +148,6 @@ function makeApi(initialSettings = {}) {
publishedEvents.push({ name, payload }); publishedEvents.push({ name, payload });
}, },
}, },
files: {
list: async () => [
{ type: 'folder', relativePath: 'Project', name: 'Project' },
{ type: 'folder', relativePath: 'Client', name: 'Client' },
],
},
storedEntries(key) { storedEntries(key) {
return settings[key] || []; return settings[key] || [];
}, },
@ -331,22 +325,6 @@ 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);

View File

@ -264,19 +264,12 @@ 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 createModal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-notes-create-modal') !== undefined); const input = walk(container, (node) => node.getAttribute && node.getAttribute('data-notes-create-input') !== 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');
const confirm = walk(createModal, (node) => node.tagName === 'BUTTON' && node.textContent === 'Create'); input.value = 'First Note';
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');
@ -326,25 +319,21 @@ 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 renameModal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-notes-rename-modal') !== undefined); const renameInput = walk(container, (node) => node.getAttribute && node.getAttribute('data-notes-rename-input') !== 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';
renameInput.dispatchEvent('keydown', { key: 'Enter' }); const renameConfirm = walk(container, (node) => node.tagName === 'BUTTON' && node.textContent === 'Rename');
if (!renameConfirm) throw new Error('rename confirm button not found');
renameConfirm.click();
await flush(); await flush();
const renameError = walk(renameModal, (node) => node.getAttribute && node.getAttribute('data-notes-rename-error') !== undefined); const conflictModal = walk(document.body, (node) => node.className === 'notes-modal-msg');
if (!renameError || !renameError.textContent.includes('Project/Notes/Second_Note.md')) { if (!conflictModal || !conflictModal.textContent.includes('Project/Notes/Second_Note.md')) {
throw new Error(`rename conflict should stay in the form modal, got ${renameError && renameError.textContent}`); throw new Error(`rename conflict modal should include existing path, got ${conflictModal && conflictModal.textContent}`);
} }
if (walk(document.body, (node) => node.className === 'notes-modal-msg')) { const conflictOk = walk(document.body, (node) => node.tagName === 'BUTTON' && node.textContent === 'OK');
throw new Error('rename conflict opened a second modal instead of reporting in the form'); if (!conflictOk) throw new Error('rename conflict OK button not found');
} 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');

View File

@ -8,7 +8,6 @@ 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) {
@ -101,15 +100,7 @@ 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) {
@ -262,13 +253,11 @@ 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('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 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';

View File

@ -28,16 +28,6 @@ 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);
} }
@ -106,18 +96,10 @@ function makeDocument() {
}; };
} }
function loadComponent(document, errorLog) { function loadComponent(document) {
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) {
@ -147,24 +129,19 @@ 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 errorLog = []; const component = loadComponent(document);
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 = [];
@ -172,7 +149,6 @@ 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;
@ -201,12 +177,6 @@ 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');
@ -218,21 +188,6 @@ 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();
@ -245,17 +200,7 @@ 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 must stay hidden until the user explicitly reveals it'); if (!container.textContent.includes('secret-value')) throw new Error('secret value was not shown in the field table');
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');
@ -281,69 +226,8 @@ 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);

View File

@ -9,17 +9,14 @@ 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('function reportError')) { if (!source.includes('sanitizeError(settings.lastError)')) {
throw new Error('SyncSettings must map technical failures to localized action-specific errors'); throw new Error('SyncSettings must sanitize persisted sync errors before rendering');
} }
if (/sanitizeError\(/.test(source) || /\{ error: sanitizeError/.test(source)) { if (!source.includes('Last sync error')) {
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("tr('ui.syncConflictItem'")) { if (!source.includes('function formatSyncConflict')) {
throw new Error('SyncSettings must hide technical conflict identifiers behind a user-facing summary'); throw new Error('SyncSettings must format individual sync conflicts');
} }
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');

View File

@ -146,12 +146,10 @@ function loadComponent(document, emittedEvents) {
function makeApi(initialSettings = {}, initialLocale = 'en') { function makeApi(initialSettings = {}, initialLocale = 'en') {
const settings = { ...initialSettings }; const settings = { ...initialSettings };
const notificationCalls = [];
let locale = initialLocale; let locale = initialLocale;
const localeListeners = []; 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) => {
@ -165,11 +163,6 @@ function makeApi(initialSettings = {}, initialLocale = 'en') {
{ 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) { setLocale(nextLocale) {
locale = nextLocale; locale = nextLocale;
localeListeners.slice().forEach((listener) => listener(locale)); localeListeners.slice().forEach((listener) => listener(locale));
@ -178,7 +171,6 @@ function makeApi(initialSettings = {}, initialLocale = 'en') {
return { return {
settings: this.settingsApi, settings: this.settingsApi,
files: this.files, files: this.files,
notifications: this.notifications,
i18n: { i18n: {
getLocale: () => locale, getLocale: () => locale,
t: (key, params, fallback) => { t: (key, params, fallback) => {
@ -216,8 +208,6 @@ 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');
@ -231,13 +221,8 @@ 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 = '01/02/2000'; byData(container, 'data-todo-input', 'dueAt').value = '2000-01-01';
const reminderDate = byData(container, 'data-todo-input', 'reminderDate'); byData(container, 'data-todo-input', 'reminderAt').value = '2000-01-01T09:00';
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();
@ -246,16 +231,8 @@ 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-02' || createdTodo.reminderDate !== '2000-01-02' || createdTodo.reminderAt !== '2000-01-02T09:30') throw new Error('Todo due/reminder metadata 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 (!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'); apiState.setLocale('ru');
if (!container.textContent.includes('Задачи · Project') || !container.textContent.includes('Просрочено')) { if (!container.textContent.includes('Задачи · Project') || !container.textContent.includes('Просрочено')) {
@ -266,29 +243,15 @@ async function mountWithApi(apiState, props, emittedEvents = [], document = make
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');

View File

@ -1,19 +0,0 @@
#!/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"

View File

@ -1,22 +0,0 @@
#!/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"

View File

@ -1,98 +0,0 @@
#!/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"