Compare commits

..

11 Commits

46 changed files with 1064 additions and 296 deletions

View File

@ -79,6 +79,7 @@
'.activity-btn.danger{border-color:rgba(233,69,96,.42);color:#ff9a9a}',
'.activity-status{font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3);white-space:nowrap}',
'.activity-status.error{display:inline-flex;border:1px solid rgba(233,69,96,.45);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-danger-muted,rgba(233,69,96,.14));color:#ffc6ce;padding:.18rem .4rem}',
'.activity-modal-host[hidden]{display:none}.activity-modal-overlay{position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;padding:1rem;background:rgba(0,0,0,.58)}.activity-modal{width:440px;max-width:96vw;display:grid;gap:.75rem;padding:1rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-lg,8px);background:var(--vt-color-surface,#15152c);box-shadow:0 18px 44px rgba(0,0,0,.38)}.activity-modal-title{font-size:.95rem;font-weight:650}.activity-modal-copy{color:var(--vt-color-text-secondary,#b7c0d4);font-size:.84rem;line-height:1.45}.activity-modal-actions{display:flex;justify-content:flex-end;gap:.5rem}.activity-btn.destructive{background:var(--vt-color-danger,#e94560);border-color:var(--vt-color-danger,#e94560);color:#fff}',
'.activity-candidates{border-bottom:1px solid rgba(32,43,70,.72);background:var(--vt-color-surface-muted,#111629);padding:.65rem .75rem;display:grid;gap:.5rem}',
'.activity-candidates-title{font-size:.76rem;font-weight:600;color:var(--vt-color-text-muted,#7f8aa3);text-transform:uppercase;letter-spacing:.04em}',
'.activity-candidate{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.65rem;align-items:start;padding:.65rem .75rem;border:1px solid rgba(78,204,163,.34);border-radius:var(--vt-radius-lg,8px);background:var(--vt-color-surface,#15152c)}',
@ -562,6 +563,14 @@
var disposed = false;
var unsubscribers = [];
function reportError(key, fallback, err) {
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
console.warn('[verstak.activity] ' + key, err);
}
statusText = tr(key, null, fallback);
statusClass = 'error';
}
var toolbar = el('div', { className: 'activity-toolbar' });
var titleEl = el('span', { className: 'activity-title', textContent: scope.mode === 'global' ? tr('ui.title', null, 'Activity') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Activity · ' + scope.label) });
var countEl = el('span', { className: 'activity-count' });
@ -570,18 +579,7 @@
className: 'activity-btn danger',
'data-activity-action': 'clear',
textContent: tr('ui.clear', null, 'Clear'),
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);
}
onClick: showClearConfirmation
});
toolbar.appendChild(titleEl);
toolbar.appendChild(countEl);
@ -594,9 +592,11 @@
'data-activity-section': 'work-session-candidates'
});
var listEl = el('div', { className: 'activity-list' });
var modalHost = el('div', { className: 'activity-modal-host', hidden: 'hidden' });
containerEl.appendChild(toolbar);
containerEl.appendChild(candidatesEl);
containerEl.appendChild(listEl);
containerEl.appendChild(modalHost);
function candidatesForWorkspace(workspaceRoot) {
return visibleCandidates(candidateSourceEvents, workspaceRoot, sessionRegistry, dismissedByWorkspace, handledSessions);
@ -642,8 +642,7 @@
? events.filter(function (item) { return !item._storageKey || item._storageKey === GLOBAL_KEY; })
: events;
return api.settings.write(scope.key, storageEvents(toStore)).then(persistSessionRegistry).then(persistCandidateCaches).catch(function (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';
reportError('ui.saveError', 'Could not save activity. Please try again.', err);
});
}
@ -676,11 +675,61 @@
statusText = tr('ui.cleared', null, 'Activity cleared');
statusClass = '';
}).catch(function (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';
reportError('ui.clearError', 'Could not clear activity. Please try again.', err);
});
}
function closeClearConfirmation() {
modalHost.innerHTML = '';
modalHost.setAttribute('hidden', 'hidden');
}
function clearCurrentScope() {
if (scope.mode === 'global') {
return clearGlobal().then(render);
}
events = [];
candidateSourceEvents = candidateSourceEvents.filter(function (activity) {
return candidateWorkspace(activity) !== scope.workspaceRoot;
});
updateCandidates();
return clearWorkspaceRaw(scope.workspaceRoot).then(persist).then(render);
}
function showClearConfirmation() {
var scopeMessage = scope.mode === 'global'
? tr('ui.clearGlobalWarning', null, 'This permanently deletes all recorded activity and journal suggestions in every case.')
: tr('ui.clearWorkspaceWarning', { workspace: scope.label }, 'This permanently deletes recorded activity for ' + scope.label + '. Activity in other cases remains.');
var confirmBtn = el('button', {
className: 'activity-btn danger destructive',
type: 'button',
'data-activity-clear-confirm': '',
textContent: tr('ui.confirmClear', null, 'Clear activity'),
onClick: function () {
confirmBtn.disabled = true;
clearCurrentScope().then(closeClearConfirmation);
}
});
modalHost.innerHTML = '';
if (typeof modalHost.removeAttribute === 'function') modalHost.removeAttribute('hidden');
else delete modalHost.attributes.hidden;
modalHost.appendChild(el('div', {
className: 'activity-modal-overlay',
onClick: function (event) {
if (event.target === event.currentTarget) closeClearConfirmation();
}
}, [
el('div', { className: 'activity-modal', role: 'dialog', 'aria-modal': 'true', 'data-activity-clear-confirmation': '' }, [
el('div', { className: 'activity-modal-title', textContent: tr('ui.clearConfirmTitle', null, 'Clear activity?') }),
el('div', { className: 'activity-modal-copy', textContent: scopeMessage }),
el('div', { className: 'activity-modal-actions' }, [
el('button', { className: 'activity-btn', type: 'button', 'data-activity-clear-cancel': '', textContent: tr('ui.cancel', null, 'Cancel'), onClick: closeClearConfirmation }),
confirmBtn
])
])
]));
}
function clearWorkspaceRaw(workspaceRoot) {
if (!api || !api.storage || !api.storage.data || typeof api.storage.data.readNDJSON !== 'function' || typeof api.storage.data.writeNDJSON !== 'function') {
return Promise.resolve();
@ -754,8 +803,7 @@
statusText = tr('ui.dismissed', null, 'Candidate dismissed');
statusClass = '';
}).catch(function (err) {
statusText = 'Could not dismiss candidate: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
reportError('ui.dismissError', 'Could not dismiss the suggestion. Please try again.', err);
}).then(render);
}
@ -827,8 +875,7 @@
updateCandidates();
return persistSessionRegistry().then(persistCandidateCaches);
}).catch(function (err) {
statusText = 'Could not load activity: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
reportError('ui.loadError', 'Could not load activity. Please try again.', err);
});
}
@ -855,8 +902,7 @@
return api.commands.register(WORKLOG_COMMAND_ID, listWorkSessionCandidates).then(function (unregister) {
if (typeof unregister === 'function') unsubscribers.push(unregister);
}).catch(function (err) {
statusText = 'Activity commands unavailable: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
reportError('ui.commandsUnavailable', 'Activity actions are unavailable. Please try again.', err);
});
}
@ -874,8 +920,7 @@
statusText = scope.mode === 'global' ? 'Listening for all activity' : 'Listening for workspace activity';
statusClass = '';
}).catch(function (err) {
statusText = 'Activity subscriptions unavailable: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
reportError('ui.subscriptionsUnavailable', 'Activity updates are unavailable. Please try again.', err);
});
}

View File

@ -9,9 +9,18 @@
"ui.title": "Activity",
"ui.workspaceTitle": "Activity · {workspace}",
"ui.clear": "Clear",
"ui.saveError": "Could not save activity: {error}",
"ui.clearConfirmTitle": "Clear activity?",
"ui.clearGlobalWarning": "This permanently deletes all recorded activity and journal suggestions in every case.",
"ui.clearWorkspaceWarning": "This permanently deletes recorded activity for {workspace}. Activity in other cases remains.",
"ui.confirmClear": "Clear activity",
"ui.cancel": "Cancel",
"ui.saveError": "Could not save activity. Please try again.",
"ui.cleared": "Activity cleared",
"ui.clearError": "Could not clear activity: {error}",
"ui.clearError": "Could not clear activity. Please try again.",
"ui.dismissError": "Could not dismiss the suggestion. Please try again.",
"ui.loadError": "Could not load activity. Please try again.",
"ui.commandsUnavailable": "Activity actions are unavailable. Please try again.",
"ui.subscriptionsUnavailable": "Activity updates are unavailable. Please try again.",
"ui.empty": "No activity events yet",
"ui.emptyHint": "File changes, browser captures, and conversions will appear here.",
"ui.details": "Details",

View File

@ -9,9 +9,18 @@
"ui.title": "Активность",
"ui.workspaceTitle": "Активность · {workspace}",
"ui.clear": "Очистить",
"ui.saveError": "Не удалось сохранить активность: {error}",
"ui.clearConfirmTitle": "Очистить активность?",
"ui.clearGlobalWarning": "Будут безвозвратно удалены вся записанная активность и предложения для журнала во всех Делах.",
"ui.clearWorkspaceWarning": "Будет безвозвратно удалена записанная активность Дела «{workspace}». Активность других Дел останется.",
"ui.confirmClear": "Очистить активность",
"ui.cancel": "Отмена",
"ui.saveError": "Не удалось сохранить активность. Повторите попытку.",
"ui.cleared": "Активность очищена",
"ui.clearError": "Не удалось очистить активность: {error}",
"ui.clearError": "Не удалось очистить активность. Повторите попытку.",
"ui.dismissError": "Не удалось отклонить предложение. Повторите попытку.",
"ui.loadError": "Не удалось загрузить активность. Повторите попытку.",
"ui.commandsUnavailable": "Действия с активностью недоступны. Повторите попытку.",
"ui.subscriptionsUnavailable": "Обновления активности недоступны. Повторите попытку.",
"ui.empty": "Событий активности пока нет",
"ui.emptyHint": "Здесь появятся изменения файлов, материалы из браузера и преобразования.",
"ui.details": "Подробнее",

View File

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

View File

