feat: localize official plugins in Russian

This commit is contained in:
mirivlad 2026-07-11 12:42:37 +08:00
parent 077de94e61
commit 3dd0ec9f1f
57 changed files with 1478 additions and 330 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

@ -354,9 +354,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 +375,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 +393,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 +407,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 +418,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);
@ -748,25 +753,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 +824,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 +833,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 +843,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 +853,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);
} }
@ -941,6 +946,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 +980,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 +1006,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' }, [
@ -1090,6 +1108,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,33 @@
{
"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"
}

View File

@ -0,0 +1,33 @@
{
"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": "Токен сопряжения"
}

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,8 +497,8 @@
]), ]),
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
])); ]));
}); });
@ -516,6 +520,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,33 @@
{
"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"
}

View File

@ -0,0 +1,33 @@
{
"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": "Удалить"
}

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,6 +63,11 @@
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 = [];
function trackCleanup(fn) { function trackCleanup(fn) {
@ -79,7 +84,7 @@
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']), el('h2', { className: 'pt-plugin-name' }, [tr('ui.diagnostics', null, '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,7 +93,7 @@
/* ── 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']), el('span', {}, [tr('ui.bundleLoaded', null, 'Frontend Bundle Loaded')]),
]); ]);
var badgeRow = div('', [badge]); var badgeRow = div('', [badge]);
@ -274,7 +279,7 @@
}); });
var bridgeCard = div('pt-card', [ var bridgeCard = div('pt-card', [
el('h3', { className: 'pt-card-title' }, ['Real Plugin API Bridge']), el('h3', { className: 'pt-card-title' }, [tr('ui.apiBridge', null, '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'), span('pt-list-label', 'Persisted setting'),
@ -361,7 +366,7 @@
}); });
var testsCard = div('pt-card', [ var testsCard = div('pt-card', [
el('h3', { className: 'pt-card-title' }, ['Test Results']), el('h3', { className: 'pt-card-title' }, [tr('ui.testResults', null, 'Test Results')]),
summaryRow, summaryRow,
testsList, testsList,
]); ]);
@ -398,7 +403,7 @@
}); });
var capsCard = div('pt-card', [ var capsCard = div('pt-card', [
el('h3', { className: 'pt-card-title' }, ['Registered Capabilities']), el('h3', { className: 'pt-card-title' }, [tr('ui.capabilities', null, 'Registered Capabilities')]),
capList, capList,
]); ]);
@ -421,7 +426,7 @@
}); });
var infoCard = div('pt-card', [ var infoCard = div('pt-card', [
el('h3', { className: 'pt-card-title' }, ['Plugin Info']), el('h3', { className: 'pt-card-title' }, [tr('ui.pluginInfo', null, 'Plugin Info')]),
infoList, infoList,
]); ]);
@ -456,7 +461,7 @@
}); });
var apiCard = div('pt-card', [ var apiCard = div('pt-card', [
el('h3', { className: 'pt-card-title' }, ['Host API Methods']), el('h3', { className: 'pt-card-title' }, [tr('ui.hostMethods', null, 'Host API Methods')]),
apiStatusList, apiStatusList,
]); ]);
@ -584,7 +589,7 @@
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']), el('h3', { className: 'pt-card-title' }, [tr('ui.mouseInspector', null, 'Mouse Event Inspector')]),
el('p', { style: { margin: '0 0 0.5rem', color: '#a0a0b8', fontSize: '0.8rem' } }, [ el('p', { style: { margin: '0 0 0.5rem', color: '#a0a0b8', fontSize: '0.8rem' } }, [
'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.',
]), ]),
@ -667,6 +672,11 @@
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;
}
/* ── Counter state (local, not persisted) ──────────────────── */ /* ── Counter state (local, not persisted) ──────────────────── */
var counterState = { value: 0 }; var counterState = { value: 0 };
@ -674,7 +684,7 @@
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']), el('h2', { className: 'pt-plugin-name' }, [tr('ui.settings', null, 'Platform Test Settings')]),
el('p', { className: 'pt-plugin-id' }, [api.pluginId]), el('p', { className: 'pt-plugin-id' }, [api.pluginId]),
]), ]),
]); ]);
@ -697,24 +707,24 @@
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']); }}, [tr('ui.increment', null, '+ 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']); }}, [tr('ui.decrement', null, ' 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']); }}, [tr('ui.reset', null, '↺ 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)']), el('h3', { className: 'pt-card-title' }, [tr('ui.counter', null, 'Interactive Counter (Local State)')]),
counterDisplay, counterDisplay,
btnGroup, btnGroup,
el('p', { style: { marginTop: '0.75rem', color: '#6c6c8a', fontSize: '0.7rem' } }, [ el('p', { style: { marginTop: '0.75rem', color: '#6c6c8a', fontSize: '0.7rem' } }, [
@ -740,7 +750,7 @@
}); });
var settingsCard = div('pt-card', [ var settingsCard = div('pt-card', [
el('h3', { className: 'pt-card-title' }, ['Plugin Settings (Demo)']), el('h3', { className: 'pt-card-title' }, [tr('ui.demoSettings', null, 'Plugin Settings (Demo)')]),
settingsDemoList, settingsDemoList,
el('p', { style: { marginTop: '0.5rem', color: '#6c6c8a', fontSize: '0.7rem' } }, [ el('p', { style: { marginTop: '0.5rem', color: '#6c6c8a', fontSize: '0.7rem' } }, [
'Use api.settings.read() / api.settings.write() for persisted settings.', 'Use api.settings.read() / api.settings.write() for persisted settings.',

View File

@ -0,0 +1,25 @@
{
"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"
}

View File

@ -0,0 +1,25 @@
{
"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": "Демонстрационные настройки"
}

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])
]) ])
@ -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,36 @@
{
"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"
}

View File

@ -0,0 +1,36 @@
{
"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": "Отмена"
}

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

@ -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();