Compare commits

..

4 Commits

59 changed files with 2059 additions and 496 deletions

View File

@ -409,19 +409,23 @@
var candidateSourceEvents = []; var candidateSourceEvents = [];
var candidates = []; var candidates = [];
var dismissedByWorkspace = {}; var dismissedByWorkspace = {};
var statusText = 'Loading activity...'; function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
var statusText = tr('ui.loading', null, 'Loading activity...');
var statusClass = ''; var statusClass = '';
var disposed = false; var disposed = false;
var unsubscribers = []; var unsubscribers = [];
var toolbar = el('div', { className: 'activity-toolbar' }); var toolbar = el('div', { className: 'activity-toolbar' });
var titleEl = el('span', { className: 'activity-title', textContent: scope.mode === 'global' ? 'Activity' : 'Activity · ' + scope.label }); var titleEl = el('span', { className: 'activity-title', textContent: scope.mode === 'global' ? tr('ui.title', null, 'Activity') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Activity · ' + scope.label) });
var countEl = el('span', { className: 'activity-count' }); var countEl = el('span', { className: 'activity-count' });
var statusEl = el('span', { className: 'activity-status' }); var statusEl = el('span', { className: 'activity-status' });
var clearBtn = el('button', { var clearBtn = el('button', {
className: 'activity-btn danger', className: 'activity-btn danger',
'data-activity-action': 'clear', 'data-activity-action': 'clear',
textContent: 'Clear', textContent: tr('ui.clear', null, 'Clear'),
onClick: function () { onClick: function () {
if (scope.mode === 'global') { if (scope.mode === 'global') {
clearGlobal().then(render); clearGlobal().then(render);
@ -489,7 +493,7 @@
? events.filter(function (item) { return !item._storageKey || item._storageKey === GLOBAL_KEY; }) ? events.filter(function (item) { return !item._storageKey || item._storageKey === GLOBAL_KEY; })
: events; : events;
return api.settings.write(scope.key, storageEvents(toStore)).then(persistCandidateCaches).catch(function (err) { return api.settings.write(scope.key, storageEvents(toStore)).then(persistCandidateCaches).catch(function (err) {
statusText = 'Could not save activity: ' + (err && err.message ? err.message : String(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'; statusClass = 'error';
}); });
} }
@ -513,10 +517,10 @@
return api.settings.write(key, []); return api.settings.write(key, []);
})); }));
}).then(function () { }).then(function () {
statusText = 'Activity cleared'; statusText = tr('ui.cleared', null, 'Activity cleared');
statusClass = ''; statusClass = '';
}).catch(function (err) { }).catch(function (err) {
statusText = 'Could not clear activity: ' + (err && err.message ? err.message : String(err)); 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'; statusClass = 'error';
}); });
} }
@ -525,8 +529,8 @@
listEl.innerHTML = ''; listEl.innerHTML = '';
if (events.length === 0) { if (events.length === 0) {
listEl.appendChild(el('div', { className: 'activity-empty' }, [ listEl.appendChild(el('div', { className: 'activity-empty' }, [
el('div', { className: 'activity-empty-title', textContent: 'No activity events yet' }), el('div', { className: 'activity-empty-title', textContent: tr('ui.empty', null, 'No activity events yet') }),
el('div', { textContent: 'File changes, browser captures, and conversions will appear here.' }) el('div', { textContent: tr('ui.emptyHint', null, 'File changes, browser captures, and conversions will appear here.') })
])); ]));
return; return;
} }
@ -543,8 +547,8 @@
]), ]),
activity.summary ? el('div', { className: 'activity-summary', textContent: activity.summary }) : null, activity.summary ? el('div', { className: 'activity-summary', textContent: activity.summary }) : null,
el('details', { className: 'activity-details' }, [ el('details', { className: 'activity-details' }, [
el('summary', {}, ['Details']), el('summary', {}, [tr('ui.details', null, 'Details')]),
el('div', { className: 'activity-source', textContent: 'Event: ' + activity.type + (activity.sourcePluginId ? ' · Source: ' + activity.sourcePluginId : '') }) el('div', { className: 'activity-source', textContent: tr('ui.eventSource', { event: activity.type, source: activity.sourcePluginId ? ' · ' + activity.sourcePluginId : '' }, 'Event: ' + activity.type + (activity.sourcePluginId ? ' · Source: ' + activity.sourcePluginId : '')) })
]) ])
]) ])
])); ]));
@ -574,7 +578,7 @@
persistDismissals(candidate.workspaceRootPath), persistDismissals(candidate.workspaceRootPath),
persistCandidateCache(candidate.workspaceRootPath) persistCandidateCache(candidate.workspaceRootPath)
]).then(function () { ]).then(function () {
statusText = 'Candidate dismissed'; statusText = tr('ui.dismissed', null, 'Candidate dismissed');
statusClass = ''; statusClass = '';
}).catch(function (err) { }).catch(function (err) {
statusText = 'Could not dismiss candidate: ' + (err && err.message ? err.message : String(err)); statusText = 'Could not dismiss candidate: ' + (err && err.message ? err.message : String(err));
@ -590,14 +594,14 @@
} }
if (typeof candidatesEl.removeAttribute === 'function') candidatesEl.removeAttribute('hidden'); if (typeof candidatesEl.removeAttribute === 'function') candidatesEl.removeAttribute('hidden');
else delete candidatesEl.attributes.hidden; else delete candidatesEl.attributes.hidden;
candidatesEl.appendChild(el('div', { className: 'activity-candidates-title', textContent: 'Possible journal entries' })); candidatesEl.appendChild(el('div', { className: 'activity-candidates-title', textContent: tr('ui.candidates', null, 'Possible journal entries') }));
candidates.forEach(function (candidate) { candidates.forEach(function (candidate) {
candidatesEl.appendChild(el('div', { candidatesEl.appendChild(el('div', {
className: 'activity-candidate', className: 'activity-candidate',
'data-work-session-candidate': candidate.candidateId 'data-work-session-candidate': candidate.candidateId
}, [ }, [
el('div', {}, [ el('div', {}, [
el('div', { className: 'activity-candidate-title', textContent: 'Possible journal entry' }), el('div', { className: 'activity-candidate-title', textContent: tr('ui.candidate', null, 'Possible journal entry') }),
el('div', { className: 'activity-candidate-facts' }, [ el('div', { className: 'activity-candidate-facts' }, [
el('div', { textContent: 'Workspace: ' + candidate.workspaceRootPath }), el('div', { textContent: 'Workspace: ' + candidate.workspaceRootPath }),
el('div', { textContent: 'Time: ' + candidateTimeRange(candidate) }), el('div', { textContent: 'Time: ' + candidateTimeRange(candidate) }),
@ -612,8 +616,8 @@
]), ]),
el('div', { className: 'activity-candidate-actions' }, [ el('div', { className: 'activity-candidate-actions' }, [
el('div', { className: 'activity-candidate-duration', textContent: candidate.estimatedMinutes + ' min' }), el('div', { className: 'activity-candidate-duration', textContent: candidate.estimatedMinutes + ' min' }),
el('button', { className: 'activity-btn', type: 'button', 'data-work-session-action': 'review', textContent: 'Review', onClick: function () { reviewCandidate(candidate); } }), el('button', { className: 'activity-btn', type: 'button', 'data-work-session-action': 'review', textContent: tr('ui.review', null, 'Review'), onClick: function () { reviewCandidate(candidate); } }),
el('button', { className: 'activity-btn', type: 'button', 'data-work-session-action': 'dismiss', textContent: 'Dismiss', onClick: function () { dismissCandidate(candidate); } }) el('button', { className: 'activity-btn', type: 'button', 'data-work-session-action': 'dismiss', textContent: tr('ui.dismiss', null, 'Dismiss'), onClick: function () { dismissCandidate(candidate); } })
]) ])
])); ]));
}); });
@ -696,6 +700,13 @@
}).then(function () { }).then(function () {
if (!disposed) render(); if (!disposed) render();
}); });
if (api && api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
api.i18n.onDidChangeLocale(function () {
titleEl.textContent = scope.mode === 'global' ? tr('ui.title', null, 'Activity') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Activity · ' + scope.label);
clearBtn.textContent = tr('ui.clear', null, 'Clear');
render();
});
}
containerEl.__activityUnmount = function () { containerEl.__activityUnmount = function () {
disposed = true; disposed = true;

View File

@ -0,0 +1,24 @@
{
"manifest.name": "Activity",
"manifest.description": "Workspace-scoped activity log for public plugin events.",
"contributions.views.verstak.activity.view.title": "Activity",
"contributions.sidebarItems.verstak.activity.sidebar.title": "Activity",
"contributions.workspaceItems.verstak.activity.workspace.title": "Activity",
"contributions.commands.verstak.activity.suggestWorklog.title": "List Possible Journal Entries",
"ui.loading": "Loading activity...",
"ui.title": "Activity",
"ui.workspaceTitle": "Activity · {workspace}",
"ui.clear": "Clear",
"ui.saveError": "Could not save activity: {error}",
"ui.cleared": "Activity cleared",
"ui.clearError": "Could not clear activity: {error}",
"ui.empty": "No activity events yet",
"ui.emptyHint": "File changes, browser captures, and conversions will appear here.",
"ui.details": "Details",
"ui.eventSource": "Event: {event}{source}",
"ui.dismissed": "Candidate dismissed",
"ui.candidates": "Possible journal entries",
"ui.candidate": "Possible journal entry",
"ui.review": "Review",
"ui.dismiss": "Dismiss"
}

View File

@ -0,0 +1,24 @@
{
"manifest.name": "Активность",
"manifest.description": "Журнал публичных событий плагинов в рабочем пространстве.",
"contributions.views.verstak.activity.view.title": "Активность",
"contributions.sidebarItems.verstak.activity.sidebar.title": "Активность",
"contributions.workspaceItems.verstak.activity.workspace.title": "Активность",
"contributions.commands.verstak.activity.suggestWorklog.title": "Показать возможные записи журнала",
"ui.loading": "Загрузка активности...",
"ui.title": "Активность",
"ui.workspaceTitle": "Активность · {workspace}",
"ui.clear": "Очистить",
"ui.saveError": "Не удалось сохранить активность: {error}",
"ui.cleared": "Активность очищена",
"ui.clearError": "Не удалось очистить активность: {error}",
"ui.empty": "Событий активности пока нет",
"ui.emptyHint": "Здесь появятся изменения файлов, материалы из браузера и преобразования.",
"ui.details": "Подробнее",
"ui.eventSource": "Событие: {event}{source}",
"ui.dismissed": "Кандидат скрыт",
"ui.candidates": "Возможные записи журнала",
"ui.candidate": "Возможная запись журнала",
"ui.review": "Проверить",
"ui.dismiss": "Скрыть"
}

View File

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

View File

@ -12,6 +12,7 @@
var LEGACY_KEY = 'captures'; var LEGACY_KEY = 'captures';
var GLOBAL_KEY = 'captures:global'; var GLOBAL_KEY = 'captures:global';
var WORKSPACE_PREFIX = 'captures:workspace:'; var WORKSPACE_PREFIX = 'captures:workspace:';
var MUTATION_EVENT = 'browser-inbox.storage.mutate';
function injectStyles() { function injectStyles() {
if (document.getElementById('browser-inbox-style-injected')) return; if (document.getElementById('browser-inbox-style-injected')) return;
@ -29,7 +30,9 @@
'.browser-inbox-filters{display:flex;align-items:center;gap:.35rem;min-width:0;flex:1;flex-wrap:wrap}', '.browser-inbox-filters{display:flex;align-items:center;gap:.35rem;min-width:0;flex:1;flex-wrap:wrap}',
'.browser-inbox-input,.browser-inbox-select{box-sizing:border-box;min-height:1.85rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-surface,#15152c);color:var(--vt-color-text-secondary,#b7c0d4);font:inherit;font-size:.76rem;padding:.25rem .42rem}', '.browser-inbox-input,.browser-inbox-select{box-sizing:border-box;min-height:1.85rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-surface,#15152c);color:var(--vt-color-text-secondary,#b7c0d4);font:inherit;font-size:.76rem;padding:.25rem .42rem}',
'.browser-inbox-input{width:min(15rem,100%)}', '.browser-inbox-input{width:min(15rem,100%)}',
'.browser-inbox-select{max-width:12rem}', '.browser-inbox-select{max-width:12rem;appearance:none;background-color:var(--vt-color-surface,#15152c);background-image:linear-gradient(45deg,transparent 50%,var(--vt-color-text-muted,#7f8aa3) 50%),linear-gradient(135deg,var(--vt-color-text-muted,#7f8aa3) 50%,transparent 50%);background-position:calc(100% - 14px) 50%,calc(100% - 9px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:1.65rem}',
'.browser-inbox-select option{background:var(--vt-color-surface,#15152c);color:var(--vt-color-text-primary,#f4f7fb)}',
'.browser-inbox-input:focus,.browser-inbox-select:focus{outline:none;border-color:var(--vt-color-accent,#4ecca3);box-shadow:0 0 0 1px var(--vt-color-accent,#4ecca3)}',
'.browser-inbox-spacer{flex:1}', '.browser-inbox-spacer{flex:1}',
'.browser-inbox-btn{font-size:.78rem;padding:.32rem .65rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-md,6px);background:var(--vt-color-surface-hover,#1b2440);color:var(--vt-color-text-secondary,#b7c0d4);cursor:pointer}', '.browser-inbox-btn{font-size:.78rem;padding:.32rem .65rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-md,6px);background:var(--vt-color-surface-hover,#1b2440);color:var(--vt-color-text-secondary,#b7c0d4);cursor:pointer}',
'.browser-inbox-btn:hover{background:var(--vt-color-surface-hover,#1b2440);border-color:var(--vt-color-accent,#4ecca3);color:var(--vt-color-text-primary,#f4f7fb)}', '.browser-inbox-btn:hover{background:var(--vt-color-surface-hover,#1b2440);border-color:var(--vt-color-accent,#4ecca3);color:var(--vt-color-text-primary,#f4f7fb)}',
@ -354,9 +357,14 @@
var statusFilter = 'all'; var statusFilter = 'all';
var workspaceFilter = ''; var workspaceFilter = '';
var searchQuery = ''; var searchQuery = '';
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
statusText = tr('ui.connecting', null, 'Connecting to receiver events...');
var toolbar = el('div', { className: 'browser-inbox-toolbar' }); var toolbar = el('div', { className: 'browser-inbox-toolbar' });
var titleEl = el('span', { className: 'browser-inbox-title', textContent: scope.mode === 'global' ? 'Browser Inbox' : 'Browser Inbox · ' + scope.label }); var titleEl = el('span', { className: 'browser-inbox-title', textContent: scope.mode === 'global' ? tr('ui.title', null, 'Browser Inbox') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Browser Inbox · ' + scope.label) });
var countEl = el('span', { className: 'browser-inbox-count' }); var countEl = el('span', { className: 'browser-inbox-count' });
var statusEl = el('span', { className: 'browser-inbox-status' }); var statusEl = el('span', { className: 'browser-inbox-status' });
var filtersEl = el('div', { className: 'browser-inbox-filters' }); var filtersEl = el('div', { className: 'browser-inbox-filters' });
@ -370,10 +378,10 @@
render(); render();
} }
}, [ }, [
el('option', { value: 'all', textContent: 'All captures' }), el('option', { value: 'all', textContent: tr('ui.allCaptures', null, 'All captures') }),
el('option', { value: 'unassigned', textContent: 'Unassigned' }), el('option', { value: 'unassigned', textContent: tr('ui.unassigned', null, 'Unassigned') }),
el('option', { value: 'unprocessed', textContent: 'Unprocessed' }), el('option', { value: 'unprocessed', textContent: tr('ui.unprocessed', null, 'Unprocessed') }),
el('option', { value: 'processed', textContent: 'Processed' }) el('option', { value: 'processed', textContent: tr('ui.processed', null, 'Processed') })
]); ]);
var workspaceFilterEl = el('select', { var workspaceFilterEl = el('select', {
className: 'browser-inbox-select', className: 'browser-inbox-select',
@ -388,7 +396,7 @@
var searchInput = el('input', { var searchInput = el('input', {
className: 'browser-inbox-input', className: 'browser-inbox-input',
type: 'search', type: 'search',
placeholder: 'Search captures', placeholder: tr('ui.search', null, 'Search captures'),
'data-browser-inbox-filter': 'search', 'data-browser-inbox-filter': 'search',
'aria-label': 'Search captures', 'aria-label': 'Search captures',
onInput: function (event) { onInput: function (event) {
@ -402,7 +410,7 @@
var clearBtn = el('button', { var clearBtn = el('button', {
className: 'browser-inbox-btn danger', className: 'browser-inbox-btn danger',
'data-browser-inbox-action': 'clear', 'data-browser-inbox-action': 'clear',
textContent: 'Clear', textContent: tr('ui.clear', null, 'Clear'),
onClick: function () { onClick: function () {
clearScope().then(render); clearScope().then(render);
} }
@ -413,7 +421,7 @@
if (scope.mode === 'global') { if (scope.mode === 'global') {
filtersEl.appendChild(workspaceFilterEl); filtersEl.appendChild(workspaceFilterEl);
} else { } else {
filtersEl.appendChild(el('span', { className: 'browser-inbox-count', textContent: 'Assigned to this workspace' })); filtersEl.appendChild(el('span', { className: 'browser-inbox-count', textContent: tr('ui.assignedHere', null, 'Assigned to this workspace') }));
} }
filtersEl.appendChild(searchInput); filtersEl.appendChild(searchInput);
toolbar.appendChild(filtersEl); toolbar.appendChild(filtersEl);
@ -468,11 +476,32 @@
}); });
} }
function persist() { function publishMutation(action, payload, verify, verifySettings) {
if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve(); if (!api || !api.events || typeof api.events.publish !== 'function') {
return api.settings.write(GLOBAL_KEY, storageCaptures(sortCaptures(captures))).catch(function (err) { statusText = 'Could not save inbox: events API unavailable';
statusClass = 'error';
render();
return Promise.resolve(false);
}
return api.events.publish(MUTATION_EVENT, Object.assign({ action: action }, payload || {})).then(function () {
if (!api.settings || typeof api.settings.read !== 'function') throw new Error('settings API unavailable');
return api.settings.read();
}).then(function (settings) {
settings = settings || {};
if (typeof verifySettings === 'function' && !verifySettings(settings)) {
throw new Error('the stored inbox did not reach the expected state');
}
return loadStored(true);
}).then(function () {
if (typeof verify === 'function' && !verify()) {
throw new Error('the stored capture did not change');
}
return true;
}).catch(function (err) {
statusText = 'Could not save inbox: ' + (err && err.message ? err.message : String(err)); statusText = 'Could not save inbox: ' + (err && err.message ? err.message : String(err));
statusClass = 'error'; statusClass = 'error';
render();
return false;
}); });
} }
@ -481,30 +510,14 @@
captureIds.forEach(function (captureId) { captureIds.forEach(function (captureId) {
ids[captureId] = true; ids[captureId] = true;
}); });
captures = captures.filter(function (item) { return publishMutation('delete', { captureIds: captureIds }, function () {
return !ids[item.captureId]; return !captures.some(function (capture) { return ids[capture.captureId]; });
}); }).then(function (saved) {
if (ids[selectedId]) selectedId = ''; if (!saved) return;
if (!api || !api.settings || typeof api.settings.read !== 'function' || typeof api.settings.write !== 'function') { if (ids[selectedId]) selectedId = '';
return persist().then(function () {
statusText = successText;
statusClass = '';
});
}
return api.settings.read().then(function (settings) {
var keys = globalCaptureKeys(settings || {});
return Promise.all(keys.map(function (key) {
var next = normalizeStoredCaptures((settings || {})[key], key).filter(function (item) {
return !ids[item.captureId];
});
return api.settings.write(key, storageCaptures(next));
}));
}).then(function () {
statusText = successText; statusText = successText;
statusClass = ''; statusClass = '';
}).catch(function (err) { render();
statusText = 'Could not update inbox: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
}); });
} }
@ -529,11 +542,13 @@
return item.captureId === capture.captureId; return item.captureId === capture.captureId;
}); });
if (existing) return Promise.resolve(); if (existing) return Promise.resolve();
captures = sortCaptures([capture].concat(captures));
selectedId = capture.captureId; selectedId = capture.captureId;
statusText = 'Capture received'; statusText = 'Capture received';
statusClass = ''; statusClass = '';
return persist().then(render); statusText = 'Could not load received capture from storage';
statusClass = 'error';
render();
return Promise.resolve();
} }
function applyDomainBinding(capture) { function applyDomainBinding(capture) {
@ -547,17 +562,20 @@
function assignWorkspace(captureId, workspaceRoot) { function assignWorkspace(captureId, workspaceRoot) {
workspaceRoot = cleanWorkspace(workspaceRoot); workspaceRoot = cleanWorkspace(workspaceRoot);
captures = captures.map(function (capture) { return publishMutation('assign', {
if (capture.captureId !== captureId) return capture; captureId: captureId,
return Object.assign({}, capture, { workspaceRootPath: workspaceRoot
workspaceRootPath: workspaceRoot, }, function () {
workspaceName: workspaceRoot return captures.some(function (capture) {
return capture.captureId === captureId && cleanWorkspace(capture.workspaceRootPath) === workspaceRoot;
}); });
}).then(function (saved) {
if (!saved) return;
if (workspaceRoot && workspaceOptions.indexOf(workspaceRoot) === -1) workspaceOptions.push(workspaceRoot);
statusText = workspaceRoot ? 'Capture assigned to ' + workspaceRoot : 'Capture is unassigned';
statusClass = '';
render();
}); });
if (workspaceRoot && workspaceOptions.indexOf(workspaceRoot) === -1) workspaceOptions.push(workspaceRoot);
statusText = workspaceRoot ? 'Capture assigned to ' + workspaceRoot : 'Capture is unassigned';
statusClass = '';
return persist().then(render);
} }
function removeCapture(captureId) { function removeCapture(captureId) {
@ -565,13 +583,19 @@
} }
function setProcessed(captureId, processed) { function setProcessed(captureId, processed) {
captures = captures.map(function (capture) { return publishMutation('processed', {
if (capture.captureId !== captureId) return capture; captureId: captureId,
return Object.assign({}, capture, { processed: processed === true }); processed: processed === true
}, function () {
return captures.some(function (capture) {
return capture.captureId === captureId && capture.processed === (processed === true);
});
}).then(function (saved) {
if (!saved) return;
statusText = processed ? 'Capture marked processed' : 'Capture marked unprocessed';
statusClass = '';
render();
}); });
statusText = processed ? 'Capture marked processed' : 'Capture marked unprocessed';
statusClass = '';
return persist().then(render);
} }
function createNoteFromCapture(capture) { function createNoteFromCapture(capture) {
@ -748,25 +772,25 @@
detailEl.innerHTML = ''; detailEl.innerHTML = '';
var capture = selectedCapture(); var capture = selectedCapture();
if (!capture) { if (!capture) {
detailEl.appendChild(el('div', { className: 'browser-inbox-detail-empty', textContent: 'Select a capture to inspect it.' })); detailEl.appendChild(el('div', { className: 'browser-inbox-detail-empty', textContent: tr('ui.selectCapture', null, 'Select a capture to inspect it.') }));
return; return;
} }
selectedId = capture.captureId; selectedId = capture.captureId;
detailEl.appendChild(el('div', { className: 'browser-inbox-detail-title', textContent: displayTitle(capture) })); detailEl.appendChild(el('div', { className: 'browser-inbox-detail-title', textContent: displayTitle(capture) }));
detailEl.appendChild(el('div', { className: 'browser-inbox-meta' }, [ detailEl.appendChild(el('div', { className: 'browser-inbox-meta' }, [
el('div', { className: 'browser-inbox-meta-label', textContent: 'Kind' }), el('div', { className: 'browser-inbox-meta-label', textContent: tr('ui.kind', null, 'Kind') }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.kind }), el('div', { className: 'browser-inbox-meta-value', textContent: capture.kind }),
el('div', { className: 'browser-inbox-meta-label', textContent: 'URL' }), el('div', { className: 'browser-inbox-meta-label', textContent: 'URL' }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.url || '-' }), el('div', { className: 'browser-inbox-meta-value', textContent: capture.url || '-' }),
el('div', { className: 'browser-inbox-meta-label', textContent: 'Domain' }), el('div', { className: 'browser-inbox-meta-label', textContent: tr('ui.domain', null, 'Domain') }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.domain || '-' }), el('div', { className: 'browser-inbox-meta-value', textContent: capture.domain || '-' }),
el('div', { className: 'browser-inbox-meta-label', textContent: 'Captured' }), el('div', { className: 'browser-inbox-meta-label', textContent: tr('ui.captured', null, 'Captured') }),
el('div', { className: 'browser-inbox-meta-value', textContent: formatDate(capture.capturedAt) || '-' }), el('div', { className: 'browser-inbox-meta-value', textContent: formatDate(capture.capturedAt) || '-' }),
el('div', { className: 'browser-inbox-meta-label', textContent: 'Browser' }), el('div', { className: 'browser-inbox-meta-label', textContent: 'Browser' }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.browserName || capture.source || '-' }), el('div', { className: 'browser-inbox-meta-value', textContent: capture.browserName || capture.source || '-' }),
el('div', { className: 'browser-inbox-meta-label', textContent: 'Workspace' }), el('div', { className: 'browser-inbox-meta-label', textContent: tr('ui.workspace', null, 'Workspace') }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.workspaceRootPath || 'Unassigned' }), el('div', { className: 'browser-inbox-meta-value', textContent: capture.workspaceRootPath || 'Unassigned' }),
el('div', { className: 'browser-inbox-meta-label', textContent: 'Status' }), el('div', { className: 'browser-inbox-meta-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 ? 'Processed' : 'Unprocessed' })
])); ]));
var assignmentSelect = el('select', { var assignmentSelect = el('select', {
@ -819,7 +843,7 @@
actionButtons.push(el('button', { actionButtons.push(el('button', {
className: 'browser-inbox-btn', className: 'browser-inbox-btn',
'data-browser-inbox-action': 'create-note', 'data-browser-inbox-action': 'create-note',
textContent: 'Create Note', textContent: tr('ui.createNote', null, 'Create Note'),
onClick: function () { onClick: function () {
createNoteFromCapture(capture); createNoteFromCapture(capture);
} }
@ -828,7 +852,7 @@
actionButtons.push(el('button', { actionButtons.push(el('button', {
className: 'browser-inbox-btn', className: 'browser-inbox-btn',
'data-browser-inbox-action': 'create-link', 'data-browser-inbox-action': 'create-link',
textContent: 'Create Link', textContent: tr('ui.createLink', null, 'Create Link'),
onClick: function () { onClick: function () {
createLinkFromCapture(capture); createLinkFromCapture(capture);
} }
@ -838,7 +862,7 @@
actionButtons.push(el('button', { actionButtons.push(el('button', {
className: 'browser-inbox-btn', className: 'browser-inbox-btn',
'data-browser-inbox-action': 'create-file', 'data-browser-inbox-action': 'create-file',
textContent: 'Create File', textContent: tr('ui.createFile', null, 'Create File'),
onClick: function () { onClick: function () {
createFileFromCapture(capture); createFileFromCapture(capture);
} }
@ -848,7 +872,7 @@
actionButtons.push(el('button', { actionButtons.push(el('button', {
className: 'browser-inbox-btn danger', className: 'browser-inbox-btn danger',
'data-browser-inbox-action': 'remove', 'data-browser-inbox-action': 'remove',
textContent: 'Delete', textContent: tr('ui.delete', null, 'Delete'),
onClick: function () { onClick: function () {
removeCapture(capture.captureId); removeCapture(capture.captureId);
} }
@ -879,7 +903,7 @@
renderDetail(); renderDetail();
} }
function loadStored() { function loadStored(skipMigration) {
if (!api || !api.settings || typeof api.settings.read !== 'function') return Promise.resolve(); if (!api || !api.settings || typeof api.settings.read !== 'function') return Promise.resolve();
return api.settings.read().then(function (settings) { return api.settings.read().then(function (settings) {
domainBindings = normalizeDomainBindings((settings || {}).domainBindings); domainBindings = normalizeDomainBindings((settings || {}).domainBindings);
@ -891,10 +915,28 @@
if (key !== GLOBAL_KEY && stored.length > 0) hasLegacyCaptures = true; if (key !== GLOBAL_KEY && stored.length > 0) hasLegacyCaptures = true;
all = all.concat(stored); all = all.concat(stored);
}); });
captures = sortCaptures(all); captures = sortCaptures(all).map(applyDomainBinding);
if (!selectedId && captures[0]) selectedId = captures[0].captureId; if (!selectedId && captures[0]) selectedId = captures[0].captureId;
// Keep legacy records readable, then mirror the canonical state into the global queue. // Read legacy records once, then ask the backend to migrate them atomically.
return hasLegacyCaptures ? persist() : undefined; if (!hasLegacyCaptures || skipMigration) return undefined;
var expectedIds = captures.map(function (capture) { return capture.captureId; });
return publishMutation('migrate', {}, function () {
return expectedIds.every(function (captureId) {
return captures.some(function (capture) { return capture.captureId === captureId; });
});
}, function (migratedSettings) {
var canonicalIds = normalizeStoredCaptures(migratedSettings[GLOBAL_KEY], GLOBAL_KEY).map(function (capture) {
return capture.captureId;
});
var legacyIsEmpty = globalCaptureKeys(migratedSettings).filter(function (key) {
return key !== GLOBAL_KEY;
}).every(function (key) {
return normalizeStoredCaptures(migratedSettings[key], key).length === 0;
});
return legacyIsEmpty && expectedIds.every(function (captureId) {
return canonicalIds.indexOf(captureId) !== -1;
});
});
}).catch(function (err) { }).catch(function (err) {
statusText = 'Could not load inbox: ' + (err && err.message ? err.message : String(err)); statusText = 'Could not load inbox: ' + (err && err.message ? err.message : String(err));
statusClass = 'error'; statusClass = 'error';
@ -920,7 +962,34 @@
if (!api || !api.events || typeof api.events.subscribe !== 'function') return Promise.resolve(); if (!api || !api.events || typeof api.events.subscribe !== 'function') return Promise.resolve();
return Promise.all(CAPTURE_EVENTS.map(function (eventName) { return Promise.all(CAPTURE_EVENTS.map(function (eventName) {
return api.events.subscribe(eventName, function (event) { return api.events.subscribe(eventName, function (event) {
return addCapture(captureFromEvent(event)); var received = captureFromEvent(event);
return loadStored(true).then(function () {
var stored = captures.find(function (capture) {
return capture.captureId === received.captureId;
});
if (!stored) return addCapture(received);
if (!received.workspaceRootPath && stored.workspaceRootPath) {
return publishMutation('assign', {
captureId: stored.captureId,
workspaceRootPath: stored.workspaceRootPath
}, function () {
return captures.some(function (capture) {
return capture.captureId === stored.captureId && capture.workspaceRootPath === stored.workspaceRootPath;
});
}).then(function (saved) {
if (!saved) return;
selectedId = received.captureId;
statusText = 'Capture received';
statusClass = '';
render();
});
}
selectedId = received.captureId;
statusText = 'Capture received';
statusClass = '';
render();
return undefined;
});
}).then(function (unsubscribe) { }).then(function (unsubscribe) {
if (typeof unsubscribe === 'function') unsubscribers.push(unsubscribe); if (typeof unsubscribe === 'function') unsubscribers.push(unsubscribe);
}); });
@ -941,6 +1010,14 @@
}).then(function () { }).then(function () {
if (!disposed) render(); if (!disposed) render();
}); });
if (api && api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
api.i18n.onDidChangeLocale(function () {
titleEl.textContent = scope.mode === 'global' ? tr('ui.title', null, 'Browser Inbox') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Browser Inbox · ' + scope.label);
searchInput.setAttribute('placeholder', tr('ui.search', null, 'Search captures'));
clearBtn.textContent = tr('ui.clear', null, 'Clear');
render();
});
}
containerEl.__browserInboxUnmount = function () { containerEl.__browserInboxUnmount = function () {
disposed = true; disposed = true;
@ -967,6 +1044,11 @@
containerEl.innerHTML = ''; containerEl.innerHTML = '';
containerEl.className = 'browser-inbox-settings'; containerEl.className = 'browser-inbox-settings';
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
var receiverURLInput = el('input', { var receiverURLInput = el('input', {
className: 'browser-inbox-settings-input', className: 'browser-inbox-settings-input',
type: 'text', type: 'text',
@ -988,27 +1070,27 @@
className: 'browser-inbox-btn', className: 'browser-inbox-btn',
type: 'button', type: 'button',
'data-browser-inbox-settings-action': 'copy-url', 'data-browser-inbox-settings-action': 'copy-url',
textContent: 'Copy URL' textContent: tr('ui.copyUrl', null, 'Copy URL')
}); });
var copyTokenButton = el('button', { var copyTokenButton = el('button', {
className: 'browser-inbox-btn', className: 'browser-inbox-btn',
type: 'button', type: 'button',
'data-browser-inbox-settings-action': 'copy-token', 'data-browser-inbox-settings-action': 'copy-token',
textContent: 'Copy Token' textContent: tr('ui.copyToken', null, 'Copy Token')
}); });
var rotateTokenButton = el('button', { var rotateTokenButton = el('button', {
className: 'browser-inbox-btn danger', className: 'browser-inbox-btn danger',
type: 'button', type: 'button',
'data-browser-inbox-settings-action': 'rotate-token', 'data-browser-inbox-settings-action': 'rotate-token',
textContent: 'Rotate Token' textContent: tr('ui.rotateToken', null, 'Rotate Token')
}); });
containerEl.appendChild(el('div', { className: 'browser-inbox-settings-field' }, [ containerEl.appendChild(el('div', { className: 'browser-inbox-settings-field' }, [
el('label', { className: 'browser-inbox-settings-label', textContent: 'Receiver URL' }), el('label', { className: 'browser-inbox-settings-label', textContent: tr('ui.receiverUrl', null, 'Receiver URL') }),
receiverURLInput receiverURLInput
])); ]));
containerEl.appendChild(el('div', { className: 'browser-inbox-settings-field' }, [ containerEl.appendChild(el('div', { className: 'browser-inbox-settings-field' }, [
el('label', { className: 'browser-inbox-settings-label', textContent: 'Pairing Token' }), el('label', { className: 'browser-inbox-settings-label', textContent: tr('ui.pairingToken', null, 'Pairing Token') }),
receiverTokenInput receiverTokenInput
])); ]));
containerEl.appendChild(el('div', { className: 'browser-inbox-settings-actions' }, [ containerEl.appendChild(el('div', { className: 'browser-inbox-settings-actions' }, [
@ -1041,7 +1123,7 @@
function loadPairing() { function loadPairing() {
setBusy(true); setBusy(true);
setStatus('Loading...', false); setStatus(tr('ui.loading', null, 'Loading...'), false);
return Promise.resolve().then(function () { return Promise.resolve().then(function () {
return pairingAPI().pairing(); return pairingAPI().pairing();
}).then(function (pairing) { }).then(function (pairing) {
@ -1057,11 +1139,11 @@
function copyValue(value, label) { function copyValue(value, label) {
if (!value) return; if (!value) return;
if (typeof navigator === 'undefined' || !navigator.clipboard || typeof navigator.clipboard.writeText !== 'function') { if (typeof navigator === 'undefined' || !navigator.clipboard || typeof navigator.clipboard.writeText !== 'function') {
setStatus('Clipboard unavailable', true); setStatus(tr('ui.clipboardUnavailable', null, 'Clipboard unavailable'), true);
return; return;
} }
navigator.clipboard.writeText(value).then(function () { navigator.clipboard.writeText(value).then(function () {
setStatus(label + ' copied', false); setStatus(tr('ui.copied', { label: label }, '{label} copied'), false);
}).catch(function (err) { }).catch(function (err) {
setStatus(text(err && err.message ? err.message : err), true); setStatus(text(err && err.message ? err.message : err), true);
}); });
@ -1074,14 +1156,14 @@
copyValue(receiverTokenInput.value, 'Token'); copyValue(receiverTokenInput.value, 'Token');
}); });
rotateTokenButton.addEventListener('click', function () { rotateTokenButton.addEventListener('click', function () {
if (typeof window.confirm === 'function' && !window.confirm('Rotate pairing token?')) return; if (typeof window.confirm === 'function' && !window.confirm(tr('ui.rotateConfirm', null, 'Rotate pairing token?'))) return;
setBusy(true); setBusy(true);
setStatus('Rotating...', false); setStatus(tr('ui.rotating', null, 'Rotating...'), false);
Promise.resolve().then(function () { Promise.resolve().then(function () {
return pairingAPI().rotateToken(); return pairingAPI().rotateToken();
}).then(function (pairing) { }).then(function (pairing) {
applyPairing(pairing); applyPairing(pairing);
setStatus('Token rotated', false); setStatus(tr('ui.tokenRotated', null, 'Token rotated'), false);
}).catch(function (err) { }).catch(function (err) {
setStatus(text(err && err.message ? err.message : err), true); setStatus(text(err && err.message ? err.message : err), true);
}).then(function () { }).then(function () {
@ -1090,6 +1172,13 @@
}); });
loadPairing(); loadPairing();
if (api && api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
api.i18n.onDidChangeLocale(function () {
copyURLButton.textContent = tr('ui.copyUrl', null, 'Copy URL');
copyTokenButton.textContent = tr('ui.copyToken', null, 'Copy Token');
rotateTokenButton.textContent = tr('ui.rotateToken', null, 'Rotate Token');
});
}
}, },
unmount: function (containerEl) { unmount: function (containerEl) {
if (containerEl) containerEl.innerHTML = ''; if (containerEl) containerEl.innerHTML = '';

View File

@ -0,0 +1,39 @@
{
"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",
"ui.connecting": "Connecting to receiver events...",
"ui.title": "Browser Inbox",
"ui.workspaceTitle": "Browser Inbox · {workspace}",
"ui.allCaptures": "All captures",
"ui.unassigned": "Unassigned",
"ui.unprocessed": "Unprocessed",
"ui.processed": "Processed",
"ui.search": "Search captures",
"ui.clear": "Clear",
"ui.assignedHere": "Assigned to this workspace",
"ui.selectCapture": "Select a capture to inspect it.",
"ui.kind": "Kind",
"ui.domain": "Domain",
"ui.captured": "Captured",
"ui.workspace": "Workspace",
"ui.status": "Status",
"ui.createNote": "Create Note",
"ui.createLink": "Create Link",
"ui.createFile": "Create File",
"ui.delete": "Delete",
"ui.copyUrl": "Copy URL",
"ui.copyToken": "Copy Token",
"ui.rotateToken": "Rotate Token",
"ui.receiverUrl": "Receiver URL",
"ui.pairingToken": "Pairing Token",
"ui.loading": "Loading...",
"ui.clipboardUnavailable": "Clipboard unavailable",
"ui.copied": "{label} copied",
"ui.rotateConfirm": "Rotate pairing token?",
"ui.rotating": "Rotating...",
"ui.tokenRotated": "Token rotated"
}

View File

@ -0,0 +1,39 @@
{
"manifest.name": "Входящие из браузера",
"manifest.description": "Общая очередь материалов из браузера с явным назначением рабочего пространства через локальный протокол приёма.",
"contributions.views.verstak.browser-inbox.view.title": "Входящие из браузера",
"contributions.sidebarItems.verstak.browser-inbox.sidebar.title": "Входящие из браузера",
"contributions.workspaceItems.verstak.browser-inbox.workspace.title": "Входящие из браузера",
"contributions.settingsPanels.verstak.browser-inbox.settings.title": "Входящие из браузера",
"ui.connecting": "Подключение к событиям приёмника...",
"ui.title": "Входящие из браузера",
"ui.workspaceTitle": "Входящие из браузера · {workspace}",
"ui.allCaptures": "Все материалы",
"ui.unassigned": "Не назначено",
"ui.unprocessed": "Не обработано",
"ui.processed": "Обработано",
"ui.search": "Поиск материалов",
"ui.clear": "Очистить",
"ui.assignedHere": "Назначено этому рабочему пространству",
"ui.selectCapture": "Выберите материал для просмотра.",
"ui.kind": "Тип",
"ui.domain": "Домен",
"ui.captured": "Получено",
"ui.workspace": "Рабочее пространство",
"ui.status": "Состояние",
"ui.createNote": "Создать заметку",
"ui.createLink": "Создать ссылку",
"ui.createFile": "Создать файл",
"ui.delete": "Удалить",
"ui.copyUrl": "Копировать URL",
"ui.copyToken": "Копировать токен",
"ui.rotateToken": "Сменить токен",
"ui.receiverUrl": "URL приёмника",
"ui.pairingToken": "Токен сопряжения",
"ui.loading": "Загрузка...",
"ui.clipboardUnavailable": "Буфер обмена недоступен",
"ui.copied": "{label} скопирован",
"ui.rotateConfirm": "Сменить токен сопряжения?",
"ui.rotating": "Смена токена...",
"ui.tokenRotated": "Токен изменён"
}

View File

@ -5,6 +5,7 @@
"version": "0.1.0", "version": "0.1.0",
"apiVersion": "0.1.0", "apiVersion": "0.1.0",
"description": "Global browser capture queue with explicit workspace assignment delivered through the local receiver event protocol.", "description": "Global browser capture queue with explicit workspace assignment delivered through the local receiver event protocol.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official", "source": "official",
"icon": "inbox", "icon": "inbox",
"provides": [ "provides": [

View File

@ -291,6 +291,10 @@
var linesEl = null; var linesEl = null;
var previewEl = null; var previewEl = null;
var secretLinksAvailable = false; var secretLinksAvailable = false;
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
containerEl.setAttribute('data-editor-mode', editorMode); containerEl.setAttribute('data-editor-mode', editorMode);
containerEl.setAttribute('data-resource-path', resourcePath); containerEl.setAttribute('data-resource-path', resourcePath);
@ -298,13 +302,13 @@
var modeLabel = el('span', { className: 'de-toolbar-mode' }, [editorMode]); var modeLabel = el('span', { className: 'de-toolbar-mode' }, [editorMode]);
var contextLabel = el('span', { className: 'de-toolbar-context', title: resourcePath }, [resourcePath || fileName(resourcePath)]); var contextLabel = el('span', { className: 'de-toolbar-context', title: resourcePath }, [resourcePath || fileName(resourcePath)]);
var notesBadge = editorMode === 'notes-markdown' ? el('span', { className: 'de-notes-badge', 'data-notes-badge': '' }, ['notes context']) : null; var notesBadge = editorMode === 'notes-markdown' ? el('span', { className: 'de-notes-badge', 'data-notes-badge': '' }, [tr('ui.notesContext', null, 'notes context')]) : null;
var spacer = el('span', { className: 'de-toolbar-spacer' }); var spacer = el('span', { className: 'de-toolbar-spacer' });
var editBtn = isMarkdown ? el('button', { className: 'de-toolbar-btn', 'data-editor-mode-button': 'edit' }, ['Edit']) : null; var editBtn = isMarkdown ? el('button', { className: 'de-toolbar-btn', 'data-editor-mode-button': 'edit' }, [tr('ui.edit', null, 'Edit')]) : null;
var previewBtn = isMarkdown ? el('button', { className: 'de-toolbar-btn', 'data-editor-mode-button': 'preview' }, ['Preview']) : null; var previewBtn = isMarkdown ? el('button', { className: 'de-toolbar-btn', 'data-editor-mode-button': 'preview' }, [tr('ui.preview', null, 'Preview')]) : null;
var splitBtn = isMarkdown ? el('button', { className: 'de-toolbar-btn', 'data-editor-mode-button': 'split' }, ['Split']) : null; var splitBtn = isMarkdown ? el('button', { className: 'de-toolbar-btn', 'data-editor-mode-button': 'split' }, [tr('ui.split', null, 'Split')]) : null;
var reloadBtn = el('button', { className: 'de-toolbar-btn', 'data-editor-action': 'reload' }, ['Reload']); var reloadBtn = el('button', { className: 'de-toolbar-btn', 'data-editor-action': 'reload' }, [tr('ui.reload', null, 'Reload')]);
var saveBtn = el('button', { className: 'de-toolbar-btn', 'data-editor-action': 'save' }, ['Save']); var saveBtn = el('button', { className: 'de-toolbar-btn', 'data-editor-action': 'save' }, [tr('ui.save', null, 'Save')]);
var statusEl = el('span', { className: 'de-status', 'data-save-state': '' }); var statusEl = el('span', { className: 'de-status', 'data-save-state': '' });
var toolbarChildren = [modeLabel, contextLabel]; var toolbarChildren = [modeLabel, contextLabel];
if (notesBadge) toolbarChildren.push(notesBadge); if (notesBadge) toolbarChildren.push(notesBadge);
@ -336,7 +340,7 @@
containerEl.appendChild(editorWrap); containerEl.appendChild(editorWrap);
if (editorMode === 'notes-markdown') { if (editorMode === 'notes-markdown') {
containerEl.appendChild(el('div', { className: 'de-notes-info' }, ['Notes context active. Note actions, backlinks, and graph tools are reserved for the future Notes plugin.'])); containerEl.appendChild(el('div', { className: 'de-notes-info' }, [tr('ui.notesInfo', null, 'Notes context active. Note actions, backlinks, and graph tools are reserved for the future Notes plugin.')]));
} }
function updateLineNumbers() { function updateLineNumbers() {
@ -349,16 +353,16 @@
function updateStatus() { function updateStatus() {
if (saveState === 'saving') { if (saveState === 'saving') {
statusEl.textContent = 'Saving...'; statusEl.textContent = tr('ui.saving', null, 'Saving...');
statusEl.className = 'de-status saving'; statusEl.className = 'de-status saving';
} else if (saveState === 'error') { } else if (saveState === 'error') {
statusEl.textContent = 'Error saving'; statusEl.textContent = tr('ui.saveError', null, 'Error saving');
statusEl.className = 'de-status error'; statusEl.className = 'de-status error';
} else if (dirty) { } else if (dirty) {
statusEl.textContent = 'Modified'; statusEl.textContent = tr('ui.modified', null, 'Modified');
statusEl.className = 'de-status dirty'; statusEl.className = 'de-status dirty';
} else if (lastSavedAt) { } else if (lastSavedAt) {
statusEl.textContent = saveState === 'saved' ? 'Saved ' + lastSavedAt : 'Saved'; statusEl.textContent = saveState === 'saved' ? tr('ui.savedAt', { time: lastSavedAt }, 'Saved ' + lastSavedAt) : tr('ui.saved', null, 'Saved');
statusEl.className = 'de-status saved'; statusEl.className = 'de-status saved';
} else { } else {
statusEl.textContent = ''; statusEl.textContent = '';
@ -475,9 +479,9 @@
} }
function reloadFromDisk() { function reloadFromDisk() {
if (dirty && !window.confirm('Discard unsaved changes and reload from disk?')) return; if (dirty && !window.confirm(tr('ui.discardConfirm', null, 'Discard unsaved changes and reload from disk?'))) return;
editorWrap.innerHTML = ''; editorWrap.innerHTML = '';
editorWrap.appendChild(el('div', { className: 'de-loading' }, ['Loading...'])); editorWrap.appendChild(el('div', { className: 'de-loading' }, [tr('ui.loading', null, 'Loading...')]));
var readPromise = api.files.readText(resourcePath); var readPromise = api.files.readText(resourcePath);
readPromise.then(function (content) { readPromise.then(function (content) {
if (disposed) return; if (disposed) return;
@ -490,7 +494,7 @@
if (disposed) return; if (disposed) return;
editorWrap.innerHTML = ''; editorWrap.innerHTML = '';
editorWrap.appendChild(el('div', { className: 'de-error' }, [ editorWrap.appendChild(el('div', { className: 'de-error' }, [
el('div', {}, ['Failed to load file']), el('div', {}, [tr('ui.loadFailed', null, 'Failed to load file')]),
el('div', { className: 'de-error-msg' }, [(err && err.message) ? err.message : String(err)]) el('div', { className: 'de-error-msg' }, [(err && err.message) ? err.message : String(err)])
])); ]));
}); });
@ -534,6 +538,17 @@
loadSecretProviderAvailability(); loadSecretProviderAvailability();
reloadFromDisk(); reloadFromDisk();
var localeUnsubscribe = api.i18n && typeof api.i18n.onDidChangeLocale === 'function'
? api.i18n.onDidChangeLocale(function () {
if (notesBadge) notesBadge.textContent = tr('ui.notesContext', null, 'notes context');
if (editBtn) editBtn.textContent = tr('ui.edit', null, 'Edit');
if (previewBtn) previewBtn.textContent = tr('ui.preview', null, 'Preview');
if (splitBtn) splitBtn.textContent = tr('ui.split', null, 'Split');
reloadBtn.textContent = tr('ui.reload', null, 'Reload');
saveBtn.textContent = tr('ui.save', null, 'Save');
updateStatus();
})
: null;
containerEl.addEventListener('click', function (event) { containerEl.addEventListener('click', function (event) {
var secretLink = event.target.closest('.secret-link'); var secretLink = event.target.closest('.secret-link');
@ -582,6 +597,7 @@
containerEl.__deCleanup = function () { containerEl.__deCleanup = function () {
disposed = true; disposed = true;
if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
if (saveTimer) clearTimeout(saveTimer); if (saveTimer) clearTimeout(saveTimer);
}; };
}, },

View File

@ -0,0 +1,22 @@
{
"manifest.name": "Default Editor",
"manifest.description": "Built-in text and markdown editor/viewer for Verstak. Provides openProviders for generic text, generic markdown, and notes-context markdown files.",
"contributions.openProviders.verstak.default-editor.text.title": "Default Text Editor",
"contributions.openProviders.verstak.default-editor.markdown.title": "Default Markdown Editor",
"contributions.openProviders.verstak.default-editor.notes-markdown.title": "Default Notes Markdown Editor",
"ui.notesContext": "notes context",
"ui.edit": "Edit",
"ui.preview": "Preview",
"ui.split": "Split",
"ui.reload": "Reload",
"ui.save": "Save",
"ui.notesInfo": "Notes context active. Note actions, backlinks, and graph tools are reserved for the future Notes plugin.",
"ui.saving": "Saving...",
"ui.saveError": "Error saving",
"ui.modified": "Modified",
"ui.savedAt": "Saved {time}",
"ui.saved": "Saved",
"ui.discardConfirm": "Discard unsaved changes and reload from disk?",
"ui.loading": "Loading...",
"ui.loadFailed": "Failed to load file"
}

View File

@ -0,0 +1,22 @@
{
"manifest.name": "Стандартный редактор",
"manifest.description": "Встроенный редактор и просмотрщик текста и Markdown для Верстака.",
"contributions.openProviders.verstak.default-editor.text.title": "Стандартный текстовый редактор",
"contributions.openProviders.verstak.default-editor.markdown.title": "Стандартный редактор Markdown",
"contributions.openProviders.verstak.default-editor.notes-markdown.title": "Стандартный редактор Markdown-заметок",
"ui.notesContext": "контекст заметки",
"ui.edit": "Редактировать",
"ui.preview": "Просмотр",
"ui.split": "Разделить",
"ui.reload": "Перезагрузить",
"ui.save": "Сохранить",
"ui.notesInfo": "Активен контекст заметки. Действия, обратные ссылки и граф будут развиваться в плагине заметок.",
"ui.saving": "Сохранение...",
"ui.saveError": "Ошибка сохранения",
"ui.modified": "Изменён",
"ui.savedAt": "Сохранено {time}",
"ui.saved": "Сохранено",
"ui.discardConfirm": "Отменить несохранённые изменения и перечитать файл с диска?",
"ui.loading": "Загрузка...",
"ui.loadFailed": "Не удалось загрузить файл"
}

View File

@ -5,6 +5,7 @@
"version": "0.1.0", "version": "0.1.0",
"apiVersion": "0.1.0", "apiVersion": "0.1.0",
"description": "Built-in text and markdown editor/viewer for Verstak. Provides openProviders for generic text, generic markdown, and notes-context markdown files.", "description": "Built-in text and markdown editor/viewer for Verstak. Provides openProviders for generic text, generic markdown, and notes-context markdown files.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official", "source": "official",
"icon": "edit", "icon": "edit",
"provides": [ "provides": [

View File

@ -108,6 +108,10 @@
var request = props && props.request || {}; var request = props && props.request || {};
var path = request.path || ''; var path = request.path || '';
var ext = extension(path, request.extension); var ext = extension(path, request.extension);
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
containerEl.innerHTML = ''; containerEl.innerHTML = '';
containerEl.className = 'fp-root'; containerEl.className = 'fp-root';
containerEl.setAttribute('data-plugin-id', 'verstak.file-preview'); containerEl.setAttribute('data-plugin-id', 'verstak.file-preview');
@ -116,19 +120,19 @@
var openButton = el('button', { var openButton = el('button', {
className: 'fp-btn', className: 'fp-btn',
'data-action': 'open-external', 'data-action': 'open-external',
textContent: 'Open External', textContent: tr('ui.openExternal', null, 'Open External'),
onClick: function () { onClick: function () {
if (api.files.openExternal) api.files.openExternal(path).catch(function (err) { console.error('[file-preview] openExternal:', err); }); if (api.files.openExternal) api.files.openExternal(path).catch(function (err) { console.error('[file-preview] openExternal:', err); });
} }
}); });
containerEl.appendChild(el('div', { className: 'fp-toolbar' }, [ containerEl.appendChild(el('div', { className: 'fp-toolbar' }, [
el('span', { className: 'fp-mode' }, ['Preview']), el('span', { className: 'fp-mode' }, [tr('ui.preview', null, 'Preview')]),
el('span', { className: 'fp-path' }, [path]), el('span', { className: 'fp-path' }, [path]),
el('span', { className: 'fp-spacer' }), el('span', { className: 'fp-spacer' }),
openButton openButton
])); ]));
var body = el('div', { className: 'fp-loading' }, ['Loading...']); var body = el('div', { className: 'fp-loading' }, [tr('ui.loading', null, 'Loading...')]);
containerEl.appendChild(body); containerEl.appendChild(body);
api.files.metadata(path).then(function (meta) { api.files.metadata(path).then(function (meta) {
@ -140,8 +144,13 @@
}); });
}).catch(function (err) { }).catch(function (err) {
body.className = 'fp-error'; body.className = 'fp-error';
body.textContent = 'Preview error: ' + (err && err.message ? err.message : String(err)); body.textContent = tr('ui.error', { error: err && err.message ? err.message : String(err) }, 'Preview error: ' + (err && err.message ? err.message : String(err)));
}); });
if (api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
api.i18n.onDidChangeLocale(function () {
openButton.textContent = tr('ui.openExternal', null, 'Open External');
});
}
}, },
unmount: function (containerEl) { unmount: function (containerEl) {
containerEl.innerHTML = ''; containerEl.innerHTML = '';

View File

@ -0,0 +1,9 @@
{
"manifest.name": "File Preview",
"manifest.description": "Read-only inline image preview provider for vault files.",
"contributions.openProviders.verstak.file-preview.image.title": "Image Preview",
"ui.openExternal": "Open External",
"ui.preview": "Preview",
"ui.loading": "Loading...",
"ui.error": "Preview error: {error}"
}

View File

@ -0,0 +1,9 @@
{
"manifest.name": "Просмотр файлов",
"manifest.description": "Встроенный просмотр изображений из хранилища без редактирования.",
"contributions.openProviders.verstak.file-preview.image.title": "Просмотр изображения",
"ui.openExternal": "Открыть во внешнем приложении",
"ui.preview": "Просмотр",
"ui.loading": "Загрузка...",
"ui.error": "Ошибка просмотра: {error}"
}

View File

@ -5,6 +5,7 @@
"version": "0.1.0", "version": "0.1.0",
"apiVersion": "0.1.0", "apiVersion": "0.1.0",
"description": "Read-only inline image preview provider for vault files.", "description": "Read-only inline image preview provider for vault files.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official", "source": "official",
"icon": "eye", "icon": "eye",
"provides": [ "provides": [

View File

@ -369,6 +369,11 @@
} }
var navigatingHistory = false; var navigatingHistory = false;
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
function scopedPath(local) { function scopedPath(local) {
local = cleanPath(local); local = cleanPath(local);
return workspaceRoot ? (local ? workspaceRoot + '/' + local : workspaceRoot) : local; return workspaceRoot ? (local ? workspaceRoot + '/' + local : workspaceRoot) : local;
@ -390,26 +395,26 @@
var toolbar = el('div', { className: 'files-toolbar' }); var toolbar = el('div', { className: 'files-toolbar' });
var breadcrumb = el('div', { className: 'files-breadcrumb' }); var breadcrumb = el('div', { className: 'files-breadcrumb' });
var backBtn = iconButton('back', 'Back', 'back', goBack); var backBtn = iconButton('back', tr('ui.back', null, 'Back'), 'back', goBack);
var forwardBtn = iconButton('forward', 'Forward', 'forward', goForward); var forwardBtn = iconButton('forward', tr('ui.forward', null, 'Forward'), 'forward', goForward);
var upBtn = iconButton('up', 'Up', 'up', goUp); var upBtn = iconButton('up', tr('ui.up', null, 'Up'), 'up', goUp);
var refreshBtn = iconButton('refresh', 'Refresh', 'refresh', loadEntries); var refreshBtn = iconButton('refresh', tr('ui.refresh', null, 'Refresh'), 'refresh', loadEntries);
var newFolderBtn = iconButton('new-folder', 'New folder', 'folderAdd', function () { startCreate('folder'); }); var newFolderBtn = iconButton('new-folder', tr('ui.newFolder', null, 'New folder'), 'folderAdd', function () { startCreate('folder'); });
var newMdBtn = iconButton('new-markdown', 'New markdown file', 'markdownAdd', function () { startCreate('markdown'); }); var newMdBtn = iconButton('new-markdown', tr('ui.newMarkdown', null, 'New markdown file'), 'markdownAdd', function () { startCreate('markdown'); });
var newTextBtn = iconButton('new-text', 'New text file', 'textAdd', function () { startCreate('text'); }); var newTextBtn = iconButton('new-text', tr('ui.newText', null, 'New text file'), 'textAdd', function () { startCreate('text'); });
var openBtn = iconButton('open', 'Open', 'open', function () { openEntry(selectedEntry()); }); var openBtn = iconButton('open', tr('ui.open', null, 'Open'), 'open', function () { openEntry(selectedEntry()); });
var renameBtn = iconButton('rename', 'Rename', 'rename', function () { beginRename(); }); var renameBtn = iconButton('rename', tr('ui.rename', null, 'Rename'), 'rename', function () { beginRename(); });
var trashBtn = iconButton('trash', 'Move to trash', 'trash', function () { trashEntry(); }); var trashBtn = iconButton('trash', tr('ui.trash', null, 'Move to trash'), 'trash', function () { trashEntry(); });
var cutBtn = iconButton('cut', 'Cut', 'cut', function () { cutSelection(); }); var cutBtn = iconButton('cut', tr('ui.cut', null, 'Cut'), 'cut', function () { cutSelection(); });
var copyBtn = iconButton('copy', 'Copy', 'copy', function () { copySelection(); }); var copyBtn = iconButton('copy', tr('ui.copy', null, 'Copy'), 'copy', function () { copySelection(); });
var pasteBtn = iconButton('paste', 'Paste', 'paste', function () { pasteEntry(); }); var pasteBtn = iconButton('paste', tr('ui.paste', null, 'Paste'), 'paste', function () { pasteEntry(); });
var filterInput = el('input', { className: 'files-filter', 'data-files-filter': '', placeholder: 'Filter current folder' }); var filterInput = el('input', { className: 'files-filter', 'data-files-filter': '', placeholder: tr('ui.filter', null, 'Filter current folder') });
var sortSelect = el('select', { className: 'files-sort', 'data-files-sort': '' }, [ var sortSelect = el('select', { className: 'files-sort', 'data-files-sort': '' }, [
el('option', { value: 'folder-name' }, ['Folders + name']), el('option', { value: 'folder-name' }, [tr('ui.sort.foldersName', null, 'Folders + name')]),
el('option', { value: 'name-asc' }, ['Name']), el('option', { value: 'name-asc' }, [tr('ui.column.name', null, 'Name')]),
el('option', { value: 'type' }, ['Type']), el('option', { value: 'type' }, [tr('ui.column.type', null, 'Type')]),
el('option', { value: 'modified-desc' }, ['Modified']), el('option', { value: 'modified-desc' }, [tr('ui.column.modified', null, 'Modified')]),
el('option', { value: 'size-desc' }, ['Size']) el('option', { value: 'size-desc' }, [tr('ui.column.size', null, 'Size')])
]); ]);
trashBtn.classList.add('danger'); trashBtn.classList.add('danger');
toolbar.appendChild(breadcrumb); toolbar.appendChild(breadcrumb);
@ -430,8 +435,8 @@
var createField = el('div', { className: 'files-field-stack' }); var createField = el('div', { className: 'files-field-stack' });
var createInput = el('input', { className: 'files-create-input', 'data-files-create-input': '' }); var createInput = el('input', { className: 'files-create-input', 'data-files-create-input': '' });
var createError = el('div', { className: 'files-panel-error', 'data-files-create-error': '', role: 'alert' }); var createError = el('div', { className: 'files-panel-error', 'data-files-create-error': '', role: 'alert' });
var createConfirm = el('button', { className: 'files-toolbar-btn', 'data-files-create-confirm': '' }, ['Create']); var createConfirm = el('button', { className: 'files-toolbar-btn', 'data-files-create-confirm': '' }, [tr('ui.create', null, 'Create')]);
var createCancel = el('button', { className: 'files-toolbar-btn' }, ['Cancel']); var createCancel = el('button', { className: 'files-toolbar-btn' }, [tr('ui.cancel', null, 'Cancel')]);
createField.appendChild(createInput); createField.appendChild(createInput);
createField.appendChild(createError); createField.appendChild(createError);
createPanel.appendChild(createField); createPanel.appendChild(createField);
@ -443,8 +448,8 @@
var renameField = el('div', { className: 'files-field-stack' }); var renameField = el('div', { className: 'files-field-stack' });
var renameInput = el('input', { className: 'files-rename-input', 'data-files-rename-input': '' }); var renameInput = el('input', { className: 'files-rename-input', 'data-files-rename-input': '' });
var renameError = el('div', { className: 'files-panel-error', 'data-files-rename-error': '', role: 'alert' }); var renameError = el('div', { className: 'files-panel-error', 'data-files-rename-error': '', role: 'alert' });
var renameConfirm = el('button', { className: 'files-toolbar-btn', 'data-files-rename-confirm': '' }, ['Rename']); var renameConfirm = el('button', { className: 'files-toolbar-btn', 'data-files-rename-confirm': '' }, [tr('ui.rename', null, 'Rename')]);
var renameCancel = el('button', { className: 'files-toolbar-btn' }, ['Cancel']); var renameCancel = el('button', { className: 'files-toolbar-btn' }, [tr('ui.cancel', null, 'Cancel')]);
renameField.appendChild(renameInput); renameField.appendChild(renameInput);
renameField.appendChild(renameError); renameField.appendChild(renameError);
renamePanel.appendChild(renameField); renamePanel.appendChild(renameField);
@ -598,27 +603,27 @@
function renderEmptyFolderState() { function renderEmptyFolderState() {
return el('div', { className: 'files-empty' }, [ return el('div', { className: 'files-empty' }, [
el('div', { className: 'files-empty-title' }, ['Empty folder']), el('div', { className: 'files-empty-title' }, [tr('ui.emptyFolder', null, 'Empty folder')]),
el('div', { className: 'files-empty-actions' }, [ el('div', { className: 'files-empty-actions' }, [
emptyCreateAction('new-folder', 'New folder', 'folder', 'folderAdd'), emptyCreateAction('new-folder', tr('ui.newFolder', null, 'New folder'), 'folder', 'folderAdd'),
emptyCreateAction('new-markdown', 'New markdown file', 'markdown', 'markdownAdd'), emptyCreateAction('new-markdown', tr('ui.newMarkdown', null, 'New markdown file'), 'markdown', 'markdownAdd'),
emptyCreateAction('new-text', 'New text file', 'text', 'textAdd') emptyCreateAction('new-text', tr('ui.newText', null, 'New text file'), 'text', 'textAdd')
]) ])
]); ]);
} }
function renderNoMatchesState() { function renderNoMatchesState() {
return el('div', { className: 'files-empty' }, [ return el('div', { className: 'files-empty' }, [
el('div', { className: 'files-empty-title' }, ['No matches']), el('div', { className: 'files-empty-title' }, [tr('ui.noMatches', null, 'No matches')]),
el('div', { className: 'files-empty-actions' }, [ el('div', { className: 'files-empty-actions' }, [
el('button', { el('button', {
className: 'files-empty-btn', className: 'files-empty-btn',
'data-files-empty-action': 'clear-filter', 'data-files-empty-action': 'clear-filter',
'data-files-icon': 'refresh', 'data-files-icon': 'refresh',
type: 'button', type: 'button',
title: 'Clear filter', title: tr('ui.clearFilter', null, 'Clear filter'),
'aria-label': 'Clear filter', 'aria-label': tr('ui.clearFilter', null, 'Clear filter'),
innerHTML: svgIcon(ACTION_ICONS.refresh) + '<span>Clear filter</span>', innerHTML: svgIcon(ACTION_ICONS.refresh) + '<span>' + tr('ui.clearFilter', null, 'Clear filter') + '</span>',
onClick: function () { onClick: function () {
filterText = ''; filterText = '';
filterInput.value = ''; filterInput.value = '';
@ -633,11 +638,11 @@
function renderList() { function renderList() {
listContainer.innerHTML = ''; listContainer.innerHTML = '';
var header = el('div', { className: 'files-header' }, [ var header = el('div', { className: 'files-header' }, [
el('span', {}, ['Name']), el('span', {}, [tr('ui.column.name', null, 'Name')]),
el('span', {}, ['Type']), el('span', {}, [tr('ui.column.type', null, 'Type')]),
el('span', {}, ['Size']), el('span', {}, [tr('ui.column.size', null, 'Size')]),
el('span', {}, ['Modified']), el('span', {}, [tr('ui.column.modified', null, 'Modified')]),
el('span', {}, ['Actions']) el('span', {}, [tr('ui.column.actions', null, 'Actions')])
]); ]);
listContainer.appendChild(header); listContainer.appendChild(header);
@ -687,9 +692,9 @@
el('span', { className: 'files-item-meta hide-narrow' }, [entry.type === 'folder' ? '' : formatSize(entry.size)]), el('span', { className: 'files-item-meta hide-narrow' }, [entry.type === 'folder' ? '' : formatSize(entry.size)]),
el('span', { className: 'files-item-meta hide-narrow' }, [formatDate(entry.modifiedAt)]), el('span', { className: 'files-item-meta hide-narrow' }, [formatDate(entry.modifiedAt)]),
el('div', { className: 'files-row-actions' }, [ el('div', { className: 'files-row-actions' }, [
iconButton('row-open', 'Open', 'open', function (event) { event.stopPropagation(); openEntry(entry); }, 'files-row-btn'), iconButton('row-open', tr('ui.open', null, 'Open'), 'open', function (event) { event.stopPropagation(); openEntry(entry); }, 'files-row-btn'),
iconButton('row-rename', 'Rename', 'rename', function (event) { event.stopPropagation(); beginRename(entry); }, 'files-row-btn'), iconButton('row-rename', tr('ui.rename', null, 'Rename'), 'rename', function (event) { event.stopPropagation(); beginRename(entry); }, 'files-row-btn'),
iconButton('row-trash', 'Move to trash', 'trash', function (event) { event.stopPropagation(); trashEntry(entry); }, 'files-row-btn danger') iconButton('row-trash', tr('ui.trash', null, 'Move to trash'), 'trash', function (event) { event.stopPropagation(); trashEntry(entry); }, 'files-row-btn danger')
]) ])
]); ]);
listContainer.appendChild(row); listContainer.appendChild(row);
@ -701,7 +706,7 @@
selectedPaths = {}; selectedPaths = {};
lastClickedPath = ''; lastClickedPath = '';
listContainer.innerHTML = ''; listContainer.innerHTML = '';
listContainer.appendChild(el('div', { className: 'files-loading' }, ['Loading...'])); listContainer.appendChild(el('div', { className: 'files-loading' }, [tr('ui.loading', null, 'Loading...')]));
updateBreadcrumb(); updateBreadcrumb();
api.files.list(scopedPath(currentPath)).then(function (result) { api.files.list(scopedPath(currentPath)).then(function (result) {
if (disposed) return; if (disposed) return;
@ -711,7 +716,7 @@
if (disposed) return; if (disposed) return;
listContainer.innerHTML = ''; listContainer.innerHTML = '';
listContainer.appendChild(el('div', { className: 'files-error' }, [ listContainer.appendChild(el('div', { className: 'files-error' }, [
el('div', {}, ['Failed to load files']), el('div', {}, [tr('ui.loadFailed', null, 'Failed to load files')]),
el('div', { className: 'files-error-msg' }, [(err && err.message) ? err.message : String(err)]) el('div', { className: 'files-error-msg' }, [(err && err.message) ? err.message : String(err)])
])); ]));
}); });
@ -1478,6 +1483,29 @@
loadContributionActions(); loadContributionActions();
loadEntries(); loadEntries();
var localeUnsubscribe = null;
if (api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
localeUnsubscribe = api.i18n.onDidChangeLocale(function () {
[
[backBtn, 'ui.back', 'Back'], [forwardBtn, 'ui.forward', 'Forward'], [upBtn, 'ui.up', 'Up'],
[refreshBtn, 'ui.refresh', 'Refresh'], [newFolderBtn, 'ui.newFolder', 'New folder'],
[newMdBtn, 'ui.newMarkdown', 'New markdown file'], [newTextBtn, 'ui.newText', 'New text file'],
[openBtn, 'ui.open', 'Open'], [renameBtn, 'ui.rename', 'Rename'], [trashBtn, 'ui.trash', 'Move to trash'],
[cutBtn, 'ui.cut', 'Cut'], [copyBtn, 'ui.copy', 'Copy'], [pasteBtn, 'ui.paste', 'Paste']
].forEach(function (item) {
var label = tr(item[1], null, item[2]);
item[0].setAttribute('title', label);
item[0].setAttribute('aria-label', label);
});
filterInput.setAttribute('placeholder', tr('ui.filter', null, 'Filter current folder'));
createConfirm.textContent = tr('ui.create', null, 'Create');
createCancel.textContent = tr('ui.cancel', null, 'Cancel');
renameConfirm.textContent = tr('ui.rename', null, 'Rename');
renameCancel.textContent = tr('ui.cancel', null, 'Cancel');
renderList();
});
}
var fileChangedUnsubscribe = null; var fileChangedUnsubscribe = null;
if (api.events && typeof api.events.subscribe === 'function') { if (api.events && typeof api.events.subscribe === 'function') {
api.events.subscribe('file.changed', function (event) { api.events.subscribe('file.changed', function (event) {
@ -1492,6 +1520,7 @@
containerEl.__filesCleanup = function () { containerEl.__filesCleanup = function () {
disposed = true; disposed = true;
if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
if (typeof fileChangedUnsubscribe === 'function') fileChangedUnsubscribe(); if (typeof fileChangedUnsubscribe === 'function') fileChangedUnsubscribe();
document.removeEventListener('click', onDocClick); document.removeEventListener('click', onDocClick);
document.removeEventListener('keydown', onDocKeydown); document.removeEventListener('keydown', onDocKeydown);

View File

@ -0,0 +1,32 @@
{
"manifest.name": "Files",
"manifest.description": "Workspace-scoped vault file explorer with create, rename, trash, filtering, sorting, and Workbench openResource integration.",
"contributions.workspaceItems.verstak.files.workspace.title": "Files",
"ui.back": "Back",
"ui.forward": "Forward",
"ui.up": "Up",
"ui.refresh": "Refresh",
"ui.newFolder": "New folder",
"ui.newMarkdown": "New markdown file",
"ui.newText": "New text file",
"ui.open": "Open",
"ui.rename": "Rename",
"ui.trash": "Move to trash",
"ui.cut": "Cut",
"ui.copy": "Copy",
"ui.paste": "Paste",
"ui.filter": "Filter current folder",
"ui.sort.foldersName": "Folders + name",
"ui.column.name": "Name",
"ui.column.type": "Type",
"ui.column.modified": "Modified",
"ui.column.size": "Size",
"ui.column.actions": "Actions",
"ui.create": "Create",
"ui.cancel": "Cancel",
"ui.emptyFolder": "Empty folder",
"ui.noMatches": "No matches",
"ui.clearFilter": "Clear filter",
"ui.loading": "Loading...",
"ui.loadFailed": "Failed to load files"
}

View File

@ -0,0 +1,32 @@
{
"manifest.name": "Файлы",
"manifest.description": "Файловый менеджер рабочего пространства с созданием, переименованием, корзиной, фильтрацией и сортировкой.",
"contributions.workspaceItems.verstak.files.workspace.title": "Файлы",
"ui.back": "Назад",
"ui.forward": "Вперёд",
"ui.up": "На уровень выше",
"ui.refresh": "Обновить",
"ui.newFolder": "Новая папка",
"ui.newMarkdown": "Новый Markdown-файл",
"ui.newText": "Новый текстовый файл",
"ui.open": "Открыть",
"ui.rename": "Переименовать",
"ui.trash": "Переместить в корзину",
"ui.cut": "Вырезать",
"ui.copy": "Копировать",
"ui.paste": "Вставить",
"ui.filter": "Фильтр текущей папки",
"ui.sort.foldersName": "Сначала папки, затем по имени",
"ui.column.name": "Имя",
"ui.column.type": "Тип",
"ui.column.modified": "Изменён",
"ui.column.size": "Размер",
"ui.column.actions": "Действия",
"ui.create": "Создать",
"ui.cancel": "Отмена",
"ui.emptyFolder": "Папка пуста",
"ui.noMatches": "Совпадений нет",
"ui.clearFilter": "Сбросить фильтр",
"ui.loading": "Загрузка...",
"ui.loadFailed": "Не удалось загрузить файлы"
}

View File

@ -5,6 +5,7 @@
"version": "0.1.0", "version": "0.1.0",
"apiVersion": "0.1.0", "apiVersion": "0.1.0",
"description": "Workspace-scoped vault file explorer with create, rename, trash, filtering, sorting, and Workbench openResource integration.", "description": "Workspace-scoped vault file explorer with create, rename, trash, filtering, sorting, and Workbench openResource integration.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official", "source": "official",
"icon": "folder", "icon": "folder",
"provides": [ "provides": [

View File

@ -273,18 +273,22 @@
var scope = scopeFromProps(props || {}); var scope = scopeFromProps(props || {});
var entries = []; var entries = [];
var statusText = 'Loading journal...'; function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
var statusText = tr('ui.loading', null, 'Loading journal...');
var statusClass = ''; var statusClass = '';
var modalHost = el('div', { className: 'journal-modal-host', hidden: 'hidden' }); var modalHost = el('div', { className: 'journal-modal-host', hidden: 'hidden' });
var toolbar = el('div', { className: 'journal-toolbar' }); var toolbar = el('div', { className: 'journal-toolbar' });
var titleEl = el('span', { className: 'journal-title', textContent: scope.mode === 'global' ? 'Journal' : 'Journal · ' + scope.label }); var titleEl = el('span', { className: 'journal-title', textContent: scope.mode === 'global' ? tr('ui.title', null, 'Journal') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Journal · ' + scope.label) });
var countEl = el('span', { className: 'journal-count' }); var countEl = el('span', { className: 'journal-count' });
var statusEl = el('span', { className: 'journal-status' }); var statusEl = el('span', { className: 'journal-status' });
var addBtn = el('button', { var addBtn = el('button', {
className: 'journal-btn primary', className: 'journal-btn primary',
'data-journal-action': 'add', 'data-journal-action': 'add',
innerHTML: iconSvg('add') + '<span>Add</span>', innerHTML: iconSvg('add') + '<span>' + tr('ui.add', null, 'Add') + '</span>',
onClick: function () { showEntryModal(); } onClick: function () { showEntryModal(); }
}); });
toolbar.appendChild(titleEl); toolbar.appendChild(titleEl);
@ -302,7 +306,7 @@
if (scope.mode !== 'workspace') return Promise.resolve(); if (scope.mode !== 'workspace') return Promise.resolve();
if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve(); if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
return api.settings.write(scope.key, storageEntries(entries)).catch(function (err) { return api.settings.write(scope.key, storageEntries(entries)).catch(function (err) {
statusText = 'Could not save journal: ' + (err && err.message ? err.message : String(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'; statusClass = 'error';
}); });
} }
@ -316,19 +320,19 @@
all = all.concat(normalizeEntries((settings || {})[key], key)); all = all.concat(normalizeEntries((settings || {})[key], key));
}); });
entries = sortEntries(all); entries = sortEntries(all);
statusText = 'Aggregating worklogs'; statusText = tr('ui.aggregating', null, 'Aggregating worklogs');
statusClass = ''; statusClass = '';
}).catch(function (err) { }).catch(function (err) {
statusText = 'Could not load journal: ' + (err && err.message ? err.message : String(err)); 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'; statusClass = 'error';
}); });
} }
return api.settings.read(scope.key).then(function (stored) { return api.settings.read(scope.key).then(function (stored) {
entries = sortEntries(normalizeEntries(stored, scope.key)); entries = sortEntries(normalizeEntries(stored, scope.key));
statusText = 'Ready'; statusText = tr('ui.ready', null, 'Ready');
statusClass = ''; statusClass = '';
}).catch(function (err) { }).catch(function (err) {
statusText = 'Could not load journal: ' + (err && err.message ? err.message : String(err)); 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'; statusClass = 'error';
}); });
} }
@ -344,8 +348,8 @@
var reviewingCandidate = !editing && !!candidate; var reviewingCandidate = !editing && !!candidate;
var reviewingTodo = !editing && !!completedTodo; var reviewingTodo = !editing && !!completedTodo;
var dateInput = el('input', { className: 'journal-input', type: 'date', value: editing ? existingEntry.date : (reviewingCandidate ? candidateDate(candidate.startedAt) : (reviewingTodo ? candidateDate(completedTodo.completedAt) : today())), 'data-journal-input': 'date' }); var dateInput = el('input', { className: 'journal-input', type: 'date', value: editing ? existingEntry.date : (reviewingCandidate ? candidateDate(candidate.startedAt) : (reviewingTodo ? candidateDate(completedTodo.completedAt) : today())), 'data-journal-input': 'date' });
var titleInput = el('input', { className: 'journal-input', type: 'text', placeholder: 'Work item', value: editing ? existingEntry.title : (reviewingTodo ? completedTodo.title : ''), 'data-journal-input': 'title' }); var titleInput = el('input', { className: 'journal-input', type: 'text', placeholder: tr('ui.workItem', null, 'Work item'), value: editing ? existingEntry.title : (reviewingTodo ? completedTodo.title : ''), 'data-journal-input': 'title' });
var summaryInput = el('textarea', { className: 'journal-input textarea', placeholder: 'Body', 'data-journal-input': 'summary' }); var summaryInput = el('textarea', { className: 'journal-input textarea', placeholder: tr('ui.body', null, 'Body'), 'data-journal-input': 'summary' });
summaryInput.value = editing ? existingEntry.summary : (reviewingTodo ? completedTodo.description : ''); summaryInput.value = editing ? existingEntry.summary : (reviewingTodo ? completedTodo.description : '');
var minutesInput = el('input', { className: 'journal-input', type: 'number', min: '0', step: '1', value: editing ? existingEntry.minutes : (reviewingCandidate ? candidate.estimatedMinutes : (reviewingTodo ? '0' : '30')), 'data-journal-input': 'minutes' }); var minutesInput = el('input', { className: 'journal-input', type: 'number', min: '0', step: '1', value: editing ? existingEntry.minutes : (reviewingCandidate ? candidate.estimatedMinutes : (reviewingTodo ? '0' : '30')), 'data-journal-input': 'minutes' });
var billableInput = el('input', { type: 'checkbox', 'data-journal-input': 'billable' }); var billableInput = el('input', { type: 'checkbox', 'data-journal-input': 'billable' });
@ -397,20 +401,20 @@
if (event.target === event.currentTarget) closeEntryModal(); if (event.target === event.currentTarget) closeEntryModal();
} }, [ } }, [
el('div', { className: 'journal-modal' }, [ el('div', { className: 'journal-modal' }, [
el('div', { className: 'journal-modal-title', textContent: editing ? 'Edit journal entry' : (reviewingCandidate ? 'Review possible journal entry' : (reviewingTodo ? 'Create journal entry from completed todo' : 'Add journal entry')) }), el('div', { className: 'journal-modal-title', textContent: editing ? tr('ui.editEntry', null, 'Edit journal entry') : (reviewingCandidate ? tr('ui.reviewCandidate', null, 'Review possible journal entry') : (reviewingTodo ? tr('ui.fromTodo', null, 'Create journal entry from completed todo') : tr('ui.addEntry', null, 'Add journal entry'))) }),
candidateContext, candidateContext,
todoContext, todoContext,
el('div', { className: 'journal-modal-grid' }, [ el('div', { className: 'journal-modal-grid' }, [
el('label', { className: 'journal-field' }, ['Date', dateInput]), el('label', { className: 'journal-field' }, [tr('ui.date', null, 'Date'), dateInput]),
el('label', { className: 'journal-field' }, ['Minutes', minutesInput]), el('label', { className: 'journal-field' }, [tr('ui.minutes', null, 'Minutes'), minutesInput]),
el('label', { className: 'journal-field wide' }, ['Title', titleInput]), el('label', { className: 'journal-field wide' }, [tr('ui.fieldTitle', null, 'Title'), titleInput]),
el('label', { className: 'journal-field wide' }, ['Body', summaryInput]), el('label', { className: 'journal-field wide' }, [tr('ui.body', null, 'Body'), summaryInput]),
el('label', { className: 'journal-billable' }, [billableInput, 'Billable']) el('label', { className: 'journal-billable' }, [billableInput, tr('ui.billable', null, 'Billable')])
]), ]),
candidateActivities, candidateActivities,
el('div', { className: 'journal-modal-actions' }, [ el('div', { className: 'journal-modal-actions' }, [
el('button', { className: 'journal-btn ghost', type: 'button', textContent: 'Cancel', onClick: closeEntryModal }), el('button', { className: 'journal-btn ghost', type: 'button', textContent: tr('ui.cancel', null, 'Cancel'), onClick: closeEntryModal }),
el('button', { className: 'journal-btn primary', type: 'button', 'data-journal-action': 'save-entry', textContent: editing ? 'Save changes' : 'Add entry', onClick: saveEntry }) el('button', { className: 'journal-btn primary', type: 'button', 'data-journal-action': 'save-entry', textContent: editing ? tr('ui.saveChanges', null, 'Save changes') : tr('ui.addEntryShort', null, 'Add entry'), onClick: saveEntry })
]) ])
]) ])
])); ]));
@ -421,7 +425,7 @@
if (scope.mode !== 'workspace') return; if (scope.mode !== 'workspace') return;
var title = text(formValue && formValue.title).trim(); var title = text(formValue && formValue.title).trim();
if (!title) { if (!title) {
statusText = 'Title is required'; statusText = tr('ui.titleRequired', null, 'Title is required');
statusClass = 'error'; statusClass = 'error';
render(); render();
return; return;
@ -469,7 +473,7 @@
function deleteEntry(entry) { function deleteEntry(entry) {
if (scope.mode !== 'workspace' || !entry) return; if (scope.mode !== 'workspace' || !entry) return;
entries = entries.filter(function (item) { return item.entryId !== entry.entryId; }); entries = entries.filter(function (item) { return item.entryId !== entry.entryId; });
statusText = 'Entry deleted'; statusText = tr('ui.deleted', null, 'Entry deleted');
statusClass = ''; statusClass = '';
persist().then(render); persist().then(render);
} }
@ -477,7 +481,7 @@
function renderList() { function renderList() {
listEl.innerHTML = ''; listEl.innerHTML = '';
if (!entries.length) { if (!entries.length) {
listEl.appendChild(el('div', { className: 'journal-empty', textContent: scope.mode === 'global' ? 'No worklog entries yet.' : 'No worklog entries yet.' })); listEl.appendChild(el('div', { className: 'journal-empty', textContent: tr('ui.empty', null, 'No worklog entries yet.') }));
return; return;
} }
entries.forEach(function (entry) { entries.forEach(function (entry) {
@ -493,15 +497,19 @@
]), ]),
el('div', { className: 'journal-minutes', textContent: entry.minutes + ' min' }), el('div', { className: 'journal-minutes', textContent: entry.minutes + ' min' }),
scope.mode === 'workspace' ? el('div', { className: 'journal-row-actions' }, [ scope.mode === 'workspace' ? el('div', { className: 'journal-row-actions' }, [
el('button', { className: 'journal-icon-btn', type: 'button', title: 'Edit', 'aria-label': 'Edit', 'data-journal-action': 'edit', innerHTML: iconSvg('edit'), onClick: function () { showEntryModal(entry); } }), el('button', { className: 'journal-icon-btn', type: 'button', title: tr('ui.edit', null, 'Edit'), 'aria-label': tr('ui.edit', null, 'Edit'), 'data-journal-action': 'edit', innerHTML: iconSvg('edit'), onClick: function () { showEntryModal(entry); } }),
el('button', { className: 'journal-icon-btn danger', type: 'button', title: 'Delete', 'aria-label': 'Delete', 'data-journal-action': 'delete', innerHTML: iconSvg('trash'), onClick: function () { deleteEntry(entry); } }) el('button', { className: 'journal-icon-btn danger', type: 'button', title: tr('ui.delete', null, 'Delete'), 'aria-label': tr('ui.delete', null, 'Delete'), 'data-journal-action': 'delete', innerHTML: iconSvg('trash'), onClick: function () { deleteEntry(entry); } })
]) : null ]) : null
])); ]));
}); });
} }
function render() { function render() {
countEl.textContent = entries.length + ' entr' + (entries.length === 1 ? 'y' : 'ies'); countEl.textContent = tr(
entries.length === 1 ? 'ui.entryCount.one' : 'ui.entryCount.many',
{ count: entries.length },
entries.length === 1 ? '{count} entry' : '{count} entries'
);
statusEl.textContent = statusText; statusEl.textContent = statusText;
statusEl.className = 'journal-status' + (statusClass ? ' ' + statusClass : ''); statusEl.className = 'journal-status' + (statusClass ? ' ' + statusClass : '');
addBtn.disabled = scope.mode !== 'workspace'; addBtn.disabled = scope.mode !== 'workspace';
@ -516,6 +524,13 @@
if (candidate) showEntryModal(null, candidate); if (candidate) showEntryModal(null, candidate);
else if (completedTodo) showEntryModal(null, null, completedTodo); else if (completedTodo) showEntryModal(null, null, completedTodo);
}); });
if (api && api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
api.i18n.onDidChangeLocale(function () {
titleEl.textContent = scope.mode === 'global' ? tr('ui.title', null, 'Journal') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Journal · ' + scope.label);
addBtn.innerHTML = iconSvg('add') + '<span>' + tr('ui.add', null, 'Add') + '</span>';
render();
});
}
}; };
JournalView.unmount = function (containerEl) { JournalView.unmount = function (containerEl) {

View File

@ -0,0 +1,35 @@
{
"manifest.name": "Journal",
"manifest.description": "Workspace-scoped journal with user-authored entries and optional Activity links.",
"contributions.views.verstak.journal.view.title": "Journal",
"contributions.sidebarItems.verstak.journal.sidebar.title": "Journal",
"contributions.workspaceItems.verstak.journal.workspace.title": "Journal",
"ui.loading": "Loading journal...",
"ui.title": "Journal",
"ui.workspaceTitle": "Journal · {workspace}",
"ui.add": "Add",
"ui.saveError": "Could not save journal: {error}",
"ui.aggregating": "Aggregating worklogs",
"ui.loadError": "Could not load journal: {error}",
"ui.ready": "Ready",
"ui.workItem": "Work item",
"ui.body": "Body",
"ui.editEntry": "Edit journal entry",
"ui.reviewCandidate": "Review possible journal entry",
"ui.fromTodo": "Create journal entry from completed todo",
"ui.addEntry": "Add journal entry",
"ui.date": "Date",
"ui.minutes": "Minutes",
"ui.fieldTitle": "Title",
"ui.billable": "Billable",
"ui.cancel": "Cancel",
"ui.saveChanges": "Save changes",
"ui.addEntryShort": "Add entry",
"ui.titleRequired": "Title is required",
"ui.deleted": "Entry deleted",
"ui.empty": "No worklog entries yet.",
"ui.edit": "Edit",
"ui.delete": "Delete",
"ui.entryCount.one": "{count} entry",
"ui.entryCount.many": "{count} entries"
}

View File

@ -0,0 +1,35 @@
{
"manifest.name": "Журнал",
"manifest.description": "Журнал рабочего пространства с ручными записями и необязательными связями с активностью.",
"contributions.views.verstak.journal.view.title": "Журнал",
"contributions.sidebarItems.verstak.journal.sidebar.title": "Журнал",
"contributions.workspaceItems.verstak.journal.workspace.title": "Журнал",
"ui.loading": "Загрузка журнала...",
"ui.title": "Журнал",
"ui.workspaceTitle": "Журнал · {workspace}",
"ui.add": "Добавить",
"ui.saveError": "Не удалось сохранить журнал: {error}",
"ui.aggregating": "Сбор записей о работе",
"ui.loadError": "Не удалось загрузить журнал: {error}",
"ui.ready": "Готово",
"ui.workItem": "Выполненная работа",
"ui.body": "Описание",
"ui.editEntry": "Изменить запись журнала",
"ui.reviewCandidate": "Проверить возможную запись журнала",
"ui.fromTodo": "Создать запись журнала из выполненной задачи",
"ui.addEntry": "Добавить запись журнала",
"ui.date": "Дата",
"ui.minutes": "Минуты",
"ui.fieldTitle": "Название",
"ui.billable": "Оплачиваемая работа",
"ui.cancel": "Отмена",
"ui.saveChanges": "Сохранить изменения",
"ui.addEntryShort": "Добавить запись",
"ui.titleRequired": "Введите название",
"ui.deleted": "Запись удалена",
"ui.empty": "Записей о работе пока нет.",
"ui.edit": "Изменить",
"ui.delete": "Удалить",
"ui.entryCount.one": "{count} запись",
"ui.entryCount.many": "Записей: {count}"
}

View File

@ -5,6 +5,7 @@
"version": "0.1.0", "version": "0.1.0",
"apiVersion": "0.1.0", "apiVersion": "0.1.0",
"description": "Workspace-scoped journal with user-authored entries and optional Activity links.", "description": "Workspace-scoped journal with user-authored entries and optional Activity links.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official", "source": "official",
"icon": "book-open", "icon": "book-open",
"provides": [ "provides": [

View File

@ -183,6 +183,11 @@
var sortMode = 'title-asc'; var sortMode = 'title-asc';
var renameTarget = null; var renameTarget = null;
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
function notesParent() { function notesParent() {
return workspaceRoot || ''; return workspaceRoot || '';
} }
@ -262,8 +267,8 @@
// ─── UI Elements ──────────────────────────────────────── // ─── UI Elements ────────────────────────────────────────
var toolbar = el('div', { className: 'notes-toolbar' }); var toolbar = el('div', { className: 'notes-toolbar' });
var createBtn = el('button', { className: 'notes-btn primary', 'data-action': 'create', innerHTML: iconSvg('add') + ' New Note' }); var createBtn = el('button', { className: 'notes-btn primary', 'data-action': 'create', innerHTML: iconSvg('add') + ' ' + tr('ui.newNote', null, 'New Note') });
var filterInput = el('input', { className: 'notes-filter', 'data-notes-filter': '', placeholder: 'Filter notes' }); var filterInput = el('input', { className: 'notes-filter', 'data-notes-filter': '', placeholder: tr('ui.filter', null, 'Filter notes') });
var sortSelect = el('select', { className: 'notes-sort', 'data-notes-sort': '' }, [ var sortSelect = el('select', { className: 'notes-sort', 'data-notes-sort': '' }, [
el('option', { value: 'title-asc' }, ['A-Z']), el('option', { value: 'title-asc' }, ['A-Z']),
el('option', { value: 'title-desc' }, ['Z-A']) el('option', { value: 'title-desc' }, ['Z-A'])
@ -276,25 +281,25 @@
toolbar.appendChild(statusEl); toolbar.appendChild(statusEl);
containerEl.appendChild(toolbar); containerEl.appendChild(toolbar);
var titleBar = el('div', { className: 'notes-title-bar' }, ['Notes in ' + workspaceName]); var titleBar = el('div', { className: 'notes-title-bar' }, [tr('ui.title', { workspace: workspaceName }, 'Notes in ' + workspaceName)]);
containerEl.appendChild(titleBar); containerEl.appendChild(titleBar);
var listContainer = el('div', { className: 'notes-list', 'data-notes-list': '' }); var listContainer = el('div', { className: 'notes-list', 'data-notes-list': '' });
containerEl.appendChild(listContainer); containerEl.appendChild(listContainer);
var createPanel = el('div', { className: 'notes-panel', style: { display: 'none' } }); var createPanel = el('div', { className: 'notes-panel', style: { display: 'none' } });
var createInput = el('input', { className: 'notes-input', 'data-notes-create-input': '', placeholder: 'Note title' }); var createInput = el('input', { className: 'notes-input', 'data-notes-create-input': '', placeholder: tr('ui.noteTitle', null, 'Note title') });
var createConfirm = el('button', { className: 'notes-btn', textContent: 'Create' }); var createConfirm = el('button', { className: 'notes-btn', textContent: tr('ui.create', null, 'Create') });
var createCancel = el('button', { className: 'notes-btn', textContent: 'Cancel' }); var createCancel = el('button', { className: 'notes-btn', textContent: tr('ui.cancel', null, 'Cancel') });
createPanel.appendChild(createInput); createPanel.appendChild(createInput);
createPanel.appendChild(createConfirm); createPanel.appendChild(createConfirm);
createPanel.appendChild(createCancel); createPanel.appendChild(createCancel);
containerEl.appendChild(createPanel); containerEl.appendChild(createPanel);
var renamePanel = el('div', { className: 'notes-panel', style: { display: 'none' } }); var renamePanel = el('div', { className: 'notes-panel', style: { display: 'none' } });
var renameInput = el('input', { className: 'notes-input', 'data-notes-rename-input': '', placeholder: 'New title' }); var renameInput = el('input', { className: 'notes-input', 'data-notes-rename-input': '', placeholder: tr('ui.newTitle', null, 'New title') });
var renameConfirm = el('button', { className: 'notes-btn', textContent: 'Rename' }); var renameConfirm = el('button', { className: 'notes-btn', textContent: tr('ui.rename', null, 'Rename') });
var renameCancel = el('button', { className: 'notes-btn', textContent: 'Cancel' }); var renameCancel = el('button', { className: 'notes-btn', textContent: tr('ui.cancel', null, 'Cancel') });
renamePanel.appendChild(renameInput); renamePanel.appendChild(renameInput);
renamePanel.appendChild(renameConfirm); renamePanel.appendChild(renameConfirm);
renamePanel.appendChild(renameCancel); renamePanel.appendChild(renameCancel);
@ -321,7 +326,7 @@
function loadNotes() { function loadNotes() {
listContainer.innerHTML = ''; listContainer.innerHTML = '';
listContainer.appendChild(el('div', { className: 'notes-empty' }, ['Loading...'])); listContainer.appendChild(el('div', { className: 'notes-empty' }, [tr('ui.loading', null, 'Loading...')]));
var parent = notesParent(); var parent = notesParent();
listNotes(parent).then(function (result) { listNotes(parent).then(function (result) {
@ -392,36 +397,36 @@
function renderList() { function renderList() {
listContainer.innerHTML = ''; listContainer.innerHTML = '';
if (!notes || notes.length === 0) { if (!notes || notes.length === 0) {
renderEmpty('No notes yet'); renderEmpty(tr('ui.empty', null, 'No notes yet'));
return; return;
} }
var shown = visibleNotes(); var shown = visibleNotes();
if (shown.length === 0) { if (shown.length === 0) {
renderEmpty('No matching notes', 'Clear the filter to show all notes'); renderEmpty(tr('ui.noMatches', null, 'No matching notes'), tr('ui.clearFilterHint', null, 'Clear the filter to show all notes'));
return; return;
} }
shown.forEach(function (note) { shown.forEach(function (note) {
var actionButtons = [ var actionButtons = [
el('button', { el('button', {
className: 'notes-item-btn', className: 'notes-item-btn',
title: 'Open', title: tr('ui.open', null, 'Open'),
'aria-label': 'Open', 'aria-label': tr('ui.open', null, 'Open'),
'data-note-action': 'open', 'data-note-action': 'open',
innerHTML: iconSvg('open'), innerHTML: iconSvg('open'),
onClick: function (e) { e.stopPropagation(); openNote(note); } onClick: function (e) { e.stopPropagation(); openNote(note); }
}), }),
el('button', { el('button', {
className: 'notes-item-btn', className: 'notes-item-btn',
title: 'Rename', title: tr('ui.rename', null, 'Rename'),
'aria-label': 'Rename', 'aria-label': tr('ui.rename', null, 'Rename'),
'data-note-action': 'rename', 'data-note-action': 'rename',
innerHTML: iconSvg('rename'), innerHTML: iconSvg('rename'),
onClick: function (e) { e.stopPropagation(); beginRename(note); } onClick: function (e) { e.stopPropagation(); beginRename(note); }
}), }),
el('button', { el('button', {
className: 'notes-item-btn', className: 'notes-item-btn',
title: 'Move to Trash', title: tr('ui.trash', null, 'Move to Trash'),
'aria-label': 'Move to Trash', 'aria-label': tr('ui.trash', null, 'Move to Trash'),
'data-note-action': 'trash', 'data-note-action': 'trash',
innerHTML: iconSvg('trash'), innerHTML: iconSvg('trash'),
onClick: function (e) { e.stopPropagation(); confirmTrashNote(note); } onClick: function (e) { e.stopPropagation(); confirmTrashNote(note); }
@ -457,7 +462,7 @@
listContainer.appendChild(el('div', { className: 'notes-empty' }, [ listContainer.appendChild(el('div', { className: 'notes-empty' }, [
el('div', { innerHTML: iconSvg('note') }), el('div', { innerHTML: iconSvg('note') }),
el('div', {}, [msg]), el('div', {}, [msg]),
el('div', { className: 'notes-empty-hint' }, [hint || 'Click "New Note" to create one']) el('div', { className: 'notes-empty-hint' }, [hint || tr('ui.emptyHint', null, 'Click "New Note" to create one')])
])); ]));
} }
@ -499,7 +504,7 @@
function confirmCreate() { function confirmCreate() {
var title = createInput.value.trim(); var title = createInput.value.trim();
if (!title) return; if (!title) return;
setStatus('Creating note...', 'loading'); setStatus(tr('ui.creating', null, 'Creating note...'), 'loading');
var parent = notesParent(); var parent = notesParent();
createNote(parent, title).then(function (data) { createNote(parent, title).then(function (data) {
if (disposed) return; if (disposed) return;
@ -509,7 +514,7 @@
return; return;
} }
hideCreate(); hideCreate();
setStatus('Note created', 'success'); setStatus(tr('ui.created', null, 'Note created'), 'success');
loadNotes(); loadNotes();
// Open the newly created note // Open the newly created note
if (data.path) { if (data.path) {
@ -551,7 +556,7 @@
if (!renameTarget) return; if (!renameTarget) return;
var newTitle = renameInput.value.trim(); var newTitle = renameInput.value.trim();
if (!newTitle) return; if (!newTitle) return;
setStatus('Renaming...', 'loading'); setStatus(tr('ui.renaming', null, 'Renaming...'), 'loading');
renameNote(renameTarget.path, newTitle).then(function (data) { renameNote(renameTarget.path, newTitle).then(function (data) {
if (disposed) return; if (disposed) return;
data = data || {}; data = data || {};
@ -560,7 +565,7 @@
return; return;
} }
hideRename(); hideRename();
setStatus('Note renamed', 'success'); setStatus(tr('ui.renamed', null, 'Note renamed'), 'success');
loadNotes(); loadNotes();
}).catch(function (err) { }).catch(function (err) {
setStatus('Error: ' + (err.message || err), 'error'); setStatus('Error: ' + (err.message || err), 'error');
@ -573,11 +578,11 @@
if (!note) return; if (!note) return;
showTrashModal(note).then(function (confirmed) { showTrashModal(note).then(function (confirmed) {
if (!confirmed || disposed) return; if (!confirmed || disposed) return;
setStatus('Moving note to trash...', 'loading'); setStatus(tr('ui.trashing', null, 'Moving note to trash...'), 'loading');
api.files.trash(note.path).then(function () { api.files.trash(note.path).then(function () {
if (disposed) return; if (disposed) return;
if (selectedPath === note.path) selectedPath = ''; if (selectedPath === note.path) selectedPath = '';
setStatus('Note moved to trash', 'success'); setStatus(tr('ui.trashed', null, 'Note moved to trash'), 'success');
loadNotes(); loadNotes();
}).catch(function (err) { }).catch(function (err) {
setStatus('Error: ' + (err.message || err), 'error'); setStatus('Error: ' + (err.message || err), 'error');
@ -590,11 +595,11 @@
function showConflictModal(title, existingPath, focusTarget) { function showConflictModal(title, existingPath, focusTarget) {
var overlay = el('div', { className: 'notes-modal-overlay' }); var overlay = el('div', { className: 'notes-modal-overlay' });
var modal = el('div', { className: 'notes-modal' }, [ var modal = el('div', { className: 'notes-modal' }, [
el('div', { className: 'notes-modal-title' }, ['Name Conflict']), el('div', { className: 'notes-modal-title' }, [tr('ui.conflictTitle', null, 'Name Conflict')]),
el('div', { className: 'notes-modal-msg' }, [ el('div', { className: 'notes-modal-msg' }, [
'A note with the title "' + title + '" already exists.', tr('ui.conflictMessage', { title: title }, 'A note with the title "' + title + '" already exists.'),
existingPath ? ' Existing file: ' + existingPath + '.' : '', existingPath ? tr('ui.existingFile', { path: existingPath }, ' Existing file: ' + existingPath + '.') : '',
' Please choose a different title.' tr('ui.chooseDifferent', null, ' Please choose a different title.')
].join('')), ].join('')),
el('div', { className: 'notes-modal-actions' }, [ el('div', { className: 'notes-modal-actions' }, [
el('button', { className: 'notes-modal-btn confirm', textContent: 'OK', onClick: function () { overlay.remove(); (focusTarget || createInput).focus(); } }) el('button', { className: 'notes-modal-btn confirm', textContent: 'OK', onClick: function () { overlay.remove(); (focusTarget || createInput).focus(); } })
@ -612,13 +617,13 @@
resolve(value); resolve(value);
} }
var modal = el('div', { className: 'notes-modal' }, [ var modal = el('div', { className: 'notes-modal' }, [
el('div', { className: 'notes-modal-title' }, ['Move Note to Trash']), el('div', { className: 'notes-modal-title' }, [tr('ui.trashTitle', null, 'Move Note to Trash')]),
el('div', { className: 'notes-modal-msg' }, [ el('div', { className: 'notes-modal-msg' }, [
'Move "' + (note.title || fileName(note.path)) + '" to trash?' tr('ui.trashConfirm', { title: note.title || fileName(note.path) }, 'Move "' + (note.title || fileName(note.path)) + '" to trash?')
]), ]),
el('div', { className: 'notes-modal-actions' }, [ el('div', { className: 'notes-modal-actions' }, [
el('button', { className: 'notes-modal-btn cancel', textContent: 'Cancel', onClick: function () { close(false); } }), el('button', { className: 'notes-modal-btn cancel', textContent: tr('ui.cancel', null, 'Cancel'), onClick: function () { close(false); } }),
el('button', { className: 'notes-modal-btn danger', 'data-notes-confirm-trash': '', textContent: 'Move to Trash', onClick: function () { close(true); } }) el('button', { className: 'notes-modal-btn danger', 'data-notes-confirm-trash': '', textContent: tr('ui.trash', null, 'Move to Trash'), onClick: function () { close(true); } })
]) ])
]); ]);
overlay.appendChild(modal); overlay.appendChild(modal);
@ -655,6 +660,22 @@
loadContributionActions(); loadContributionActions();
loadNotes(); loadNotes();
var localeUnsubscribe = null;
if (api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
localeUnsubscribe = api.i18n.onDidChangeLocale(function () {
createBtn.innerHTML = iconSvg('add') + ' ' + tr('ui.newNote', null, 'New Note');
filterInput.setAttribute('placeholder', tr('ui.filter', null, 'Filter notes'));
titleBar.textContent = tr('ui.title', { workspace: workspaceName }, 'Notes in ' + workspaceName);
createInput.setAttribute('placeholder', tr('ui.noteTitle', null, 'Note title'));
renameInput.setAttribute('placeholder', tr('ui.newTitle', null, 'New title'));
createConfirm.textContent = tr('ui.create', null, 'Create');
createCancel.textContent = tr('ui.cancel', null, 'Cancel');
renameConfirm.textContent = tr('ui.rename', null, 'Rename');
renameCancel.textContent = tr('ui.cancel', null, 'Cancel');
renderList();
});
}
var fileChangedUnsubscribe = null; var fileChangedUnsubscribe = null;
if (api.events && typeof api.events.subscribe === 'function') { if (api.events && typeof api.events.subscribe === 'function') {
api.events.subscribe('file.changed', function (event) { api.events.subscribe('file.changed', function (event) {
@ -669,6 +690,7 @@
containerEl.__notesCleanup = function () { containerEl.__notesCleanup = function () {
disposed = true; disposed = true;
if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
if (typeof fileChangedUnsubscribe === 'function') fileChangedUnsubscribe(); if (typeof fileChangedUnsubscribe === 'function') fileChangedUnsubscribe();
}; };
}, },

View File

@ -0,0 +1,32 @@
{
"manifest.name": "Notes",
"manifest.description": "Workspace-scoped notes manager for Markdown files in canonical Notes folders with create, rename, trash, and Workbench integration.",
"contributions.workspaceItems.verstak.notes.workspace.title": "Notes",
"ui.newNote": "New Note",
"ui.filter": "Filter notes",
"ui.title": "Notes in {workspace}",
"ui.noteTitle": "Note title",
"ui.create": "Create",
"ui.cancel": "Cancel",
"ui.newTitle": "New title",
"ui.rename": "Rename",
"ui.loading": "Loading...",
"ui.empty": "No notes yet",
"ui.noMatches": "No matching notes",
"ui.clearFilterHint": "Clear the filter to show all notes",
"ui.open": "Open",
"ui.trash": "Move to Trash",
"ui.emptyHint": "Click \"New Note\" to create one",
"ui.creating": "Creating note...",
"ui.created": "Note created",
"ui.renaming": "Renaming...",
"ui.renamed": "Note renamed",
"ui.trashing": "Moving note to trash...",
"ui.trashed": "Note moved to trash",
"ui.conflictTitle": "Name Conflict",
"ui.conflictMessage": "A note with the title \"{title}\" already exists.",
"ui.existingFile": " Existing file: {path}.",
"ui.chooseDifferent": " Please choose a different title.",
"ui.trashTitle": "Move Note to Trash",
"ui.trashConfirm": "Move \"{title}\" to trash?"
}

View File

@ -0,0 +1,32 @@
{
"manifest.name": "Заметки",
"manifest.description": "Менеджер Markdown-заметок рабочего пространства с созданием, переименованием и корзиной.",
"contributions.workspaceItems.verstak.notes.workspace.title": "Заметки",
"ui.newNote": "Новая заметка",
"ui.filter": "Фильтр заметок",
"ui.title": "Заметки · {workspace}",
"ui.noteTitle": "Название заметки",
"ui.create": "Создать",
"ui.cancel": "Отмена",
"ui.newTitle": "Новое название",
"ui.rename": "Переименовать",
"ui.loading": "Загрузка...",
"ui.empty": "Заметок пока нет",
"ui.noMatches": "Подходящих заметок нет",
"ui.clearFilterHint": "Сбросьте фильтр, чтобы показать все заметки",
"ui.open": "Открыть",
"ui.trash": "Переместить в корзину",
"ui.emptyHint": "Нажмите «Новая заметка», чтобы создать первую",
"ui.creating": "Создание заметки...",
"ui.created": "Заметка создана",
"ui.renaming": "Переименование...",
"ui.renamed": "Заметка переименована",
"ui.trashing": "Перемещение заметки в корзину...",
"ui.trashed": "Заметка перемещена в корзину",
"ui.conflictTitle": "Конфликт названия",
"ui.conflictMessage": "Заметка с названием «{title}» уже существует.",
"ui.existingFile": " Существующий файл: {path}.",
"ui.chooseDifferent": " Выберите другое название.",
"ui.trashTitle": "Переместить заметку в корзину",
"ui.trashConfirm": "Переместить «{title}» в корзину?"
}

View File

@ -5,6 +5,7 @@
"version": "0.1.0", "version": "0.1.0",
"apiVersion": "0.1.0", "apiVersion": "0.1.0",
"description": "Workspace-scoped notes manager for Markdown files in canonical Notes folders with create, rename, trash, and Workbench integration.", "description": "Workspace-scoped notes manager for Markdown files in canonical Notes folders with create, rename, trash, and Workbench integration.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official", "source": "official",
"icon": "edit", "icon": "edit",
"provides": [ "provides": [

View File

@ -63,7 +63,34 @@
containerEl.innerHTML = ''; containerEl.innerHTML = '';
containerEl.className = 'pt-root'; containerEl.className = 'pt-root';
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
containerEl.__ptCleanup = []; containerEl.__ptCleanup = [];
var localizedNodes = [];
function localized(tag, attrs, key, fallback) {
var node = el(tag, attrs, [tr(key, null, fallback)]);
var item = { node: node, key: key, params: null, fallback: fallback };
node.__ptLocalized = item;
localizedNodes.push(item);
return node;
}
function setLocalized(node, key, params, fallback) {
var item = node.__ptLocalized;
if (!item) {
item = { node: node };
node.__ptLocalized = item;
localizedNodes.push(item);
}
item.key = key;
item.params = params || null;
item.fallback = fallback;
node.textContent = tr(key, params, fallback);
}
function trackCleanup(fn) { function trackCleanup(fn) {
if (typeof fn === 'function') { if (typeof fn === 'function') {
@ -74,12 +101,19 @@
containerEl.__ptCleanup.push(fn); containerEl.__ptCleanup.push(fn);
} }
} }
if (api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
trackCleanup(api.i18n.onDidChangeLocale(function () {
localizedNodes.forEach(function (item) {
if (item.key) item.node.textContent = tr(item.key, item.params, item.fallback);
});
}));
}
/* ── Header ─────────────────────────────────────────────────── */ /* ── Header ─────────────────────────────────────────────────── */
var header = div('pt-header', [ var header = div('pt-header', [
span('pt-icon', '◉'), span('pt-icon', '◉'),
div('pt-title-group', [ div('pt-title-group', [
el('h2', { className: 'pt-plugin-name' }, ['Platform Diagnostics']), localized('h2', { className: 'pt-plugin-name' }, 'ui.diagnostics', 'Platform Diagnostics'),
el('p', { className: 'pt-plugin-id' }, [api.pluginId]), el('p', { className: 'pt-plugin-id' }, [api.pluginId]),
]), ]),
span('pt-version', 'v' + (props && props.version ? props.version : '0.1.0')), span('pt-version', 'v' + (props && props.version ? props.version : '0.1.0')),
@ -88,49 +122,49 @@
/* ── Status badge ──────────────────────────────────────────── */ /* ── Status badge ──────────────────────────────────────────── */
var badge = div('pt-badge pt-badge-success', [ var badge = div('pt-badge pt-badge-success', [
el('span', {}, ['✅']), el('span', {}, ['✅']),
el('span', {}, ['Frontend Bundle Loaded']), localized('span', {}, 'ui.bundleLoaded', 'Frontend Bundle Loaded'),
]); ]);
var badgeRow = div('', [badge]); var badgeRow = div('', [badge]);
/* ── Real Plugin API bridge checks ─────────────────────────── */ /* ── Real Plugin API bridge checks ─────────────────────────── */
var savedValue = span('pt-list-value pt-saved-setting', 'Saved setting: loading...'); var savedValue = localized('span', { className: 'pt-list-value pt-saved-setting' }, 'ui.savedLoading', 'Saved setting: loading...');
var capabilityValue = span('pt-list-value pt-capability-result', 'Capabilities: loading...'); var capabilityValue = localized('span', { className: 'pt-list-value pt-capability-result' }, 'ui.capabilitiesLoading', 'Capabilities: loading...');
var commandValue = span('pt-list-value pt-command-result', 'Command: registering...'); var commandValue = localized('span', { className: 'pt-list-value pt-command-result' }, 'ui.commandRegistering', 'Command: registering...');
var eventValue = span('pt-list-value pt-event-result', 'Event: subscribing...'); var eventValue = localized('span', { className: 'pt-list-value pt-event-result' }, 'ui.eventSubscribing', 'Event: subscribing...');
var filesValue = span('pt-list-value pt-files-result', 'Files: running...'); var filesValue = localized('span', { className: 'pt-list-value pt-files-result' }, 'ui.filesRunning', 'Files: running...');
var filesErrorValue = span('pt-list-value pt-files-error-result', 'Files error path: checking...'); var filesErrorValue = localized('span', { className: 'pt-list-value pt-files-error-result' }, 'ui.filesPathChecking', 'Files error path: checking...');
var workbenchValue = span('pt-list-value pt-workbench-result', 'Workbench: ready'); var workbenchValue = localized('span', { className: 'pt-list-value pt-workbench-result' }, 'ui.workbenchReady', 'Workbench: ready');
function makeWorkbenchButton(className, label, request) { function makeWorkbenchButton(className, key, label, request) {
return el('button', { return el('button', {
className: 'btn btn-primary ' + className, className: 'btn btn-primary ' + className,
onClick: function () { onClick: function () {
workbenchValue.textContent = 'Workbench: opening...'; setLocalized(workbenchValue, 'ui.workbenchOpening', null, 'Workbench: opening...');
api.workbench.editResource(request) api.workbench.editResource(request)
.then(function (result) { .then(function (result) {
workbenchValue.textContent = 'Workbench: opened ' + result.request.path + ' with ' + (result.providerId || 'no-provider'); setLocalized(workbenchValue, 'ui.workbenchOpened', { path: result.request.path, provider: result.providerId || 'no-provider' }, 'Workbench: opened {path} with {provider}');
workbenchValue.setAttribute('data-workbench-status', result.status === 'opened' ? 'ok' : result.status); workbenchValue.setAttribute('data-workbench-status', result.status === 'opened' ? 'ok' : result.status);
}) })
.catch(function (err) { .catch(function (err) {
workbenchValue.textContent = 'Workbench error: ' + (err && err.message ? err.message : String(err)); setLocalized(workbenchValue, 'ui.workbenchError', { error: err && err.message ? err.message : String(err) }, 'Workbench error: {error}');
workbenchValue.setAttribute('data-workbench-status', 'error'); workbenchValue.setAttribute('data-workbench-status', 'error');
}); });
}, },
}, [label]); }, [localized('span', {}, key, label)]);
} }
var openTextWorkbenchButton = makeWorkbenchButton('pt-open-workbench-text', 'Open Text Diagnostic', { var openTextWorkbenchButton = makeWorkbenchButton('pt-open-workbench-text', 'ui.openTextDiagnostic', 'Open Text Diagnostic', {
kind: 'vault-file', kind: 'vault-file',
path: 'Docs/todo.txt', path: 'Docs/todo.txt',
extension: '.txt', extension: '.txt',
mime: 'text/plain', mime: 'text/plain',
context: { sourceView: 'files' }, context: { sourceView: 'files' },
}); });
var openMarkdownWorkbenchButton = makeWorkbenchButton('pt-open-workbench-markdown', 'Open Markdown Diagnostic', { var openMarkdownWorkbenchButton = makeWorkbenchButton('pt-open-workbench-markdown', 'ui.openMarkdownDiagnostic', 'Open Markdown Diagnostic', {
kind: 'vault-file', kind: 'vault-file',
path: 'Docs/readme.md', path: 'Docs/readme.md',
extension: '.md', extension: '.md',
context: { sourceView: 'files' }, context: { sourceView: 'files' },
}); });
var openNotesWorkbenchButton = makeWorkbenchButton('pt-open-workbench-notes', 'Open Notes Diagnostic', { var openNotesWorkbenchButton = makeWorkbenchButton('pt-open-workbench-notes', 'ui.openNotesDiagnostic', 'Open Notes Diagnostic', {
kind: 'vault-file', kind: 'vault-file',
path: 'Notes/example.md', path: 'Notes/example.md',
extension: '.md', extension: '.md',
@ -143,51 +177,51 @@
var settingInput = el('input', { var settingInput = el('input', {
className: 'pt-setting-input', className: 'pt-setting-input',
type: 'text', type: 'text',
'aria-label': 'Saved setting', 'aria-label': tr('ui.savedSetting', null, 'Saved setting'),
value: 'changed value', value: 'changed value',
}); });
var saveStatus = span('pt-list-value', ''); var saveStatus = span('pt-list-value', '');
var saveButton = el('button', { var saveButton = el('button', {
className: 'btn btn-primary pt-save-setting', className: 'btn btn-primary pt-save-setting',
onClick: function () { onClick: function () {
saveStatus.textContent = 'Saving...'; setLocalized(saveStatus, 'ui.saving', null, 'Saving...');
api.settings.write('savedText', settingInput.value) api.settings.write('savedText', settingInput.value)
.then(function () { .then(function () {
savedValue.textContent = 'Saved setting: ' + settingInput.value; setLocalized(savedValue, 'ui.savedSettingValue', { value: settingInput.value }, 'Saved setting: {value}');
saveStatus.textContent = 'Saved'; setLocalized(saveStatus, 'ui.saved', null, 'Saved');
}) })
.catch(function (err) { .catch(function (err) {
saveStatus.textContent = 'Error: ' + (err && err.message ? err.message : String(err)); setLocalized(saveStatus, 'ui.errorValue', { error: err && err.message ? err.message : String(err) }, 'Error: {error}');
}); });
}, },
}, ['Save Setting']); }, [localized('span', {}, 'ui.saveSetting', 'Save Setting')]);
api.settings.read('savedText') api.settings.read('savedText')
.then(function (value) { .then(function (value) {
var text = value || ''; var text = value || '';
settingInput.value = text || 'changed value'; settingInput.value = text || 'changed value';
savedValue.textContent = 'Saved setting: ' + text; setLocalized(savedValue, 'ui.savedSettingValue', { value: text }, 'Saved setting: {value}');
}) })
.catch(function (err) { .catch(function (err) {
savedValue.textContent = 'Settings error: ' + (err && err.message ? err.message : String(err)); setLocalized(savedValue, 'ui.settingsError', { error: err && err.message ? err.message : String(err) }, 'Settings error: {error}');
}); });
api.capabilities.list() api.capabilities.list()
.then(function (caps) { .then(function (caps) {
capabilityValue.textContent = 'Capabilities: ' + caps.length + ' available'; setLocalized(capabilityValue, 'ui.capabilitiesAvailable', { count: caps.length }, 'Capabilities: {count} available');
}) })
.catch(function (err) { .catch(function (err) {
capabilityValue.textContent = 'Capabilities error: ' + (err && err.message ? err.message : String(err)); setLocalized(capabilityValue, 'ui.capabilitiesError', { error: err && err.message ? err.message : String(err) }, 'Capabilities error: {error}');
}); });
api.capabilities.has('verstak/platform-test/v1') api.capabilities.has('verstak/platform-test/v1')
.then(function (available) { .then(function (available) {
badge.setAttribute('data-capability-status', available ? 'available' : 'missing'); badge.setAttribute('data-capability-status', available ? 'available' : 'missing');
badge.lastChild.textContent = 'Frontend Bundle Loaded | capability ' + (available ? 'available' : 'missing'); setLocalized(badge.lastChild, available ? 'ui.bundleCapabilityAvailable' : 'ui.bundleCapabilityMissing', null, available ? 'Frontend Bundle Loaded | capability available' : 'Frontend Bundle Loaded | capability missing');
}) })
.catch(function (err) { .catch(function (err) {
badge.setAttribute('data-capability-status', 'error'); badge.setAttribute('data-capability-status', 'error');
badge.lastChild.textContent = 'Capability error: ' + (err && err.message ? err.message : String(err)); setLocalized(badge.lastChild, 'ui.capabilityError', { error: err && err.message ? err.message : String(err) }, 'Capability error: {error}');
}); });
api.commands.register('verstak.platform-test.show-version', function () { api.commands.register('verstak.platform-test.show-version', function () {
@ -202,17 +236,17 @@
}) })
.then(function (result) { .then(function (result) {
badge.setAttribute('data-command-status', result.status || ''); badge.setAttribute('data-command-status', result.status || '');
commandValue.textContent = 'Command: ' + result.status + ' ' + result.result.version + ' from ' + result.result.source; setLocalized(commandValue, 'ui.commandResult', { status: result.status, version: result.result.version, source: result.result.source }, 'Command: {status} {version} from {source}');
}) })
.catch(function (err) { .catch(function (err) {
badge.setAttribute('data-command-status', 'error'); badge.setAttribute('data-command-status', 'error');
commandValue.textContent = 'Command error: ' + (err && err.message ? err.message : String(err)); setLocalized(commandValue, 'ui.commandError', { error: err && err.message ? err.message : String(err) }, 'Command error: {error}');
console.error('[platform-test] command bridge error:', err); console.error('[platform-test] command bridge error:', err);
}); });
api.events.subscribe('verstak.platform-test.echo', function (event) { api.events.subscribe('verstak.platform-test.echo', function (event) {
var message = event && event.payload ? event.payload.message : ''; var message = event && event.payload ? event.payload.message : '';
eventValue.textContent = 'Event: received ' + message; setLocalized(eventValue, 'ui.eventReceived', { message: message }, 'Event: received {message}');
eventValue.setAttribute('data-event-status', 'received'); eventValue.setAttribute('data-event-status', 'received');
}) })
.then(function (unsubscribe) { .then(function (unsubscribe) {
@ -220,7 +254,7 @@
return api.events.publish('verstak.platform-test.echo', { message: 'hello-event' }); return api.events.publish('verstak.platform-test.echo', { message: 'hello-event' });
}) })
.catch(function (err) { .catch(function (err) {
eventValue.textContent = 'Event error: ' + (err && err.message ? err.message : String(err)); setLocalized(eventValue, 'ui.eventError', { error: err && err.message ? err.message : String(err) }, 'Event error: {error}');
eventValue.setAttribute('data-event-status', 'error'); eventValue.setAttribute('data-event-status', 'error');
}); });
@ -249,39 +283,39 @@
return api.files.trash('PlatformTest/files-api-moved.txt'); return api.files.trash('PlatformTest/files-api-moved.txt');
}) })
.then(function () { .then(function () {
filesValue.textContent = 'Files: wrote/read/listed/moved/trashed'; setLocalized(filesValue, 'ui.filesPassed', null, 'Files: wrote/read/listed/moved/trashed');
filesValue.setAttribute('data-files-status', 'ok'); filesValue.setAttribute('data-files-status', 'ok');
}) })
.catch(function (err) { .catch(function (err) {
filesValue.textContent = 'Files error: ' + (err && err.message ? err.message : String(err)); setLocalized(filesValue, 'ui.filesError', { error: err && err.message ? err.message : String(err) }, 'Files error: {error}');
filesValue.setAttribute('data-files-status', 'error'); filesValue.setAttribute('data-files-status', 'error');
}); });
api.files.readText('.verstak/vault.json') api.files.readText('.verstak/vault.json')
.then(function () { .then(function () {
filesErrorValue.textContent = 'Files error path: unexpectedly allowed'; setLocalized(filesErrorValue, 'ui.filesPathAllowed', null, 'Files error path: unexpectedly allowed');
filesErrorValue.setAttribute('data-files-error-status', 'error'); filesErrorValue.setAttribute('data-files-error-status', 'error');
}) })
.catch(function (err) { .catch(function (err) {
var message = err && err.message ? err.message : String(err); var message = err && err.message ? err.message : String(err);
if (message.indexOf('reserved-path') === -1 && message.indexOf('.verstak') === -1) { if (message.indexOf('reserved-path') === -1 && message.indexOf('.verstak') === -1) {
filesErrorValue.textContent = 'Files error path: wrong error ' + message; setLocalized(filesErrorValue, 'ui.filesPathWrongError', { error: message }, 'Files error path: wrong error {error}');
filesErrorValue.setAttribute('data-files-error-status', 'error'); filesErrorValue.setAttribute('data-files-error-status', 'error');
return; return;
} }
filesErrorValue.textContent = 'Files error path: rejected reserved-path'; setLocalized(filesErrorValue, 'ui.filesPathRejected', null, 'Files error path: rejected reserved-path');
filesErrorValue.setAttribute('data-files-error-status', 'expected'); filesErrorValue.setAttribute('data-files-error-status', 'expected');
}); });
var bridgeCard = div('pt-card', [ var bridgeCard = div('pt-card', [
el('h3', { className: 'pt-card-title' }, ['Real Plugin API Bridge']), localized('h3', { className: 'pt-card-title' }, 'ui.apiBridge', 'Real Plugin API Bridge'),
el('ul', { className: 'pt-list' }, [ el('ul', { className: 'pt-list' }, [
el('li', { className: 'pt-list-item' }, [ el('li', { className: 'pt-list-item' }, [
span('pt-list-label', 'Persisted setting'), localized('span', { className: 'pt-list-label' }, 'ui.persistedSetting', 'Persisted setting'),
savedValue, savedValue,
]), ]),
el('li', { className: 'pt-list-item' }, [ el('li', { className: 'pt-list-item' }, [
span('pt-list-label', 'New value'), localized('span', { className: 'pt-list-label' }, 'ui.newValue', 'New value'),
settingInput, settingInput,
]), ]),
el('li', { className: 'pt-list-item' }, [ el('li', { className: 'pt-list-item' }, [
@ -289,27 +323,27 @@
saveStatus, saveStatus,
]), ]),
el('li', { className: 'pt-list-item' }, [ el('li', { className: 'pt-list-item' }, [
span('pt-list-label', 'Capabilities'), localized('span', { className: 'pt-list-label' }, 'ui.capabilitiesLabel', 'Capabilities'),
capabilityValue, capabilityValue,
]), ]),
el('li', { className: 'pt-list-item' }, [ el('li', { className: 'pt-list-item' }, [
span('pt-list-label', 'Command runtime'), localized('span', { className: 'pt-list-label' }, 'ui.commandRuntime', 'Command runtime'),
commandValue, commandValue,
]), ]),
el('li', { className: 'pt-list-item' }, [ el('li', { className: 'pt-list-item' }, [
span('pt-list-label', 'Event runtime'), localized('span', { className: 'pt-list-label' }, 'ui.eventRuntime', 'Event runtime'),
eventValue, eventValue,
]), ]),
el('li', { className: 'pt-list-item' }, [ el('li', { className: 'pt-list-item' }, [
span('pt-list-label', 'Files runtime'), localized('span', { className: 'pt-list-label' }, 'ui.filesRuntime', 'Files runtime'),
filesValue, filesValue,
]), ]),
el('li', { className: 'pt-list-item' }, [ el('li', { className: 'pt-list-item' }, [
span('pt-list-label', 'Files reserved path'), localized('span', { className: 'pt-list-label' }, 'ui.filesReservedPath', 'Files reserved path'),
filesErrorValue, filesErrorValue,
]), ]),
el('li', { className: 'pt-list-item' }, [ el('li', { className: 'pt-list-item' }, [
span('pt-list-label', 'Workbench routing'), localized('span', { className: 'pt-list-label' }, 'ui.workbenchRouting', 'Workbench routing'),
openTextWorkbenchButton, openTextWorkbenchButton,
openMarkdownWorkbenchButton, openMarkdownWorkbenchButton,
openNotesWorkbenchButton, openNotesWorkbenchButton,
@ -320,12 +354,12 @@
/* ── Test results summary ──────────────────────────────────── */ /* ── Test results summary ──────────────────────────────────── */
var testsData = [ var testsData = [
{ label: 'Plugin Registration', status: 'pass' }, { key: 'registration', label: 'Plugin Registration', status: 'pass' },
{ label: 'Capability: verstak/platform-test/v1', status: 'pass' }, { key: 'platformCapability', label: 'Capability: verstak/platform-test/v1', status: 'pass' },
{ label: 'Capability: verstak/diagnostics/v1', status: 'pass' }, { key: 'diagnosticsCapability', label: 'Capability: verstak/diagnostics/v1', status: 'pass' },
{ label: 'Capability: verstak/core/files/v1', status: 'pass' }, { key: 'filesCapability', label: 'Capability: verstak/core/files/v1', status: 'pass' },
{ label: 'Capability: verstak/core/workbench/v1', status: 'pass' }, { key: 'workbenchCapability', label: 'Capability: verstak/core/workbench/v1', status: 'pass' },
{ label: 'API Contract Compliance', status: 'pass' }, { key: 'apiContract', label: 'API Contract Compliance', status: 'pass' },
]; ];
var totalTests = testsData.length; var totalTests = testsData.length;
@ -334,19 +368,19 @@
var summaryRow = div('pt-test-summary', [ var summaryRow = div('pt-test-summary', [
div('pt-test-stat', [ div('pt-test-stat', [
span('pt-test-stat-value pt-pass', String(passedTests)), span('pt-test-stat-value pt-pass', String(passedTests)),
span('pt-test-stat-label', 'Passed'), localized('span', { className: 'pt-test-stat-label' }, 'ui.passed', 'Passed'),
]), ]),
div('pt-test-stat', [ div('pt-test-stat', [
span('pt-test-stat-value pt-fail', String(totalTests - passedTests)), span('pt-test-stat-value pt-fail', String(totalTests - passedTests)),
span('pt-test-stat-label', 'Failed'), localized('span', { className: 'pt-test-stat-label' }, 'ui.failed', 'Failed'),
]), ]),
div('pt-test-stat', [ div('pt-test-stat', [
span('pt-test-stat-value', String(totalTests)), span('pt-test-stat-value', String(totalTests)),
span('pt-test-stat-label', 'Total'), localized('span', { className: 'pt-test-stat-label' }, 'ui.total', 'Total'),
]), ]),
div('pt-test-stat', [ div('pt-test-stat', [
span('pt-test-stat-value pt-pass', '100%'), span('pt-test-stat-value pt-pass', '100%'),
span('pt-test-stat-label', 'Success Rate'), localized('span', { className: 'pt-test-stat-label' }, 'ui.successRate', 'Success Rate'),
]), ]),
]); ]);
@ -354,14 +388,14 @@
testsData.forEach(function (t) { testsData.forEach(function (t) {
var dot = el('span', { className: 'pt-cap-dot pt-cap-dot-ok' }); var dot = el('span', { className: 'pt-cap-dot pt-cap-dot-ok' });
var item = el('li', { className: 'pt-list-item' }, [ var item = el('li', { className: 'pt-list-item' }, [
el('span', { className: 'pt-list-label' }, [dot, ' ', t.label]), el('span', { className: 'pt-list-label' }, [dot, ' ', localized('span', {}, 'ui.test.' + t.key, t.label)]),
span('pt-list-value pt-pass', t.status === 'pass' ? '✓ PASS' : '✗ FAIL'), localized('span', { className: 'pt-list-value pt-pass' }, t.status === 'pass' ? 'ui.pass' : 'ui.fail', t.status === 'pass' ? '✓ PASS' : '✗ FAIL'),
]); ]);
testsList.appendChild(item); testsList.appendChild(item);
}); });
var testsCard = div('pt-card', [ var testsCard = div('pt-card', [
el('h3', { className: 'pt-card-title' }, ['Test Results']), localized('h3', { className: 'pt-card-title' }, 'ui.testResults', 'Test Results'),
summaryRow, summaryRow,
testsList, testsList,
]); ]);
@ -380,7 +414,7 @@
var dot = el('span', { var dot = el('span', {
className: 'pt-cap-dot pt-cap-dot-missing', className: 'pt-cap-dot pt-cap-dot-missing',
}); });
var statusText = span('pt-list-value', 'Checking...'); var statusText = localized('span', { className: 'pt-list-value' }, 'ui.checking', 'Checking...');
var item = el('li', { className: 'pt-list-item' }, [ var item = el('li', { className: 'pt-list-item' }, [
el('span', { className: 'pt-list-label' }, [dot, ' ', cap.label]), el('span', { className: 'pt-list-label' }, [dot, ' ', cap.label]),
statusText, statusText,
@ -389,39 +423,39 @@
api.capabilities.has(cap.id) api.capabilities.has(cap.id)
.then(function (available) { .then(function (available) {
dot.className = 'pt-cap-dot ' + (available ? 'pt-cap-dot-ok' : 'pt-cap-dot-missing'); dot.className = 'pt-cap-dot ' + (available ? 'pt-cap-dot-ok' : 'pt-cap-dot-missing');
statusText.textContent = available ? '✓ Available' : '— Unavailable'; setLocalized(statusText, available ? 'ui.available' : 'ui.unavailable', null, available ? '✓ Available' : '— Unavailable');
}) })
.catch(function () { .catch(function () {
dot.className = 'pt-cap-dot pt-cap-dot-missing'; dot.className = 'pt-cap-dot pt-cap-dot-missing';
statusText.textContent = 'Error'; setLocalized(statusText, 'ui.error', null, 'Error');
}); });
}); });
var capsCard = div('pt-card', [ var capsCard = div('pt-card', [
el('h3', { className: 'pt-card-title' }, ['Registered Capabilities']), localized('h3', { className: 'pt-card-title' }, 'ui.capabilities', 'Registered Capabilities'),
capList, capList,
]); ]);
/* ── Plugin info ───────────────────────────────────────────── */ /* ── Plugin info ───────────────────────────────────────────── */
var infoList = el('ul', { className: 'pt-list' }); var infoList = el('ul', { className: 'pt-list' });
var infoItems = [ var infoItems = [
{ label: 'Plugin ID', value: api.pluginId }, { key: 'pluginId', label: 'Plugin ID', value: api.pluginId },
{ label: 'Bundle Status', value: 'Loaded ✓' }, { key: 'bundleStatus', label: 'Bundle Status', value: tr('ui.loaded', null, 'Loaded ✓') },
{ label: 'Registration Scheme', value: 'VerstakPluginRegister' }, { key: 'registration', label: 'Registration Scheme', value: 'VerstakPluginRegister' },
{ label: 'Components', value: 'DiagnosticsPanel, PlatformTestSettings' }, { key: 'components', label: 'Components', value: 'DiagnosticsPanel, PlatformTestSettings' },
{ label: 'Container', value: containerEl.tagName.toLowerCase() + (containerEl.id ? '#' + containerEl.id : '') }, { key: 'container', label: 'Container', value: containerEl.tagName.toLowerCase() + (containerEl.id ? '#' + containerEl.id : '') },
]; ];
infoItems.forEach(function (item) { infoItems.forEach(function (item) {
infoList.appendChild( infoList.appendChild(
el('li', { className: 'pt-list-item' }, [ el('li', { className: 'pt-list-item' }, [
span('pt-list-label', item.label), localized('span', { className: 'pt-list-label' }, 'ui.info.' + item.key, item.label),
span('pt-list-value', item.value), span('pt-list-value', item.value),
]) ])
); );
}); });
var infoCard = div('pt-card', [ var infoCard = div('pt-card', [
el('h3', { className: 'pt-card-title' }, ['Plugin Info']), localized('h3', { className: 'pt-card-title' }, 'ui.pluginInfo', 'Plugin Info'),
infoList, infoList,
]); ]);
@ -450,13 +484,13 @@
apiStatusList.appendChild( apiStatusList.appendChild(
el('li', { className: 'pt-list-item' }, [ el('li', { className: 'pt-list-item' }, [
el('span', { className: 'pt-list-label' }, [dot, ' ', chk.label]), el('span', { className: 'pt-list-label' }, [dot, ' ', chk.label]),
span('pt-list-value', chk.ok ? '✓ Ready' : '✗ Missing'), localized('span', { className: 'pt-list-value' }, chk.ok ? 'ui.ready' : 'ui.missing', chk.ok ? '✓ Ready' : '✗ Missing'),
]) ])
); );
}); });
var apiCard = div('pt-card', [ var apiCard = div('pt-card', [
el('h3', { className: 'pt-card-title' }, ['Host API Methods']), localized('h3', { className: 'pt-card-title' }, 'ui.hostMethods', 'Host API Methods'),
apiStatusList, apiStatusList,
]); ]);
@ -487,7 +521,7 @@
function renderMouseLog() { function renderMouseLog() {
if (mouseLog.length === 0) { if (mouseLog.length === 0) {
mouseLogContainer.textContent = '(no events captured)'; setLocalized(mouseLogContainer, 'ui.noMouseEvents', null, '(no events captured)');
mouseCountSpan.textContent = '0'; mouseCountSpan.textContent = '0';
return; return;
} }
@ -497,6 +531,7 @@
' defaultPrevented=' + e.defaultPrevented + ' defaultPrevented=' + e.defaultPrevented +
' target=' + e.targetTag + (e.targetClass ? '.' + e.targetClass : ''); ' target=' + e.targetTag + (e.targetClass ? '.' + e.targetClass : '');
}); });
if (mouseLogContainer.__ptLocalized) mouseLogContainer.__ptLocalized.key = '';
mouseLogContainer.textContent = lines.join('\n'); mouseLogContainer.textContent = lines.join('\n');
mouseCountSpan.textContent = String(mouseLog.length); mouseCountSpan.textContent = String(mouseLog.length);
mouseLogContainer.scrollTop = mouseLogContainer.scrollHeight; mouseLogContainer.scrollTop = mouseLogContainer.scrollHeight;
@ -526,7 +561,7 @@
window.addEventListener(type, handler, true); window.addEventListener(type, handler, true);
mouseHandlers.push({ type: type, handler: handler }); mouseHandlers.push({ type: type, handler: handler });
}); });
startStopBtn.textContent = '■ Stop Capture'; setLocalized(startStopBtn, 'ui.stopCapture', null, '■ Stop Capture');
startStopBtn.setAttribute('data-mouse-capturing', 'true'); startStopBtn.setAttribute('data-mouse-capturing', 'true');
renderMouseLog(); renderMouseLog();
} }
@ -538,35 +573,35 @@
window.removeEventListener(h.type, h.handler, true); window.removeEventListener(h.type, h.handler, true);
}); });
mouseHandlers = []; mouseHandlers = [];
startStopBtn.textContent = '▶ Start Capture'; setLocalized(startStopBtn, 'ui.startCapture', null, '▶ Start Capture');
startStopBtn.setAttribute('data-mouse-capturing', 'false'); startStopBtn.setAttribute('data-mouse-capturing', 'false');
} }
var startStopBtn = el('button', { var startStopBtn = localized('button', {
className: 'btn btn-primary', className: 'btn btn-primary',
style: { marginRight: '0.5rem' }, style: { marginRight: '0.5rem' },
onClick: function () { onClick: function () {
if (mouseCapturing) { stopMouseCapture(); } else { startMouseCapture(); } if (mouseCapturing) { stopMouseCapture(); } else { startMouseCapture(); }
}, },
}, ['▶ Start Capture']); }, 'ui.startCapture', '▶ Start Capture');
var clearBtn = el('button', { var clearBtn = localized('button', {
className: 'btn btn-secondary', className: 'btn btn-secondary',
style: { marginRight: '0.5rem' }, style: { marginRight: '0.5rem' },
onClick: function () { onClick: function () {
mouseLog = []; mouseLog = [];
renderMouseLog(); renderMouseLog();
}, },
}, ['Clear']); }, 'ui.clear', 'Clear');
var copyBtn = el('button', { var copyBtn = localized('button', {
className: 'btn btn-secondary', className: 'btn btn-secondary',
onClick: function () { onClick: function () {
var json = JSON.stringify(mouseLog, null, 2); var json = JSON.stringify(mouseLog, null, 2);
if (navigator.clipboard) { if (navigator.clipboard) {
navigator.clipboard.writeText(json).then(function () { navigator.clipboard.writeText(json).then(function () {
copyBtn.textContent = '✓ Copied!'; setLocalized(copyBtn, 'ui.copied', null, '✓ Copied!');
setTimeout(function () { copyBtn.textContent = 'Copy JSON'; }, 1500); setTimeout(function () { setLocalized(copyBtn, 'ui.copyJson', null, 'Copy JSON'); }, 1500);
}); });
} else { } else {
var ta = document.createElement('textarea'); var ta = document.createElement('textarea');
@ -575,24 +610,22 @@
ta.select(); ta.select();
document.execCommand('copy'); document.execCommand('copy');
document.body.removeChild(ta); document.body.removeChild(ta);
copyBtn.textContent = '✓ Copied!'; setLocalized(copyBtn, 'ui.copied', null, '✓ Copied!');
setTimeout(function () { copyBtn.textContent = 'Copy JSON'; }, 1500); setTimeout(function () { setLocalized(copyBtn, 'ui.copyJson', null, 'Copy JSON'); }, 1500);
} }
}, },
}, ['Copy JSON']); }, 'ui.copyJson', 'Copy JSON');
trackCleanup(function () { stopMouseCapture(); }); trackCleanup(function () { stopMouseCapture(); });
var mouseCard = div('pt-card', [ var mouseCard = div('pt-card', [
el('h3', { className: 'pt-card-title' }, ['Mouse Event Inspector']), localized('h3', { className: 'pt-card-title' }, 'ui.mouseInspector', 'Mouse Event Inspector'),
el('p', { style: { margin: '0 0 0.5rem', color: '#a0a0b8', fontSize: '0.8rem' } }, [ localized('p', { style: { margin: '0 0 0.5rem', color: '#a0a0b8', fontSize: '0.8rem' } }, 'ui.mouseHint', 'Captures ALL mouse/pointer events on window. Press back/forward buttons to see what WebKitGTK reports.'),
'Captures ALL mouse/pointer events on window. Press back/forward buttons to see what WebKitGTK reports.',
]),
el('div', { style: { display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' } }, [ el('div', { style: { display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' } }, [
startStopBtn, startStopBtn,
clearBtn, clearBtn,
copyBtn, copyBtn,
span('pt-list-label', ' Events:'), localized('span', { className: 'pt-list-label' }, 'ui.eventsCount', 'Events:'),
mouseCountSpan, mouseCountSpan,
]), ]),
mouseLogContainer, mouseLogContainer,
@ -667,6 +700,28 @@
containerEl.innerHTML = ''; containerEl.innerHTML = '';
containerEl.className = 'pt-root'; containerEl.className = 'pt-root';
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
var localizedNodes = [];
var unsubscribeLocale = null;
function localized(tag, attrs, key, fallback) {
var node = el(tag, attrs, [tr(key, null, fallback)]);
localizedNodes.push({ node: node, key: key, fallback: fallback });
return node;
}
if (api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
unsubscribeLocale = api.i18n.onDidChangeLocale(function () {
localizedNodes.forEach(function (item) {
item.node.textContent = tr(item.key, null, item.fallback);
});
});
containerEl.__ptSettingsCleanup = unsubscribeLocale;
}
/* ── Counter state (local, not persisted) ──────────────────── */ /* ── Counter state (local, not persisted) ──────────────────── */
var counterState = { value: 0 }; var counterState = { value: 0 };
@ -674,77 +729,69 @@
var header = div('pt-header', [ var header = div('pt-header', [
span('pt-icon', '⚙️'), span('pt-icon', '⚙️'),
div('pt-title-group', [ div('pt-title-group', [
el('h2', { className: 'pt-plugin-name' }, ['Platform Test Settings']), localized('h2', { className: 'pt-plugin-name' }, 'ui.settings', 'Platform Test Settings'),
el('p', { className: 'pt-plugin-id' }, [api.pluginId]), el('p', { className: 'pt-plugin-id' }, [api.pluginId]),
]), ]),
]); ]);
/* ── Info card ──────────────────────────────────────────────── */ /* ── Info card ──────────────────────────────────────────────── */
var infoCard = div('pt-card', [ var infoCard = div('pt-card', [
el('p', { style: { margin: '0', color: '#a0a0b8', fontSize: '0.85rem' } }, [ localized('p', { style: { margin: '0', color: '#a0a0b8', fontSize: '0.85rem' } }, 'ui.settingsInfo', 'Settings panel loaded from the plugin frontend bundle through the VerstakPluginRegister contract.'),
'Settings panel loaded from plugin frontend bundle via ',
el('code', { style: { background: '#1a1a2e', padding: '0.1rem 0.3rem', borderRadius: '3px', color: '#4ecca3' } }, ['VerstakPluginRegister']),
' contract.',
]),
]); ]);
/* ── Counter section ────────────────────────────────────────── */ /* ── Counter section ────────────────────────────────────────── */
var counterDisplay = div('pt-counter', [ var counterDisplay = div('pt-counter', [
el('span', { className: 'pt-counter-value' }, [String(counterState.value)]), el('span', { className: 'pt-counter-value' }, [String(counterState.value)]),
span('pt-counter-label', 'clicks (session only, no persistence)'), localized('span', { className: 'pt-counter-label' }, 'ui.counterClicks', 'clicks (session only, no persistence)'),
]); ]);
var incrementBtn = el('button', { className: 'btn btn-primary', onClick: function () { var incrementBtn = el('button', { className: 'btn btn-primary', onClick: function () {
counterState.value += 1; counterState.value += 1;
counterDisplay.firstChild.textContent = String(counterState.value); counterDisplay.firstChild.textContent = String(counterState.value);
}}, ['+ Increment']); }}, [localized('span', {}, 'ui.increment', '+ Increment')]);
var decrementBtn = el('button', { className: 'btn btn-secondary', onClick: function () { var decrementBtn = el('button', { className: 'btn btn-secondary', onClick: function () {
counterState.value = Math.max(0, counterState.value - 1); counterState.value = Math.max(0, counterState.value - 1);
counterDisplay.firstChild.textContent = String(counterState.value); counterDisplay.firstChild.textContent = String(counterState.value);
}}, [' Decrement']); }}, [localized('span', {}, 'ui.decrement', ' Decrement')]);
var resetBtn = el('button', { className: 'btn btn-secondary', onClick: function () { var resetBtn = el('button', { className: 'btn btn-secondary', onClick: function () {
counterState.value = 0; counterState.value = 0;
counterDisplay.firstChild.textContent = '0'; counterDisplay.firstChild.textContent = '0';
}}, ['↺ Reset']); }}, [localized('span', {}, 'ui.reset', '↺ Reset')]);
var btnGroup = el('div', { style: { display: 'flex', gap: '0.5rem' } }, [ var btnGroup = el('div', { style: { display: 'flex', gap: '0.5rem' } }, [
incrementBtn, decrementBtn, resetBtn, incrementBtn, decrementBtn, resetBtn,
]); ]);
var counterCard = div('pt-card', [ var counterCard = div('pt-card', [
el('h3', { className: 'pt-card-title' }, ['Interactive Counter (Local State)']), localized('h3', { className: 'pt-card-title' }, 'ui.counter', 'Interactive Counter (Local State)'),
counterDisplay, counterDisplay,
btnGroup, btnGroup,
el('p', { style: { marginTop: '0.75rem', color: '#6c6c8a', fontSize: '0.7rem' } }, [ localized('p', { style: { marginTop: '0.75rem', color: '#6c6c8a', fontSize: '0.7rem' } }, 'ui.counterHint', 'This counter is a local demo. State is not persisted — refreshing resets it.'),
'This counter is a local demo. State is not persisted — refreshing resets it.',
]),
]); ]);
/* ── Settings stub ─────────────────────────────────────────── */ /* ── Settings stub ─────────────────────────────────────────── */
var settingsDemoList = el('ul', { className: 'pt-list' }); var settingsDemoList = el('ul', { className: 'pt-list' });
var settingsItems = [ var settingsItems = [
{ label: 'Auto-run on load', value: 'true' }, { key: 'autoRun', label: 'Auto-run on load', valueKey: 'true', value: 'true' },
{ label: 'Verbose logging', value: 'false' }, { key: 'verbose', label: 'Verbose logging', valueKey: 'false', value: 'false' },
{ label: 'Theme override', value: 'inherit' }, { key: 'theme', label: 'Theme override', valueKey: 'inherit', value: 'inherit' },
{ label: 'Notifications', value: 'enabled' }, { key: 'notifications', label: 'Notifications', valueKey: 'enabled', value: 'enabled' },
]; ];
settingsItems.forEach(function (s) { settingsItems.forEach(function (s) {
settingsDemoList.appendChild( settingsDemoList.appendChild(
el('li', { className: 'pt-list-item' }, [ el('li', { className: 'pt-list-item' }, [
span('pt-list-label', s.label), localized('span', { className: 'pt-list-label' }, 'ui.setting.' + s.key, s.label),
span('pt-list-value', s.value), localized('span', { className: 'pt-list-value' }, 'ui.value.' + s.valueKey, s.value),
]) ])
); );
}); });
var settingsCard = div('pt-card', [ var settingsCard = div('pt-card', [
el('h3', { className: 'pt-card-title' }, ['Plugin Settings (Demo)']), localized('h3', { className: 'pt-card-title' }, 'ui.demoSettings', 'Plugin Settings (Demo)'),
settingsDemoList, settingsDemoList,
el('p', { style: { marginTop: '0.5rem', color: '#6c6c8a', fontSize: '0.7rem' } }, [ localized('p', { style: { marginTop: '0.5rem', color: '#6c6c8a', fontSize: '0.7rem' } }, 'ui.settingsHint', 'Use api.settings.read() / api.settings.write() for persisted settings.'),
'Use api.settings.read() / api.settings.write() for persisted settings.',
]),
]); ]);
/* ── Assemble ──────────────────────────────────────────────── */ /* ── Assemble ──────────────────────────────────────────────── */
@ -755,8 +802,10 @@
}, },
unmount: function (containerEl) { unmount: function (containerEl) {
if (typeof containerEl.__ptSettingsCleanup === 'function') containerEl.__ptSettingsCleanup();
containerEl.innerHTML = ''; containerEl.innerHTML = '';
containerEl.className = ''; containerEl.className = '';
delete containerEl.__ptSettingsCleanup;
}, },
}; };

View File

@ -0,0 +1,111 @@
{
"manifest.name": "Platform Test",
"manifest.description": "Runtime test plugin for verifying the Verstak platform: manifest loading, capability registration, contribution injection, event bus, and UI rendering.",
"contributions.views.verstak.platform-test.diagnostics.title": "Platform Diagnostics",
"contributions.commands.verstak.platform-test.run-tests.title": "Run Platform Tests",
"contributions.commands.verstak.platform-test.show-version.title": "Show Version Info",
"contributions.sidebarItems.verstak.platform-test.sidebar.title": "Platform Test",
"contributions.statusBarItems.verstak.platform-test.status.label": "[OK] All Tests Pass",
"contributions.settingsPanels.verstak.platform-test.settings.title": "Platform Test Settings",
"contributions.openProviders.verstak.platform-test.markdown-diagnostic.title": "Platform Test Markdown Diagnostic",
"ui.diagnostics": "Platform Diagnostics",
"ui.bundleLoaded": "Plugin Bundle Loaded",
"ui.apiBridge": "API Bridge",
"ui.testResults": "Test Results",
"ui.capabilities": "Granted Capabilities",
"ui.pluginInfo": "Plugin Info",
"ui.hostMethods": "Host Methods",
"ui.mouseInspector": "Mouse Event Inspector",
"ui.settings": "Platform Test Settings",
"ui.increment": "+ Increment",
"ui.decrement": " Decrement",
"ui.reset": "Reset",
"ui.counter": "Interactive Counter",
"ui.demoSettings": "Demo Settings",
"ui.savedLoading": "Saved setting: loading...",
"ui.capabilitiesLoading": "Capabilities: loading...",
"ui.commandRegistering": "Command: registering...",
"ui.eventSubscribing": "Event: subscribing...",
"ui.filesRunning": "Files: running...",
"ui.filesPathChecking": "Files error path: checking...",
"ui.workbenchReady": "Workbench: ready",
"ui.workbenchOpening": "Workbench: opening...",
"ui.workbenchOpened": "Workbench: opened {path} with {provider}",
"ui.workbenchError": "Workbench error: {error}",
"ui.openTextDiagnostic": "Open Text Diagnostic",
"ui.openMarkdownDiagnostic": "Open Markdown Diagnostic",
"ui.openNotesDiagnostic": "Open Notes Diagnostic",
"ui.savedSetting": "Saved setting",
"ui.saving": "Saving...",
"ui.savedSettingValue": "Saved setting: {value}",
"ui.saved": "Saved",
"ui.errorValue": "Error: {error}",
"ui.saveSetting": "Save Setting",
"ui.settingsError": "Settings error: {error}",
"ui.capabilitiesAvailable": "Capabilities: {count} available",
"ui.capabilitiesError": "Capabilities error: {error}",
"ui.bundleCapabilityAvailable": "Frontend Bundle Loaded | capability available",
"ui.bundleCapabilityMissing": "Frontend Bundle Loaded | capability missing",
"ui.capabilityError": "Capability error: {error}",
"ui.commandResult": "Command: {status} {version} from {source}",
"ui.commandError": "Command error: {error}",
"ui.eventReceived": "Event: received {message}",
"ui.eventError": "Event error: {error}",
"ui.filesPassed": "Files: wrote/read/listed/moved/trashed",
"ui.filesError": "Files error: {error}",
"ui.filesPathAllowed": "Files error path: unexpectedly allowed",
"ui.filesPathWrongError": "Files error path: wrong error {error}",
"ui.filesPathRejected": "Files error path: rejected reserved-path",
"ui.persistedSetting": "Persisted setting",
"ui.newValue": "New value",
"ui.capabilitiesLabel": "Capabilities",
"ui.commandRuntime": "Command runtime",
"ui.eventRuntime": "Event runtime",
"ui.filesRuntime": "Files runtime",
"ui.filesReservedPath": "Files reserved path",
"ui.workbenchRouting": "Workbench routing",
"ui.passed": "Passed",
"ui.failed": "Failed",
"ui.total": "Total",
"ui.successRate": "Success Rate",
"ui.checking": "Checking...",
"ui.available": "✓ Available",
"ui.unavailable": "— Unavailable",
"ui.error": "Error",
"ui.ready": "✓ Ready",
"ui.missing": "✗ Missing",
"ui.loaded": "Loaded ✓",
"ui.info.pluginId": "Plugin ID",
"ui.info.bundleStatus": "Bundle Status",
"ui.info.registration": "Registration Scheme",
"ui.info.components": "Components",
"ui.info.container": "Container",
"ui.test.registration": "Plugin Registration",
"ui.test.platformCapability": "Capability: verstak/platform-test/v1",
"ui.test.diagnosticsCapability": "Capability: verstak/diagnostics/v1",
"ui.test.filesCapability": "Capability: verstak/core/files/v1",
"ui.test.workbenchCapability": "Capability: verstak/core/workbench/v1",
"ui.test.apiContract": "API Contract Compliance",
"ui.pass": "✓ PASS",
"ui.fail": "✗ FAIL",
"ui.noMouseEvents": "(no events captured)",
"ui.stopCapture": "■ Stop Capture",
"ui.startCapture": "▶ Start Capture",
"ui.clear": "Clear",
"ui.copyJson": "Copy JSON",
"ui.copied": "✓ Copied!",
"ui.mouseHint": "Captures ALL mouse/pointer events on window. Press back/forward buttons to see what WebKitGTK reports.",
"ui.eventsCount": "Events:",
"ui.settingsInfo": "Settings panel loaded from the plugin frontend bundle through the VerstakPluginRegister contract.",
"ui.counterClicks": "clicks (session only, no persistence)",
"ui.counterHint": "This counter is a local demo. State is not persisted — refreshing resets it.",
"ui.setting.autoRun": "Auto-run on load",
"ui.setting.verbose": "Verbose logging",
"ui.setting.theme": "Theme override",
"ui.setting.notifications": "Notifications",
"ui.value.true": "true",
"ui.value.false": "false",
"ui.value.inherit": "inherit",
"ui.value.enabled": "enabled",
"ui.settingsHint": "Use api.settings.read() / api.settings.write() for persisted settings."
}

View File

@ -0,0 +1,111 @@
{
"manifest.name": "Тест платформы",
"manifest.description": "Диагностический плагин для проверки загрузки manifest, capabilities, contributions, шины событий и интерфейса Верстака.",
"contributions.views.verstak.platform-test.diagnostics.title": "Диагностика платформы",
"contributions.commands.verstak.platform-test.run-tests.title": "Запустить тесты платформы",
"contributions.commands.verstak.platform-test.show-version.title": "Показать сведения о версии",
"contributions.sidebarItems.verstak.platform-test.sidebar.title": "Тест платформы",
"contributions.statusBarItems.verstak.platform-test.status.label": "[OK] Все тесты пройдены",
"contributions.settingsPanels.verstak.platform-test.settings.title": "Настройки теста платформы",
"contributions.openProviders.verstak.platform-test.markdown-diagnostic.title": "Диагностика Markdown платформы",
"ui.diagnostics": "Диагностика платформы",
"ui.bundleLoaded": "Пакет плагина загружен",
"ui.apiBridge": "Мост API",
"ui.testResults": "Результаты тестов",
"ui.capabilities": "Предоставленные возможности",
"ui.pluginInfo": "Сведения о плагине",
"ui.hostMethods": "Методы хоста",
"ui.mouseInspector": "Инспектор событий мыши",
"ui.settings": "Настройки теста платформы",
"ui.increment": "+ Увеличить",
"ui.decrement": " Уменьшить",
"ui.reset": "Сбросить",
"ui.counter": "Интерактивный счётчик",
"ui.demoSettings": "Демонстрационные настройки",
"ui.savedLoading": "Загрузка сохранённой настройки...",
"ui.capabilitiesLoading": "Загрузка возможностей...",
"ui.commandRegistering": "Регистрация команды...",
"ui.eventSubscribing": "Подписка на событие...",
"ui.filesRunning": "Проверка файлов...",
"ui.filesPathChecking": "Проверка запрещённого пути...",
"ui.workbenchReady": "Workbench готов",
"ui.workbenchOpening": "Открытие Workbench...",
"ui.workbenchOpened": "Workbench открыл {path} через {provider}",
"ui.workbenchError": "Ошибка Workbench: {error}",
"ui.openTextDiagnostic": "Открыть текстовую диагностику",
"ui.openMarkdownDiagnostic": "Открыть диагностику Markdown",
"ui.openNotesDiagnostic": "Открыть диагностику заметок",
"ui.savedSetting": "Сохранённая настройка",
"ui.saving": "Сохранение...",
"ui.savedSettingValue": "Сохранённая настройка: {value}",
"ui.saved": "Сохранено",
"ui.errorValue": "Ошибка: {error}",
"ui.saveSetting": "Сохранить настройку",
"ui.settingsError": "Ошибка настроек: {error}",
"ui.capabilitiesAvailable": "Доступно возможностей: {count}",
"ui.capabilitiesError": "Ошибка возможностей: {error}",
"ui.bundleCapabilityAvailable": "Frontend bundle загружен | capability доступна",
"ui.bundleCapabilityMissing": "Frontend bundle загружен | capability отсутствует",
"ui.capabilityError": "Ошибка capability: {error}",
"ui.commandResult": "Команда: {status} {version}, источник: {source}",
"ui.commandError": "Ошибка команды: {error}",
"ui.eventReceived": "Получено событие: {message}",
"ui.eventError": "Ошибка события: {error}",
"ui.filesPassed": "Файлы: запись/чтение/список/перемещение/удаление выполнены",
"ui.filesError": "Ошибка файлов: {error}",
"ui.filesPathAllowed": "Ошибка: запрещённый путь неожиданно разрешён",
"ui.filesPathWrongError": "Неверная ошибка запрещённого пути: {error}",
"ui.filesPathRejected": "Зарезервированный путь отклонён",
"ui.persistedSetting": "Сохранённая настройка",
"ui.newValue": "Новое значение",
"ui.capabilitiesLabel": "Возможности",
"ui.commandRuntime": "Выполнение команд",
"ui.eventRuntime": "Обработка событий",
"ui.filesRuntime": "Работа с файлами",
"ui.filesReservedPath": "Зарезервированный путь",
"ui.workbenchRouting": "Маршрутизация Workbench",
"ui.passed": "Пройдено",
"ui.failed": "Ошибок",
"ui.total": "Всего",
"ui.successRate": "Успешность",
"ui.checking": "Проверка...",
"ui.available": "✓ Доступно",
"ui.unavailable": "— Недоступно",
"ui.error": "Ошибка",
"ui.ready": "✓ Готово",
"ui.missing": "✗ Отсутствует",
"ui.loaded": "Загружено ✓",
"ui.info.pluginId": "ID плагина",
"ui.info.bundleStatus": "Состояние bundle",
"ui.info.registration": "Схема регистрации",
"ui.info.components": "Компоненты",
"ui.info.container": "Контейнер",
"ui.test.registration": "Регистрация плагина",
"ui.test.platformCapability": "Capability: verstak/platform-test/v1",
"ui.test.diagnosticsCapability": "Capability: verstak/diagnostics/v1",
"ui.test.filesCapability": "Capability: verstak/core/files/v1",
"ui.test.workbenchCapability": "Capability: verstak/core/workbench/v1",
"ui.test.apiContract": "Соответствие контракту API",
"ui.pass": "✓ ПРОЙДЕНО",
"ui.fail": "✗ ОШИБКА",
"ui.noMouseEvents": "(события не зафиксированы)",
"ui.stopCapture": "■ Остановить запись",
"ui.startCapture": "▶ Начать запись",
"ui.clear": "Очистить",
"ui.copyJson": "Копировать JSON",
"ui.copied": "✓ Скопировано!",
"ui.mouseHint": "Записывает все события мыши и указателя в окне. Нажмите кнопки назад/вперёд, чтобы увидеть события WebKitGTK.",
"ui.eventsCount": "События:",
"ui.settingsInfo": "Панель настроек загружена из frontend bundle плагина через контракт VerstakPluginRegister.",
"ui.counterClicks": "нажатий (только в текущем сеансе)",
"ui.counterHint": "Это локальный демонстрационный счётчик. После обновления его значение сбрасывается.",
"ui.setting.autoRun": "Автозапуск при загрузке",
"ui.setting.verbose": "Подробный журнал",
"ui.setting.theme": "Переопределение темы",
"ui.setting.notifications": "Уведомления",
"ui.value.true": "да",
"ui.value.false": "нет",
"ui.value.inherit": "наследовать",
"ui.value.enabled": "включены",
"ui.settingsHint": "Для постоянных настроек используйте api.settings.read() / api.settings.write()."
}

View File

@ -5,6 +5,7 @@
"version": "0.1.0", "version": "0.1.0",
"apiVersion": "0.1.0", "apiVersion": "0.1.0",
"description": "Runtime test plugin for verifying the Verstak platform: manifest loading, capability registration, contribution injection, event bus, and UI rendering.", "description": "Runtime test plugin for verifying the Verstak platform: manifest loading, capability registration, contribution injection, event bus, and UI rendering.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official", "source": "official",
"icon": "flask", "icon": "flask",
"provides": [ "provides": [

View File

@ -315,6 +315,11 @@
var statusEl = null; var statusEl = null;
var alertEl = null; var alertEl = null;
var resultsEl = null; var resultsEl = null;
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
state.status = tr('ui.minChars', null, 'Enter at least 2 characters.');
function ensureLayout() { function ensureLayout() {
if (input) return; if (input) return;
@ -325,7 +330,7 @@
input = el('input', { input = el('input', {
className: 'search-input', className: 'search-input',
type: 'search', type: 'search',
placeholder: 'Search files, folders, text', placeholder: tr('ui.placeholder', null, 'Search files, folders, text'),
value: state.query, value: state.query,
'data-search-input': 'query', 'data-search-input': 'query',
onInput: function (event) { onInput: function (event) {
@ -341,7 +346,7 @@
containerEl.appendChild(el('div', { className: 'search-toolbar' }, [ containerEl.appendChild(el('div', { className: 'search-toolbar' }, [
input, input,
button, button,
el('span', { className: 'search-scope', title: rootPath || 'Vault' }, [rootPath || 'Vault']) el('span', { className: 'search-scope', title: rootPath || tr('ui.vault', null, 'Vault') }, [rootPath || tr('ui.vault', null, 'Vault')])
])); ]));
statusEl = el('div', { className: 'search-status' }); statusEl = el('div', { className: 'search-status' });
@ -357,7 +362,7 @@
if (document.activeElement !== input && input.value !== state.query) { if (document.activeElement !== input && input.value !== state.query) {
input.value = state.query; input.value = state.query;
} }
button.textContent = state.searching ? 'Searching...' : 'Search'; button.textContent = state.searching ? tr('ui.searching', null, 'Searching...') : tr('ui.search', null, 'Search');
button.disabled = !!state.searching; button.disabled = !!state.searching;
statusEl.className = 'search-status' + (state.error ? ' error' : ''); statusEl.className = 'search-status' + (state.error ? ' error' : '');
statusEl.textContent = state.error || state.status; statusEl.textContent = state.error || state.status;
@ -365,7 +370,7 @@
if (state.providerErrors && state.providerErrors.length) { if (state.providerErrors && state.providerErrors.length) {
if (typeof alertEl.removeAttribute === 'function') alertEl.removeAttribute('hidden'); if (typeof alertEl.removeAttribute === 'function') alertEl.removeAttribute('hidden');
alertEl.appendChild(el('details', {}, [ alertEl.appendChild(el('details', {}, [
el('summary', {}, ['Some plugin search providers did not respond']), el('summary', {}, [tr('ui.providersFailed', null, 'Some plugin search providers did not respond')]),
el('div', {}, [state.providerErrors.join('; ')]) el('div', {}, [state.providerErrors.join('; ')])
])); ]));
} else if (typeof alertEl.setAttribute === 'function') { } else if (typeof alertEl.setAttribute === 'function') {
@ -373,7 +378,7 @@
} }
resultsEl.innerHTML = ''; resultsEl.innerHTML = '';
if (!state.results.length) { if (!state.results.length) {
resultsEl.appendChild(el('div', { className: 'search-empty' }, [state.searching ? 'Searching...' : 'No results'])); resultsEl.appendChild(el('div', { className: 'search-empty' }, [state.searching ? tr('ui.searching', null, 'Searching...') : tr('ui.noResults', null, 'No results')]));
return; return;
} }
state.results.forEach(function (result) { state.results.forEach(function (result) {
@ -391,7 +396,7 @@
]), ]),
result.openable ? el('button', { result.openable ? el('button', {
className: 'search-btn search-open-btn', className: 'search-btn search-open-btn',
textContent: 'Open', textContent: tr('ui.open', null, 'Open'),
'data-search-open': result.path, 'data-search-open': result.path,
onClick: function () { onClick: function () {
api.workbench.openResource({ api.workbench.openResource({
@ -415,7 +420,7 @@
if (query.length < 2) { if (query.length < 2) {
state.searching = false; state.searching = false;
state.results = []; state.results = [];
state.status = 'Enter at least 2 characters.'; state.status = tr('ui.minChars', null, 'Enter at least 2 characters.');
state.error = ''; state.error = '';
state.providerErrors = []; state.providerErrors = [];
render(); render();
@ -432,7 +437,7 @@
state.query = String(state.query || '').trim(); state.query = String(state.query || '').trim();
if (state.query.length < 2) { if (state.query.length < 2) {
state.results = []; state.results = [];
state.status = 'Enter at least 2 characters.'; state.status = tr('ui.minChars', null, 'Enter at least 2 characters.');
state.error = ''; state.error = '';
state.providerErrors = []; state.providerErrors = [];
render(); render();
@ -441,7 +446,7 @@
state.searching = true; state.searching = true;
state.error = ''; state.error = '';
state.providerErrors = []; state.providerErrors = [];
state.status = 'Searching...'; state.status = tr('ui.searching', null, 'Searching...');
var seq = searchSeq + 1; var seq = searchSeq + 1;
searchSeq = seq; searchSeq = seq;
render(); render();
@ -451,7 +456,7 @@
var external = await runExternalProviders(api, rootPath, state.query, MAX_RESULTS - results.length); var external = await runExternalProviders(api, rootPath, state.query, MAX_RESULTS - results.length);
if (seq !== searchSeq) return; if (seq !== searchSeq) return;
state.results = results.concat(external.results); state.results = results.concat(external.results);
state.status = state.results.length + ' result' + (state.results.length === 1 ? '' : 's'); state.status = tr('ui.count', { count: state.results.length }, state.results.length + ' result' + (state.results.length === 1 ? '' : 's'));
state.providerErrors = external.errors; state.providerErrors = external.errors;
} catch (err) { } catch (err) {
if (seq !== searchSeq) return; if (seq !== searchSeq) return;
@ -507,6 +512,12 @@
setupIntegrations(); setupIntegrations();
render(); render();
if (api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
cleanupFns.push(api.i18n.onDidChangeLocale(function () {
if (input) input.setAttribute('placeholder', tr('ui.placeholder', null, 'Search files, folders, text'));
render();
}));
}
containerEl.__verstakSearchCleanup = function () { containerEl.__verstakSearchCleanup = function () {
if (searchTimer) clearTimeout(searchTimer); if (searchTimer) clearTimeout(searchTimer);
searchSeq += 1; searchSeq += 1;

View File

@ -0,0 +1,16 @@
{
"manifest.name": "Search",
"manifest.description": "Workspace-scoped vault text search provider.",
"contributions.workspaceItems.verstak.search.workspace.title": "Search",
"contributions.commands.verstak.search.searchVaultText.title": "Search Vault Text",
"contributions.searchProviders.verstak.search.vault-text.label": "Vault Text Search",
"ui.minChars": "Enter at least 2 characters.",
"ui.placeholder": "Search files, folders, text",
"ui.vault": "Vault",
"ui.searching": "Searching...",
"ui.search": "Search",
"ui.providersFailed": "Some plugin search providers did not respond",
"ui.noResults": "No results",
"ui.open": "Open",
"ui.count": "{count} result(s)"
}

View File

@ -0,0 +1,16 @@
{
"manifest.name": "Поиск",
"manifest.description": "Поиск текста в хранилище в пределах рабочего пространства.",
"contributions.workspaceItems.verstak.search.workspace.title": "Поиск",
"contributions.commands.verstak.search.searchVaultText.title": "Искать текст в хранилище",
"contributions.searchProviders.verstak.search.vault-text.label": "Поиск по тексту хранилища",
"ui.minChars": "Введите не менее 2 символов.",
"ui.placeholder": "Поиск файлов, папок и текста",
"ui.vault": "Хранилище",
"ui.searching": "Поиск...",
"ui.search": "Искать",
"ui.providersFailed": "Некоторые поставщики поиска не ответили",
"ui.noResults": "Ничего не найдено",
"ui.open": "Открыть",
"ui.count": "Результатов: {count}"
}

View File

@ -5,6 +5,7 @@
"version": "0.1.0", "version": "0.1.0",
"apiVersion": "0.1.0", "apiVersion": "0.1.0",
"description": "Workspace-scoped vault text search provider.", "description": "Workspace-scoped vault text search provider.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official", "source": "official",
"icon": "search", "icon": "search",
"provides": [ "provides": [

View File

@ -150,6 +150,10 @@
var unlocked = false; var unlocked = false;
var statusText = ''; var statusText = '';
var statusError = false; var statusError = false;
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
function setStatus(message, isError) { function setStatus(message, isError) {
statusText = message || ''; statusText = message || '';
@ -162,13 +166,13 @@
className: 'secrets-input', className: 'secrets-input',
type: 'password', type: 'password',
'data-secret-master-password': '', 'data-secret-master-password': '',
placeholder: 'Master password' placeholder: tr('ui.masterPassword', null, 'Master password')
}); });
var confirmInput = initialized ? null : el('input', { var confirmInput = initialized ? null : el('input', {
className: 'secrets-input', className: 'secrets-input',
type: 'password', type: 'password',
'data-secret-master-password-confirm': '', 'data-secret-master-password-confirm': '',
placeholder: 'Repeat master password' placeholder: tr('ui.repeatPassword', null, 'Repeat master password')
}); });
var unlockBtn = el('button', { var unlockBtn = el('button', {
className: 'secrets-btn primary', className: 'secrets-btn primary',
@ -176,7 +180,7 @@
'data-secret-unlock': '', 'data-secret-unlock': '',
onClick: function () { onClick: function () {
if (!initialized && passwordInput.value !== confirmInput.value) { if (!initialized && passwordInput.value !== confirmInput.value) {
setStatus('Master passwords do not match', true); setStatus(tr('ui.passwordMismatch', null, 'Master passwords do not match'), true);
return; return;
} }
unlockBtn.disabled = true; unlockBtn.disabled = true;
@ -189,17 +193,17 @@
setStatus((err && err.message) ? err.message : String(err), true); setStatus((err && err.message) ? err.message : String(err), true);
}); });
} }
}, ['Unlock']); }, [tr('ui.unlock', null, 'Unlock')]);
if (!initialized) unlockBtn.textContent = 'Create master password'; if (!initialized) unlockBtn.textContent = tr('ui.createMaster', null, 'Create master password');
var rows = [ var rows = [
el('div', { className: 'secrets-row' }, [ el('div', { className: 'secrets-row' }, [
el('label', { className: 'secrets-label' }, ['Password']), el('label', { className: 'secrets-label' }, [tr('ui.password', null, 'Password')]),
passwordInput passwordInput
]) ])
]; ];
if (confirmInput) { if (confirmInput) {
rows.push(el('div', { className: 'secrets-row' }, [ rows.push(el('div', { className: 'secrets-row' }, [
el('label', { className: 'secrets-label' }, ['Repeat']), el('label', { className: 'secrets-label' }, [tr('ui.repeat', null, 'Repeat')]),
confirmInput confirmInput
])); ]));
} }
@ -209,12 +213,12 @@
containerEl.appendChild(el('div', { className: 'secrets-root' }, [ containerEl.appendChild(el('div', { className: 'secrets-root' }, [
el('div', { className: 'secrets-panel' }, [ el('div', { className: 'secrets-panel' }, [
el('div', { className: 'secrets-toolbar' }, [ el('div', { className: 'secrets-toolbar' }, [
el('span', { className: 'secrets-title' }, ['Secrets']) el('span', { className: 'secrets-title' }, [tr('ui.title', null, 'Secrets')])
]) ])
]), ]),
el('div', { className: 'secrets-main' }, [ el('div', { className: 'secrets-main' }, [
el('div', { className: 'secrets-card' }, [ el('div', { className: 'secrets-card' }, [
el('h2', {}, [initialized ? 'Unlock secrets' : 'Create master password']), el('h2', {}, [initialized ? tr('ui.unlockSecrets', null, 'Unlock secrets') : tr('ui.createMaster', null, 'Create master password')]),
el('div', { className: 'secrets-form' }, rows) el('div', { className: 'secrets-form' }, rows)
]) ])
]) ])
@ -224,14 +228,14 @@
function renderList() { function renderList() {
var children = [ var children = [
el('div', { className: 'secrets-toolbar' }, [ el('div', { className: 'secrets-toolbar' }, [
el('span', { className: 'secrets-title' }, ['Secrets']), el('span', { className: 'secrets-title' }, [tr('ui.title', null, 'Secrets')]),
el('span', { className: 'secrets-count' }, [String(records.length)]), el('span', { className: 'secrets-count' }, [String(records.length)]),
el('span', { className: 'secrets-spacer' }), el('span', { className: 'secrets-spacer' }),
el('button', { className: 'secrets-btn', type: 'button', onClick: showNewSecret }, ['New']) el('button', { className: 'secrets-btn', type: 'button', onClick: showNewSecret }, [tr('ui.new', null, 'New')])
]) ])
]; ];
if (!records.length) { if (!records.length) {
children.push(el('div', { className: 'secrets-empty' }, ['No secrets'])); children.push(el('div', { className: 'secrets-empty' }, [tr('ui.empty', null, 'No secrets')]));
return children; return children;
} }
groupRecords(records).forEach(function (group) { groupRecords(records).forEach(function (group) {
@ -255,17 +259,17 @@
function renderSelected() { function renderSelected() {
if (!selectedRecord) return el('div', { className: 'secrets-card' }, [ if (!selectedRecord) return el('div', { className: 'secrets-card' }, [
el('h2', {}, ['Select a secret']) el('h2', {}, [tr('ui.select', null, 'Select a secret')])
]); ]);
return el('div', { className: 'secrets-card' }, [ return el('div', { className: 'secrets-card' }, [
el('h2', {}, [selectedRecord.title || selectedRecord.id]), el('h2', {}, [selectedRecord.title || selectedRecord.id]),
el('table', { className: 'secrets-table' }, [ el('table', { className: 'secrets-table' }, [
el('tbody', {}, [ el('tbody', {}, [
fieldRow('Group', scopeLabel(selectedRecord)), fieldRow(tr('ui.group', null, 'Group'), scopeLabel(selectedRecord)),
fieldRow('ID', selectedRecord.id), fieldRow('ID', selectedRecord.id),
fieldRow('Username', selectedRecord.username || ''), fieldRow(tr('ui.username', null, 'Username'), selectedRecord.username || ''),
fieldRow('Password', selectedValue ? selectedValue : '••••••••••••', selectedValue ? '' : 'secrets-hidden-value'), fieldRow(tr('ui.password', null, 'Password'), selectedValue ? selectedValue : '••••••••••••', selectedValue ? '' : 'secrets-hidden-value'),
fieldRow('Updated', selectedRecord.updatedAt || '') fieldRow(tr('ui.updated', null, 'Updated'), selectedRecord.updatedAt || '')
]) ])
]), ]),
el('div', { className: 'secrets-actions' }, [ el('div', { className: 'secrets-actions' }, [
@ -274,19 +278,19 @@
type: 'button', type: 'button',
'data-secret-copy-link': selectedRecord.id, 'data-secret-copy-link': selectedRecord.id,
onClick: function () { copySecretLink(selectedRecord.id); } onClick: function () { copySecretLink(selectedRecord.id); }
}, ['Copy secret link']), }, [tr('ui.copyLink', null, 'Copy secret link')]),
el('button', { el('button', {
className: 'secrets-btn', className: 'secrets-btn',
type: 'button', type: 'button',
'data-secret-edit': selectedRecord.id, 'data-secret-edit': selectedRecord.id,
onClick: function () { showEditSecret(); } onClick: function () { showEditSecret(); }
}, ['Edit']), }, [tr('ui.edit', null, 'Edit')]),
el('button', { el('button', {
className: 'secrets-btn danger', className: 'secrets-btn danger',
type: 'button', type: 'button',
'data-secret-delete': selectedRecord.id, 'data-secret-delete': selectedRecord.id,
onClick: function () { deleteSecret(selectedRecord.id); } onClick: function () { deleteSecret(selectedRecord.id); }
}, ['Delete']) }, [tr('ui.delete', null, 'Delete')])
]), ]),
el('div', { className: statusError ? 'secrets-status error' : 'secrets-status' }, [statusText]) el('div', { className: statusError ? 'secrets-status error' : 'secrets-status' }, [statusText])
]); ]);
@ -301,28 +305,28 @@
function renderSecretForm(existing) { function renderSecretForm(existing) {
var isEdit = !!existing; var isEdit = !!existing;
var title = el('input', { className: 'secrets-input', type: 'text', 'data-secret-title': '', placeholder: 'Title' }); var title = el('input', { className: 'secrets-input', type: 'text', 'data-secret-title': '', placeholder: tr('ui.fieldTitle', null, 'Title') });
title.value = existing ? text(existing.title) : ''; title.value = existing ? text(existing.title) : '';
var id = el('input', { className: 'secrets-input', type: 'text', placeholder: 'stable.id' }); var id = el('input', { className: 'secrets-input', type: 'text', placeholder: 'stable.id' });
id.value = existing ? text(existing.id) : ''; id.value = existing ? text(existing.id) : '';
id.disabled = isEdit; id.disabled = isEdit;
var username = el('input', { className: 'secrets-input', type: 'text', placeholder: 'optional username' }); var username = el('input', { className: 'secrets-input', type: 'text', placeholder: tr('ui.optionalUsername', null, 'optional username') });
username.value = existing ? text(existing.username) : ''; username.value = existing ? text(existing.username) : '';
var value = el('textarea', { className: 'secrets-textarea', 'data-secret-value': '', placeholder: 'Secret value' }); var value = el('textarea', { className: 'secrets-textarea', 'data-secret-value': '', placeholder: tr('ui.secretValue', null, 'Secret value') });
value.value = isEdit ? selectedValue : ''; value.value = isEdit ? selectedValue : '';
var scope = el('select', { className: 'secrets-select' }, [ var scope = el('select', { className: 'secrets-select' }, [
el('option', { value: ScopeGlobal }, ['Global']), el('option', { value: ScopeGlobal }, [tr('ui.global', null, 'Global')]),
el('option', { value: ScopeWorkspace }, [workspaceRoot || 'Workspace']) el('option', { value: ScopeWorkspace }, [workspaceRoot || tr('ui.workspace', null, 'Workspace')])
]); ]);
scope.value = existing && existing.scope && existing.scope.kind ? existing.scope.kind : (workspaceRoot ? ScopeWorkspace : ScopeGlobal); scope.value = existing && existing.scope && existing.scope.kind ? existing.scope.kind : (workspaceRoot ? ScopeWorkspace : ScopeGlobal);
return el('div', { className: 'secrets-card' }, [ return el('div', { className: 'secrets-card' }, [
el('h2', {}, [isEdit ? 'Edit secret' : 'New secret']), el('h2', {}, [isEdit ? tr('ui.editSecret', null, 'Edit secret') : tr('ui.newSecret', null, 'New secret')]),
el('div', { className: 'secrets-form' }, [ el('div', { className: 'secrets-form' }, [
el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, ['Title']), title]), el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, [tr('ui.fieldTitle', null, 'Title')]), title]),
el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, ['ID']), id]), el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, ['ID']), id]),
el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, ['Username']), username]), el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, [tr('ui.username', null, 'Username')]), username]),
el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, ['Scope']), scope]), el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, [tr('ui.scope', null, 'Scope')]), scope]),
el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, ['Value']), value]), el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, [tr('ui.value', null, 'Value')]), value]),
el('div', { className: 'secrets-actions' }, [ el('div', { className: 'secrets-actions' }, [
el('button', { el('button', {
className: 'secrets-btn primary', className: 'secrets-btn primary',
@ -345,8 +349,8 @@
setStatus((err && err.message) ? err.message : String(err), true); setStatus((err && err.message) ? err.message : String(err), true);
}); });
} }
}, ['Save']), }, [tr('ui.save', null, 'Save')]),
el('button', { className: 'secrets-btn', type: 'button', onClick: function () { mode = 'selected'; render(); } }, ['Cancel']) el('button', { className: 'secrets-btn', type: 'button', onClick: function () { mode = 'selected'; render(); } }, [tr('ui.cancel', null, 'Cancel')])
]), ]),
el('div', { className: statusError ? 'secrets-status error' : 'secrets-status' }, [statusText]) el('div', { className: statusError ? 'secrets-status error' : 'secrets-status' }, [statusText])
]) ])
@ -419,7 +423,7 @@
} }
function deleteSecret(id) { function deleteSecret(id) {
if (!id || !window.confirm('Delete this secret?')) return; if (!id || !window.confirm(tr('ui.deleteConfirm', null, 'Delete this secret?'))) return;
api.secrets.delete(id).then(function () { api.secrets.delete(id).then(function () {
selectedID = ''; selectedID = '';
selectedRecord = null; selectedRecord = null;
@ -433,7 +437,7 @@
function copySecretLink(id) { function copySecretLink(id) {
api.secrets.copyLink(id).then(function (link) { api.secrets.copyLink(id).then(function (link) {
return writeClipboard(api, link).then(function () { return writeClipboard(api, link).then(function () {
setStatus('Secret link copied', false); setStatus(tr('ui.linkCopied', null, 'Secret link copied'), false);
}); });
}).catch(function (err) { }).catch(function (err) {
setStatus((err && err.message) ? err.message : String(err), true); setStatus((err && err.message) ? err.message : String(err), true);
@ -451,8 +455,13 @@
renderLocked(); renderLocked();
}); });
var localeUnsubscribe = api.i18n && typeof api.i18n.onDidChangeLocale === 'function'
? api.i18n.onDidChangeLocale(render)
: null;
containerEl.__secretsCleanup = function () { containerEl.__secretsCleanup = function () {
disposed = true; disposed = true;
if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
}; };
}, },

View File

@ -0,0 +1,38 @@
{
"manifest.name": "Secrets",
"manifest.description": "Encrypted global and workspace-scoped secret manager.",
"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.unlock": "Unlock",
"ui.createMaster": "Create master password",
"ui.password": "Password",
"ui.repeat": "Repeat",
"ui.title": "Secrets",
"ui.unlockSecrets": "Unlock secrets",
"ui.new": "New",
"ui.empty": "No secrets",
"ui.select": "Select a secret",
"ui.group": "Group",
"ui.username": "Username",
"ui.updated": "Updated",
"ui.copyLink": "Copy secret link",
"ui.edit": "Edit",
"ui.delete": "Delete",
"ui.fieldTitle": "Title",
"ui.optionalUsername": "optional username",
"ui.secretValue": "Secret value",
"ui.global": "Global",
"ui.workspace": "Workspace",
"ui.editSecret": "Edit secret",
"ui.newSecret": "New secret",
"ui.scope": "Scope",
"ui.value": "Value",
"ui.save": "Save",
"ui.cancel": "Cancel",
"ui.deleteConfirm": "Delete this secret?",
"ui.linkCopied": "Secret link copied"
}

View File

@ -0,0 +1,38 @@
{
"manifest.name": "Секреты",
"manifest.description": "Зашифрованное хранилище общих секретов и секретов рабочих пространств.",
"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.unlock": "Разблокировать",
"ui.createMaster": "Создать мастер-пароль",
"ui.password": "Пароль",
"ui.repeat": "Повтор",
"ui.title": "Секреты",
"ui.unlockSecrets": "Разблокировать секреты",
"ui.new": "Новый",
"ui.empty": "Секретов нет",
"ui.select": "Выберите секрет",
"ui.group": "Группа",
"ui.username": "Имя пользователя",
"ui.updated": "Обновлён",
"ui.copyLink": "Копировать ссылку на секрет",
"ui.edit": "Изменить",
"ui.delete": "Удалить",
"ui.fieldTitle": "Название",
"ui.optionalUsername": "необязательное имя пользователя",
"ui.secretValue": "Значение секрета",
"ui.global": "Общий",
"ui.workspace": "Рабочее пространство",
"ui.editSecret": "Изменить секрет",
"ui.newSecret": "Новый секрет",
"ui.scope": "Область",
"ui.value": "Значение",
"ui.save": "Сохранить",
"ui.cancel": "Отмена",
"ui.deleteConfirm": "Удалить этот секрет?",
"ui.linkCopied": "Ссылка на секрет скопирована"
}

View File

@ -5,6 +5,7 @@
"version": "0.1.0", "version": "0.1.0",
"apiVersion": "0.1.0", "apiVersion": "0.1.0",
"description": "Encrypted global and workspace-scoped secret manager.", "description": "Encrypted global and workspace-scoped secret manager.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official", "source": "official",
"icon": "key-round", "icon": "key-round",
"provides": [ "provides": [

View File

@ -1,4 +1,6 @@
<script> <script>
import { onDestroy, onMount } from 'svelte'
export let api = null export let api = null
let settings = null let settings = null
@ -15,19 +17,26 @@
let password = '' let password = ''
let syncInterval = 5 let syncInterval = 5
let autoSync = false let autoSync = false
let locale = api?.i18n?.getLocale?.() || 'en'
let unsubscribeLocale
function tr(key, params, fallback) {
locale
return api?.i18n?.t?.(key, params, fallback) || fallback || key
}
const INPUT_STYLE = 'width:100%;background:#0f3460;border:1px solid #1a3a5c;color:#e0e0f0;padding:8px 10px;border-radius:4px;font-size:0.85rem;box-sizing:border-box;height:36px;' const INPUT_STYLE = 'width:100%;background:#0f3460;border:1px solid #1a3a5c;color:#e0e0f0;padding:8px 10px;border-radius:4px;font-size:0.85rem;box-sizing:border-box;height:36px;'
const INPUT_FOCUS_STYLE = INPUT_STYLE + 'outline:none;border-color:#4ecca3;' const INPUT_FOCUS_STYLE = INPUT_STYLE + 'outline:none;border-color:#4ecca3;'
function sanitizeError(msg) { function sanitizeError(msg) {
if (!msg) return 'Unknown error' if (!msg) return tr('ui.unknownError', null, 'Unknown error')
let s = String(msg).replace(/<[^>]+>/g, '') let s = String(msg).replace(/<[^>]+>/g, '')
if (s.length > 200) s = s.substring(0, 200) + '...' if (s.length > 200) s = s.substring(0, 200) + '...'
return s return s
} }
function syncAPI() { function syncAPI() {
if (!api?.sync) throw new Error('Plugin API sync namespace not available') if (!api?.sync) throw new Error(tr('ui.apiUnavailable', null, 'Plugin API sync namespace not available'))
return api.sync return api.sync
} }
@ -57,7 +66,7 @@
async function saveSettings() { async function saveSettings() {
if (syncInterval < 1 || syncInterval > 1440) { if (syncInterval < 1 || syncInterval > 1440) {
errorMsg = 'Sync interval must be between 1 and 1440 minutes.' errorMsg = tr('ui.intervalError', null, 'Sync interval must be between 1 and 1440 minutes.')
return return
} }
loading = true loading = true
@ -68,7 +77,7 @@
await api.settings.writeAll({ serverUrl, username, autoSync, syncInterval }) await api.settings.writeAll({ serverUrl, username, autoSync, syncInterval })
} }
await syncAPI().setInterval(autoSync ? syncInterval : 0) await syncAPI().setInterval(autoSync ? syncInterval : 0)
resultMsg = 'Settings saved.' resultMsg = tr('ui.settingsSaved', null, 'Settings saved.')
resultKind = '' resultKind = ''
} catch (e) { } catch (e) {
errorMsg = sanitizeError(e.message || e) errorMsg = sanitizeError(e.message || e)
@ -77,7 +86,7 @@
} }
async function testConnection() { async function testConnection() {
if (!serverUrl) { errorMsg = 'Server URL is required.'; return } if (!serverUrl) { errorMsg = tr('ui.serverRequired', null, 'Server URL is required.'); return }
loading = true loading = true
connectionResult = '' connectionResult = ''
connectionOk = null connectionOk = null
@ -85,22 +94,22 @@
try { try {
await syncAPI().testConnection(serverUrl, username, password) await syncAPI().testConnection(serverUrl, username, password)
connectionOk = true connectionOk = true
connectionResult = 'Connection successful.' connectionResult = tr('ui.connectionSuccessful', null, 'Connection successful.')
} catch (e) { } catch (e) {
connectionOk = false connectionOk = false
connectionResult = 'Connection failed: ' + sanitizeError(e.message || e) connectionResult = tr('ui.connectionFailed', { error: sanitizeError(e.message || e) }, 'Connection failed: {error}')
} }
loading = false loading = false
} }
async function configureSync() { async function configureSync() {
if (!serverUrl) { errorMsg = 'Server URL is required.'; return } if (!serverUrl) { errorMsg = tr('ui.serverRequired', null, 'Server URL is required.'); return }
loading = true loading = true
errorMsg = '' errorMsg = ''
connectionResult = '' connectionResult = ''
try { try {
await syncAPI().configure(serverUrl, username, password) await syncAPI().configure(serverUrl, username, password)
connectionResult = 'Connected successfully.' connectionResult = tr('ui.connectedSuccessfully', null, 'Connected successfully.')
connectionOk = true connectionOk = true
username = '' username = ''
password = '' password = ''
@ -115,8 +124,8 @@
const conflicts = Array.isArray(result?.conflicts) ? result.conflicts : [] const conflicts = Array.isArray(result?.conflicts) ? result.conflicts : []
const applyErrors = Array.isArray(result?.applyErrors) ? result.applyErrors : [] const applyErrors = Array.isArray(result?.applyErrors) ? result.applyErrors : []
const parts = [] const parts = []
if (conflicts.length > 0) parts.push(conflicts.length + ' conflict(s)') if (conflicts.length > 0) parts.push(tr('ui.conflictsCount', { count: conflicts.length }, '{count} conflict(s)'))
if (applyErrors.length > 0) parts.push(applyErrors.length + ' error(s)') if (applyErrors.length > 0) parts.push(tr('ui.errorsCount', { count: applyErrors.length }, '{count} error(s)'))
return parts.join(' · ') return parts.join(' · ')
} }
@ -146,7 +155,7 @@
conflictDetails = [] conflictDetails = []
try { try {
const r = await syncAPI().now() const r = await syncAPI().now()
const summary = 'Pushed ' + (r?.pushed || 0) + ', pulled ' + (r?.pulled || 0) const summary = tr('ui.syncSummary', { pushed: r?.pushed || 0, pulled: r?.pulled || 0 }, 'Pushed {pushed}, pulled {pulled}')
const warning = syncResultWarning(r) const warning = syncResultWarning(r)
const conflicts = Array.isArray(r?.conflicts) ? r.conflicts : [] const conflicts = Array.isArray(r?.conflicts) ? r.conflicts : []
conflictDetails = conflicts.slice(0, 5).map(formatSyncConflict) conflictDetails = conflicts.slice(0, 5).map(formatSyncConflict)
@ -167,7 +176,7 @@
function saveInterval() { function saveInterval() {
if (syncInterval < 1 || syncInterval > 1440) { if (syncInterval < 1 || syncInterval > 1440) {
errorMsg = 'Sync interval must be between 1 and 1440 minutes.' errorMsg = tr('ui.intervalError', null, 'Sync interval must be between 1 and 1440 minutes.')
return return
} }
autoSync = syncInterval > 0 autoSync = syncInterval > 0
@ -180,7 +189,7 @@
resultMsg = '' resultMsg = ''
try { try {
await syncAPI().disconnect() await syncAPI().disconnect()
resultMsg = 'Disconnected from server.' resultMsg = tr('ui.disconnected', null, 'Disconnected from server.')
resultKind = '' resultKind = ''
settings = null settings = null
await load() await load()
@ -196,7 +205,7 @@
resultMsg = '' resultMsg = ''
try { try {
await syncAPI().resetKey() await syncAPI().resetKey()
resultMsg = 'Sync key reset. Connect again to pair this device.' resultMsg = tr('ui.keyReset', null, 'Sync key reset. Connect again to pair this device.')
resultKind = '' resultKind = ''
await load() await load()
} catch (e) { } catch (e) {
@ -204,11 +213,19 @@
} }
loading = false loading = false
} }
onMount(() => {
unsubscribeLocale = api?.i18n?.onDidChangeLocale?.((nextLocale) => {
locale = nextLocale
})
})
onDestroy(() => unsubscribeLocale?.())
</script> </script>
<div style="padding:1.5rem;max-width:500px;"> <div style="padding:1.5rem;max-width:500px;">
<h2 style="margin:0 0 0.25rem;color:#e0e0f0;font-size:1.2rem;">Sync</h2> <h2 style="margin:0 0 0.25rem;color:#e0e0f0;font-size:1.2rem;">{tr('ui.title', null, 'Sync')}</h2>
<p style="color:#a0a0b8;font-size:0.85rem;margin-bottom:1.25rem;">Synchronize your vault across devices.</p> <p style="color:#a0a0b8;font-size:0.85rem;margin-bottom:1.25rem;">{tr('ui.description', null, 'Synchronize your vault across devices.')}</p>
{#if errorMsg} {#if errorMsg}
<div style="padding:0.5rem 0.75rem;margin-bottom:0.75rem;background:rgba(255,107,107,0.1);border:1px solid rgba(255,107,107,0.3);border-radius:6px;color:#ff6b6b;font-size:0.85rem;">{errorMsg}</div> <div style="padding:0.5rem 0.75rem;margin-bottom:0.75rem;background:rgba(255,107,107,0.1);border:1px solid rgba(255,107,107,0.3);border-radius:6px;color:#ff6b6b;font-size:0.85rem;">{errorMsg}</div>
@ -218,7 +235,7 @@
{/if} {/if}
{#if conflictDetails.length > 0 && !errorMsg} {#if conflictDetails.length > 0 && !errorMsg}
<div style="padding:0.5rem 0.75rem;margin-bottom:0.75rem;background:rgba(245,158,11,0.1);border:1px solid rgba(245,158,11,0.3);border-radius:6px;color:#f59e0b;font-size:0.85rem;"> <div style="padding:0.5rem 0.75rem;margin-bottom:0.75rem;background:rgba(245,158,11,0.1);border:1px solid rgba(245,158,11,0.3);border-radius:6px;color:#f59e0b;font-size:0.85rem;">
<div style="font-weight:600;margin-bottom:0.35rem;">Sync conflicts</div> <div style="font-weight:600;margin-bottom:0.35rem;">{tr('ui.syncConflicts', null, 'Sync conflicts')}</div>
{#each conflictDetails as detail} {#each conflictDetails as detail}
<div>{detail}</div> <div>{detail}</div>
{/each} {/each}
@ -226,31 +243,31 @@
{/if} {/if}
{#if settings && settings.lastError && !errorMsg} {#if settings && settings.lastError && !errorMsg}
<div style="padding:0.5rem 0.75rem;margin-bottom:0.75rem;background:rgba(255,107,107,0.1);border:1px solid rgba(255,107,107,0.3);border-radius:6px;color:#ff6b6b;font-size:0.85rem;"> <div style="padding:0.5rem 0.75rem;margin-bottom:0.75rem;background:rgba(255,107,107,0.1);border:1px solid rgba(255,107,107,0.3);border-radius:6px;color:#ff6b6b;font-size:0.85rem;">
Last sync error: {sanitizeError(settings.lastError)} {tr('ui.lastSyncError', { error: sanitizeError(settings.lastError) }, 'Last sync error: {error}')}
</div> </div>
{/if} {/if}
<div style="background:#16213e;border:1px solid #0f3460;border-radius:8px;padding:1rem 1.25rem;margin-bottom:1rem;"> <div style="background:#16213e;border:1px solid #0f3460;border-radius:8px;padding:1rem 1.25rem;margin-bottom:1rem;">
<h3 style="margin:0 0 0.75rem;color:#e0e0f0;font-size:0.95rem;">Server</h3> <h3 style="margin:0 0 0.75rem;color:#e0e0f0;font-size:0.95rem;">{tr('ui.server', null, 'Server')}</h3>
<div style="margin-bottom:0.75rem;"> <div style="margin-bottom:0.75rem;">
<label for="sync-server-url" style="display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem;">Server URL</label> <label for="sync-server-url" style="display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem;">{tr('ui.serverUrl', null, 'Server URL')}</label>
<input id="sync-server-url" type="text" style={INPUT_STYLE} on:focus={e => e.target.style.cssText = INPUT_FOCUS_STYLE} on:blur={e => e.target.style.cssText = INPUT_STYLE} bind:value={serverUrl} placeholder="https://example.com" /> <input id="sync-server-url" type="text" style={INPUT_STYLE} on:focus={e => e.target.style.cssText = INPUT_FOCUS_STYLE} on:blur={e => e.target.style.cssText = INPUT_STYLE} bind:value={serverUrl} placeholder="https://example.com" />
</div> </div>
<div style="margin-bottom:0.75rem;"> <div style="margin-bottom:0.75rem;">
<label for="sync-username" style="display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem;">Username</label> <label for="sync-username" style="display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem;">{tr('ui.username', null, 'Username')}</label>
<input id="sync-username" type="text" style={INPUT_STYLE} on:focus={e => e.target.style.cssText = INPUT_FOCUS_STYLE} on:blur={e => e.target.style.cssText = INPUT_STYLE} bind:value={username} /> <input id="sync-username" type="text" style={INPUT_STYLE} on:focus={e => e.target.style.cssText = INPUT_FOCUS_STYLE} on:blur={e => e.target.style.cssText = INPUT_STYLE} bind:value={username} />
</div> </div>
<div style="margin-bottom:0.75rem;"> <div style="margin-bottom:0.75rem;">
<label for="sync-password" style="display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem;">Password</label> <label for="sync-password" style="display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem;">{tr('ui.password', null, 'Password')}</label>
<input id="sync-password" type="password" style={INPUT_STYLE} on:focus={e => e.target.style.cssText = INPUT_FOCUS_STYLE} on:blur={e => e.target.style.cssText = INPUT_STYLE} bind:value={password} /> <input id="sync-password" type="password" style={INPUT_STYLE} on:focus={e => e.target.style.cssText = INPUT_FOCUS_STYLE} on:blur={e => e.target.style.cssText = INPUT_STYLE} bind:value={password} />
</div> </div>
<div style="display:flex;gap:0.5rem;margin-top:1rem;"> <div style="display:flex;gap:0.5rem;margin-top:1rem;">
<button style="background:#1a1a2e;color:#e0e0f0;border:1px solid #1a3a5c;padding:0.4rem 0.75rem;border-radius:4px;cursor:pointer;font-size:0.85rem;" on:click={testConnection} disabled={loading || !serverUrl}>Test Connection</button> <button style="background:#1a1a2e;color:#e0e0f0;border:1px solid #1a3a5c;padding:0.4rem 0.75rem;border-radius:4px;cursor:pointer;font-size:0.85rem;" on:click={testConnection} disabled={loading || !serverUrl}>{tr('ui.testConnection', null, 'Test Connection')}</button>
<button style="background:#4ecca3;color:#1a1a2e;border:1px solid #4ecca3;padding:0.4rem 0.75rem;border-radius:4px;cursor:pointer;font-size:0.85rem;font-weight:600;" on:click={configureSync} disabled={loading || !serverUrl}>Connect</button> <button style="background:#4ecca3;color:#1a1a2e;border:1px solid #4ecca3;padding:0.4rem 0.75rem;border-radius:4px;cursor:pointer;font-size:0.85rem;font-weight:600;" on:click={configureSync} disabled={loading || !serverUrl}>{tr('ui.connect', null, 'Connect')}</button>
</div> </div>
{#if connectionResult} {#if connectionResult}
@ -259,34 +276,34 @@
</div> </div>
<div style="background:#16213e;border:1px solid #0f3460;border-radius:8px;padding:1rem 1.25rem;margin-bottom:1rem;"> <div style="background:#16213e;border:1px solid #0f3460;border-radius:8px;padding:1rem 1.25rem;margin-bottom:1rem;">
<h3 style="margin:0 0 0.75rem;color:#e0e0f0;font-size:0.95rem;">Sync Behavior</h3> <h3 style="margin:0 0 0.75rem;color:#e0e0f0;font-size:0.95rem;">{tr('ui.behavior', null, 'Sync Behavior')}</h3>
<div style="margin-bottom:0.75rem;display:flex;align-items:center;gap:0.5rem;"> <div style="margin-bottom:0.75rem;display:flex;align-items:center;gap:0.5rem;">
<input type="checkbox" id="auto-sync" bind:checked={autoSync} on:change={toggleAutoSync} style="width:16px;height:16px;accent-color:#4ecca3;" /> <input type="checkbox" id="auto-sync" bind:checked={autoSync} on:change={toggleAutoSync} style="width:16px;height:16px;accent-color:#4ecca3;" />
<label for="auto-sync" style="color:#e0e0f0;font-size:0.9rem;cursor:pointer;">Enable auto-sync</label> <label for="auto-sync" style="color:#e0e0f0;font-size:0.9rem;cursor:pointer;">{tr('ui.enableAutoSync', null, 'Enable auto-sync')}</label>
</div> </div>
<div style="margin-bottom:0.75rem;"> <div style="margin-bottom:0.75rem;">
<label for="sync-interval" style="display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem;">Sync interval</label> <label for="sync-interval" style="display:block;color:#a0a0b8;font-size:0.85rem;margin-bottom:0.35rem;">{tr('ui.interval', null, 'Sync interval')}</label>
<div style="display:flex;align-items:center;gap:0.5rem;"> <div style="display:flex;align-items:center;gap:0.5rem;">
<input id="sync-interval" type="number" min="1" max="1440" bind:value={syncInterval} on:change={saveInterval} style="width:100px;background:#0f3460;border:1px solid #1a3a5c;color:#e0e0f0;padding:8px 10px;border-radius:4px;font-size:0.85rem;height:36px;" /> <input id="sync-interval" type="number" min="1" max="1440" bind:value={syncInterval} on:change={saveInterval} style="width:100px;background:#0f3460;border:1px solid #1a3a5c;color:#e0e0f0;padding:8px 10px;border-radius:4px;font-size:0.85rem;height:36px;" />
<span style="color:#a0a0b8;font-size:0.85rem;">minutes</span> <span style="color:#a0a0b8;font-size:0.85rem;">{tr('ui.minutes', null, 'minutes')}</span>
</div> </div>
</div> </div>
{#if settings && settings.lastSyncAt} {#if settings && settings.lastSyncAt}
<div style="color:#a0a0b8;font-size:0.85rem;"> <div style="color:#a0a0b8;font-size:0.85rem;">
Last sync: {settings.lastSyncAt} {tr('ui.lastSync', { date: settings.lastSyncAt }, 'Last sync: {date}')}
</div> </div>
{/if} {/if}
</div> </div>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;"> <div style="display:flex;gap:0.5rem;flex-wrap:wrap;">
<button style="background:#4ecca3;color:#1a1a2e;border:1px solid #4ecca3;padding:0.4rem 0.75rem;border-radius:4px;cursor:pointer;font-size:0.85rem;font-weight:600;" on:click={saveSettings} disabled={loading}>Save</button> <button style="background:#4ecca3;color:#1a1a2e;border:1px solid #4ecca3;padding:0.4rem 0.75rem;border-radius:4px;cursor:pointer;font-size:0.85rem;font-weight:600;" on:click={saveSettings} disabled={loading}>{tr('ui.save', null, 'Save')}</button>
{#if settings && settings.configured} {#if settings && settings.configured}
<button style="background:#1a1a2e;color:#e0e0f0;border:1px solid #1a3a5c;padding:0.4rem 0.75rem;border-radius:4px;cursor:pointer;font-size:0.85rem;" on:click={runSyncNow} disabled={loading}>Sync Now</button> <button style="background:#1a1a2e;color:#e0e0f0;border:1px solid #1a3a5c;padding:0.4rem 0.75rem;border-radius:4px;cursor:pointer;font-size:0.85rem;" on:click={runSyncNow} disabled={loading}>{tr('ui.syncNow', null, 'Sync Now')}</button>
<button style="background:#1a1a2e;color:#e0e0f0;border:1px solid #1a3a5c;padding:0.4rem 0.75rem;border-radius:4px;cursor:pointer;font-size:0.85rem;" on:click={resetKey} disabled={loading}>Reset Key</button> <button style="background:#1a1a2e;color:#e0e0f0;border:1px solid #1a3a5c;padding:0.4rem 0.75rem;border-radius:4px;cursor:pointer;font-size:0.85rem;" on:click={resetKey} disabled={loading}>{tr('ui.resetKey', null, 'Reset Key')}</button>
<button style="background:#e94560;color:#fff;border:1px solid #e94560;padding:0.4rem 0.75rem;border-radius:4px;cursor:pointer;font-size:0.85rem;" on:click={doDisconnect} disabled={loading}>Disconnect</button> <button style="background:#e94560;color:#fff;border:1px solid #e94560;padding:0.4rem 0.75rem;border-radius:4px;cursor:pointer;font-size:0.85rem;" on:click={doDisconnect} disabled={loading}>{tr('ui.disconnect', null, 'Disconnect')}</button>
{/if} {/if}
</div> </div>
</div> </div>

View File

@ -5,6 +5,13 @@
let status = 'disabled' let status = 'disabled'
let interval let interval
let locale = api?.i18n?.getLocale?.() || 'en'
let unsubscribeLocale
function tr(key, params, fallback) {
locale
return api?.i18n?.t?.(key, params, fallback) || fallback || key
}
async function loadStatus() { async function loadStatus() {
try { try {
@ -32,11 +39,11 @@
function statusText() { function statusText() {
const labels = { const labels = {
connected: 'Connected', connected: tr('ui.status.connected', null, 'Connected'),
disconnected: 'Disconnected', disconnected: tr('ui.status.disconnected', null, 'Disconnected'),
disabled: 'Not configured', disabled: tr('ui.status.disabled', null, 'Not configured'),
error: 'Error', error: tr('ui.status.error', null, 'Error'),
revoked: 'Revoked', revoked: tr('ui.status.revoked', null, 'Revoked'),
} }
return labels[status] || status return labels[status] || status
} }
@ -50,14 +57,18 @@
onMount(() => { onMount(() => {
loadStatus() loadStatus()
interval = setInterval(loadStatus, 15000) interval = setInterval(loadStatus, 15000)
unsubscribeLocale = api?.i18n?.onDidChangeLocale?.((nextLocale) => {
locale = nextLocale
})
}) })
onDestroy(() => { onDestroy(() => {
if (interval) clearInterval(interval) if (interval) clearInterval(interval)
unsubscribeLocale?.()
}) })
</script> </script>
<button class="sync-status-bar" on:click={openSettings} title="Sync: {statusText()}"> <button class="sync-status-bar" on:click={openSettings} title="{tr('ui.title', null, 'Sync')}: {statusText()}">
<span class="status-dot" style="background: {statusColor()}"></span> <span class="status-dot" style="background: {statusColor()}"></span>
<span class="status-label">{statusText()}</span> <span class="status-label">{statusText()}</span>
</button> </button>

View File

@ -0,0 +1,42 @@
{
"manifest.name": "Sync",
"contributions.settingsPanels.verstak.sync.settings.title": "Sync",
"contributions.statusBarItems.verstak.sync.status.label": "Sync",
"ui.title": "Sync",
"ui.description": "Synchronize your vault across devices.",
"ui.unknownError": "Unknown error",
"ui.apiUnavailable": "Plugin API sync namespace not available",
"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.connectedSuccessfully": "Connected successfully.",
"ui.conflictsCount": "{count} conflict(s)",
"ui.errorsCount": "{count} error(s)",
"ui.syncSummary": "Pushed {pushed}, pulled {pulled}",
"ui.disconnected": "Disconnected from server.",
"ui.keyReset": "Sync key reset. Connect again to pair this device.",
"ui.syncConflicts": "Sync conflicts",
"ui.lastSyncError": "Last sync error: {error}",
"ui.server": "Server",
"ui.serverUrl": "Server URL",
"ui.username": "Username",
"ui.password": "Password",
"ui.testConnection": "Test Connection",
"ui.connect": "Connect",
"ui.behavior": "Sync Behavior",
"ui.enableAutoSync": "Enable auto-sync",
"ui.interval": "Sync interval",
"ui.minutes": "minutes",
"ui.lastSync": "Last sync: {date}",
"ui.save": "Save",
"ui.syncNow": "Sync Now",
"ui.resetKey": "Reset Key",
"ui.disconnect": "Disconnect",
"ui.status.connected": "Connected",
"ui.status.disconnected": "Disconnected",
"ui.status.disabled": "Not configured",
"ui.status.error": "Error",
"ui.status.revoked": "Revoked"
}

View File

@ -0,0 +1,42 @@
{
"manifest.name": "Синхронизация",
"contributions.settingsPanels.verstak.sync.settings.title": "Синхронизация",
"contributions.statusBarItems.verstak.sync.status.label": "Синхронизация",
"ui.title": "Синхронизация",
"ui.description": "Синхронизируйте хранилище между устройствами.",
"ui.unknownError": "Неизвестная ошибка",
"ui.apiUnavailable": "API синхронизации плагина недоступен",
"ui.intervalError": "Интервал синхронизации должен быть от 1 до 1440 минут.",
"ui.settingsSaved": "Настройки сохранены.",
"ui.serverRequired": "Укажите URL сервера.",
"ui.connectionSuccessful": "Подключение успешно.",
"ui.connectionFailed": "Не удалось подключиться: {error}",
"ui.connectedSuccessfully": "Подключение установлено.",
"ui.conflictsCount": "Конфликтов: {count}",
"ui.errorsCount": "Ошибок: {count}",
"ui.syncSummary": "Отправлено: {pushed}, получено: {pulled}",
"ui.disconnected": "Сервер отключён.",
"ui.keyReset": "Ключ синхронизации сброшен. Подключитесь снова, чтобы связать устройство.",
"ui.syncConflicts": "Конфликты синхронизации",
"ui.lastSyncError": "Ошибка последней синхронизации: {error}",
"ui.server": "Сервер",
"ui.serverUrl": "URL сервера",
"ui.username": "Имя пользователя",
"ui.password": "Пароль",
"ui.testConnection": "Проверить подключение",
"ui.connect": "Подключить",
"ui.behavior": "Параметры синхронизации",
"ui.enableAutoSync": "Включить автосинхронизацию",
"ui.interval": "Интервал синхронизации",
"ui.minutes": "минут",
"ui.lastSync": "Последняя синхронизация: {date}",
"ui.save": "Сохранить",
"ui.syncNow": "Синхронизировать",
"ui.resetKey": "Сбросить ключ",
"ui.disconnect": "Отключить",
"ui.status.connected": "Подключено",
"ui.status.disconnected": "Отключено",
"ui.status.disabled": "Не настроено",
"ui.status.error": "Ошибка",
"ui.status.revoked": "Доступ отозван"
}

View File

@ -4,6 +4,7 @@
"name": "Sync", "name": "Sync",
"version": "0.1.0", "version": "0.1.0",
"apiVersion": "0.1.0", "apiVersion": "0.1.0",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official", "source": "official",
"provides": ["verstak/sync/v1", "verstak/sync.status/v1"], "provides": ["verstak/sync/v1", "verstak/sync.status/v1"],
"requires": ["verstak/core/files/v1"], "requires": ["verstak/core/files/v1"],

View File

@ -215,8 +215,13 @@
var statusText = ''; var statusText = '';
var statusClass = ''; var statusClass = '';
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
var toolbar = el('div', { className: 'todo-toolbar' }); var toolbar = el('div', { className: 'todo-toolbar' });
var titleEl = el('span', { className: 'todo-title', textContent: scope.mode === 'global' ? 'Todos' : 'Todos · ' + scope.label }); var titleEl = el('span', { className: 'todo-title', textContent: scope.mode === 'global' ? tr('ui.title', null, 'Todos') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Todos · ' + scope.label) });
var countEl = el('span', { className: 'todo-count' }); var countEl = el('span', { className: 'todo-count' });
var statusEl = el('span', { className: 'todo-status' }); var statusEl = el('span', { className: 'todo-status' });
var filtersEl = el('div', { className: 'todo-filters' }); var filtersEl = el('div', { className: 'todo-filters' });
@ -228,10 +233,10 @@
render(); render();
} }
}, [ }, [
option('all', 'All statuses'), option('all', tr('ui.allStatuses', null, 'All statuses')),
option('open', 'Open'), option('open', tr('ui.status.open', null, 'Open')),
option('done', 'Done'), option('done', tr('ui.status.done', null, 'Done')),
option('cancelled', 'Cancelled') option('cancelled', tr('ui.status.cancelled', null, 'Cancelled'))
]); ]);
var workspaceFilterEl = el('select', { var workspaceFilterEl = el('select', {
className: 'todo-select', className: 'todo-select',
@ -249,14 +254,14 @@
render(); render();
} }
}, [ }, [
option('due', 'Sort by due date'), option('due', tr('ui.sort.due', null, 'Sort by due date')),
option('reminder', 'Sort by reminder'), option('reminder', tr('ui.sort.reminder', null, 'Sort by reminder')),
option('updated', 'Sort by updated') option('updated', tr('ui.sort.updated', null, 'Sort by updated'))
]); ]);
var searchInput = el('input', { var searchInput = el('input', {
className: 'todo-input search', className: 'todo-input search',
type: 'search', type: 'search',
placeholder: 'Search todos', placeholder: tr('ui.search', null, 'Search todos'),
'data-todo-filter': 'search', 'data-todo-filter': 'search',
onInput: function (event) { onInput: function (event) {
searchQuery = text(event.target.value).trim().toLowerCase(); searchQuery = text(event.target.value).trim().toLowerCase();
@ -267,7 +272,7 @@
className: 'todo-btn primary', className: 'todo-btn primary',
type: 'button', type: 'button',
'data-todo-action': 'add', 'data-todo-action': 'add',
textContent: 'Add Todo', textContent: tr('ui.add', null, 'Add Todo'),
onClick: function () { showTodoModal(null); } onClick: function () { showTodoModal(null); }
}); });
var listEl = el('div', { className: 'todo-list' }); var listEl = el('div', { className: 'todo-list' });
@ -309,8 +314,8 @@
function renderWorkspaceFilterOptions() { function renderWorkspaceFilterOptions() {
if (scope.mode !== 'global') return; if (scope.mode !== 'global') return;
workspaceFilterEl.innerHTML = ''; workspaceFilterEl.innerHTML = '';
workspaceFilterEl.appendChild(option('', 'All workspaces')); workspaceFilterEl.appendChild(option('', tr('ui.allWorkspaces', null, 'All workspaces')));
workspaceFilterEl.appendChild(option('__unassigned__', 'Unassigned')); workspaceFilterEl.appendChild(option('__unassigned__', tr('ui.unassigned', null, 'Unassigned')));
workspaceRoots().forEach(function (workspace) { workspaceRoots().forEach(function (workspace) {
workspaceFilterEl.appendChild(option(workspace, workspace)); workspaceFilterEl.appendChild(option(workspace, workspace));
}); });
@ -338,7 +343,7 @@
function persist() { function persist() {
if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve(); if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
return api.settings.write(GLOBAL_KEY, storageTodos(sortTodos(todos))).catch(function (err) { return api.settings.write(GLOBAL_KEY, storageTodos(sortTodos(todos))).catch(function (err) {
statusText = 'Could not save todos: ' + (err && err.message ? err.message : String(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'; statusClass = 'error';
}); });
} }
@ -348,7 +353,7 @@
return api.settings.read().then(function (settings) { return api.settings.read().then(function (settings) {
todos = sortTodos(normalizeTodos((settings || {})[GLOBAL_KEY])); todos = sortTodos(normalizeTodos((settings || {})[GLOBAL_KEY]));
}).catch(function (err) { }).catch(function (err) {
statusText = 'Could not load todos: ' + (err && err.message ? err.message : String(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'; statusClass = 'error';
}); });
} }
@ -375,10 +380,10 @@
function showTodoModal(existingTodo) { function showTodoModal(existingTodo) {
var editing = !!existingTodo; var editing = !!existingTodo;
var titleInput = el('input', { className: 'todo-input', type: 'text', value: editing ? existingTodo.title : '', placeholder: 'Todo title', 'data-todo-input': 'title' }); var titleInput = el('input', { className: 'todo-input', type: 'text', value: editing ? existingTodo.title : '', placeholder: tr('ui.titlePlaceholder', null, 'Todo title'), 'data-todo-input': 'title' });
var descriptionInput = el('textarea', { className: 'todo-input textarea', placeholder: 'Optional description', 'data-todo-input': 'description' }); var descriptionInput = el('textarea', { className: 'todo-input textarea', placeholder: tr('ui.descriptionPlaceholder', null, 'Optional description'), 'data-todo-input': 'description' });
descriptionInput.value = editing ? existingTodo.description : ''; descriptionInput.value = editing ? existingTodo.description : '';
var priorityInput = el('select', { className: 'todo-select', 'data-todo-input': 'priority' }, [option('low', 'Low'), option('normal', 'Normal'), option('high', 'High')]); var priorityInput = el('select', { className: 'todo-select', 'data-todo-input': 'priority' }, [option('low', tr('ui.priority.low', null, 'Low')), option('normal', tr('ui.priority.normal', null, 'Normal')), option('high', tr('ui.priority.high', null, 'High'))]);
priorityInput.value = editing ? existingTodo.priority : 'normal'; priorityInput.value = editing ? existingTodo.priority : 'normal';
var dueInput = el('input', { className: 'todo-input', type: 'date', value: editing ? existingTodo.dueAt : '', 'data-todo-input': 'dueAt' }); var dueInput = el('input', { className: 'todo-input', type: 'date', value: editing ? existingTodo.dueAt : '', 'data-todo-input': 'dueAt' });
var reminderInput = el('input', { className: 'todo-input', type: 'datetime-local', value: editing ? existingTodo.reminderAt : '', 'data-todo-input': 'reminderAt' }); var reminderInput = el('input', { className: 'todo-input', type: 'datetime-local', value: editing ? existingTodo.reminderAt : '', 'data-todo-input': 'reminderAt' });
@ -386,7 +391,7 @@
var workspace = editing ? existingTodo.workspaceRootPath : scope.workspaceRoot; var workspace = editing ? existingTodo.workspaceRootPath : scope.workspaceRoot;
if (scope.mode === 'global') { if (scope.mode === 'global') {
workspaceInput = el('select', { className: 'todo-select', 'data-todo-input': 'workspaceRootPath' }); workspaceInput = el('select', { className: 'todo-select', 'data-todo-input': 'workspaceRootPath' });
workspaceInput.appendChild(option('', 'Unassigned')); workspaceInput.appendChild(option('', tr('ui.unassigned', null, 'Unassigned')));
workspaceRoots().forEach(function (workspaceRoot) { workspaceRoots().forEach(function (workspaceRoot) {
workspaceInput.appendChild(option(workspaceRoot, workspaceRoot)); workspaceInput.appendChild(option(workspaceRoot, workspaceRoot));
}); });
@ -396,7 +401,7 @@
function saveTodo() { function saveTodo() {
var title = text(titleInput.value).trim(); var title = text(titleInput.value).trim();
if (!title) { if (!title) {
statusText = 'Title is required'; statusText = tr('ui.titleRequired', null, 'Title is required');
statusClass = 'error'; statusClass = 'error';
render(); render();
return; return;
@ -427,20 +432,20 @@
todos = sortTodos(todos); todos = sortTodos(todos);
if (workspaceRoot && workspaceOptions.indexOf(workspaceRoot) === -1) workspaceOptions.push(workspaceRoot); if (workspaceRoot && workspaceOptions.indexOf(workspaceRoot) === -1) workspaceOptions.push(workspaceRoot);
closeTodoModal(); closeTodoModal();
statusText = editing ? 'Todo updated' : 'Todo added'; statusText = editing ? tr('ui.updated', null, 'Todo updated') : tr('ui.added', null, 'Todo added');
statusClass = ''; statusClass = '';
persist().then(render); persist().then(render);
} }
var fields = [ var fields = [
el('label', { className: 'todo-field wide' }, ['Title', titleInput]), el('label', { className: 'todo-field wide' }, [tr('ui.field.title', null, 'Title'), titleInput]),
el('label', { className: 'todo-field wide' }, ['Description', descriptionInput]), el('label', { className: 'todo-field wide' }, [tr('ui.field.description', null, 'Description'), descriptionInput]),
el('label', { className: 'todo-field' }, ['Priority', priorityInput]), el('label', { className: 'todo-field' }, [tr('ui.field.priority', null, 'Priority'), priorityInput]),
el('label', { className: 'todo-field' }, ['Due date', dueInput]), el('label', { className: 'todo-field' }, [tr('ui.field.due', null, 'Due date'), dueInput]),
el('label', { className: 'todo-field' }, ['Reminder', reminderInput]) el('label', { className: 'todo-field' }, [tr('ui.field.reminder', null, 'Reminder'), reminderInput])
]; ];
if (workspaceInput) fields.push(el('label', { className: 'todo-field' }, ['Workspace', workspaceInput])); if (workspaceInput) fields.push(el('label', { className: 'todo-field' }, [tr('ui.field.workspace', null, 'Workspace'), workspaceInput]));
else fields.push(el('div', { className: 'todo-field', textContent: 'Workspace: ' + scope.workspaceRoot })); else fields.push(el('div', { className: 'todo-field', textContent: tr('ui.workspaceValue', { workspace: scope.workspaceRoot }, 'Workspace: ' + scope.workspaceRoot) }));
modalHost.innerHTML = ''; modalHost.innerHTML = '';
if (typeof modalHost.removeAttribute === 'function') modalHost.removeAttribute('hidden'); if (typeof modalHost.removeAttribute === 'function') modalHost.removeAttribute('hidden');
@ -448,11 +453,11 @@
if (event.target === event.currentTarget) closeTodoModal(); if (event.target === event.currentTarget) closeTodoModal();
} }, [ } }, [
el('div', { className: 'todo-modal' }, [ el('div', { className: 'todo-modal' }, [
el('div', { className: 'todo-modal-title', textContent: editing ? 'Edit Todo' : 'Add Todo' }), el('div', { className: 'todo-modal-title', textContent: editing ? tr('ui.edit', null, 'Edit Todo') : tr('ui.add', null, 'Add Todo') }),
el('div', { className: 'todo-form-grid' }, fields), el('div', { className: 'todo-form-grid' }, fields),
el('div', { className: 'todo-modal-actions' }, [ el('div', { className: 'todo-modal-actions' }, [
el('button', { className: 'todo-btn', type: 'button', textContent: 'Cancel', onClick: closeTodoModal }), el('button', { className: 'todo-btn', type: 'button', textContent: tr('ui.cancel', null, 'Cancel'), onClick: closeTodoModal }),
el('button', { className: 'todo-btn primary', type: 'button', 'data-todo-action': 'save', textContent: editing ? 'Save changes' : 'Add Todo', onClick: saveTodo }) el('button', { className: 'todo-btn primary', type: 'button', 'data-todo-action': 'save', textContent: editing ? tr('ui.saveChanges', null, 'Save changes') : tr('ui.add', null, 'Add Todo'), onClick: saveTodo })
]) ])
]) ])
])); ]));
@ -469,14 +474,14 @@
updatedAt: timestamp updatedAt: timestamp
}); });
}); });
statusText = nextStatus === 'done' ? 'Todo marked done' : (nextStatus === 'cancelled' ? 'Todo cancelled' : 'Todo reopened'); statusText = nextStatus === 'done' ? tr('ui.markedDone', null, 'Todo marked done') : (nextStatus === 'cancelled' ? tr('ui.cancelled', null, 'Todo cancelled') : tr('ui.reopened', null, 'Todo reopened'));
statusClass = ''; statusClass = '';
persist().then(render); persist().then(render);
} }
function deleteTodo(todo) { function deleteTodo(todo) {
todos = todos.filter(function (item) { return item.id !== todo.id; }); todos = todos.filter(function (item) { return item.id !== todo.id; });
statusText = 'Todo deleted'; statusText = tr('ui.deleted', null, 'Todo deleted');
statusClass = ''; statusClass = '';
persist().then(render); persist().then(render);
} }
@ -513,11 +518,11 @@
var workspace = cleanWorkspace(todo.workspaceRootPath); var workspace = cleanWorkspace(todo.workspaceRootPath);
var due = dueState(todo); var due = dueState(todo);
var reminderDue = reminderIsDue(todo); var reminderDue = reminderIsDue(todo);
if (scope.mode === 'global') meta.push(el('span', { className: 'todo-badge', textContent: workspace || 'Unassigned' })); if (scope.mode === 'global') meta.push(el('span', { className: 'todo-badge', textContent: workspace || tr('ui.unassigned', null, 'Unassigned') }));
meta.push(el('span', { className: 'todo-badge ' + todo.priority, textContent: todo.priority + ' priority' })); meta.push(el('span', { className: 'todo-badge ' + todo.priority, textContent: tr('ui.priorityValue', { priority: tr('ui.priority.' + todo.priority, null, todo.priority) }, todo.priority + ' priority') }));
meta.push(el('span', { className: 'todo-badge', textContent: todo.status })); meta.push(el('span', { className: 'todo-badge', textContent: tr('ui.status.' + todo.status, null, todo.status) }));
if (todo.dueAt) meta.push(el('span', { className: 'todo-badge ' + due, textContent: (due === 'overdue' ? 'Overdue · ' : (due === 'due-soon' ? 'Due soon · ' : '')) + 'Due ' + formatDate(todo.dueAt) })); if (todo.dueAt) meta.push(el('span', { className: 'todo-badge ' + due, textContent: tr('ui.dueValue', { prefix: due === 'overdue' ? tr('ui.overduePrefix', null, 'Overdue · ') : (due === 'due-soon' ? tr('ui.dueSoonPrefix', null, 'Due soon · ') : ''), date: formatDate(todo.dueAt) }, (due === 'overdue' ? 'Overdue · ' : (due === 'due-soon' ? 'Due soon · ' : '')) + 'Due ' + formatDate(todo.dueAt)) }));
if (todo.reminderAt) meta.push(el('span', { className: 'todo-badge ' + (reminderDue ? 'reminder-due' : ''), textContent: (reminderDue ? 'Reminder due ' : 'Reminder ') + formatDate(todo.reminderAt) })); if (todo.reminderAt) meta.push(el('span', { className: 'todo-badge ' + (reminderDue ? 'reminder-due' : ''), textContent: tr(reminderDue ? 'ui.reminderDueValue' : 'ui.reminderValue', { date: formatDate(todo.reminderAt) }, (reminderDue ? 'Reminder due ' : 'Reminder ') + formatDate(todo.reminderAt)) }));
return el('div', { className: 'todo-row-meta' }, meta); return el('div', { className: 'todo-row-meta' }, meta);
} }
@ -527,30 +532,30 @@
if (!visible.length) { if (!visible.length) {
listEl.appendChild(el('div', { listEl.appendChild(el('div', {
className: 'todo-empty', className: 'todo-empty',
textContent: todos.length ? 'No todos match the current filters.' : 'No todos yet.' textContent: todos.length ? tr('ui.noMatches', null, 'No todos match the current filters.') : tr('ui.empty', null, 'No todos yet.')
})); }));
return; return;
} }
visible.forEach(function (todo) { visible.forEach(function (todo) {
var actionButtons = []; var actionButtons = [];
if (scope.mode === 'global' && todo.workspaceRootPath) { if (scope.mode === 'global' && todo.workspaceRootPath) {
actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'open-workspace', textContent: 'Open workspace', onClick: function () { openWorkspace(todo); } })); actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'open-workspace', textContent: tr('ui.openWorkspace', null, 'Open workspace'), onClick: function () { openWorkspace(todo); } }));
} }
if (todo.status === 'open') { if (todo.status === 'open') {
actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'mark-done', textContent: 'Done', onClick: function () { setTodoStatus(todo, 'done'); } })); actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'mark-done', textContent: tr('ui.status.done', null, 'Done'), onClick: function () { setTodoStatus(todo, 'done'); } }));
actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'cancel', textContent: 'Cancel', onClick: function () { setTodoStatus(todo, 'cancelled'); } })); actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'cancel', textContent: tr('ui.cancel', null, 'Cancel'), onClick: function () { setTodoStatus(todo, 'cancelled'); } }));
} else { } else {
actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'reopen', textContent: 'Reopen', onClick: function () { setTodoStatus(todo, 'open'); } })); actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'reopen', textContent: tr('ui.reopen', null, 'Reopen'), onClick: function () { setTodoStatus(todo, 'open'); } }));
} }
if (scope.mode === 'workspace' && todo.status === 'done') { if (scope.mode === 'workspace' && todo.status === 'done') {
actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'create-journal-entry', textContent: 'Create Journal Entry', onClick: function () { createJournalEntry(todo); } })); actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'create-journal-entry', textContent: tr('ui.createJournal', null, 'Create Journal Entry'), onClick: function () { createJournalEntry(todo); } }));
} }
actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'edit', textContent: 'Edit', onClick: function () { showTodoModal(todo); } })); actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'edit', textContent: tr('ui.editAction', null, 'Edit'), onClick: function () { showTodoModal(todo); } }));
actionButtons.push(el('button', { className: 'todo-btn danger', type: 'button', 'data-todo-action': 'delete', textContent: 'Delete', onClick: function () { deleteTodo(todo); } })); actionButtons.push(el('button', { className: 'todo-btn danger', type: 'button', 'data-todo-action': 'delete', textContent: tr('ui.delete', null, 'Delete'), onClick: function () { deleteTodo(todo); } }));
listEl.appendChild(el('div', { className: 'todo-row' + (todo.status === 'done' ? ' done' : ''), 'data-todo-id': todo.id }, [ listEl.appendChild(el('div', { className: 'todo-row' + (todo.status === 'done' ? ' done' : ''), 'data-todo-id': todo.id }, [
el('div', { className: 'todo-row-main' }, [ el('div', { className: 'todo-row-main' }, [
el('div', { className: 'todo-row-head' }, [ el('div', { className: 'todo-row-head' }, [
el('div', { className: 'todo-row-title', textContent: todo.title || 'Untitled todo' }) el('div', { className: 'todo-row-title', textContent: todo.title || tr('ui.untitled', null, 'Untitled todo') })
]), ]),
todo.description ? el('div', { className: 'todo-row-description', textContent: todo.description }) : null, todo.description ? el('div', { className: 'todo-row-description', textContent: todo.description }) : null,
renderTodoMeta(todo) renderTodoMeta(todo)
@ -563,8 +568,8 @@
function render() { function render() {
var visible = visibleTodos(); var visible = visibleTodos();
countEl.textContent = visible.length === todos.length countEl.textContent = visible.length === todos.length
? todos.length + ' todo' + (todos.length === 1 ? '' : 's') ? tr('ui.count', { count: todos.length }, todos.length + ' todo' + (todos.length === 1 ? '' : 's'))
: visible.length + ' of ' + todos.length + ' todos'; : tr('ui.filteredCount', { visible: visible.length, count: todos.length }, visible.length + ' of ' + todos.length + ' todos');
statusFilterEl.value = statusFilter; statusFilterEl.value = statusFilter;
sortEl.value = sortMode; sortEl.value = sortMode;
searchInput.value = searchQuery; searchInput.value = searchQuery;
@ -578,6 +583,14 @@
Promise.all([loadStored(), loadWorkspaceOptions()]).then(function () { Promise.all([loadStored(), loadWorkspaceOptions()]).then(function () {
render(); render();
}); });
if (api && api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
api.i18n.onDidChangeLocale(function () {
titleEl.textContent = scope.mode === 'global' ? tr('ui.title', null, 'Todos') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Todos · ' + scope.label);
searchInput.setAttribute('placeholder', tr('ui.search', null, 'Search todos'));
addBtn.textContent = tr('ui.add', null, 'Add Todo');
render();
});
}
}; };
TodoView.unmount = function (containerEl) { TodoView.unmount = function (containerEl) {

View File

@ -0,0 +1,60 @@
{
"manifest.name": "Todos",
"manifest.description": "Global and workspace todo tracking with due and reminder metadata.",
"contributions.views.verstak.todo.view.title": "Todos",
"contributions.sidebarItems.verstak.todo.sidebar.title": "Todos",
"contributions.workspaceItems.verstak.todo.workspace.title": "Todos",
"ui.title": "Todos",
"ui.workspaceTitle": "Todos · {workspace}",
"ui.allStatuses": "All statuses",
"ui.status.open": "Open",
"ui.status.done": "Done",
"ui.status.cancelled": "Cancelled",
"ui.sort.due": "Sort by due date",
"ui.sort.reminder": "Sort by reminder",
"ui.sort.updated": "Sort by updated",
"ui.search": "Search todos",
"ui.add": "Add Todo",
"ui.allWorkspaces": "All workspaces",
"ui.unassigned": "Unassigned",
"ui.saveError": "Could not save todos: {error}",
"ui.loadError": "Could not load todos: {error}",
"ui.titlePlaceholder": "Todo title",
"ui.descriptionPlaceholder": "Optional description",
"ui.priority.low": "Low",
"ui.priority.normal": "Normal",
"ui.priority.high": "High",
"ui.titleRequired": "Title is required",
"ui.updated": "Todo updated",
"ui.added": "Todo added",
"ui.field.title": "Title",
"ui.field.description": "Description",
"ui.field.priority": "Priority",
"ui.field.due": "Due date",
"ui.field.reminder": "Reminder",
"ui.field.workspace": "Workspace",
"ui.workspaceValue": "Workspace: {workspace}",
"ui.edit": "Edit Todo",
"ui.cancel": "Cancel",
"ui.saveChanges": "Save changes",
"ui.markedDone": "Todo marked done",
"ui.cancelled": "Todo cancelled",
"ui.reopened": "Todo reopened",
"ui.deleted": "Todo deleted",
"ui.priorityValue": "{priority} priority",
"ui.overduePrefix": "Overdue · ",
"ui.dueSoonPrefix": "Due soon · ",
"ui.dueValue": "{prefix}Due {date}",
"ui.reminderDueValue": "Reminder due {date}",
"ui.reminderValue": "Reminder {date}",
"ui.noMatches": "No todos match the current filters.",
"ui.empty": "No todos yet.",
"ui.openWorkspace": "Open workspace",
"ui.reopen": "Reopen",
"ui.createJournal": "Create Journal Entry",
"ui.editAction": "Edit",
"ui.delete": "Delete",
"ui.untitled": "Untitled todo",
"ui.count": "{count} todo(s)",
"ui.filteredCount": "{visible} of {count} todos"
}

View File

@ -0,0 +1,60 @@
{
"manifest.name": "Задачи",
"manifest.description": "Общие задачи и задачи рабочих пространств со сроками и напоминаниями.",
"contributions.views.verstak.todo.view.title": "Задачи",
"contributions.sidebarItems.verstak.todo.sidebar.title": "Задачи",
"contributions.workspaceItems.verstak.todo.workspace.title": "Задачи",
"ui.title": "Задачи",
"ui.workspaceTitle": "Задачи · {workspace}",
"ui.allStatuses": "Все состояния",
"ui.status.open": "Открыта",
"ui.status.done": "Выполнена",
"ui.status.cancelled": "Отменена",
"ui.sort.due": "Сортировать по сроку",
"ui.sort.reminder": "Сортировать по напоминанию",
"ui.sort.updated": "Сортировать по обновлению",
"ui.search": "Поиск задач",
"ui.add": "Добавить задачу",
"ui.allWorkspaces": "Все рабочие пространства",
"ui.unassigned": "Не назначено",
"ui.saveError": "Не удалось сохранить задачи: {error}",
"ui.loadError": "Не удалось загрузить задачи: {error}",
"ui.titlePlaceholder": "Название задачи",
"ui.descriptionPlaceholder": "Необязательное описание",
"ui.priority.low": "Низкий",
"ui.priority.normal": "Обычный",
"ui.priority.high": "Высокий",
"ui.titleRequired": "Введите название",
"ui.updated": "Задача обновлена",
"ui.added": "Задача добавлена",
"ui.field.title": "Название",
"ui.field.description": "Описание",
"ui.field.priority": "Приоритет",
"ui.field.due": "Срок",
"ui.field.reminder": "Напоминание",
"ui.field.workspace": "Рабочее пространство",
"ui.workspaceValue": "Рабочее пространство: {workspace}",
"ui.edit": "Изменить задачу",
"ui.cancel": "Отмена",
"ui.saveChanges": "Сохранить изменения",
"ui.markedDone": "Задача выполнена",
"ui.cancelled": "Задача отменена",
"ui.reopened": "Задача открыта снова",
"ui.deleted": "Задача удалена",
"ui.priorityValue": "Приоритет: {priority}",
"ui.overduePrefix": "Просрочено · ",
"ui.dueSoonPrefix": "Скоро срок · ",
"ui.dueValue": "{prefix}Срок: {date}",
"ui.reminderDueValue": "Пора напомнить: {date}",
"ui.reminderValue": "Напоминание: {date}",
"ui.noMatches": "Нет задач, соответствующих фильтрам.",
"ui.empty": "Задач пока нет.",
"ui.openWorkspace": "Открыть рабочее пространство",
"ui.reopen": "Открыть снова",
"ui.createJournal": "Создать запись журнала",
"ui.editAction": "Изменить",
"ui.delete": "Удалить",
"ui.untitled": "Задача без названия",
"ui.count": "Задач: {count}",
"ui.filteredCount": "Показано {visible} из {count}"
}

View File

@ -5,6 +5,7 @@
"version": "0.1.0", "version": "0.1.0",
"apiVersion": "0.1.0", "apiVersion": "0.1.0",
"description": "Global and workspace todo tracking with due and reminder metadata.", "description": "Global and workspace todo tracking with due and reminder metadata.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official", "source": "official",
"icon": "list-todo", "icon": "list-todo",
"provides": [ "provides": [

View File

@ -152,6 +152,10 @@
statusError: false, statusError: false,
disposed: false disposed: false
}; };
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
}
function workspaceOptions() { function workspaceOptions() {
var values = {}; var values = {};
@ -170,18 +174,18 @@
} }
function statusText(entries) { function statusText(entries) {
if (state.loading) return 'Loading deleted items...'; if (state.loading) return tr('ui.loading', null, 'Loading deleted items...');
if (state.status) return state.status; if (state.status) return state.status;
return entries.length === 1 ? '1 deleted item' : entries.length + ' deleted items'; return tr('ui.count', { count: entries.length }, entries.length === 1 ? '1 deleted item' : entries.length + ' deleted items');
} }
function renderConfirmation() { function renderConfirmation() {
var entry = state.entries.find(function (item) { return item.trashId === state.confirmingId; }); var entry = state.entries.find(function (item) { return item.trashId === state.confirmingId; });
if (!entry) return null; if (!entry) return null;
return el('div', { className: 'trash-confirm-backdrop', 'data-trash-confirm': entry.trashId }, [ return el('div', { className: 'trash-confirm-backdrop', 'data-trash-confirm': entry.trashId }, [
el('section', { className: 'trash-confirm', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Delete permanently' }, [ el('section', { className: 'trash-confirm', role: 'dialog', 'aria-modal': 'true', 'aria-label': tr('ui.deletePermanently', null, 'Delete permanently') }, [
el('h3', {}, ['Delete permanently?']), el('h3', {}, [tr('ui.deleteQuestion', null, 'Delete permanently?')]),
el('p', {}, ['This cannot be undone.']), el('p', {}, [tr('ui.cannotUndo', null, 'This cannot be undone.')]),
el('p', { className: 'trash-confirm-path' }, [entry.originalPath || nameFor(entry)]), el('p', { className: 'trash-confirm-path' }, [entry.originalPath || nameFor(entry)]),
el('div', { className: 'trash-confirm-actions' }, [ el('div', { className: 'trash-confirm-actions' }, [
el('button', { el('button', {
@ -189,14 +193,14 @@
type: 'button', type: 'button',
'data-trash-confirm-cancel': entry.trashId, 'data-trash-confirm-cancel': entry.trashId,
onClick: function () { state.confirmingId = ''; render(); } onClick: function () { state.confirmingId = ''; render(); }
}, ['Cancel']), }, [tr('ui.cancel', null, 'Cancel')]),
el('button', { el('button', {
className: 'trash-btn trash-btn-danger', className: 'trash-btn trash-btn-danger',
type: 'button', type: 'button',
disabled: state.busyId === entry.trashId, disabled: state.busyId === entry.trashId,
'data-trash-confirm-delete': entry.trashId, 'data-trash-confirm-delete': entry.trashId,
onClick: function () { deletePermanently(entry); } onClick: function () { deletePermanently(entry); }
}, [state.busyId === entry.trashId ? 'Deleting...' : 'Delete permanently']) }, [state.busyId === entry.trashId ? tr('ui.deleting', null, 'Deleting...') : tr('ui.deletePermanently', null, 'Delete permanently')])
]) ])
]) ])
]); ]);
@ -213,7 +217,7 @@
value: state.workspace, value: state.workspace,
'data-trash-filter-workspace': '', 'data-trash-filter-workspace': '',
onChange: function (event) { state.workspace = event.target.value; render(); } onChange: function (event) { state.workspace = event.target.value; render(); }
}, [el('option', { value: '' }, ['All workspaces'])]); }, [el('option', { value: '' }, [tr('ui.allWorkspaces', null, 'All workspaces')])]);
workspaceOptions().forEach(function (workspace) { workspaceOptions().forEach(function (workspace) {
workspaceSelect.appendChild(el('option', { value: workspace }, [workspace])); workspaceSelect.appendChild(el('option', { value: workspace }, [workspace]));
}); });
@ -224,9 +228,9 @@
'data-trash-filter-type': '', 'data-trash-filter-type': '',
onChange: function (event) { state.type = event.target.value; render(); } onChange: function (event) { state.type = event.target.value; render(); }
}, [ }, [
el('option', { value: '' }, ['All types']), el('option', { value: '' }, [tr('ui.allTypes', null, 'All types')]),
el('option', { value: 'file' }, ['Files']), el('option', { value: 'file' }, [tr('ui.files', null, 'Files')]),
el('option', { value: 'folder' }, ['Folders']) el('option', { value: 'folder' }, [tr('ui.folders', null, 'Folders')])
]); ]);
var sortSelect = el('select', { var sortSelect = el('select', {
@ -235,28 +239,28 @@
'data-trash-sort': '', 'data-trash-sort': '',
onChange: function (event) { state.sort = event.target.value; render(); } onChange: function (event) { state.sort = event.target.value; render(); }
}, [ }, [
el('option', { value: 'date-desc' }, ['Deleted: newest']), el('option', { value: 'date-desc' }, [tr('ui.newest', null, 'Deleted: newest')]),
el('option', { value: 'date-asc' }, ['Deleted: oldest']), el('option', { value: 'date-asc' }, [tr('ui.oldest', null, 'Deleted: oldest')]),
el('option', { value: 'name-asc' }, ['Name']), el('option', { value: 'name-asc' }, [tr('ui.name', null, 'Name')]),
el('option', { value: 'type-asc' }, ['Type']) el('option', { value: 'type-asc' }, [tr('ui.type', null, 'Type')])
]); ]);
containerEl.appendChild(el('div', { className: 'trash-toolbar' }, [ containerEl.appendChild(el('div', { className: 'trash-toolbar' }, [
el('span', { className: 'trash-title' }, ['Trash']), el('span', { className: 'trash-title' }, [tr('ui.title', null, 'Trash')]),
el('span', { className: 'trash-count' }, [state.entries.length + ' total']), el('span', { className: 'trash-count' }, [tr('ui.total', { count: state.entries.length }, state.entries.length + ' total')]),
el('span', { className: 'trash-spacer' }), el('span', { className: 'trash-spacer' }),
el('input', { el('input', {
className: 'trash-control trash-search', className: 'trash-control trash-search',
type: 'search', type: 'search',
value: state.query, value: state.query,
placeholder: 'Filter name or path', placeholder: tr('ui.filter', null, 'Filter name or path'),
'data-trash-filter-search': '', 'data-trash-filter-search': '',
onInput: function (event) { state.query = event.target.value; render(); } onInput: function (event) { state.query = event.target.value; render(); }
}), }),
workspaceSelect, workspaceSelect,
typeSelect, typeSelect,
sortSelect, sortSelect,
el('button', { className: 'trash-btn', type: 'button', onClick: loadEntries }, ['Refresh']) el('button', { className: 'trash-btn', type: 'button', onClick: loadEntries }, [tr('ui.refresh', null, 'Refresh')])
])); ]));
containerEl.appendChild(el('div', { containerEl.appendChild(el('div', {
className: 'trash-status' + (state.statusError ? ' error' : ''), className: 'trash-status' + (state.statusError ? ' error' : ''),
@ -265,17 +269,17 @@
var list = el('div', { className: 'trash-list', 'data-trash-list': '' }); var list = el('div', { className: 'trash-list', 'data-trash-list': '' });
list.appendChild(el('div', { className: 'trash-header' }, [ list.appendChild(el('div', { className: 'trash-header' }, [
el('span', {}, ['Name']), el('span', {}, [tr('ui.name', null, 'Name')]),
el('span', {}, ['Workspace']), el('span', {}, [tr('ui.workspace', null, 'Workspace')]),
el('span', {}, ['Original path']), el('span', {}, [tr('ui.originalPath', null, 'Original path')]),
el('span', {}, ['Deleted']), el('span', {}, [tr('ui.deleted', null, 'Deleted')]),
el('span', {}, ['Type / size']), el('span', {}, [tr('ui.typeSize', null, 'Type / size')]),
el('span', {}, ['Actions']) el('span', {}, [tr('ui.actions', null, 'Actions')])
])); ]));
if (state.loading) { if (state.loading) {
list.appendChild(el('div', { className: 'trash-empty' }, ['Loading deleted items...'])); list.appendChild(el('div', { className: 'trash-empty' }, [tr('ui.loading', null, 'Loading deleted items...')]));
} else if (!rows.length) { } else if (!rows.length) {
list.appendChild(el('div', { className: 'trash-empty' }, [state.entries.length ? 'No deleted items match the current filters.' : 'Trash is empty.'])); list.appendChild(el('div', { className: 'trash-empty' }, [state.entries.length ? tr('ui.noMatches', null, 'No deleted items match the current filters.') : tr('ui.empty', null, 'Trash is empty.')]));
} else { } else {
rows.forEach(function (entry) { rows.forEach(function (entry) {
var size = formatSize(entry.size); var size = formatSize(entry.size);
@ -296,14 +300,14 @@
disabled: state.busyId === entry.trashId, disabled: state.busyId === entry.trashId,
'data-trash-restore': entry.trashId, 'data-trash-restore': entry.trashId,
onClick: function () { restoreEntry(entry); } onClick: function () { restoreEntry(entry); }
}, [state.busyId === entry.trashId ? 'Restoring...' : 'Restore']), }, [state.busyId === entry.trashId ? tr('ui.restoring', null, 'Restoring...') : tr('ui.restore', null, 'Restore')]),
el('button', { el('button', {
className: 'trash-btn trash-btn-danger', className: 'trash-btn trash-btn-danger',
type: 'button', type: 'button',
disabled: state.busyId === entry.trashId, disabled: state.busyId === entry.trashId,
'data-trash-delete': entry.trashId, 'data-trash-delete': entry.trashId,
onClick: function () { state.confirmingId = entry.trashId; render(); } onClick: function () { state.confirmingId = entry.trashId; render(); }
}, ['Delete permanently']) }, [tr('ui.deletePermanently', null, 'Delete permanently')])
]) ])
])); ]));
}); });
@ -386,8 +390,12 @@
} }
loadEntries(); loadEntries();
var localeUnsubscribe = api.i18n && typeof api.i18n.onDidChangeLocale === 'function'
? api.i18n.onDidChangeLocale(render)
: null;
containerEl.__trashCleanup = function () { containerEl.__trashCleanup = function () {
state.disposed = true; state.disposed = true;
if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
containerEl.innerHTML = ''; containerEl.innerHTML = '';
}; };
}, },

View File

@ -0,0 +1,34 @@
{
"manifest.name": "Trash",
"manifest.description": "Global vault trash manager for restoring or permanently deleting deleted files and folders.",
"contributions.views.verstak.trash.view.title": "Trash",
"contributions.sidebarItems.verstak.trash.sidebar.title": "Trash",
"ui.loading": "Loading deleted items...",
"ui.count": "{count} deleted item(s)",
"ui.deletePermanently": "Delete permanently",
"ui.deleteQuestion": "Delete permanently?",
"ui.cannotUndo": "This cannot be undone.",
"ui.cancel": "Cancel",
"ui.deleting": "Deleting...",
"ui.allWorkspaces": "All workspaces",
"ui.allTypes": "All types",
"ui.files": "Files",
"ui.folders": "Folders",
"ui.newest": "Deleted: newest",
"ui.oldest": "Deleted: oldest",
"ui.name": "Name",
"ui.type": "Type",
"ui.title": "Trash",
"ui.total": "{count} total",
"ui.filter": "Filter name or path",
"ui.refresh": "Refresh",
"ui.workspace": "Workspace",
"ui.originalPath": "Original path",
"ui.deleted": "Deleted",
"ui.typeSize": "Type / size",
"ui.actions": "Actions",
"ui.noMatches": "No deleted items match the current filters.",
"ui.empty": "Trash is empty.",
"ui.restoring": "Restoring...",
"ui.restore": "Restore"
}

View File

@ -0,0 +1,34 @@
{
"manifest.name": "Корзина",
"manifest.description": "Общая корзина хранилища для восстановления или окончательного удаления файлов и папок.",
"contributions.views.verstak.trash.view.title": "Корзина",
"contributions.sidebarItems.verstak.trash.sidebar.title": "Корзина",
"ui.loading": "Загрузка удалённых элементов...",
"ui.count": "Удалённых элементов: {count}",
"ui.deletePermanently": "Удалить навсегда",
"ui.deleteQuestion": "Удалить навсегда?",
"ui.cannotUndo": "Это действие нельзя отменить.",
"ui.cancel": "Отмена",
"ui.deleting": "Удаление...",
"ui.allWorkspaces": "Все рабочие пространства",
"ui.allTypes": "Все типы",
"ui.files": "Файлы",
"ui.folders": "Папки",
"ui.newest": "Сначала недавно удалённые",
"ui.oldest": "Сначала давно удалённые",
"ui.name": "Имя",
"ui.type": "Тип",
"ui.title": "Корзина",
"ui.total": "Всего: {count}",
"ui.filter": "Фильтр по имени или пути",
"ui.refresh": "Обновить",
"ui.workspace": "Рабочее пространство",
"ui.originalPath": "Исходный путь",
"ui.deleted": "Удалён",
"ui.typeSize": "Тип / размер",
"ui.actions": "Действия",
"ui.noMatches": "Нет удалённых элементов, соответствующих фильтрам.",
"ui.empty": "Корзина пуста.",
"ui.restoring": "Восстановление...",
"ui.restore": "Восстановить"
}

View File

@ -5,6 +5,7 @@
"version": "0.1.0", "version": "0.1.0",
"apiVersion": "0.1.0", "apiVersion": "0.1.0",
"description": "Global vault trash manager for restoring or permanently deleting deleted files and folders.", "description": "Global vault trash manager for restoring or permanently deleting deleted files and folders.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official", "source": "official",
"icon": "trash", "icon": "trash",
"provides": [ "provides": [

View File

@ -79,7 +79,14 @@ with open(path, 'w', encoding='utf-8') as f:
fi fi
fi fi
# 3. backend binary # 3. locale catalogs declared by plugin.json
if [ -d "$plugin_dir/locales" ]; then
mkdir -p "$dist_dir/locales"
cp -r "$plugin_dir/locales/." "$dist_dir/locales/"
echo " └─ locales ($(find "$dist_dir/locales" -type f | wc -l) file(s))"
fi
# 4. backend binary
if [ -d "$plugin_dir/backend" ]; then if [ -d "$plugin_dir/backend" ]; then
# Find the compiled binary (same name as plugin directory) # Find the compiled binary (same name as plugin directory)
local bin_name="$plugin_name" local bin_name="$plugin_name"
@ -91,7 +98,7 @@ with open(path, 'w', encoding='utf-8') as f:
fi fi
fi fi
# 4. Verify dist package has at least plugin.json # 5. Verify dist package has at least plugin.json
if [ ! -f "$dist_dir/plugin.json" ]; then if [ ! -f "$dist_dir/plugin.json" ]; then
echo " ❌ dist package missing plugin.json" echo " ❌ dist package missing plugin.json"
return 1 return 1

81
scripts/check-locales.mjs Normal file
View File

@ -0,0 +1,81 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const pluginsRoot = path.join(root, 'plugins');
const contributionFields = {
views: 'title',
commands: 'title',
settingsPanels: 'title',
sidebarItems: 'title',
fileActions: 'label',
noteActions: 'label',
contextMenuEntries: 'label',
searchProviders: 'label',
statusBarItems: 'label',
openProviders: 'title',
workspaceItems: 'title',
};
const problems = [];
for (const entry of fs.readdirSync(pluginsRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const pluginRoot = path.join(pluginsRoot, entry.name);
const manifestPath = path.join(pluginRoot, 'plugin.json');
if (!fs.existsSync(manifestPath)) continue;
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const localization = manifest.localization;
if (!localization || localization.defaultLocale !== 'en') {
problems.push(`${manifest.id}: localization.defaultLocale must be en`);
continue;
}
if (!localization.locales || !localization.locales.en || !localization.locales.ru) {
problems.push(`${manifest.id}: en and ru catalogs must be declared`);
continue;
}
const catalogs = {};
for (const locale of ['en', 'ru']) {
const relative = localization.locales[locale];
const catalogPath = path.resolve(pluginRoot, relative);
if (!catalogPath.startsWith(pluginRoot + path.sep) || !fs.existsSync(catalogPath)) {
problems.push(`${manifest.id}: missing safe ${locale} catalog at ${relative}`);
continue;
}
try {
catalogs[locale] = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
} catch (error) {
problems.push(`${manifest.id}: invalid ${locale} catalog: ${error.message}`);
continue;
}
for (const [key, value] of Object.entries(catalogs[locale])) {
if (typeof value !== 'string') problems.push(`${manifest.id}: ${locale}.${key} must be a string`);
}
}
if (!catalogs.en || !catalogs.ru) continue;
const enKeys = Object.keys(catalogs.en).sort();
const ruKeys = Object.keys(catalogs.ru).sort();
if (JSON.stringify(enKeys) !== JSON.stringify(ruKeys)) {
problems.push(`${manifest.id}: en/ru catalog keys differ`);
}
const required = ['manifest.name'];
if (manifest.description) required.push('manifest.description');
for (const [point, field] of Object.entries(contributionFields)) {
for (const item of manifest.contributes?.[point] || []) {
required.push(`contributions.${point}.${item.id}.${field}`);
}
}
for (const key of required) {
if (!Object.prototype.hasOwnProperty.call(catalogs.en, key)) problems.push(`${manifest.id}: en catalog missing ${key}`);
if (!Object.prototype.hasOwnProperty.call(catalogs.ru, key)) problems.push(`${manifest.id}: ru catalog missing ${key}`);
}
}
if (problems.length > 0) {
problems.forEach((problem) => console.error(` FAIL ${problem}`));
process.exit(1);
}
console.log(' OK official plugin locale catalogs are complete and aligned');

View File

@ -76,6 +76,19 @@ else
echo " python3 not available — skipping manifest validation" echo " python3 not available — skipping manifest validation"
fi fi
echo ""
# Verify plugin-owned English and Russian catalogs.
echo "[localization catalogs]"
if command -v node &>/dev/null; then
set +e
node "$ROOT/scripts/check-locales.mjs"
STATUS=$?
set -e
report "localization catalogs" "$STATUS"
else
echo " ⚠️ node not available — skipping localization catalog validation"
fi
echo "" echo ""
# Guard official plugins against bypassing the v2 plugin API for note features. # Guard official plugins against bypassing the v2 plugin API for note features.
echo "[frontend API boundary]" echo "[frontend API boundary]"

View File

@ -152,6 +152,48 @@ function makeApi(initialSettings = {}) {
receiverToken: 'initial-browser-token', receiverToken: 'initial-browser-token',
}; };
let nextWriteError = null; let nextWriteError = null;
function backendCaptures() {
const keys = ['captures:global', 'captures', ...Object.keys(settings).filter((key) => key.startsWith('captures:workspace:'))];
const seen = new Set();
const captures = [];
for (const key of keys) {
for (const original of Array.isArray(settings[key]) ? settings[key] : []) {
if (!original || !original.captureId || seen.has(original.captureId)) continue;
seen.add(original.captureId);
const capture = { ...original };
if (!capture.workspaceRootPath && key.startsWith('captures:workspace:')) {
capture.workspaceRootPath = decodeURIComponent(key.slice('captures:workspace:'.length));
capture.workspaceName = capture.workspaceRootPath;
}
captures.push(capture);
}
}
return { keys, captures };
}
function backendWriteCaptures(captures, keys) {
settings['captures:global'] = captures.slice(0, 100);
keys.forEach((key) => {
if (key !== 'captures:global') settings[key] = [];
});
}
function backendMutate(payload) {
const { keys, captures } = backendCaptures();
const ids = new Set([payload.captureId, ...(payload.captureIds || [])].filter(Boolean));
const next = captures.flatMap((capture) => {
if (!ids.has(capture.captureId)) return [capture];
if (payload.action === 'delete') return [];
if (payload.action === 'assign') return [{ ...capture, workspaceRootPath: payload.workspaceRootPath || '', workspaceName: payload.workspaceRootPath || '' }];
if (payload.action === 'processed') return [{ ...capture, processed: payload.processed === true }];
return [capture];
});
backendWriteCaptures(next, keys);
}
function backendAppend(event) {
const { keys, captures } = backendCaptures();
const payload = { ...(event.payload || {}) };
const next = [payload, ...captures.filter((capture) => capture.captureId !== payload.captureId)];
backendWriteCaptures(next, keys);
}
return { return {
settings, settings,
handlers, handlers,
@ -165,9 +207,13 @@ function makeApi(initialSettings = {}) {
events: { events: {
publish: async (name, payload) => { publish: async (name, payload) => {
publishedEvents.push({ name, payload }); publishedEvents.push({ name, payload });
if (name === 'browser-inbox.storage.mutate') backendMutate(payload || {});
}, },
subscribe: async (name, handler) => { subscribe: async (name, handler) => {
handlers[name] = handler; handlers[name] = async (event) => {
if (name.startsWith('browser.capture.')) backendAppend(event);
return handler(event);
};
return () => { return () => {
unsubscribed.push(name); unsubscribed.push(name);
delete handlers[name]; delete handlers[name];
@ -214,7 +260,7 @@ function makeApi(initialSettings = {}) {
} }
async function flush() { async function flush() {
for (let i = 0; i < 8; i += 1) await Promise.resolve(); for (let i = 0; i < 16; i += 1) await Promise.resolve();
} }
async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, workspaceRootPath: 'Project' }, document = makeDocument()) { async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, workspaceRootPath: 'Project' }, document = makeDocument()) {
@ -237,6 +283,14 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
const settingsComponents = loadComponents(makeDocument()); const settingsComponents = loadComponents(makeDocument());
if (!settingsComponents.BrowserInboxSettings) throw new Error('BrowserInboxSettings was not registered'); if (!settingsComponents.BrowserInboxSettings) throw new Error('BrowserInboxSettings was not registered');
const styleDocument = makeDocument();
const styledView = await mountWithApi(makeApi(), {}, styleDocument);
const injectedStyles = styleDocument.head.children.map((node) => node.textContent).join('\n');
if (!injectedStyles.includes('.browser-inbox-select{') || !injectedStyles.includes('appearance:none')) {
throw new Error('Browser Inbox selects do not use the application select styling');
}
styledView.component.unmount && styledView.component.unmount(styledView.container);
const api = makeApi(); const api = makeApi();
const settingsView = await mountSettingsWithApi(makeApi()); const settingsView = await mountSettingsWithApi(makeApi());
const receiverURLInput = walk(settingsView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-pairing-url') === ''); const receiverURLInput = walk(settingsView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-pairing-url') === '');
@ -414,6 +468,9 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
if (!walk(legacyGlobal.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'legacy-project-capture')) { if (!walk(legacyGlobal.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'legacy-project-capture')) {
throw new Error('legacy workspace capture was not rendered in global view'); throw new Error('legacy workspace capture was not rendered in global view');
} }
if (legacyApi.getStoredCaptures('captures').length !== 0) {
throw new Error('legacy captures were not removed after canonical migration');
}
component.unmount && component.unmount(legacyGlobal.container); component.unmount && component.unmount(legacyGlobal.container);
const legacyProject = await mountWithApi(legacyApi, { workspaceNode: { name: 'Project' }, workspaceRootPath: 'Project' }); const legacyProject = await mountWithApi(legacyApi, { workspaceNode: { name: 'Project' }, workspaceRootPath: 'Project' });
@ -612,7 +669,7 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
if (!noteWrite.content.includes('# Example Article')) throw new Error('note content missing heading'); if (!noteWrite.content.includes('# Example Article')) throw new Error('note content missing heading');
if (!noteWrite.content.includes('Source: https://example.com/article')) throw new Error('note content missing source URL'); if (!noteWrite.content.includes('Source: https://example.com/article')) throw new Error('note content missing source URL');
if (!noteWrite.content.includes('Selected text from the page')) throw new Error('note content missing selected text'); if (!noteWrite.content.includes('Selected text from the page')) throw new Error('note content missing selected text');
if (conversionApi.getStoredCaptures(projectKey).some((capture) => capture.captureId === 'convert-selection')) { if (conversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-selection')) {
throw new Error('converted capture was not removed from queue'); throw new Error('converted capture was not removed from queue');
} }
const convertedEvent = conversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted'); const convertedEvent = conversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted');
@ -639,7 +696,7 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
failedConversionApi.failNextWrite('file already exists'); failedConversionApi.failNextWrite('file already exists');
failedCreateNoteButton.click(); failedCreateNoteButton.click();
await flush(); await flush();
if (!failedConversionApi.getStoredCaptures(projectKey).some((capture) => capture.captureId === 'convert-conflict')) { if (!failedConversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-conflict')) {
throw new Error('failed conversion removed capture from queue'); throw new Error('failed conversion removed capture from queue');
} }
if (!failedConversionView.container.textContent.includes('Could not create note')) { if (!failedConversionView.container.textContent.includes('Could not create note')) {
@ -677,7 +734,7 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
} }
if (!linkWrite.content.includes('[InternetShortcut]')) throw new Error('link content missing InternetShortcut header'); if (!linkWrite.content.includes('[InternetShortcut]')) throw new Error('link content missing InternetShortcut header');
if (!linkWrite.content.includes('URL=https://example.com/article')) throw new Error('link content missing URL'); if (!linkWrite.content.includes('URL=https://example.com/article')) throw new Error('link content missing URL');
if (linkConversionApi.getStoredCaptures(projectKey).some((capture) => capture.captureId === 'convert-link')) { if (linkConversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-link')) {
throw new Error('converted link capture was not removed from queue'); throw new Error('converted link capture was not removed from queue');
} }
const convertedLinkEvent = linkConversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted'); const convertedLinkEvent = linkConversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted');
@ -704,7 +761,7 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
failedLinkApi.failNextWrite('link already exists'); failedLinkApi.failNextWrite('link already exists');
failedCreateLinkButton.click(); failedCreateLinkButton.click();
await flush(); await flush();
if (!failedLinkApi.getStoredCaptures(projectKey).some((capture) => capture.captureId === 'convert-link-conflict')) { if (!failedLinkApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-link-conflict')) {
throw new Error('failed link conversion removed capture from queue'); throw new Error('failed link conversion removed capture from queue');
} }
if (!failedLinkView.container.textContent.includes('Could not create link')) { if (!failedLinkView.container.textContent.includes('Could not create link')) {
@ -745,7 +802,7 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
if (fileWrite.options.createIfMissing !== true || fileWrite.options.overwrite !== false) { if (fileWrite.options.createIfMissing !== true || fileWrite.options.overwrite !== false) {
throw new Error(`file write options mismatch: ${JSON.stringify(fileWrite.options)}`); throw new Error(`file write options mismatch: ${JSON.stringify(fileWrite.options)}`);
} }
if (fileConversionApi.getStoredCaptures(projectKey).some((capture) => capture.captureId === 'convert-file')) { if (fileConversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-file')) {
throw new Error('converted file capture was not removed from queue'); throw new Error('converted file capture was not removed from queue');
} }
const convertedFileEvent = fileConversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted'); const convertedFileEvent = fileConversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted');
@ -811,7 +868,7 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
failedFileApi.failNextWrite('file already exists'); failedFileApi.failNextWrite('file already exists');
failedCreateFileButton.click(); failedCreateFileButton.click();
await flush(); await flush();
if (!failedFileApi.getStoredCaptures(projectKey).some((capture) => capture.captureId === 'convert-file-conflict')) { if (!failedFileApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-file-conflict')) {
throw new Error('failed file conversion removed capture from queue'); throw new Error('failed file conversion removed capture from queue');
} }
if (!failedFileView.container.textContent.includes('Could not create file')) { if (!failedFileView.container.textContent.includes('Could not create file')) {

View File

@ -6,6 +6,10 @@ const vm = require('vm');
const root = path.resolve(__dirname, '..'); const root = path.resolve(__dirname, '..');
const bundlePath = path.join(root, 'plugins', 'platform-test', 'frontend', 'src', 'index.js'); const bundlePath = path.join(root, 'plugins', 'platform-test', 'frontend', 'src', 'index.js');
const source = fs.readFileSync(bundlePath, 'utf8'); const source = fs.readFileSync(bundlePath, 'utf8');
const platformCatalogs = {
en: JSON.parse(fs.readFileSync(path.join(root, 'plugins', 'platform-test', 'locales', 'en.json'), 'utf8')),
ru: JSON.parse(fs.readFileSync(path.join(root, 'plugins', 'platform-test', 'locales', 'ru.json'), 'utf8')),
};
class FakeNode { class FakeNode {
constructor(tagName) { constructor(tagName) {
@ -37,6 +41,19 @@ class FakeNode {
addEventListener() {} addEventListener() {}
} }
function findByClass(node, className) {
if (String(node.className || '').split(/\s+/).includes(className)) return node;
for (const child of node.children || []) {
const found = findByClass(child, className);
if (found) return found;
}
return null;
}
function allText(node) {
return [String(node.textContent || '')].concat((node.children || []).map(allText)).join(' ');
}
function makeDocument() { function makeDocument() {
return { return {
head: new FakeNode('head'), head: new FakeNode('head'),
@ -76,6 +93,22 @@ if (!components) {
const api = { const api = {
pluginId: 'verstak.platform-test', pluginId: 'verstak.platform-test',
i18n: {
locale: 'en',
listeners: new Set(),
messages: platformCatalogs,
getLocale() { return this.locale; },
t(key, params, fallback) { return this.messages[this.locale][key] || fallback || key; },
onDidChangeLocale(listener) {
this.listeners.add(listener);
listener(this.locale);
return () => this.listeners.delete(listener);
},
setLocale(locale) {
this.locale = locale;
this.listeners.forEach((listener) => listener(locale));
},
},
settings: { settings: {
read: async () => 'initial value', read: async () => 'initial value',
write: async () => undefined, write: async () => undefined,
@ -179,9 +212,35 @@ const api = {
if (container.children.length === 0) { if (container.children.length === 0) {
throw new Error(`${name} mounted no DOM nodes`); throw new Error(`${name} mounted no DOM nodes`);
} }
if (name === 'DiagnosticsPanel') {
api.i18n.setLocale('ru');
if (findByClass(container, 'pt-plugin-name')?.textContent !== 'Диагностика платформы') {
throw new Error('DiagnosticsPanel did not update after locale change');
}
const diagnosticsText = allText(container);
if (!diagnosticsText.includes('Сохранённая настройка') || !diagnosticsText.includes('Пройдено')) {
throw new Error('DiagnosticsPanel left primary diagnostic chrome untranslated');
}
}
if (name === 'PlatformTestSettings') {
const counter = findByClass(container, 'pt-counter-value');
counter.textContent = '3';
api.i18n.setLocale('ru');
if (findByClass(container, 'pt-plugin-name')?.textContent !== 'Настройки теста платформы') {
throw new Error('PlatformTestSettings did not update after locale change');
}
if (counter.textContent !== '3') {
throw new Error('PlatformTestSettings lost counter state after locale change');
}
const settingsText = allText(container);
if (!settingsText.includes('Панель настроек загружена') || !settingsText.includes('нажатий')) {
throw new Error('PlatformTestSettings left primary settings copy untranslated');
}
}
if (typeof component.unmount === 'function') { if (typeof component.unmount === 'function') {
component.unmount(container); component.unmount(container);
} }
api.i18n.setLocale('en');
} }
console.log('platform-test frontend smoke passed'); console.log('platform-test frontend smoke passed');

View File

@ -8,6 +8,10 @@ const sourcePath = path.join(root, 'plugins', 'todo', 'frontend', 'src', 'index.
const manifestPath = path.join(root, 'plugins', 'todo', 'plugin.json'); const manifestPath = path.join(root, 'plugins', 'todo', 'plugin.json');
const source = fs.readFileSync(sourcePath, 'utf8'); const source = fs.readFileSync(sourcePath, 'utf8');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const localeCatalogs = {
en: JSON.parse(fs.readFileSync(path.join(root, 'plugins', 'todo', 'locales', 'en.json'), 'utf8')),
ru: JSON.parse(fs.readFileSync(path.join(root, 'plugins', 'todo', 'locales', 'ru.json'), 'utf8')),
};
class FakeNode { class FakeNode {
constructor(tagName) { constructor(tagName) {
@ -140,8 +144,10 @@ function loadComponent(document, emittedEvents) {
return component; return component;
} }
function makeApi(initialSettings = {}) { function makeApi(initialSettings = {}, initialLocale = 'en') {
const settings = { ...initialSettings }; const settings = { ...initialSettings };
let locale = initialLocale;
const localeListeners = [];
return { return {
settings, settings,
settingsApi: { settingsApi: {
@ -157,8 +163,31 @@ function makeApi(initialSettings = {}) {
{ name: 'Project', relativePath: 'Project', type: 'folder' }, { name: 'Project', relativePath: 'Project', type: 'folder' },
], ],
}, },
setLocale(nextLocale) {
locale = nextLocale;
localeListeners.slice().forEach((listener) => listener(locale));
},
get api() { get api() {
return { settings: this.settingsApi, files: this.files }; return {
settings: this.settingsApi,
files: this.files,
i18n: {
getLocale: () => locale,
t: (key, params, fallback) => {
const message = localeCatalogs[locale]?.[key] || localeCatalogs.en[key] || fallback || key;
return message.replace(/\{([^}]+)\}/g, (placeholder, name) => (
Object.prototype.hasOwnProperty.call(params || {}, name) ? String(params[name]) : placeholder
));
},
onDidChangeLocale: (listener) => {
localeListeners.push(listener);
return () => {
const index = localeListeners.indexOf(listener);
if (index !== -1) localeListeners.splice(index, 1);
};
},
},
};
}, },
}; };
} }
@ -205,6 +234,13 @@ async function mountWithApi(apiState, props, emittedEvents = [], document = make
if (createdTodo.dueAt !== '2000-01-01' || createdTodo.reminderAt !== '2000-01-01T09:00') throw new Error('Todo due/reminder metadata was not stored'); if (createdTodo.dueAt !== '2000-01-01' || createdTodo.reminderAt !== '2000-01-01T09:00') throw new Error('Todo due/reminder metadata was not stored');
if (!container.textContent.includes('Overdue') || !container.textContent.includes('Reminder due')) throw new Error('due/reminder indicators were not rendered'); if (!container.textContent.includes('Overdue') || !container.textContent.includes('Reminder due')) throw new Error('due/reminder indicators were not rendered');
apiState.setLocale('ru');
if (!container.textContent.includes('Задачи · Project') || !container.textContent.includes('Просрочено')) {
throw new Error('Todo view did not update to Russian without remounting');
}
if ((apiState.settings['todos:global'] || []).length !== 1) throw new Error('locale change lost Todo state');
apiState.setLocale('en');
byData(container, 'data-todo-action', 'edit').click(); byData(container, 'data-todo-action', 'edit').click();
byData(container, 'data-todo-input', 'title').value = 'Prepare project review updated'; byData(container, 'data-todo-input', 'title').value = 'Prepare project review updated';
byData(container, 'data-todo-action', 'save').click(); byData(container, 'data-todo-action', 'save').click();