@ -1,25 +1,26 @@
{
"manifest.name": "Browser Inbox",
"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 Inbox",
"contributions.sidebarItems.verstak.browser-inbox.sidebar.title": "Browser Inbox",
"contributions.workspaceItems.verstak.browser-inbox.workspace.title": "Browser Inbox",
"contributions.settingsPanels.verstak.browser-inbox.settings.title": "Browser Inbox",
"manifest.name": "Browser",
"manifest.description": "Global browser materials with explicit Deal assignment delivered through the local receiver event protocol.",
"contributions.views.verstak.browser-inbox.view.title": "Browser",
"contributions.sidebarItems.verstak.browser-inbox.sidebar.title": "Browser",
"contributions.workspaceItems.verstak.browser-inbox.workspace.title": "Browser",
"contributions.settingsPanels.verstak.browser-inbox.settings.title": "Browser",
"ui.connecting": "Connecting to receiver events...",
"ui.title": "Browser Inbox",
"ui.workspaceTitle": "Browser Inbox · {workspace}",
"ui.title": "Browser",
"ui.workspaceTitle": "Browser · {workspace}",
"ui.allCaptures": "All captures",
"ui.allDeals": "All Deals",
"ui.unassigned": "Unassigned",
"ui.unprocessed": "Unprocessed",
"ui.processed": "Processed",
"ui.search": "Search captures",
"ui.clear": "Clear",
"ui.assignedHere": "Assigned to this workspace",
"ui.assignedHere": "Assigned to this Deal",
"ui.selectCapture": "Select a capture to inspect it.",
"ui.kind": "Kind",
"ui.domain": "Domain",
"ui.captured": "Captured",
"ui.workspace": "Workspace",
"ui.workspace": "Deal",
"ui.status": "Status",
"ui.createNote": "Create Note",
"ui.createLink": "Create Link",
@ -35,5 +36,55 @@
"ui.copied": "{label} copied",
"ui.rotateConfirm": "Rotate pairing token?",
"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,25 +1,26 @@
{
"manifest.name": "Входящие из браузера",
"manifest.description": "Общая очередь материалов из браузера с явным назначением рабочего пространства через локальный протокол приёма.",
"contributions.views.verstak.browser-inbox.view.title": "Входящие из браузера",
"contributions.sidebarItems.verstak.browser-inbox.sidebar.title": "Входящие из браузера",
"contributions.workspaceItems.verstak.browser-inbox.workspace.title": "Входящие из браузера",
"contributions.settingsPanels.verstak.browser-inbox.settings.title": "Входящие из браузера",
"manifest.name": "Браузер",
"manifest.description": "Общие материалы из браузера с явным назначением Дела через локальный протокол приёма.",
"contributions.views.verstak.browser-inbox.view.title": "Браузер",
"contributions.sidebarItems.verstak.browser-inbox.sidebar.title": "Браузер",
"contributions.workspaceItems.verstak.browser-inbox.workspace.title": "Браузер",
"contributions.settingsPanels.verstak.browser-inbox.settings.title": "Браузер",
"ui.connecting": "Подключение к событиям приёмника...",
"ui.title": "Входящие из браузера",
"ui.workspaceTitle": "Входящие из браузера · {workspace}",
"ui.title": "Браузер",
"ui.workspaceTitle": "Браузер · {workspace}",
"ui.allCaptures": "Все материалы",
"ui.allDeals": "Все Дела",
"ui.unassigned": "Не назначено",
"ui.unprocessed": "Не обработано",
"ui.processed": "Обработано",
"ui.search": "Поиск материалов",
"ui.clear": "Очистить",
"ui.assignedHere": "Назначено этому рабочему пространству",
"ui.assignedHere": "Назначено этому Делу",
"ui.selectCapture": "Выберите материал для просмотра.",
"ui.kind": "Тип",
"ui.domain": "Домен",
"ui.captured": "Получено",
"ui.workspace": "Рабочее пространство",
"ui.workspace": "Дело",
"ui.status": "Состояние",
"ui.createNote": "Создать заметку",
"ui.createLink": "Создать ссылку",
@ -35,5 +36,55 @@
"ui.copied": "{label} скопирован",
"ui.rotateConfirm": "Сменить токен сопряжения?",
"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,
"id": "verstak.browser-inbox",
"name": "Browser Inbox",
"name": "Browser",
"version": "0.1.0",
"apiVersion": "0.1.0",
"description": "Global browser capture queue with explicit workspace assignment delivered through the local receiver event protocol.",
"description": "Global browser materials with explicit Deal assignment delivered through the local receiver event protocol.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official",
"icon": "inbox",
@ -29,7 +29,7 @@
"views": [
{
"id": "verstak.browser-inbox.view",
"title": "Browser Inbox",
"title": "Browser",
"icon": "inbox",
"component": "BrowserInboxView"
}
@ -37,7 +37,7 @@
"sidebarItems": [
{
"id": "verstak.browser-inbox.sidebar",
"title": "Browser Inbox",
"title": "Browser",
"icon": "inbox",
"view": "verstak.browser-inbox.view",
"position": 30
@ -46,7 +46,7 @@
"workspaceItems": [
{
"id": "verstak.browser-inbox.workspace",
"title": "Browser Inbox",
"title": "Browser",
"icon": "inbox",
"component": "BrowserInboxView"
}
@ -54,7 +54,7 @@
"settingsPanels": [
{
"id": "verstak.browser-inbox.settings",
"title": "Browser Inbox",
"title": "Browser",
"component": "BrowserInboxSettings"
}
]

View File

@ -259,11 +259,14 @@
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
var value = textarea.value;
var selected = value.slice(start, end) || placeholder || '';
var lineStart = value.lastIndexOf('\n', Math.max(0, start - 1)) + 1;
var lineEnd = value.indexOf('\n', end);
if (lineEnd === -1) lineEnd = value.length;
var selected = value.slice(lineStart, lineEnd) || placeholder || '';
var replacement = selected.split('\n').map(function (line) { return prefix + line; }).join('\n');
textarea.value = value.slice(0, start) + replacement + value.slice(end);
textarea.selectionStart = start;
textarea.selectionEnd = start + replacement.length;
textarea.value = value.slice(0, lineStart) + replacement + value.slice(lineEnd);
textarea.selectionStart = lineStart;
textarea.selectionEnd = lineStart + replacement.length;
textarea.dispatchEvent(new Event('input', { bubbles: true }));
textarea.focus();
}
@ -492,10 +495,10 @@
rebuildEditorArea();
}).catch(function (err) {
if (disposed) return;
console.warn('[default-editor] load error:', err);
editorWrap.innerHTML = '';
editorWrap.appendChild(el('div', { className: 'de-error' }, [
el('div', {}, [tr('ui.loadFailed', null, 'Failed to load file')]),
el('div', { className: 'de-error-msg' }, [(err && err.message) ? err.message : String(err)])
el('div', {}, [tr('ui.loadFailed', null, 'Could not load the file. Please try again.')])
]));
});
}

View File

@ -18,5 +18,5 @@
"ui.saved": "Saved",
"ui.discardConfirm": "Discard unsaved changes and reload from disk?",
"ui.loading": "Loading...",
"ui.loadFailed": "Failed to load file"
"ui.loadFailed": "Could not load the file. Please try again."
}

View File

@ -18,5 +18,5 @@
"ui.saved": "Сохранено",
"ui.discardConfirm": "Отменить несохранённые изменения и перечитать файл с диска?",
"ui.loading": "Загрузка...",
"ui.loadFailed": "Не удалось загрузить файл"
"ui.loadFailed": "Не удалось загрузить файл. Повторите попытку."
}

View File

@ -143,8 +143,9 @@
return null;
});
}).catch(function (err) {
console.warn('[file-preview] preview error:', err);
body.className = 'fp-error';
body.textContent = tr('ui.error', { error: err && err.message ? err.message : String(err) }, 'Preview error: ' + (err && err.message ? err.message : String(err)));
body.textContent = tr('ui.error', null, 'Could not preview this file. Please try again.');
});
if (api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
api.i18n.onDidChangeLocale(function () {

View File

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

View File

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

View File

@ -320,14 +320,14 @@
return Promise.reject(new Error('clipboard unavailable'));
}
function showExternalFallback(entry, mode, reason) {
function showExternalFallback(entry, mode) {
if (!entry) return;
var pathToShow = entry.relativePath;
if (mode === 'explorer' && entry.type !== 'folder') {
pathToShow = parentPath(entry.relativePath) || entry.relativePath;
}
var title = mode === 'explorer' ? 'Show in Explorer' : 'Open External';
var message = title + ' failed.\n' + (reason ? String(reason) + '\n' : '') + 'Vault-relative path:\n' + pathToShow;
var message = title + ' failed.\nVault-relative path:\n' + pathToShow;
confirmModal(message, { confirmText: 'Copy Path', cancelText: 'Close' }).then(function (copy) {
if (!copy) return;
copyTextToClipboard(pathToShow).catch(function (err) {
@ -374,6 +374,13 @@
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) {
local = cleanPath(local);
return workspaceRoot ? (local ? workspaceRoot + '/' + local : workspaceRoot) : local;
@ -714,10 +721,10 @@
renderList();
}).catch(function (err) {
if (disposed) return;
console.warn('[verstak.files] load error:', err);
listContainer.innerHTML = '';
listContainer.appendChild(el('div', { className: 'files-error' }, [
el('div', {}, [tr('ui.loadFailed', null, 'Failed to load files')]),
el('div', { className: 'files-error-msg' }, [(err && err.message) ? err.message : String(err)])
el('div', {}, [tr('ui.loadFailed', null, 'Could not load files. Please try again.')])
]));
});
}
@ -863,7 +870,7 @@
api.workbench.openResource({ kind: 'vault-file', path: full, mode: 'edit', extension: ext ? '.' + ext : '', context: { sourcePluginId: 'verstak.files', sourceView: 'files' } }).catch(function () {});
}
}).catch(function (err) {
setCreateError('Error: ' + ((err && err.message) ? err.message : String(err)));
setCreateError(reportError('ui.createError', 'Could not create this item. Please try again.', err));
});
}
@ -923,7 +930,7 @@
setRenameError('A file with that name already exists');
return;
}
setRenameError('Error: ' + ((err && err.message) ? err.message : String(err)));
setRenameError(reportError('ui.renameError', 'Could not rename this item. Please try again.', err));
});
});
}
@ -935,7 +942,9 @@
if (!ok) return;
api.files.trash(entry.relativePath).then(function () {
loadEntries();
}).catch(function (err) { window.alert((err && err.message) ? err.message : String(err)); });
}).catch(function (err) {
window.alert(reportError('ui.trashError', 'Could not move this item to trash. Please try again.', err));
});
});
} else if (count > 1) {
confirmModal('Move ' + count + ' items to trash?', { danger: true }).then(function (ok) {
@ -994,11 +1003,12 @@
var filesApi = api && api.files;
var action = mode === 'explorer' ? filesApi && filesApi.showInFolder : filesApi && filesApi.openExternal;
if (typeof action !== 'function') {
showExternalFallback(entry, mode, 'files external-open API is unavailable.');
showExternalFallback(entry, mode);
return;
}
action(entry.relativePath).catch(function (err) {
showExternalFallback(entry, mode, err && err.message ? err.message : err);
console.warn('[verstak.files] external open error:', err);
showExternalFallback(entry, mode);
});
}

View File

@ -28,5 +28,8 @@
"ui.noMatches": "No matches",
"ui.clearFilter": "Clear filter",
"ui.loading": "Loading...",
"ui.loadFailed": "Failed to load files"
"ui.loadFailed": "Could not load files. Please try again.",
"ui.createError": "Could not create this item. Please try again.",
"ui.renameError": "Could not rename this item. Please try again.",
"ui.trashError": "Could not move this item to trash. Please try again."
}

View File

@ -28,5 +28,8 @@
"ui.noMatches": "Совпадений нет",
"ui.clearFilter": "Сбросить фильтр",
"ui.loading": "Загрузка...",
"ui.loadFailed": "Не удалось загрузить файлы"
"ui.loadFailed": "Не удалось загрузить файлы. Повторите попытку.",
"ui.createError": "Не удалось создать этот элемент. Повторите попытку.",
"ui.renameError": "Не удалось переименовать этот элемент. Повторите попытку.",
"ui.trashError": "Не удалось переместить элемент в корзину. Повторите попытку."
}

View File

@ -31,6 +31,7 @@
'.journal-status.error{display:inline-flex;border:1px solid rgba(233,69,96,.45);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-danger-muted,rgba(233,69,96,.14));color:#ffc6ce;padding:.18rem .4rem}',
'.journal-input{font-size:.8rem;padding:.38rem .5rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);min-width:0;font-family:inherit}',
'.journal-input.textarea{min-height:7rem;resize:vertical;line-height:1.4}',
'.journal-input.journal-select{appearance:none;background-color:#0f1424;background-image:linear-gradient(45deg,transparent 50%,var(--vt-color-text-muted,#7f8aa3) 50%),linear-gradient(135deg,var(--vt-color-text-muted,#7f8aa3) 50%,transparent 50%);background-position:calc(100% - 14px) 50%,calc(100% - 9px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:1.7rem}.journal-input.journal-select option{background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb)}',
'.journal-input:focus{outline:none;border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.journal-billable{display:flex;align-items:center;gap:.25rem;font-size:.74rem;color:var(--vt-color-text-secondary,#b7c0d4);white-space:nowrap}',
'.journal-list{flex:1;min-height:0;overflow:auto;background:var(--vt-color-background,#101020)}',
@ -275,6 +276,7 @@
var scope = scopeFromProps(props || {});
var entries = [];
var workspaceOptions = [];
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
@ -283,6 +285,14 @@
var statusClass = '';
var modalHost = el('div', { className: 'journal-modal-host', hidden: 'hidden' });
function reportError(key, fallback, err) {
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
console.warn('[verstak.journal] ' + key, err);
}
statusText = tr(key, null, fallback);
statusClass = 'error';
}
var toolbar = el('div', { className: 'journal-toolbar' });
var titleEl = el('span', { className: 'journal-title', textContent: scope.mode === 'global' ? tr('ui.title', null, 'Journal') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Journal · ' + scope.label) });
var countEl = el('span', { className: 'journal-count' });
@ -304,12 +314,23 @@
containerEl.appendChild(listEl);
containerEl.appendChild(modalHost);
function persist() {
if (scope.mode !== 'workspace') return Promise.resolve();
function persist(workspaceRoot, values) {
if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
return api.settings.write(scope.key, storageEntries(entries)).catch(function (err) {
statusText = tr('ui.saveError', { error: err && err.message ? err.message : String(err) }, 'Could not save journal: ' + (err && err.message ? err.message : String(err)));
statusClass = 'error';
var target = cleanWorkspace(workspaceRoot || scope.workspaceRoot);
if (!target) return Promise.resolve();
return api.settings.write(WORKLOG_PREFIX + encodeKey(target), storageEntries(values || entries)).catch(function (err) {
reportError('ui.saveError', 'Could not save journal. Please try again.', err);
});
}
function loadWorkspaceOptions() {
if (!api || !api.files || typeof api.files.list !== 'function') return Promise.resolve();
return api.files.list('').then(function (items) {
workspaceOptions = (Array.isArray(items) ? items : []).filter(function (item) {
return text(item && item.type).toLowerCase() === 'folder';
}).map(function (item) { return cleanWorkspace(item.relativePath || item.name); }).filter(function (value) {
return value && value.indexOf('/') === -1;
});
});
}
@ -325,8 +346,7 @@
statusText = tr('ui.aggregating', null, 'Aggregating worklogs');
statusClass = '';
}).catch(function (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';
reportError('ui.loadError', 'Could not load journal. Please try again.', err);
});
}
return api.settings.read(scope.key).then(function (stored) {
@ -334,8 +354,7 @@
statusText = tr('ui.ready', null, 'Ready');
statusClass = '';
}).catch(function (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';
reportError('ui.loadError', 'Could not load journal. Please try again.', err);
});
}
@ -345,7 +364,6 @@
}
function showEntryModal(existingEntry, candidate, completedTodo) {
if (scope.mode !== 'workspace') return;
var editing = !!existingEntry;
var reviewingCandidate = !editing && !!candidate;
var reviewingTodo = !editing && !!completedTodo;
@ -356,6 +374,13 @@
var minutesInput = el('input', { className: 'journal-input', type: 'number', min: '0', step: '1', value: editing ? existingEntry.minutes : (reviewingCandidate ? candidate.estimatedMinutes : (reviewingTodo ? '0' : '30')), 'data-journal-input': 'minutes' });
var billableInput = el('input', { type: 'checkbox', 'data-journal-input': 'billable' });
billableInput.checked = editing ? existingEntry.billable === true : false;
var workspaceInput = null;
if (scope.mode === 'global') {
workspaceInput = el('select', { className: 'journal-input journal-select', 'data-journal-input': 'workspaceRootPath' });
workspaceOptions.forEach(function (workspace) {
workspaceInput.appendChild(el('option', { value: workspace, textContent: workspace }));
});
}
var activityInputs = reviewingCandidate ? candidate.activities.map(function (activity) {
var input = el('input', { type: 'checkbox', value: activity.activityId, checked: 'checked', 'data-journal-candidate-activity': activity.activityId });
input.checked = true;
@ -369,6 +394,7 @@
summary: summaryInput.value,
minutes: minutesInput.value,
billable: billableInput.checked === true,
workspaceRootPath: workspaceInput ? workspaceInput.value : scope.workspaceRoot,
sourceCandidateId: reviewingCandidate ? candidate.candidateId : (existingEntry ? existingEntry.sourceCandidateId : ''),
sessionId: reviewingCandidate ? candidate.sessionId : '',
handledThrough: reviewingCandidate ? candidate.handledThrough : '',
@ -411,6 +437,7 @@
el('div', { className: 'journal-modal-grid' }, [
el('label', { className: 'journal-field' }, [tr('ui.date', null, 'Date'), dateInput]),
el('label', { className: 'journal-field' }, [tr('ui.minutes', null, 'Minutes'), minutesInput]),
workspaceInput ? el('label', { className: 'journal-field wide' }, [tr('ui.workspace', null, 'Deal'), workspaceInput]) : null,
el('label', { className: 'journal-field wide' }, [tr('ui.fieldTitle', null, 'Title'), titleInput]),
el('label', { className: 'journal-field wide' }, [tr('ui.body', null, 'Body'), summaryInput]),
el('label', { className: 'journal-billable' }, [billableInput, tr('ui.billable', null, 'Billable')])
@ -426,7 +453,6 @@
}
function addOrUpdateEntry(existingEntry, formValue) {
if (scope.mode !== 'workspace') return;
var title = text(formValue && formValue.title).trim();
if (!title) {
statusText = tr('ui.titleRequired', null, 'Title is required');
@ -434,6 +460,8 @@
render();
return;
}
var workspaceRoot = cleanWorkspace(formValue && formValue.workspaceRootPath || scope.workspaceRoot);
if (!workspaceRoot) return;
var sourceCandidateId = text(formValue && formValue.sourceCandidateId || (existingEntry && existingEntry.sourceCandidateId)).trim();
var sessionID = text(formValue && formValue.sessionId).trim();
var handledThrough = text(formValue && formValue.handledThrough).trim();
@ -451,8 +479,8 @@
return;
}
var entry = normalizeEntry({
entryId: existingEntry ? existingEntry.entryId : entryId(scope.workspaceRoot, formValue.date || today(), title),
workspaceRootPath: scope.workspaceRoot,
entryId: existingEntry ? existingEntry.entryId : entryId(workspaceRoot, formValue.date || today(), title),
workspaceRootPath: workspaceRoot,
date: formValue.date || today(),
title: title,
summary: formValue.summary,
@ -470,10 +498,11 @@
entries = [entry].concat(entries);
}
entries = sortEntries(entries);
var targetEntries = entries.filter(function (item) { return item.workspaceRootPath === workspaceRoot; });
closeEntryModal();
statusText = existingEntry ? 'Entry updated' : 'Entry added';
statusClass = '';
persist().then(function () {
persist(workspaceRoot, targetEntries).then(function () {
if (!sessionID || !handledThrough || !api || !api.events || typeof api.events.publish !== 'function') return undefined;
return api.events.publish('activity.session.handled', {
sessionId: sessionID,
@ -525,12 +554,12 @@
);
statusEl.textContent = statusText;
statusEl.className = 'journal-status' + (statusClass ? ' ' + statusClass : '');
addBtn.disabled = scope.mode !== 'workspace';
addBtn.disabled = false;
renderList();
}
render();
loadStored().then(function () {
Promise.all([loadStored(), loadWorkspaceOptions()]).then(function () {
render();
var candidate = candidateFromRequest(props && props.toolRequest, scope.workspaceRoot);
var completedTodo = completedTodoFromRequest(props && props.toolRequest, scope.workspaceRoot);

View File

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

View File

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

View File

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

View File

@ -324,6 +324,13 @@
}
}
function userFacingError(key, fallback, err) {
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
console.warn('[verstak.notes] ' + key, err);
}
return tr(key, null, fallback);
}
function loadNotes() {
listContainer.innerHTML = '';
listContainer.appendChild(el('div', { className: 'notes-empty' }, [tr('ui.loading', null, 'Loading...')]));
@ -335,7 +342,7 @@
renderList();
}).catch(function (err) {
if (disposed) return;
renderEmpty('Error: ' + (err.message || err));
renderEmpty(userFacingError('ui.loadError', 'Could not load notes. Please try again.', err));
});
}
@ -379,8 +386,7 @@
}).then(function () {
if (!disposed) setStatus(action.label + ' complete', 'success');
}).catch(function (err) {
console.error('[notes] contribution action failed:', err);
if (!disposed) setStatus('Error: ' + (err && err.message ? err.message : err), 'error');
if (!disposed) setStatus(userFacingError('ui.actionError', 'Could not complete this note action. Please try again.', err), 'error');
});
}
@ -533,7 +539,7 @@
}).catch(function () {});
}
}).catch(function (err) {
setStatus('Error: ' + (err.message || err), 'error');
setStatus(userFacingError('ui.createError', 'Could not create the note. Please try again.', err), 'error');
});
}
@ -568,7 +574,7 @@
setStatus(tr('ui.renamed', null, 'Note renamed'), 'success');
loadNotes();
}).catch(function (err) {
setStatus('Error: ' + (err.message || err), 'error');
setStatus(userFacingError('ui.renameError', 'Could not rename the note. Please try again.', err), 'error');
});
}
@ -585,7 +591,7 @@
setStatus(tr('ui.trashed', null, 'Note moved to trash'), 'success');
loadNotes();
}).catch(function (err) {
setStatus('Error: ' + (err.message || err), 'error');
setStatus(userFacingError('ui.trashError', 'Could not move the note to trash. Please try again.', err), 'error');
});
});
}

View File

@ -28,5 +28,10 @@
"ui.existingFile": " Existing file: {path}.",
"ui.chooseDifferent": " Please choose a different title.",
"ui.trashTitle": "Move Note to Trash",
"ui.trashConfirm": "Move \"{title}\" to trash?"
"ui.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

@ -28,5 +28,10 @@
"ui.existingFile": " Существующий файл: {path}.",
"ui.chooseDifferent": " Выберите другое название.",
"ui.trashTitle": "Переместить заметку в корзину",
"ui.trashConfirm": "Переместить «{title}» в корзину?"
"ui.trashConfirm": "Переместить «{title}» в корзину?",
"ui.loadError": "Не удалось загрузить заметки. Повторите попытку.",
"ui.actionError": "Не удалось выполнить действие с заметкой. Повторите попытку.",
"ui.createError": "Не удалось создать заметку. Повторите попытку.",
"ui.renameError": "Не удалось переименовать заметку. Повторите попытку.",
"ui.trashError": "Не удалось переместить заметку в корзину. Повторите попытку."
}

View File

@ -277,7 +277,8 @@
try {
providers = await api.contributions.list('searchProviders');
} catch (err) {
output.errors.push(err && err.message ? err.message : String(err));
console.warn('[verstak.search] list search providers:', err);
output.errors.push(true);
return output;
}
providers = Array.isArray(providers) ? providers : [];
@ -294,7 +295,8 @@
var normalized = normalizeProviderResults(provider, response && response.result);
output.results = output.results.concat(normalized.slice(0, remaining - output.results.length));
} catch (err) {
output.errors.push(err && err.message ? err.message : String(err));
console.warn('[verstak.search] provider search:', err);
output.errors.push(true);
}
}
return output;
@ -371,7 +373,9 @@
if (typeof alertEl.removeAttribute === 'function') alertEl.removeAttribute('hidden');
alertEl.appendChild(el('details', {}, [
el('summary', {}, [tr('ui.providersFailed', null, 'Some plugin search providers did not respond')]),
el('div', {}, [state.providerErrors.join('; ')])
el('div', {}, [state.providerErrors.map(function () {
return tr('ui.providerUnavailable', null, 'A search provider is unavailable.');
}).join(' ')])
]));
} else if (typeof alertEl.setAttribute === 'function') {
alertEl.setAttribute('hidden', 'hidden');
@ -460,8 +464,9 @@
state.providerErrors = external.errors;
} catch (err) {
if (seq !== searchSeq) return;
console.warn('[verstak.search] search:', err);
state.results = [];
state.error = err && err.message ? err.message : String(err);
state.error = tr('ui.searchError', null, 'Could not search the vault. Please try again.');
state.providerErrors = [];
} finally {
if (seq !== searchSeq) return;

View File

@ -10,6 +10,8 @@
"ui.searching": "Searching...",
"ui.search": "Search",
"ui.providersFailed": "Some plugin search providers did not respond",
"ui.providerUnavailable": "A search provider is unavailable.",
"ui.searchError": "Could not search the vault. Please try again.",
"ui.noResults": "No results",
"ui.open": "Open",
"ui.count": "{count} result(s)"

View File

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

View File

@ -23,6 +23,7 @@
'.secrets-panel{min-height:0;overflow:auto;border-right:1px solid var(--vt-color-border,#202b46);background:var(--vt-color-surface-muted,#111629)}',
'.secrets-main{min-width:0;min-height:0;overflow:auto;padding:1rem;background:var(--vt-color-background,#101020)}',
'.secrets-toolbar{display:flex;align-items:center;gap:.5rem;min-height:2.75rem;padding:.65rem .75rem;border-bottom:1px solid var(--vt-color-border,#202b46)}',
'.secrets-filters{display:grid;grid-template-columns:minmax(0,1fr) minmax(8rem,.8fr);gap:.4rem;padding:.5rem .55rem;border-bottom:1px solid var(--vt-color-border,#202b46)}',
'.secrets-title{font-weight:600;font-size:.88rem}',
'.secrets-count{color:var(--vt-color-text-muted,#7f8aa3);font-size:.76rem}',
'.secrets-spacer{flex:1}',
@ -44,10 +45,11 @@
'.secrets-form{display:grid;gap:.65rem;border:1px solid var(--vt-color-border,#202b46);border-radius:var(--vt-radius-lg,8px);padding:.9rem;background:var(--vt-color-surface,#15152c)}',
'.secrets-row{display:grid;grid-template-columns:8rem minmax(0,1fr);gap:.65rem;align-items:center}',
'.secrets-label{font-size:.78rem;color:var(--vt-color-text-muted,#7f8aa3)}',
'.secrets-input,.secrets-textarea,.secrets-select{width:100%;box-sizing:border-box;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);font:inherit;font-size:.84rem;padding:.45rem .55rem;outline:none}',
'.secrets-input,.secrets-textarea,.secrets-select,.secrets-search{width:100%;box-sizing:border-box;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);font:inherit;font-size:.84rem;padding:.45rem .55rem;outline:none}',
'.secrets-search{font-size:.76rem}',
'.secrets-select{appearance:none;background-color:#0d1117;background-image:linear-gradient(45deg,transparent 50%,#8b949e 50%),linear-gradient(135deg,#8b949e 50%,transparent 50%);background-position:calc(100% - 16px) 50%,calc(100% - 11px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:2rem}.secrets-select option{background:#0d1117;color:var(--vt-color-text-primary,#f4f7fb)}',
'.secrets-textarea{min-height:6rem;resize:vertical;font-family:ui-monospace,SFMono-Regular,Consolas,monospace}',
'.secrets-input:focus,.secrets-textarea:focus,.secrets-select:focus{border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.secrets-input:focus,.secrets-textarea:focus,.secrets-select:focus,.secrets-search:focus{border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.secrets-actions{display:flex;gap:.5rem;flex-wrap:wrap}',
'.secrets-status{font-size:.78rem;color:var(--vt-color-text-muted,#7f8aa3);min-height:1rem}',
'.secrets-status.error{color:#ffc6ce}',
@ -58,7 +60,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 td{color:var(--vt-color-text-primary,#f4f7fb);overflow-wrap:anywhere}',
'.secrets-table tr:last-child th,.secrets-table tr:last-child td{border-bottom:0}',
'@media(max-width:780px){.secrets-root{grid-template-columns:1fr}.secrets-panel{border-right:0;border-bottom:1px solid #252b36;max-height:45vh}.secrets-row{grid-template-columns:1fr}}'
'@media(max-width:780px){.secrets-root{grid-template-columns:1fr}.secrets-panel{border-right:0;border-bottom:1px solid #252b36;max-height:45vh}.secrets-row,.secrets-filters{grid-template-columns:1fr}}'
].join('\n');
function el(tag, attrs, children) {
@ -97,13 +99,14 @@
function scopeLabel(record) {
var scope = record && record.scope || {};
if (scope.kind === ScopeWorkspace) return cleanWorkspace(scope.workspaceRootPath) || 'Workspace';
if (scope.kind === ScopeWorkspace) return cleanWorkspace(scope.workspaceRootPath) || 'Deal';
return 'Global';
}
function selectedIDFromProps(props) {
var resource = props && props.resource || {};
var path = text(resource.path || props && props.secretId);
var request = props && props.request || {};
var path = text(resource.path || request.path || props && props.secretId);
if (path.indexOf('verstak-secret://') === 0) return decodeURIComponent(path.slice('verstak-secret://'.length));
return decodeURIComponent(path.replace(/^\/+/, ''));
}
@ -144,6 +147,9 @@
var workspaceRoot = workspaceFromProps(props || {});
var selectedID = selectedIDFromProps(props || {});
var records = [];
var workspaceOptions = [];
var scopeFilter = 'all';
var searchQuery = '';
var selectedRecord = null;
var selectedValue = '';
var initialized = false;
@ -161,6 +167,91 @@
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.',
copyLink: 'Could not copy the secret link. Please try again.',
unlock: 'Could not unlock secrets. Check the master password and try again.'
}[action] || 'Could not complete this action. Please try again.');
}
function setUserFacingError(action, err) {
setStatus(userFacingError(action, err), true);
}
function recordWorkspaceRoot(record) {
var scope = record && record.scope || {};
return scope.kind === ScopeWorkspace ? cleanWorkspace(scope.workspaceRootPath) : '';
}
function workspaceRoots() {
var seen = {};
var values = [];
workspaceOptions.concat(records.map(recordWorkspaceRoot)).forEach(function (value) {
value = cleanWorkspace(value);
if (!value || seen[value]) return;
seen[value] = true;
values.push(value);
});
return values.sort(function (a, b) { return a.localeCompare(b); });
}
function filteredRecords() {
var query = text(searchQuery).trim().toLowerCase();
return records.filter(function (record) {
var recordWorkspace = recordWorkspaceRoot(record);
if (scopeFilter === ScopeGlobal && recordWorkspace) return false;
if (scopeFilter.indexOf(ScopeWorkspace + ':') === 0 && recordWorkspace !== scopeFilter.slice((ScopeWorkspace + ':').length)) return false;
if (!query) return true;
return [record.title, record.id, record.username, recordWorkspace].some(function (value) {
return text(value).toLowerCase().indexOf(query) !== -1;
});
});
}
function clearHiddenSelection() {
if (!selectedRecord) return;
var visible = filteredRecords();
if (visible.some(function (record) { return record.id === selectedRecord.id; })) return;
selectedRecord = null;
selectedValue = '';
}
function loadWorkspaceOptions() {
if (!api || !api.files || typeof api.files.list !== 'function') return Promise.resolve();
return api.files.list('').then(function (items) {
workspaceOptions = (Array.isArray(items) ? items : []).filter(function (item) {
return text(item && item.type).toLowerCase() === 'folder';
}).map(function (item) {
return cleanWorkspace(item.relativePath || item.name);
}).filter(function (value) {
return value && value.indexOf('/') === -1;
});
}).catch(function () {
workspaceOptions = [];
});
}
function renderLocked() {
var passwordInput = el('input', {
className: 'secrets-input',
@ -190,7 +281,7 @@
return loadRecords();
}).catch(function (err) {
unlockBtn.disabled = false;
setStatus((err && err.message) ? err.message : String(err), true);
setUserFacingError('unlock', err);
});
}
}, [tr('ui.unlock', null, 'Unlock')]);
@ -226,19 +317,48 @@
}
function renderList() {
var visibleRecords = filteredRecords();
var scopeSelect = el('select', {
className: 'secrets-select',
'data-secret-scope-filter': '',
onChange: function (event) {
scopeFilter = text(event.target.value) || 'all';
clearHiddenSelection();
render();
}
}, [
el('option', { value: 'all' }, [tr('ui.scopeAll', null, 'All scopes')]),
el('option', { value: ScopeGlobal }, [tr('ui.global', null, 'Global')])
].concat(workspaceRoots().map(function (workspace) {
return el('option', { value: ScopeWorkspace + ':' + workspace }, [workspace]);
})));
scopeSelect.value = scopeFilter;
var searchInput = el('input', {
className: 'secrets-search',
type: 'search',
'data-secret-search': '',
placeholder: tr('ui.search', null, 'Search secrets'),
value: searchQuery,
onInput: function (event) {
searchQuery = text(event.target.value);
clearHiddenSelection();
render();
}
});
var children = [
el('div', { className: 'secrets-toolbar' }, [
el('span', { className: 'secrets-title' }, [tr('ui.title', null, 'Secrets')]),
el('span', { className: 'secrets-count' }, [String(records.length)]),
el('span', { className: 'secrets-count' }, [String(visibleRecords.length)]),
el('span', { className: 'secrets-spacer' }),
el('button', { className: 'secrets-btn', type: 'button', onClick: showNewSecret }, [tr('ui.new', null, 'New')])
])
]),
el('div', { className: 'secrets-filters' }, [searchInput, scopeSelect])
];
if (!records.length) {
if (!visibleRecords.length) {
children.push(el('div', { className: 'secrets-empty' }, [tr('ui.empty', null, 'No secrets')]));
return children;
}
groupRecords(records).forEach(function (group) {
groupRecords(visibleRecords).forEach(function (group) {
children.push(el('div', { className: 'secrets-group' }, [group.label]));
children.push(el('div', { className: 'secrets-list' }, group.records.map(function (record) {
var active = selectedRecord && selectedRecord.id === record.id;
@ -259,7 +379,8 @@
function renderSelected() {
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])
]);
return el('div', { className: 'secrets-card' }, [
el('h2', {}, [selectedRecord.title || selectedRecord.id]),
@ -316,9 +437,26 @@
value.value = isEdit ? selectedValue : '';
var scope = el('select', { className: 'secrets-select' }, [
el('option', { value: ScopeGlobal }, [tr('ui.global', null, 'Global')]),
el('option', { value: ScopeWorkspace }, [workspaceRoot || tr('ui.workspace', null, 'Workspace')])
el('option', { value: ScopeWorkspace }, [tr('ui.deal', null, 'Deal')])
]);
scope.setAttribute('data-secret-scope', '');
var existingWorkspace = existing ? recordWorkspaceRoot(existing) : '';
var defaultWorkspace = existingWorkspace || workspaceRoot || workspaceRoots()[0] || '';
var workspace = el('select', { className: 'secrets-select', 'data-secret-workspace': '' }, [
el('option', { value: '' }, [tr('ui.chooseWorkspace', null, 'Choose a Deal')])
].concat(workspaceRoots().map(function (item) {
return el('option', { value: item }, [item]);
})));
workspace.value = defaultWorkspace;
var workspaceRow = el('div', { className: 'secrets-row' }, [
el('label', { className: 'secrets-label' }, [tr('ui.deal', null, 'Deal')]), workspace
]);
function updateWorkspaceVisibility() {
workspaceRow.hidden = scope.value !== ScopeWorkspace;
}
scope.addEventListener('change', updateWorkspaceVisibility);
scope.value = existing && existing.scope && existing.scope.kind ? existing.scope.kind : (workspaceRoot ? ScopeWorkspace : ScopeGlobal);
updateWorkspaceVisibility();
return el('div', { className: 'secrets-card' }, [
el('h2', {}, [isEdit ? tr('ui.editSecret', null, 'Edit secret') : tr('ui.newSecret', null, 'New secret')]),
el('div', { className: 'secrets-form' }, [
@ -326,6 +464,7 @@
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.scope', null, 'Scope')]), scope]),
workspaceRow,
el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, [tr('ui.value', null, 'Value')]), value]),
el('div', { className: 'secrets-actions' }, [
el('button', {
@ -334,19 +473,24 @@
'data-secret-save': '',
onClick: function () {
var nextID = text(id.value).trim() || text(title.value).trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '.').replace(/^\.+|\.+$/g, '');
var targetWorkspace = cleanWorkspace(workspace.value);
if (scope.value === ScopeWorkspace && !targetWorkspace) {
setStatus(tr('ui.workspaceRequired', null, 'Choose a Deal for this secret.'), true);
return;
}
api.secrets.write({
id: nextID,
title: text(title.value).trim() || nextID,
username: text(username.value).trim(),
value: text(value.value),
scope: scope.value === ScopeWorkspace ? { kind: ScopeWorkspace, workspaceRootPath: workspaceRoot } : { kind: ScopeGlobal }
scope: scope.value === ScopeWorkspace ? { kind: ScopeWorkspace, workspaceRootPath: targetWorkspace } : { kind: ScopeGlobal }
}).then(function (record) {
selectedID = record.id;
selectedRecord = record;
selectedValue = '';
return loadRecords();
}).catch(function (err) {
setStatus((err && err.message) ? err.message : String(err), true);
setUserFacingError('save', err);
});
}
}, [tr('ui.save', null, 'Save')]),
@ -381,11 +525,20 @@
if (wanted) {
var found = records.find(function (record) { return record.id === wanted; });
if (found) return selectRecord(found.id);
selectedRecord = null;
selectedValue = '';
mode = 'selected';
statusText = tr('ui.requestedUnavailable', null, 'The requested secret is unavailable.');
statusError = true;
render();
return;
}
selectedRecord = records[0] || null;
selectedValue = '';
mode = 'selected';
render();
}).catch(function (err) {
setUserFacingError('load', err);
});
}
@ -403,7 +556,7 @@
render();
}).catch(function (err) {
if (disposed) return;
setStatus((err && err.message) ? err.message : String(err), true);
setUserFacingError('read', err);
});
}
@ -430,7 +583,7 @@
selectedValue = '';
return loadRecords();
}).catch(function (err) {
setStatus((err && err.message) ? err.message : String(err), true);
setUserFacingError('delete', err);
});
}
@ -440,17 +593,17 @@
setStatus(tr('ui.linkCopied', null, 'Secret link copied'), false);
});
}).catch(function (err) {
setStatus((err && err.message) ? err.message : String(err), true);
setUserFacingError('copyLink', err);
});
}
api.secrets.status().then(function (status) {
initialized = !!(status && status.initialized);
unlocked = !!(status && status.unlocked);
if (unlocked) return loadRecords();
if (unlocked) return loadWorkspaceOptions().then(loadRecords);
render();
}).catch(function (err) {
statusText = (err && err.message) ? err.message : String(err);
statusText = userFacingError('status', err);
statusError = true;
renderLocked();
});

View File

@ -1,12 +1,17 @@
{
"manifest.name": "Secrets",
"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.settingsPanels.verstak.secrets.settings.title": "Secrets",
"contributions.workspaceItems.verstak.secrets.workspace.title": "Secrets",
"ui.masterPassword": "Master password",
"ui.repeatPassword": "Repeat master password",
"ui.passwordMismatch": "Master passwords do not match",
"ui.masterPasswordMinLength": "Master password must be at least {count} characters.",
"ui.masterPasswordRequired": "Enter a master password.",
"ui.masterPasswordInvalid": "The master password is incorrect.",
"ui.unlock": "Unlock",
"ui.createMaster": "Create master password",
"ui.password": "Password",
@ -16,6 +21,7 @@
"ui.new": "New",
"ui.empty": "No secrets",
"ui.select": "Select a secret",
"ui.requestedUnavailable": "The requested secret is unavailable.",
"ui.group": "Group",
"ui.username": "Username",
"ui.updated": "Updated",
@ -30,6 +36,18 @@
"ui.editSecret": "Edit secret",
"ui.newSecret": "New secret",
"ui.scope": "Scope",
"ui.deal": "Deal",
"ui.scopeAll": "All scopes",
"ui.search": "Search secrets",
"ui.chooseWorkspace": "Choose a Deal",
"ui.workspaceRequired": "Choose a Deal for this secret.",
"ui.statusError": "Could not check the secret storage. Please try again.",
"ui.loadError": "Could not load secrets. Please try again.",
"ui.readError": "Could not open this secret. Please try again.",
"ui.saveError": "Could not save the secret. Please try again.",
"ui.deleteError": "Could not delete the secret. Please try again.",
"ui.copyLinkError": "Could not copy the secret link. Please try again.",
"ui.unlockError": "Could not unlock secrets. Check the master password and try again.",
"ui.value": "Value",
"ui.save": "Save",
"ui.cancel": "Cancel",

View File

@ -1,12 +1,17 @@
{
"manifest.name": "Секреты",
"manifest.description": "Зашифрованное хранилище общих секретов и секретов рабочих пространств.",
"contributions.views.verstak.secrets.view.title": "Секреты",
"contributions.sidebarItems.verstak.secrets.sidebar.title": "Секреты",
"contributions.openProviders.verstak.secrets.secret.title": "Секреты",
"contributions.settingsPanels.verstak.secrets.settings.title": "Секреты",
"contributions.workspaceItems.verstak.secrets.workspace.title": "Секреты",
"ui.masterPassword": "Мастер-пароль",
"ui.repeatPassword": "Повторите мастер-пароль",
"ui.passwordMismatch": "Мастер-пароли не совпадают",
"ui.masterPasswordMinLength": "Мастер-пароль должен содержать не менее {count} символов.",
"ui.masterPasswordRequired": "Введите мастер-пароль.",
"ui.masterPasswordInvalid": "Мастер-пароль указан неверно.",
"ui.unlock": "Разблокировать",
"ui.createMaster": "Создать мастер-пароль",
"ui.password": "Пароль",
@ -16,6 +21,7 @@
"ui.new": "Новый",
"ui.empty": "Секретов нет",
"ui.select": "Выберите секрет",
"ui.requestedUnavailable": "Запрошенный секрет недоступен.",
"ui.group": "Группа",
"ui.username": "Имя пользователя",
"ui.updated": "Обновлён",
@ -30,6 +36,18 @@
"ui.editSecret": "Изменить секрет",
"ui.newSecret": "Новый секрет",
"ui.scope": "Область",
"ui.deal": "Дело",
"ui.scopeAll": "Все области",
"ui.search": "Поиск секретов",
"ui.chooseWorkspace": "Выберите дело",
"ui.workspaceRequired": "Выберите дело для этого секрета.",
"ui.statusError": "Не удалось проверить хранилище секретов. Повторите попытку.",
"ui.loadError": "Не удалось загрузить секреты. Повторите попытку.",
"ui.readError": "Не удалось открыть секрет. Повторите попытку.",
"ui.saveError": "Не удалось сохранить секрет. Повторите попытку.",
"ui.deleteError": "Не удалось удалить секрет. Повторите попытку.",
"ui.copyLinkError": "Не удалось скопировать ссылку на секрет. Повторите попытку.",
"ui.unlockError": "Не удалось разблокировать секреты. Проверьте мастер-пароль и повторите попытку.",
"ui.value": "Значение",
"ui.save": "Сохранить",
"ui.cancel": "Отмена",

View File

@ -14,6 +14,7 @@
"secrets.write-ui"
],
"permissions": [
"files.read",
"secrets.read",
"secrets.write",
"ui.register"
@ -22,6 +23,23 @@
"entry": "frontend/src/index.js"
},
"contributes": {
"views": [
{
"id": "verstak.secrets.view",
"title": "Secrets",
"icon": "key-round",
"component": "SecretsView"
}
],
"sidebarItems": [
{
"id": "verstak.secrets.sidebar",
"title": "Secrets",
"icon": "key-round",
"view": "verstak.secrets.view",
"position": 45
}
],
"openProviders": [
{
"id": "verstak.secrets.secret",

View File

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

View File

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

View File

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

View File

@ -18,7 +18,7 @@
'.todo-title{font-size:.86rem;font-weight:600}.todo-count,.todo-status,.todo-scope{font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3)}.todo-spacer{flex:1}',
'.todo-filters{display:flex;align-items:center;gap:.35rem;min-width:0;flex:1;flex-wrap:wrap}',
'.todo-input,.todo-select{box-sizing:border-box;min-height:1.9rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-surface,#15152c);color:var(--vt-color-text-primary,#f4f7fb);color-scheme:dark;font:inherit;font-size:.78rem;padding:.32rem .45rem}',
'.todo-input.search{width:min(15rem,100%)}.todo-input.textarea{min-height:6.5rem;resize:vertical;line-height:1.4}.todo-select{max-width:12rem;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-input.todo-time-input{width:100%;font-variant-numeric:tabular-nums}.todo-select{max-width:12rem;appearance:none;background-color:var(--vt-color-surface,#15152c);background-image:linear-gradient(45deg,transparent 50%,var(--vt-color-text-muted,#7f8aa3) 50%),linear-gradient(135deg,var(--vt-color-text-muted,#7f8aa3) 50%,transparent 50%);background-position:calc(100% - 14px) 50%,calc(100% - 9px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:1.7rem}.todo-select option{background:var(--vt-color-surface,#15152c);color:var(--vt-color-text-primary,#f4f7fb)}',
'.todo-btn{min-height:1.9rem;padding:.32rem .62rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-md,6px);background:var(--vt-color-surface-hover,#1b2440);color:var(--vt-color-text-secondary,#b7c0d4);font-size:.78rem;cursor:pointer}.todo-btn:hover{border-color:var(--vt-color-accent,#4ecca3);color:var(--vt-color-text-primary,#f4f7fb)}.todo-btn.primary{background:var(--vt-color-accent,#4ecca3);border-color:var(--vt-color-accent,#4ecca3);color:#101827}.todo-btn.danger{border-color:rgba(233,69,96,.5);color:#ff9a9a}.todo-btn:disabled{opacity:.45;cursor:default}',
'.todo-list{flex:1;min-height:0;overflow:auto;padding:.5rem .75rem .85rem}.todo-empty{height:100%;display:flex;align-items:center;justify-content:center;padding:2rem;text-align:center;color:var(--vt-color-text-muted,#7f8aa3);font-size:.86rem}',
'.todo-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.75rem;align-items:start;margin-top:.5rem;padding:.75rem .85rem;border:1px solid var(--vt-color-border,#202b46);border-radius:var(--vt-radius-lg,8px);background:var(--vt-color-surface,#15152c)}.todo-row:hover{background:var(--vt-color-surface-hover,#1b2440)}.todo-row.done .todo-row-title{text-decoration:line-through;color:var(--vt-color-text-muted,#7f8aa3)}',
@ -132,10 +132,19 @@
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 = text(time).trim();
if (!date || !/^\d{2}:\d{2}$/.test(time)) return '';
time = cleanReminderTime(time);
if (!date || !time) return '';
return cleanDateTime(date + 'T' + time);
}
@ -146,6 +155,8 @@
function normalizeTodo(value) {
value = value || {};
var status = cleanStatus(value.status);
var reminderAt = cleanDateTime(value.reminderAt || value.reminderDateTime);
var reminder = splitReminderDateTime(reminderAt);
var createdAt = text(value.createdAt).trim() || now();
var completedAt = status === 'done' ? (text(value.completedAt).trim() || createdAt) : '';
return {
@ -157,7 +168,8 @@
status: status,
priority: cleanPriority(value.priority),
dueAt: cleanDate(value.dueAt || value.dueDate),
reminderAt: cleanDateTime(value.reminderAt || value.reminderDateTime),
reminderDate: cleanDate(value.reminderDate) || reminder.date,
reminderAt: reminderAt,
createdAt: createdAt,
updatedAt: text(value.updatedAt).trim() || createdAt,
completedAt: completedAt,
@ -186,6 +198,7 @@
status: todo.status,
priority: todo.priority,
dueAt: todo.dueAt,
reminderDate: todo.reminderDate,
reminderAt: todo.reminderAt,
createdAt: todo.createdAt,
updatedAt: todo.updatedAt,
@ -389,11 +402,18 @@
}).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) {
statusText = tr('ui.notificationError', { error: err && err.message ? err.message : String(err) }, 'Could not schedule reminders: ' + (err && err.message ? err.message : String(err)));
statusClass = 'error';
reportError('ui.notificationError', 'Could not schedule reminders. Please try again.', err);
});
}
@ -402,8 +422,7 @@
return api.settings.write(GLOBAL_KEY, storageTodos(sortTodos(todos))).then(function () {
return syncNotifications();
}).catch(function (err) {
statusText = tr('ui.saveError', { error: err && err.message ? err.message : String(err) }, 'Could not save todos: ' + (err && err.message ? err.message : String(err)));
statusClass = 'error';
reportError('ui.saveError', 'Could not save tasks. Please try again.', err);
});
}
@ -413,8 +432,7 @@
todos = sortTodos(normalizeTodos((settings || {})[GLOBAL_KEY]));
return syncNotifications();
}).catch(function (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';
reportError('ui.loadError', 'Could not load tasks. Please try again.', err);
});
}
@ -447,8 +465,8 @@
priorityInput.value = editing ? existingTodo.priority : 'normal';
var dueInput = el('input', { className: 'todo-input', type: 'date', value: editing ? existingTodo.dueAt : '', 'data-todo-input': 'dueAt' });
var reminder = splitReminderDateTime(editing ? existingTodo.reminderAt : '');
var reminderDateInput = el('input', { className: 'todo-input', type: 'date', value: reminder.date, 'data-todo-input': 'reminderDate' });
var reminderTimeInput = el('input', { className: 'todo-input', type: 'time', value: reminder.time, 'data-todo-input': 'reminderTime' });
var reminderDateInput = el('input', { className: 'todo-input', type: 'date', value: editing ? existingTodo.reminderDate || reminder.date : '', 'data-todo-input': 'reminderDate' });
var reminderTimeInput = el('input', { className: 'todo-input todo-time-input', type: 'text', inputmode: 'numeric', maxlength: '5', placeholder: tr('ui.reminderTimePlaceholder', null, '14:30'), value: reminder.time, 'data-todo-input': 'reminderTime', 'aria-label': tr('ui.field.reminderTime', null, 'Reminder time') });
var workspaceInput = null;
var workspace = editing ? existingTodo.workspaceRootPath : scope.workspaceRoot;
if (scope.mode === 'global') {
@ -469,6 +487,20 @@
return;
}
var workspaceRoot = scope.mode === 'workspace' ? scope.workspaceRoot : cleanWorkspace(workspaceInput && workspaceInput.value);
var reminderDate = cleanDate(reminderDateInput.value);
var reminderTime = cleanReminderTime(reminderTimeInput.value);
if (text(reminderTimeInput.value).trim() && !reminderTime) {
statusText = tr('ui.reminderTimeInvalid', null, 'Enter a valid reminder time (HH:MM)');
statusClass = 'error';
render();
return;
}
if (reminderTime && !reminderDate) {
statusText = tr('ui.reminderDateRequired', null, 'Choose a reminder date before setting its time');
statusClass = 'error';
render();
return;
}
var timestamp = now();
var next = normalizeTodo({
id: editing ? existingTodo.id : todoId(workspaceRoot, title),
@ -479,7 +511,8 @@
status: editing ? existingTodo.status : 'open',
priority: priorityInput.value,
dueAt: dueInput.value,
reminderAt: joinReminderDateTime(reminderDateInput.value, reminderTimeInput.value),
reminderDate: reminderDate,
reminderAt: joinReminderDateTime(reminderDate, reminderTime),
createdAt: editing ? existingTodo.createdAt : timestamp,
updatedAt: timestamp,
completedAt: editing ? existingTodo.completedAt : '',
@ -586,6 +619,7 @@
meta.push(el('span', { className: 'todo-badge', textContent: tr('ui.status.' + todo.status, null, todo.status) }));
if (todo.dueAt) meta.push(el('span', { className: 'todo-badge ' + due, textContent: tr('ui.dueValue', { prefix: due === 'overdue' ? tr('ui.overduePrefix', null, 'Overdue · ') : (due === 'due-soon' ? tr('ui.dueSoonPrefix', null, 'Due soon · ') : ''), date: formatDate(todo.dueAt) }, (due === 'overdue' ? 'Overdue · ' : (due === 'due-soon' ? 'Due soon · ' : '')) + 'Due ' + formatDate(todo.dueAt)) }));
if (todo.reminderAt) meta.push(el('span', { className: 'todo-badge ' + (reminderDue ? 'reminder-due' : ''), textContent: tr(reminderDue ? 'ui.reminderDueValue' : 'ui.reminderValue', { date: formatDate(todo.reminderAt) }, (reminderDue ? 'Reminder due ' : 'Reminder ') + formatDate(todo.reminderAt)) }));
else if (todo.reminderDate) meta.push(el('span', { className: 'todo-badge', textContent: tr('ui.reminderDateValue', { date: formatDate(todo.reminderDate) }, 'Reminder date ' + formatDate(todo.reminderDate)) }));
return el('div', { className: 'todo-row-meta' }, meta);
}

View File

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

View File

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

View File

@ -8,6 +8,7 @@ const checks = [
['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'],
];

View File

@ -0,0 +1,51 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
function frontendSources(dir) {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const target = path.join(dir, entry.name);
if (entry.isDirectory()) return frontendSources(target);
return /\.(?:js|svelte)$/.test(entry.name) ? [target] : [];
});
}
const files = fs.readdirSync(path.join(root, 'plugins'))
.flatMap((plugin) => frontendSources(path.join(root, 'plugins', plugin, 'frontend', 'src')));
const renderPatterns = [
/statusText\s*=/,
/state\.error\s*=/,
/errorMsg\s*=/,
/setStatus\(/,
/setCreateError\(/,
/setRenameError\(/,
/renderEmpty\(/,
/output\.errors\.push\(/,
/window\.alert\(/,
/showExternalFallback\(/,
/de-error-msg/,
/files-error-msg/,
/body\.textContent\s*=/
];
const violations = [];
for (const file of files) {
const lines = fs.readFileSync(file, 'utf8').split('\n');
lines.forEach((line, index) => {
if (!/(?:err(?:or)?\.message|String\(err(?:or)?\b)/.test(line)) return;
if (!renderPatterns.some((pattern) => pattern.test(line))) return;
violations.push(`${path.relative(root, file)}:${index + 1}: technical error text reaches a user-facing UI sink`);
});
}
if (violations.length) {
console.error('User-facing UI must not render raw backend/plugin errors:');
violations.forEach((violation) => console.error(` ${violation}`));
process.exit(1);
}
console.log('user-facing error messages do not expose raw backend details');

View File

@ -101,6 +101,18 @@ else
echo " ⚠️ node not available — skipping select style validation"
fi
echo ""
echo "[user-facing errors]"
if command -v node &>/dev/null; then
set +e
node "$ROOT/scripts/check-user-facing-errors.js"
STATUS=$?
set -e
report "user-facing errors" "$STATUS"
else
echo " ⚠️ node not available — skipping user-facing error validation"
fi
echo ""
# Guard official plugins against bypassing the v2 plugin API for note features.
echo "[frontend API boundary]"

View File

@ -361,6 +361,13 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
if (!clearButton) throw new Error('clear activity button not found');
clearButton.click();
await flush();
if (api.storedEvents(projectKey).length === 0) throw new Error('clear action removed activity before confirmation');
const clearConfirmation = walk(container, (node) => node.getAttribute && node.getAttribute('data-activity-clear-confirmation') === '');
if (!clearConfirmation) throw new Error('clear activity confirmation was not rendered');
const confirmClear = walk(clearConfirmation, (node) => node.getAttribute && node.getAttribute('data-activity-clear-confirm') === '');
if (!confirmClear) throw new Error('clear activity confirmation button was not rendered');
confirmClear.click();
await flush();
if (api.storedEvents(projectKey).length !== 0) throw new Error('clear action did not remove activity events');
if (api.storedEvents('work-session-candidates:workspace:Project').length !== 0) throw new Error('clear action did not remove cached candidates');
@ -531,6 +538,13 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
const rawClear = walk(rawView.container, (node) => node.getAttribute && node.getAttribute('data-activity-action') === 'clear');
rawClear.click();
await flush();
if (rawApi.storedData('activity-events').length === 0) throw new Error('append-only activity was removed before confirmation');
const rawClearConfirmation = walk(rawView.container, (node) => node.getAttribute && node.getAttribute('data-activity-clear-confirmation') === '');
if (!rawClearConfirmation) throw new Error('append-only activity confirmation was not rendered');
const rawConfirmClear = walk(rawClearConfirmation, (node) => node.getAttribute && node.getAttribute('data-activity-clear-confirm') === '');
if (!rawConfirmClear) throw new Error('append-only activity confirmation button was not rendered');
rawConfirmClear.click();
await flush();
if (rawApi.storedData('activity-events').length !== 0) throw new Error('clear activity did not replace append-only data');
component.unmount && component.unmount(rawView.container);

View File

@ -6,6 +6,11 @@ const vm = require('vm');
const root = path.resolve(__dirname, '..');
const sourcePath = path.join(root, 'plugins', 'browser-inbox', 'frontend', 'src', 'index.js');
const source = fs.readFileSync(sourcePath, 'utf8');
const catalogs = {
en: JSON.parse(fs.readFileSync(path.join(root, 'plugins', 'browser-inbox', 'locales', 'en.json'), 'utf8')),
ru: JSON.parse(fs.readFileSync(path.join(root, 'plugins', 'browser-inbox', 'locales', 'ru.json'), 'utf8')),
};
const technicalErrors = [];
class FakeNode {
constructor(tagName) {
@ -113,7 +118,15 @@ function makeDocument() {
function loadComponents(document) {
const registry = {};
const sandbox = {
console,
console: {
...console,
warn(...args) {
technicalErrors.push(args.map((value) => String(value)).join(' '));
},
error(...args) {
technicalErrors.push(args.map((value) => String(value)).join(' '));
},
},
Date,
document,
window: {
@ -136,7 +149,7 @@ function loadComponent(document) {
return component;
}
function makeApi(initialSettings = {}) {
function makeApi(initialSettings = {}, locale = 'en') {
const settings = { ...initialSettings };
const handlers = {};
const unsubscribed = [];
@ -153,6 +166,13 @@ function makeApi(initialSettings = {}) {
receiverToken: 'initial-browser-token',
};
let nextWriteError = null;
function translate(key, params, fallback) {
let value = catalogs[locale]?.[key] || catalogs.en[key] || fallback || key;
Object.entries(params || {}).forEach(([name, replacement]) => {
value = value.replace(new RegExp(`\\{${name}\\}`, 'g'), String(replacement));
});
return value;
}
function backendCaptures() {
const keys = ['captures:global', 'captures', ...Object.keys(settings).filter((key) => key.startsWith('captures:workspace:'))];
const seen = new Set();
@ -205,6 +225,11 @@ function makeApi(initialSettings = {}) {
fileByteWrites,
openedURLs,
publishedEvents,
i18n: {
getLocale: () => locale,
t: translate,
onDidChangeLocale: () => () => {},
},
failNextWrite(message) {
nextWriteError = new Error(message || 'write failed');
},
@ -298,6 +323,18 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
}
styledView.component.unmount && styledView.component.unmount(styledView.container);
const russianView = await mountWithApi(makeApi({}, 'ru'), {});
if (!russianView.container.textContent.includes('Браузер')) {
throw new Error('Browser Inbox does not use the localized Browser title');
}
if (!russianView.container.textContent.includes('Все Дела')) {
throw new Error('Browser Inbox does not use the localized Deal filter');
}
if (!russianView.container.textContent.includes('Пока нет материалов из браузера')) {
throw new Error('Browser Inbox empty state is not localized');
}
russianView.component.unmount && russianView.component.unmount(russianView.container);
const api = makeApi();
const settingsView = await mountSettingsWithApi(makeApi());
const receiverURLInput = walk(settingsView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-pairing-url') === '');
@ -738,9 +775,12 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
if (!failedConversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-conflict')) {
throw new Error('failed conversion removed capture from queue');
}
if (!failedConversionView.container.textContent.includes('Could not create note')) {
if (!failedConversionView.container.textContent.includes('Could not create the note. Please try again.')) {
throw new Error('failed conversion did not render an error status');
}
if (failedConversionView.container.textContent.includes('file already exists')) {
throw new Error('failed conversion exposed a raw backend error');
}
if (failedConversionApi.publishedEvents.some((event) => event.name === 'browser.capture.converted')) {
throw new Error('failed conversion published converted event');
}
@ -827,7 +867,7 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
if (!failedLinkApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-link-conflict')) {
throw new Error('failed link conversion removed capture from queue');
}
if (!failedLinkView.container.textContent.includes('Could not create link')) {
if (!failedLinkView.container.textContent.includes('Could not create the link. Please try again.')) {
throw new Error('failed link conversion did not render an error status');
}
if (failedLinkApi.publishedEvents.some((event) => event.name === 'browser.capture.converted')) {
@ -934,7 +974,7 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
if (!failedFileApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-file-conflict')) {
throw new Error('failed file conversion removed capture from queue');
}
if (!failedFileView.container.textContent.includes('Could not create file')) {
if (!failedFileView.container.textContent.includes('Could not create the file. Please try again.')) {
throw new Error('failed file conversion did not render an error status');
}
if (failedFileApi.publishedEvents.some((event) => event.name === 'browser.capture.converted')) {
@ -942,6 +982,10 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
}
component.unmount && component.unmount(failedFileView.container);
if (!technicalErrors.some((entry) => entry.includes('file already exists'))) {
throw new Error('failed conversion did not retain its technical details in the console log');
}
console.log('browser inbox plugin smoke passed');
})().catch((err) => {
console.error(err);

View File

@ -148,6 +148,12 @@ function makeApi(initialSettings = {}) {
publishedEvents.push({ name, payload });
},
},
files: {
list: async () => [
{ type: 'folder', relativePath: 'Project', name: 'Project' },
{ type: 'folder', relativePath: 'Client', name: 'Client' },
],
},
storedEntries(key) {
return settings[key] || [];
},
@ -325,6 +331,22 @@ function byData(container, attr, value) {
if (!globalView.container.textContent.includes('Review research capture') && !globalView.container.textContent.includes('Draft brief updated')) {
throw new Error('global journal did not aggregate remaining entries');
}
const globalAdd = byData(globalView.container, 'data-journal-action', 'add');
if (globalAdd.disabled) throw new Error('global Journal Add must be available');
globalAdd.click();
await flush();
const globalWorkspace = byData(globalView.container, 'data-journal-input', 'workspaceRootPath');
if (!globalWorkspace || globalWorkspace.tagName !== 'SELECT') throw new Error('global Journal form did not render the Deal selector');
globalWorkspace.value = 'Client';
byData(globalView.container, 'data-journal-input', 'title').value = 'Prepare client summary';
byData(globalView.container, 'data-journal-input', 'minutes').value = '30';
byData(globalView.container, 'data-journal-action', 'save-entry').click();
await flush();
const clientKey = 'worklog:workspace:Client';
if (api.storedEntries(clientKey).length !== 1 || api.storedEntries(clientKey)[0].title !== 'Prepare client summary') {
throw new Error('global Journal entry was not stored under the selected Deal');
}
if (!globalView.container.textContent.includes('Prepare client summary')) throw new Error('global Journal did not render the created entry');
component.unmount && component.unmount(container);
component.unmount && component.unmount(candidateView.container);

View File

@ -8,6 +8,7 @@ const sourcePath = path.join(root, 'plugins', 'search', 'frontend', 'src', 'inde
const manifestPath = path.join(root, 'plugins', 'search', 'plugin.json');
const source = fs.readFileSync(sourcePath, 'utf8');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const technicalErrors = [];
class FakeNode {
constructor(tagName) {
@ -100,7 +101,15 @@ function makeDocument() {
function loadComponent(document) {
const registry = {};
vm.runInNewContext(source, {
console,
console: {
...console,
warn(...args) {
technicalErrors.push(args.map((value) => String(value)).join(' '));
},
error(...args) {
technicalErrors.push(args.map((value) => String(value)).join(' '));
},
},
document,
window: {
VerstakPluginRegister(pluginId, bundle) {
@ -253,11 +262,13 @@ async function wait(ms) {
if (!container.textContent.includes('Project/Target Assets')) throw new Error('typing should search folder paths');
if (!container.textContent.includes('Project/External/target.note')) throw new Error('external provider result should be rendered');
if (!container.textContent.includes('External Notes')) throw new Error('external provider label should be rendered');
if (!container.textContent.includes('provider unavailable')) throw new Error('provider failure should be reported without failing search');
if (!container.textContent.includes('A search provider is unavailable.')) throw new Error('provider failure should be reported without failing search');
if (container.textContent.includes('provider unavailable')) throw new Error('provider failure leaked a raw backend error');
if (!container.textContent.includes('Content match')) throw new Error('content result type was not rendered');
if (!container.textContent.includes('Folder name')) throw new Error('folder result type was not rendered');
if (!pluginData['search-index'] || !Array.isArray(pluginData['search-index'].files)) throw new Error('search index was not written to plugin data storage');
if (providerCalls.some((call) => call.pluginId === 'verstak.search')) throw new Error('search must not call itself as an external provider');
if (!technicalErrors.some((entry) => entry.includes('provider unavailable'))) throw new Error('provider failure was not retained in the console log');
input = queryInput();
input.value = 'image';

View File

@ -96,10 +96,18 @@ function makeDocument() {
};
}
function loadComponent(document) {
function loadComponent(document, errorLog) {
const registry = {};
vm.runInNewContext(source, {
console,
console: {
...console,
warn(...args) {
errorLog.push(args.map((value) => String(value)).join(' '));
},
error(...args) {
errorLog.push(args.map((value) => String(value)).join(' '));
},
},
document,
window: {
VerstakPluginRegister(pluginId, bundle) {
@ -129,19 +137,24 @@ async function flush() {
if (!manifest.provides.includes('secrets.write-ui')) throw new Error('secrets manifest must provide secrets.write-ui');
if (!manifest.permissions.includes('secrets.read')) throw new Error('secrets manifest must request secrets.read');
if (!manifest.permissions.includes('secrets.write')) throw new Error('secrets manifest must request secrets.write');
if (!manifest.permissions.includes('files.read')) throw new Error('secrets manifest must request files.read for the Deal selector');
if (!manifest.permissions.includes('ui.register')) throw new Error('secrets manifest must request ui.register');
if (!(manifest.contributes.openProviders || []).some((item) => (item.supports || []).some((support) => support.kind === 'secret'))) throw new Error('secrets secret open provider missing');
if (!(manifest.contributes.views || []).some((item) => item.component === 'SecretsView')) throw new Error('secrets global view missing');
if (!(manifest.contributes.sidebarItems || []).some((item) => item.view === 'verstak.secrets.view')) throw new Error('secrets global sidebar item missing');
if (!(manifest.contributes.workspaceItems || []).some((item) => item.component === 'SecretsView')) throw new Error('secrets workspace item missing');
if (!(manifest.contributes.settingsPanels || []).some((item) => item.component === 'SecretsView')) throw new Error('secrets settings panel missing');
const document = makeDocument();
const component = loadComponent(document);
const errorLog = [];
const component = loadComponent(document, errorLog);
const records = [
{ id: 'global.server', title: 'Global Server', username: 'root', scope: { kind: 'global' }, updatedAt: '2026-06-29T00:00:00Z' },
{ id: 'client-a.db', title: 'Client A DB', username: 'app', scope: { kind: 'workspace', workspaceRootPath: 'ClientA' }, updatedAt: '2026-06-29T00:00:00Z' },
];
let initialized = false;
let unlocked = false;
let unlockError = '';
const readCalls = [];
const copied = [];
const deleted = [];
@ -149,6 +162,7 @@ async function flush() {
secrets: {
status: async () => ({ initialized, unlocked }),
unlock: async (password) => {
if (unlockError) throw new Error(unlockError);
if (password !== 'master-password') throw new Error('bad password');
initialized = true;
unlocked = true;
@ -177,6 +191,12 @@ async function flush() {
clipboard: {
writeText: async (text) => copied.push(text),
},
files: {
list: async () => [
{ type: 'folder', relativePath: 'ClientA' },
{ type: 'folder', relativePath: 'ClientB' },
],
},
};
const container = document.createElement('div');
@ -188,6 +208,21 @@ async function flush() {
const confirmInput = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-master-password-confirm') === '');
const unlockButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-unlock') === '');
if (!passwordInput || !confirmInput || !unlockButton) throw new Error('setup controls missing');
unlockError = '[plugin:verstak.secrets] secrets.unlock failed: master password must be at least 8 characters';
passwordInput.value = 'short';
confirmInput.value = 'short';
unlockButton.click();
await flush();
if (!container.textContent.includes('Master password must be at least 8 characters')) {
throw new Error('weak master password error was not explained clearly');
}
if (container.textContent.includes('[plugin:') || container.textContent.includes('secrets.unlock')) {
throw new Error('technical unlock details leaked into the Secrets UI');
}
if (!errorLog.some((entry) => entry.includes('[plugin:verstak.secrets] secrets.unlock failed'))) {
throw new Error('technical unlock details were not retained in the console log');
}
unlockError = '';
passwordInput.value = 'master-password';
confirmInput.value = 'master-password';
unlockButton.click();
@ -228,6 +263,53 @@ async function flush() {
await flush();
if (!deleted.includes('client-a.db')) throw new Error('secret delete was not called');
records.push({ id: 'client-a.global-view', title: 'Client A Global View', username: 'app', scope: { kind: 'workspace', workspaceRootPath: 'ClientA' }, updatedAt: '2026-06-29T00:00:00Z' });
const globalContainer = document.createElement('div');
component.mount(globalContainer, {}, api);
await flush();
if (!globalContainer.textContent.includes('Global Server') || !globalContainer.textContent.includes('Client A Global View')) {
throw new Error('global secrets view did not show global and Deal-scoped records');
}
const scopeFilter = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-scope-filter') === '');
if (!scopeFilter) throw new Error('global secrets scope filter missing');
scopeFilter.value = 'workspace:ClientA';
scopeFilter.dispatchEvent('change');
await flush();
if (!globalContainer.textContent.includes('Client A Global View') || globalContainer.textContent.includes('Global Server')) {
throw new Error('global secrets scope filter did not isolate the selected Deal');
}
const searchInput = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-search') === '');
if (!searchInput) throw new Error('global secrets search missing');
searchInput.value = 'no-match';
searchInput.dispatchEvent('input');
await flush();
if (!globalContainer.textContent.includes('No secrets')) throw new Error('global secrets search did not filter records');
scopeFilter.value = 'all';
scopeFilter.dispatchEvent('change');
await flush();
const newButton = walk(globalContainer, (node) => node.tagName === 'BUTTON' && node.textContent === 'New');
if (!newButton) throw new Error('global secrets new button missing');
newButton.click();
await flush();
const globalTitleInput = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-title') === '');
const globalValueInput = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-value') === '');
const scopeInput = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-scope') === '');
const workspaceInput = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-workspace') === '');
const globalSaveButton = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-save') === '');
if (!globalTitleInput || !globalValueInput || !scopeInput || !workspaceInput || !globalSaveButton) throw new Error('global Deal-scoped secret form controls missing');
globalTitleInput.value = 'Client B API';
globalValueInput.value = 'client-b-api-value';
scopeInput.value = 'workspace';
scopeInput.dispatchEvent('change');
workspaceInput.value = 'ClientB';
globalSaveButton.click();
await flush();
if (!records.some((record) => record.title === 'Client B API' && record.scope && record.scope.workspaceRootPath === 'ClientB')) {
throw new Error('global secrets form did not create a Deal-scoped secret');
}
console.log('secrets plugin smoke passed');
})().catch((err) => {
console.error(err);

View File

@ -9,14 +9,17 @@ const source = fs.readFileSync(sourcePath, 'utf8');
if (!source.includes('settings.lastError')) {
throw new Error('SyncSettings must render persisted settings.lastError');
}
if (!source.includes('sanitizeError(settings.lastError)')) {
throw new Error('SyncSettings must sanitize persisted sync errors before rendering');
if (!source.includes('function reportError')) {
throw new Error('SyncSettings must map technical failures to localized action-specific errors');
}
if (!source.includes('Last sync error')) {
if (/sanitizeError\(/.test(source) || /\{ error: sanitizeError/.test(source)) {
throw new Error('SyncSettings must not interpolate raw technical errors into user-facing messages');
}
if (!source.includes("tr('ui.lastSyncError'")) {
throw new Error('SyncSettings must label the persisted sync error');
}
if (!source.includes('function formatSyncConflict')) {
throw new Error('SyncSettings must format individual sync conflicts');
if (!source.includes("tr('ui.syncConflictItem'")) {
throw new Error('SyncSettings must hide technical conflict identifiers behind a user-facing summary');
}
if (!source.includes('conflictDetails')) {
throw new Error('SyncSettings must store sync conflict details after Sync Now');

View File

@ -235,9 +235,9 @@ async function mountWithApi(apiState, props, emittedEvents = [], document = make
const reminderDate = byData(container, 'data-todo-input', 'reminderDate');
const reminderTime = byData(container, 'data-todo-input', 'reminderTime');
if (!reminderDate || reminderDate.getAttribute('type') !== 'date') throw new Error('Todo reminder date input was not rendered');
if (!reminderTime || reminderTime.getAttribute('type') !== 'time') throw new Error('Todo reminder time 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:00';
reminderTime.value = '09:30';
byData(container, 'data-todo-action', 'save').click();
await flush();
@ -246,7 +246,7 @@ async function mountWithApi(apiState, props, emittedEvents = [], document = make
const createdTodo = storedAfterCreate[0];
if (createdTodo.workspaceRootPath !== 'Project') throw new Error('workspace Todo did not keep the Project root path');
if (createdTodo.status !== 'open' || createdTodo.priority !== 'high') throw new Error('Todo status or priority was not stored');
if (createdTodo.dueAt !== '2000-01-02' || createdTodo.reminderAt !== '2000-01-02T09:00') throw new Error('Todo due/reminder metadata was not stored');
if (createdTodo.dueAt !== '2000-01-02' || createdTodo.reminderDate !== '2000-01-02' || createdTodo.reminderAt !== '2000-01-02T09:30') throw new Error('Todo due/reminder metadata was not stored');
if (!container.textContent.includes('Overdue') || !container.textContent.includes('Reminder due')) throw new Error('due/reminder indicators were not rendered');
const scheduledAfterCreate = apiState.notificationCalls.at(-1) || [];
if (scheduledAfterCreate.length !== 1
@ -266,9 +266,20 @@ async function mountWithApi(apiState, props, emittedEvents = [], document = make
byData(container, 'data-todo-action', 'edit').click();
byData(container, 'data-todo-input', 'title').value = 'Prepare project review updated';
byData(container, 'data-todo-input', 'reminderTime').value = '29:30';
byData(container, 'data-todo-action', 'save').click();
await flush();
if (apiState.settings['todos:global'][0].title !== 'Prepare project review' || !container.textContent.includes('Enter a valid reminder time')) {
throw new Error('invalid reminder time was saved instead of showing a human-readable error');
}
byData(container, 'data-todo-input', 'reminderTime').value = '';
byData(container, 'data-todo-action', 'save').click();
await flush();
if (apiState.settings['todos:global'][0].title !== 'Prepare project review updated') throw new Error('Todo edit was not persisted');
if (apiState.settings['todos:global'][0].reminderDate !== '2000-01-02' || apiState.settings['todos:global'][0].reminderAt !== '') {
throw new Error('clearing reminder time did not preserve its date while cancelling the reminder');
}
if ((apiState.notificationCalls.at(-1) || []).length !== 0) throw new Error('clearing reminder time did not remove the native notification');
byData(container, 'data-todo-action', 'mark-done').click();
await flush();