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 candidates = [];
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 disposed = false;
var unsubscribers = [];
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 statusEl = el('span', { className: 'activity-status' });
var clearBtn = el('button', {
className: 'activity-btn danger',
'data-activity-action': 'clear',
textContent: 'Clear',
textContent: tr('ui.clear', null, 'Clear'),
onClick: function () {
if (scope.mode === 'global') {
clearGlobal().then(render);
@ -489,7 +493,7 @@
? events.filter(function (item) { return !item._storageKey || item._storageKey === GLOBAL_KEY; })
: events;
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';
});
}
@ -513,10 +517,10 @@
return api.settings.write(key, []);
}));
}).then(function () {
statusText = 'Activity cleared';
statusText = tr('ui.cleared', null, 'Activity cleared');
statusClass = '';
}).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';
});
}
@ -525,8 +529,8 @@
listEl.innerHTML = '';
if (events.length === 0) {
listEl.appendChild(el('div', { className: 'activity-empty' }, [
el('div', { className: 'activity-empty-title', textContent: 'No activity events yet' }),
el('div', { textContent: 'File changes, browser captures, and conversions will appear here.' })
el('div', { className: 'activity-empty-title', textContent: tr('ui.empty', null, 'No activity events yet') }),
el('div', { textContent: tr('ui.emptyHint', null, 'File changes, browser captures, and conversions will appear here.') })
]));
return;
}
@ -543,8 +547,8 @@
]),
activity.summary ? el('div', { className: 'activity-summary', textContent: activity.summary }) : null,
el('details', { className: 'activity-details' }, [
el('summary', {}, ['Details']),
el('div', { className: 'activity-source', textContent: 'Event: ' + activity.type + (activity.sourcePluginId ? ' · Source: ' + activity.sourcePluginId : '') })
el('summary', {}, [tr('ui.details', null, 'Details')]),
el('div', { className: 'activity-source', textContent: tr('ui.eventSource', { event: activity.type, source: activity.sourcePluginId ? ' · ' + activity.sourcePluginId : '' }, 'Event: ' + activity.type + (activity.sourcePluginId ? ' · Source: ' + activity.sourcePluginId : '')) })
])
])
]));
@ -574,7 +578,7 @@
persistDismissals(candidate.workspaceRootPath),
persistCandidateCache(candidate.workspaceRootPath)
]).then(function () {
statusText = 'Candidate dismissed';
statusText = tr('ui.dismissed', null, 'Candidate dismissed');
statusClass = '';
}).catch(function (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');
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) {
candidatesEl.appendChild(el('div', {
className: 'activity-candidate',
'data-work-session-candidate': candidate.candidateId
}, [
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', { textContent: 'Workspace: ' + candidate.workspaceRootPath }),
el('div', { textContent: 'Time: ' + candidateTimeRange(candidate) }),
@ -612,8 +616,8 @@
]),
el('div', { className: 'activity-candidate-actions' }, [
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': 'dismiss', textContent: 'Dismiss', onClick: function () { dismissCandidate(candidate); } })
el('button', { className: 'activity-btn', type: 'button', 'data-work-session-action': 'review', textContent: tr('ui.review', null, 'Review'), onClick: function () { reviewCandidate(candidate); } }),
el('button', { className: 'activity-btn', type: 'button', 'data-work-session-action': 'dismiss', textContent: tr('ui.dismiss', null, 'Dismiss'), onClick: function () { dismissCandidate(candidate); } })
])
]));
});
@ -696,6 +700,13 @@
}).then(function () {
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 () {
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",
"apiVersion": "0.1.0",
"description": "Workspace-scoped activity log for public plugin events.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official",
"icon": "activity",
"provides": [

View File

@ -354,9 +354,14 @@
var statusFilter = 'all';
var workspaceFilter = '';
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 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 statusEl = el('span', { className: 'browser-inbox-status' });
var filtersEl = el('div', { className: 'browser-inbox-filters' });
@ -370,10 +375,10 @@
render();
}
}, [
el('option', { value: 'all', textContent: 'All captures' }),
el('option', { value: 'unassigned', textContent: 'Unassigned' }),
el('option', { value: 'unprocessed', textContent: 'Unprocessed' }),
el('option', { value: 'processed', textContent: 'Processed' })
el('option', { value: 'all', textContent: tr('ui.allCaptures', null, 'All captures') }),
el('option', { value: 'unassigned', textContent: tr('ui.unassigned', null, 'Unassigned') }),
el('option', { value: 'unprocessed', textContent: tr('ui.unprocessed', null, 'Unprocessed') }),
el('option', { value: 'processed', textContent: tr('ui.processed', null, 'Processed') })
]);
var workspaceFilterEl = el('select', {
className: 'browser-inbox-select',
@ -388,7 +393,7 @@
var searchInput = el('input', {
className: 'browser-inbox-input',
type: 'search',
placeholder: 'Search captures',
placeholder: tr('ui.search', null, 'Search captures'),
'data-browser-inbox-filter': 'search',
'aria-label': 'Search captures',
onInput: function (event) {
@ -402,7 +407,7 @@
var clearBtn = el('button', {
className: 'browser-inbox-btn danger',
'data-browser-inbox-action': 'clear',
textContent: 'Clear',
textContent: tr('ui.clear', null, 'Clear'),
onClick: function () {
clearScope().then(render);
}
@ -413,7 +418,7 @@
if (scope.mode === 'global') {
filtersEl.appendChild(workspaceFilterEl);
} 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);
toolbar.appendChild(filtersEl);
@ -748,25 +753,25 @@
detailEl.innerHTML = '';
var capture = selectedCapture();
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;
}
selectedId = capture.captureId;
detailEl.appendChild(el('div', { className: 'browser-inbox-detail-title', textContent: displayTitle(capture) }));
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-label', textContent: '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-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-label', textContent: 'Browser' }),
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-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' })
]));
var assignmentSelect = el('select', {
@ -819,7 +824,7 @@
actionButtons.push(el('button', {
className: 'browser-inbox-btn',
'data-browser-inbox-action': 'create-note',
textContent: 'Create Note',
textContent: tr('ui.createNote', null, 'Create Note'),
onClick: function () {
createNoteFromCapture(capture);
}
@ -828,7 +833,7 @@
actionButtons.push(el('button', {
className: 'browser-inbox-btn',
'data-browser-inbox-action': 'create-link',
textContent: 'Create Link',
textContent: tr('ui.createLink', null, 'Create Link'),
onClick: function () {
createLinkFromCapture(capture);
}
@ -838,7 +843,7 @@
actionButtons.push(el('button', {
className: 'browser-inbox-btn',
'data-browser-inbox-action': 'create-file',
textContent: 'Create File',
textContent: tr('ui.createFile', null, 'Create File'),
onClick: function () {
createFileFromCapture(capture);
}
@ -848,7 +853,7 @@
actionButtons.push(el('button', {
className: 'browser-inbox-btn danger',
'data-browser-inbox-action': 'remove',
textContent: 'Delete',
textContent: tr('ui.delete', null, 'Delete'),
onClick: function () {
removeCapture(capture.captureId);
}
@ -941,6 +946,14 @@
}).then(function () {
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 () {
disposed = true;
@ -967,6 +980,11 @@
containerEl.innerHTML = '';
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', {
className: 'browser-inbox-settings-input',
type: 'text',
@ -988,27 +1006,27 @@
className: 'browser-inbox-btn',
type: 'button',
'data-browser-inbox-settings-action': 'copy-url',
textContent: 'Copy URL'
textContent: tr('ui.copyUrl', null, 'Copy URL')
});
var copyTokenButton = el('button', {
className: 'browser-inbox-btn',
type: 'button',
'data-browser-inbox-settings-action': 'copy-token',
textContent: 'Copy Token'
textContent: tr('ui.copyToken', null, 'Copy Token')
});
var rotateTokenButton = el('button', {
className: 'browser-inbox-btn danger',
type: 'button',
'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' }, [
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
]));
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
]));
containerEl.appendChild(el('div', { className: 'browser-inbox-settings-actions' }, [
@ -1090,6 +1108,13 @@
});
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) {
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",
"apiVersion": "0.1.0",
"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",
"icon": "inbox",
"provides": [

View File

@ -291,6 +291,10 @@
var linesEl = null;
var previewEl = null;
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-resource-path', resourcePath);
@ -298,13 +302,13 @@
var modeLabel = el('span', { className: 'de-toolbar-mode' }, [editorMode]);
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 editBtn = isMarkdown ? el('button', { className: 'de-toolbar-btn', 'data-editor-mode-button': 'edit' }, ['Edit']) : null;
var previewBtn = isMarkdown ? el('button', { className: 'de-toolbar-btn', 'data-editor-mode-button': 'preview' }, ['Preview']) : null;
var splitBtn = isMarkdown ? el('button', { className: 'de-toolbar-btn', 'data-editor-mode-button': 'split' }, ['Split']) : null;
var reloadBtn = el('button', { className: 'de-toolbar-btn', 'data-editor-action': 'reload' }, ['Reload']);
var saveBtn = el('button', { className: 'de-toolbar-btn', 'data-editor-action': 'save' }, ['Save']);
var editBtn = isMarkdown ? el('button', { className: 'de-toolbar-btn', 'data-editor-mode-button': 'edit' }, [tr('ui.edit', null, 'Edit')]) : null;
var previewBtn = isMarkdown ? el('button', { className: 'de-toolbar-btn', 'data-editor-mode-button': 'preview' }, [tr('ui.preview', null, 'Preview')]) : null;
var 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' }, [tr('ui.reload', null, 'Reload')]);
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 toolbarChildren = [modeLabel, contextLabel];
if (notesBadge) toolbarChildren.push(notesBadge);
@ -336,7 +340,7 @@
containerEl.appendChild(editorWrap);
if (editorMode === 'notes-markdown') {
containerEl.appendChild(el('div', { className: 'de-notes-info' }, ['Notes context active. Note actions, backlinks, and graph tools are reserved for the future Notes plugin.']));
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() {
@ -349,16 +353,16 @@
function updateStatus() {
if (saveState === 'saving') {
statusEl.textContent = 'Saving...';
statusEl.textContent = tr('ui.saving', null, 'Saving...');
statusEl.className = 'de-status saving';
} else if (saveState === 'error') {
statusEl.textContent = 'Error saving';
statusEl.textContent = tr('ui.saveError', null, 'Error saving');
statusEl.className = 'de-status error';
} else if (dirty) {
statusEl.textContent = 'Modified';
statusEl.textContent = tr('ui.modified', null, 'Modified');
statusEl.className = 'de-status dirty';
} 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';
} else {
statusEl.textContent = '';
@ -475,9 +479,9 @@
}
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.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);
readPromise.then(function (content) {
if (disposed) return;
@ -490,7 +494,7 @@
if (disposed) return;
editorWrap.innerHTML = '';
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)])
]));
});
@ -534,6 +538,17 @@
loadSecretProviderAvailability();
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) {
var secretLink = event.target.closest('.secret-link');
@ -582,6 +597,7 @@
containerEl.__deCleanup = function () {
disposed = true;
if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
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",
"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.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official",
"icon": "edit",
"provides": [

View File

@ -108,6 +108,10 @@
var request = props && props.request || {};
var path = request.path || '';
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.className = 'fp-root';
containerEl.setAttribute('data-plugin-id', 'verstak.file-preview');
@ -116,19 +120,19 @@
var openButton = el('button', {
className: 'fp-btn',
'data-action': 'open-external',
textContent: 'Open External',
textContent: tr('ui.openExternal', null, 'Open External'),
onClick: function () {
if (api.files.openExternal) api.files.openExternal(path).catch(function (err) { console.error('[file-preview] openExternal:', err); });
}
});
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-spacer' }),
openButton
]));
var body = el('div', { className: 'fp-loading' }, ['Loading...']);
var body = el('div', { className: 'fp-loading' }, [tr('ui.loading', null, 'Loading...')]);
containerEl.appendChild(body);
api.files.metadata(path).then(function (meta) {
@ -140,8 +144,13 @@
});
}).catch(function (err) {
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) {
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",
"apiVersion": "0.1.0",
"description": "Read-only inline image preview provider for vault files.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official",
"icon": "eye",
"provides": [

View File

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

View File

@ -273,18 +273,22 @@
var scope = scopeFromProps(props || {});
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 modalHost = el('div', { className: 'journal-modal-host', hidden: 'hidden' });
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 statusEl = el('span', { className: 'journal-status' });
var addBtn = el('button', {
className: 'journal-btn primary',
'data-journal-action': 'add',
innerHTML: iconSvg('add') + '<span>Add</span>',
innerHTML: iconSvg('add') + '<span>' + tr('ui.add', null, 'Add') + '</span>',
onClick: function () { showEntryModal(); }
});
toolbar.appendChild(titleEl);
@ -302,7 +306,7 @@
if (scope.mode !== 'workspace') 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) {
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';
});
}
@ -316,19 +320,19 @@
all = all.concat(normalizeEntries((settings || {})[key], key));
});
entries = sortEntries(all);
statusText = 'Aggregating worklogs';
statusText = tr('ui.aggregating', null, 'Aggregating worklogs');
statusClass = '';
}).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';
});
}
return api.settings.read(scope.key).then(function (stored) {
entries = sortEntries(normalizeEntries(stored, scope.key));
statusText = 'Ready';
statusText = tr('ui.ready', null, 'Ready');
statusClass = '';
}).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';
});
}
@ -344,8 +348,8 @@
var reviewingCandidate = !editing && !!candidate;
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 titleInput = el('input', { className: 'journal-input', type: 'text', placeholder: '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 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: tr('ui.body', null, 'Body'), 'data-journal-input': 'summary' });
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 billableInput = el('input', { type: 'checkbox', 'data-journal-input': 'billable' });
@ -397,20 +401,20 @@
if (event.target === event.currentTarget) closeEntryModal();
} }, [
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,
todoContext,
el('div', { className: 'journal-modal-grid' }, [
el('label', { className: 'journal-field' }, ['Date', dateInput]),
el('label', { className: 'journal-field' }, ['Minutes', minutesInput]),
el('label', { className: 'journal-field wide' }, ['Title', titleInput]),
el('label', { className: 'journal-field wide' }, ['Body', summaryInput]),
el('label', { className: 'journal-billable' }, [billableInput, 'Billable'])
el('label', { className: 'journal-field' }, [tr('ui.date', null, 'Date'), dateInput]),
el('label', { className: 'journal-field' }, [tr('ui.minutes', null, 'Minutes'), minutesInput]),
el('label', { className: 'journal-field wide' }, [tr('ui.fieldTitle', null, 'Title'), titleInput]),
el('label', { className: 'journal-field wide' }, [tr('ui.body', null, 'Body'), summaryInput]),
el('label', { className: 'journal-billable' }, [billableInput, tr('ui.billable', null, 'Billable')])
]),
candidateActivities,
el('div', { className: 'journal-modal-actions' }, [
el('button', { className: 'journal-btn ghost', type: 'button', textContent: '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 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 ? tr('ui.saveChanges', null, 'Save changes') : tr('ui.addEntryShort', null, 'Add entry'), onClick: saveEntry })
])
])
]));
@ -421,7 +425,7 @@
if (scope.mode !== 'workspace') return;
var title = text(formValue && formValue.title).trim();
if (!title) {
statusText = 'Title is required';
statusText = tr('ui.titleRequired', null, 'Title is required');
statusClass = 'error';
render();
return;
@ -469,7 +473,7 @@
function deleteEntry(entry) {
if (scope.mode !== 'workspace' || !entry) return;
entries = entries.filter(function (item) { return item.entryId !== entry.entryId; });
statusText = 'Entry deleted';
statusText = tr('ui.deleted', null, 'Entry deleted');
statusClass = '';
persist().then(render);
}
@ -477,7 +481,7 @@
function renderList() {
listEl.innerHTML = '';
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;
}
entries.forEach(function (entry) {
@ -493,8 +497,8 @@
]),
el('div', { className: 'journal-minutes', textContent: entry.minutes + ' min' }),
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 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', 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: tr('ui.delete', null, 'Delete'), 'aria-label': tr('ui.delete', null, 'Delete'), 'data-journal-action': 'delete', innerHTML: iconSvg('trash'), onClick: function () { deleteEntry(entry); } })
]) : null
]));
});
@ -516,6 +520,13 @@
if (candidate) showEntryModal(null, candidate);
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) {

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",
"apiVersion": "0.1.0",
"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",
"icon": "book-open",
"provides": [

View File

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

View File

@ -63,6 +63,11 @@
containerEl.innerHTML = '';
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 = [];
function trackCleanup(fn) {
@ -79,7 +84,7 @@
var header = div('pt-header', [
span('pt-icon', '◉'),
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]),
]),
span('pt-version', 'v' + (props && props.version ? props.version : '0.1.0')),
@ -88,7 +93,7 @@
/* ── Status badge ──────────────────────────────────────────── */
var badge = div('pt-badge pt-badge-success', [
el('span', {}, ['✅']),
el('span', {}, ['Frontend Bundle Loaded']),
el('span', {}, [tr('ui.bundleLoaded', null, 'Frontend Bundle Loaded')]),
]);
var badgeRow = div('', [badge]);
@ -274,7 +279,7 @@
});
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('li', { className: 'pt-list-item' }, [
span('pt-list-label', 'Persisted setting'),
@ -361,7 +366,7 @@
});
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,
testsList,
]);
@ -398,7 +403,7 @@
});
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,
]);
@ -421,7 +426,7 @@
});
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,
]);
@ -456,7 +461,7 @@
});
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,
]);
@ -584,7 +589,7 @@
trackCleanup(function () { stopMouseCapture(); });
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' } }, [
'Captures ALL mouse/pointer events on window. Press back/forward buttons to see what WebKitGTK reports.',
]),
@ -667,6 +672,11 @@
containerEl.innerHTML = '';
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) ──────────────────── */
var counterState = { value: 0 };
@ -674,7 +684,7 @@
var header = div('pt-header', [
span('pt-icon', '⚙️'),
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]),
]),
]);
@ -697,24 +707,24 @@
var incrementBtn = el('button', { className: 'btn btn-primary', onClick: function () {
counterState.value += 1;
counterDisplay.firstChild.textContent = String(counterState.value);
}}, ['+ Increment']);
}}, [tr('ui.increment', null, '+ Increment')]);
var decrementBtn = el('button', { className: 'btn btn-secondary', onClick: function () {
counterState.value = Math.max(0, counterState.value - 1);
counterDisplay.firstChild.textContent = String(counterState.value);
}}, [' Decrement']);
}}, [tr('ui.decrement', null, ' Decrement')]);
var resetBtn = el('button', { className: 'btn btn-secondary', onClick: function () {
counterState.value = 0;
counterDisplay.firstChild.textContent = '0';
}}, ['↺ Reset']);
}}, [tr('ui.reset', null, '↺ Reset')]);
var btnGroup = el('div', { style: { display: 'flex', gap: '0.5rem' } }, [
incrementBtn, decrementBtn, resetBtn,
]);
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,
btnGroup,
el('p', { style: { marginTop: '0.75rem', color: '#6c6c8a', fontSize: '0.7rem' } }, [
@ -740,7 +750,7 @@
});
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,
el('p', { style: { marginTop: '0.5rem', color: '#6c6c8a', fontSize: '0.7rem' } }, [
'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",
"apiVersion": "0.1.0",
"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",
"icon": "flask",
"provides": [

View File

@ -315,6 +315,11 @@
var statusEl = null;
var alertEl = 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() {
if (input) return;
@ -325,7 +330,7 @@
input = el('input', {
className: 'search-input',
type: 'search',
placeholder: 'Search files, folders, text',
placeholder: tr('ui.placeholder', null, 'Search files, folders, text'),
value: state.query,
'data-search-input': 'query',
onInput: function (event) {
@ -341,7 +346,7 @@
containerEl.appendChild(el('div', { className: 'search-toolbar' }, [
input,
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' });
@ -357,7 +362,7 @@
if (document.activeElement !== input && 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;
statusEl.className = 'search-status' + (state.error ? ' error' : '');
statusEl.textContent = state.error || state.status;
@ -365,7 +370,7 @@
if (state.providerErrors && state.providerErrors.length) {
if (typeof alertEl.removeAttribute === 'function') alertEl.removeAttribute('hidden');
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('; ')])
]));
} else if (typeof alertEl.setAttribute === 'function') {
@ -373,7 +378,7 @@
}
resultsEl.innerHTML = '';
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;
}
state.results.forEach(function (result) {
@ -391,7 +396,7 @@
]),
result.openable ? el('button', {
className: 'search-btn search-open-btn',
textContent: 'Open',
textContent: tr('ui.open', null, 'Open'),
'data-search-open': result.path,
onClick: function () {
api.workbench.openResource({
@ -415,7 +420,7 @@
if (query.length < 2) {
state.searching = false;
state.results = [];
state.status = 'Enter at least 2 characters.';
state.status = tr('ui.minChars', null, 'Enter at least 2 characters.');
state.error = '';
state.providerErrors = [];
render();
@ -432,7 +437,7 @@
state.query = String(state.query || '').trim();
if (state.query.length < 2) {
state.results = [];
state.status = 'Enter at least 2 characters.';
state.status = tr('ui.minChars', null, 'Enter at least 2 characters.');
state.error = '';
state.providerErrors = [];
render();
@ -441,7 +446,7 @@
state.searching = true;
state.error = '';
state.providerErrors = [];
state.status = 'Searching...';
state.status = tr('ui.searching', null, 'Searching...');
var seq = searchSeq + 1;
searchSeq = seq;
render();
@ -451,7 +456,7 @@
var external = await runExternalProviders(api, rootPath, state.query, MAX_RESULTS - results.length);
if (seq !== searchSeq) return;
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;
} catch (err) {
if (seq !== searchSeq) return;
@ -507,6 +512,12 @@
setupIntegrations();
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 () {
if (searchTimer) clearTimeout(searchTimer);
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",
"apiVersion": "0.1.0",
"description": "Workspace-scoped vault text search provider.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official",
"icon": "search",
"provides": [

View File

@ -150,6 +150,10 @@
var unlocked = false;
var statusText = '';
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) {
statusText = message || '';
@ -162,13 +166,13 @@
className: 'secrets-input',
type: 'password',
'data-secret-master-password': '',
placeholder: 'Master password'
placeholder: tr('ui.masterPassword', null, 'Master password')
});
var confirmInput = initialized ? null : el('input', {
className: 'secrets-input',
type: 'password',
'data-secret-master-password-confirm': '',
placeholder: 'Repeat master password'
placeholder: tr('ui.repeatPassword', null, 'Repeat master password')
});
var unlockBtn = el('button', {
className: 'secrets-btn primary',
@ -176,7 +180,7 @@
'data-secret-unlock': '',
onClick: function () {
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;
}
unlockBtn.disabled = true;
@ -189,17 +193,17 @@
setStatus((err && err.message) ? err.message : String(err), true);
});
}
}, ['Unlock']);
if (!initialized) unlockBtn.textContent = 'Create master password';
}, [tr('ui.unlock', null, 'Unlock')]);
if (!initialized) unlockBtn.textContent = tr('ui.createMaster', null, 'Create master password');
var rows = [
el('div', { className: 'secrets-row' }, [
el('label', { className: 'secrets-label' }, ['Password']),
el('label', { className: 'secrets-label' }, [tr('ui.password', null, 'Password')]),
passwordInput
])
];
if (confirmInput) {
rows.push(el('div', { className: 'secrets-row' }, [
el('label', { className: 'secrets-label' }, ['Repeat']),
el('label', { className: 'secrets-label' }, [tr('ui.repeat', null, 'Repeat')]),
confirmInput
]));
}
@ -209,12 +213,12 @@
containerEl.appendChild(el('div', { className: 'secrets-root' }, [
el('div', { className: 'secrets-panel' }, [
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-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)
])
])
@ -224,14 +228,14 @@
function renderList() {
var children = [
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-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) {
children.push(el('div', { className: 'secrets-empty' }, ['No secrets']));
children.push(el('div', { className: 'secrets-empty' }, [tr('ui.empty', null, 'No secrets')]));
return children;
}
groupRecords(records).forEach(function (group) {
@ -255,17 +259,17 @@
function renderSelected() {
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' }, [
el('h2', {}, [selectedRecord.title || selectedRecord.id]),
el('table', { className: 'secrets-table' }, [
el('tbody', {}, [
fieldRow('Group', scopeLabel(selectedRecord)),
fieldRow(tr('ui.group', null, 'Group'), scopeLabel(selectedRecord)),
fieldRow('ID', selectedRecord.id),
fieldRow('Username', selectedRecord.username || ''),
fieldRow('Password', selectedValue ? selectedValue : '••••••••••••', selectedValue ? '' : 'secrets-hidden-value'),
fieldRow('Updated', selectedRecord.updatedAt || '')
fieldRow(tr('ui.username', null, 'Username'), selectedRecord.username || ''),
fieldRow(tr('ui.password', null, 'Password'), selectedValue ? selectedValue : '••••••••••••', selectedValue ? '' : 'secrets-hidden-value'),
fieldRow(tr('ui.updated', null, 'Updated'), selectedRecord.updatedAt || '')
])
]),
el('div', { className: 'secrets-actions' }, [
@ -274,19 +278,19 @@
type: 'button',
'data-secret-copy-link': selectedRecord.id,
onClick: function () { copySecretLink(selectedRecord.id); }
}, ['Copy secret link']),
}, [tr('ui.copyLink', null, 'Copy secret link')]),
el('button', {
className: 'secrets-btn',
type: 'button',
'data-secret-edit': selectedRecord.id,
onClick: function () { showEditSecret(); }
}, ['Edit']),
}, [tr('ui.edit', null, 'Edit')]),
el('button', {
className: 'secrets-btn danger',
type: 'button',
'data-secret-delete': selectedRecord.id,
onClick: function () { deleteSecret(selectedRecord.id); }
}, ['Delete'])
}, [tr('ui.delete', null, 'Delete')])
]),
el('div', { className: statusError ? 'secrets-status error' : 'secrets-status' }, [statusText])
]);
@ -301,28 +305,28 @@
function renderSecretForm(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) : '';
var id = el('input', { className: 'secrets-input', type: 'text', placeholder: 'stable.id' });
id.value = existing ? text(existing.id) : '';
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) : '';
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 : '';
var scope = el('select', { className: 'secrets-select' }, [
el('option', { value: ScopeGlobal }, ['Global']),
el('option', { value: ScopeWorkspace }, [workspaceRoot || 'Workspace'])
el('option', { value: ScopeGlobal }, [tr('ui.global', null, 'Global')]),
el('option', { value: ScopeWorkspace }, [workspaceRoot || tr('ui.workspace', null, 'Workspace')])
]);
scope.value = existing && existing.scope && existing.scope.kind ? existing.scope.kind : (workspaceRoot ? ScopeWorkspace : ScopeGlobal);
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-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' }, ['Username']), username]),
el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, ['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.username', null, 'Username')]), username]),
el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, [tr('ui.scope', null, 'Scope')]), scope]),
el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, [tr('ui.value', null, 'Value')]), value]),
el('div', { className: 'secrets-actions' }, [
el('button', {
className: 'secrets-btn primary',
@ -345,8 +349,8 @@
setStatus((err && err.message) ? err.message : String(err), true);
});
}
}, ['Save']),
el('button', { className: 'secrets-btn', type: 'button', onClick: function () { mode = 'selected'; render(); } }, ['Cancel'])
}, [tr('ui.save', null, 'Save')]),
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])
])
@ -451,8 +455,13 @@
renderLocked();
});
var localeUnsubscribe = api.i18n && typeof api.i18n.onDidChangeLocale === 'function'
? api.i18n.onDidChangeLocale(render)
: null;
containerEl.__secretsCleanup = function () {
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",
"apiVersion": "0.1.0",
"description": "Encrypted global and workspace-scoped secret manager.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official",
"icon": "key-round",
"provides": [

View File

@ -1,4 +1,6 @@
<script>
import { onDestroy, onMount } from 'svelte'
export let api = null
let settings = null
@ -15,19 +17,26 @@
let password = ''
let syncInterval = 5
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_FOCUS_STYLE = INPUT_STYLE + 'outline:none;border-color:#4ecca3;'
function sanitizeError(msg) {
if (!msg) return 'Unknown error'
if (!msg) return tr('ui.unknownError', null, 'Unknown error')
let s = String(msg).replace(/<[^>]+>/g, '')
if (s.length > 200) s = s.substring(0, 200) + '...'
return s
}
function 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
}
@ -57,7 +66,7 @@
async function saveSettings() {
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
}
loading = true
@ -68,7 +77,7 @@
await api.settings.writeAll({ serverUrl, username, autoSync, syncInterval })
}
await syncAPI().setInterval(autoSync ? syncInterval : 0)
resultMsg = 'Settings saved.'
resultMsg = tr('ui.settingsSaved', null, 'Settings saved.')
resultKind = ''
} catch (e) {
errorMsg = sanitizeError(e.message || e)
@ -77,7 +86,7 @@
}
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
connectionResult = ''
connectionOk = null
@ -85,22 +94,22 @@
try {
await syncAPI().testConnection(serverUrl, username, password)
connectionOk = true
connectionResult = 'Connection successful.'
connectionResult = tr('ui.connectionSuccessful', null, 'Connection successful.')
} catch (e) {
connectionOk = false
connectionResult = 'Connection failed: ' + sanitizeError(e.message || e)
connectionResult = tr('ui.connectionFailed', { error: sanitizeError(e.message || e) }, 'Connection failed: {error}')
}
loading = false
}
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
errorMsg = ''
connectionResult = ''
try {
await syncAPI().configure(serverUrl, username, password)
connectionResult = 'Connected successfully.'
connectionResult = tr('ui.connectedSuccessfully', null, 'Connected successfully.')
connectionOk = true
username = ''
password = ''
@ -115,8 +124,8 @@
const conflicts = Array.isArray(result?.conflicts) ? result.conflicts : []
const applyErrors = Array.isArray(result?.applyErrors) ? result.applyErrors : []
const parts = []
if (conflicts.length > 0) parts.push(conflicts.length + ' conflict(s)')
if (applyErrors.length > 0) parts.push(applyErrors.length + ' error(s)')
if (conflicts.length > 0) parts.push(tr('ui.conflictsCount', { count: conflicts.length }, '{count} conflict(s)'))
if (applyErrors.length > 0) parts.push(tr('ui.errorsCount', { count: applyErrors.length }, '{count} error(s)'))
return parts.join(' · ')
}
@ -146,7 +155,7 @@
conflictDetails = []
try {
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 conflicts = Array.isArray(r?.conflicts) ? r.conflicts : []
conflictDetails = conflicts.slice(0, 5).map(formatSyncConflict)
@ -167,7 +176,7 @@
function saveInterval() {
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
}
autoSync = syncInterval > 0
@ -180,7 +189,7 @@
resultMsg = ''
try {
await syncAPI().disconnect()
resultMsg = 'Disconnected from server.'
resultMsg = tr('ui.disconnected', null, 'Disconnected from server.')
resultKind = ''
settings = null
await load()
@ -196,7 +205,7 @@
resultMsg = ''
try {
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 = ''
await load()
} catch (e) {
@ -204,11 +213,19 @@
}
loading = false
}
onMount(() => {
unsubscribeLocale = api?.i18n?.onDidChangeLocale?.((nextLocale) => {
locale = nextLocale
})
})
onDestroy(() => unsubscribeLocale?.())
</script>
<div style="padding:1.5rem;max-width:500px;">
<h2 style="margin:0 0 0.25rem;color:#e0e0f0;font-size:1.2rem;">Sync</h2>
<p style="color:#a0a0b8;font-size:0.85rem;margin-bottom:1.25rem;">Synchronize your vault across devices.</p>
<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;">{tr('ui.description', null, 'Synchronize your vault across devices.')}</p>
{#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>
@ -218,7 +235,7 @@
{/if}
{#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="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}
<div>{detail}</div>
{/each}
@ -226,31 +243,31 @@
{/if}
{#if settings && settings.lastError && !errorMsg}
<div style="padding:0.5rem 0.75rem;margin-bottom:0.75rem;background:rgba(255,107,107,0.1);border:1px solid rgba(255,107,107,0.3);border-radius:6px;color:#ff6b6b;font-size:0.85rem;">
Last sync error: {sanitizeError(settings.lastError)}
{tr('ui.lastSyncError', { error: sanitizeError(settings.lastError) }, 'Last sync error: {error}')}
</div>
{/if}
<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;">
<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" />
</div>
<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} />
</div>
<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} />
</div>
<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:#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:#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}>{tr('ui.connect', null, 'Connect')}</button>
</div>
{#if connectionResult}
@ -259,34 +276,34 @@
</div>
<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;">
<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 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;">
<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>
{#if settings && settings.lastSyncAt}
<div style="color:#a0a0b8;font-size:0.85rem;">
Last sync: {settings.lastSyncAt}
{tr('ui.lastSync', { date: settings.lastSyncAt }, 'Last sync: {date}')}
</div>
{/if}
</div>
<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}
<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={resetKey} disabled={loading}>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:#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}>{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}>{tr('ui.disconnect', null, 'Disconnect')}</button>
{/if}
</div>
</div>

View File

@ -5,6 +5,13 @@
let status = 'disabled'
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() {
try {
@ -32,11 +39,11 @@
function statusText() {
const labels = {
connected: 'Connected',
disconnected: 'Disconnected',
disabled: 'Not configured',
error: 'Error',
revoked: 'Revoked',
connected: tr('ui.status.connected', null, 'Connected'),
disconnected: tr('ui.status.disconnected', null, 'Disconnected'),
disabled: tr('ui.status.disabled', null, 'Not configured'),
error: tr('ui.status.error', null, 'Error'),
revoked: tr('ui.status.revoked', null, 'Revoked'),
}
return labels[status] || status
}
@ -50,14 +57,18 @@
onMount(() => {
loadStatus()
interval = setInterval(loadStatus, 15000)
unsubscribeLocale = api?.i18n?.onDidChangeLocale?.((nextLocale) => {
locale = nextLocale
})
})
onDestroy(() => {
if (interval) clearInterval(interval)
unsubscribeLocale?.()
})
</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-label">{statusText()}</span>
</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",
"version": "0.1.0",
"apiVersion": "0.1.0",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official",
"provides": ["verstak/sync/v1", "verstak/sync.status/v1"],
"requires": ["verstak/core/files/v1"],

View File

@ -215,8 +215,13 @@
var statusText = '';
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 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 statusEl = el('span', { className: 'todo-status' });
var filtersEl = el('div', { className: 'todo-filters' });
@ -228,10 +233,10 @@
render();
}
}, [
option('all', 'All statuses'),
option('open', 'Open'),
option('done', 'Done'),
option('cancelled', 'Cancelled')
option('all', tr('ui.allStatuses', null, 'All statuses')),
option('open', tr('ui.status.open', null, 'Open')),
option('done', tr('ui.status.done', null, 'Done')),
option('cancelled', tr('ui.status.cancelled', null, 'Cancelled'))
]);
var workspaceFilterEl = el('select', {
className: 'todo-select',
@ -249,14 +254,14 @@
render();
}
}, [
option('due', 'Sort by due date'),
option('reminder', 'Sort by reminder'),
option('updated', 'Sort by updated')
option('due', tr('ui.sort.due', null, 'Sort by due date')),
option('reminder', tr('ui.sort.reminder', null, 'Sort by reminder')),
option('updated', tr('ui.sort.updated', null, 'Sort by updated'))
]);
var searchInput = el('input', {
className: 'todo-input search',
type: 'search',
placeholder: 'Search todos',
placeholder: tr('ui.search', null, 'Search todos'),
'data-todo-filter': 'search',
onInput: function (event) {
searchQuery = text(event.target.value).trim().toLowerCase();
@ -267,7 +272,7 @@
className: 'todo-btn primary',
type: 'button',
'data-todo-action': 'add',
textContent: 'Add Todo',
textContent: tr('ui.add', null, 'Add Todo'),
onClick: function () { showTodoModal(null); }
});
var listEl = el('div', { className: 'todo-list' });
@ -309,8 +314,8 @@
function renderWorkspaceFilterOptions() {
if (scope.mode !== 'global') return;
workspaceFilterEl.innerHTML = '';
workspaceFilterEl.appendChild(option('', 'All workspaces'));
workspaceFilterEl.appendChild(option('__unassigned__', 'Unassigned'));
workspaceFilterEl.appendChild(option('', tr('ui.allWorkspaces', null, 'All workspaces')));
workspaceFilterEl.appendChild(option('__unassigned__', tr('ui.unassigned', null, 'Unassigned')));
workspaceRoots().forEach(function (workspace) {
workspaceFilterEl.appendChild(option(workspace, workspace));
});
@ -338,7 +343,7 @@
function persist() {
if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
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';
});
}
@ -348,7 +353,7 @@
return api.settings.read().then(function (settings) {
todos = sortTodos(normalizeTodos((settings || {})[GLOBAL_KEY]));
}).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';
});
}
@ -375,10 +380,10 @@
function showTodoModal(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 descriptionInput = el('textarea', { className: 'todo-input textarea', placeholder: 'Optional description', 'data-todo-input': 'description' });
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: tr('ui.descriptionPlaceholder', null, 'Optional description'), 'data-todo-input': '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';
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' });
@ -386,7 +391,7 @@
var workspace = editing ? existingTodo.workspaceRootPath : scope.workspaceRoot;
if (scope.mode === 'global') {
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) {
workspaceInput.appendChild(option(workspaceRoot, workspaceRoot));
});
@ -396,7 +401,7 @@
function saveTodo() {
var title = text(titleInput.value).trim();
if (!title) {
statusText = 'Title is required';
statusText = tr('ui.titleRequired', null, 'Title is required');
statusClass = 'error';
render();
return;
@ -427,20 +432,20 @@
todos = sortTodos(todos);
if (workspaceRoot && workspaceOptions.indexOf(workspaceRoot) === -1) workspaceOptions.push(workspaceRoot);
closeTodoModal();
statusText = editing ? 'Todo updated' : 'Todo added';
statusText = editing ? tr('ui.updated', null, 'Todo updated') : tr('ui.added', null, 'Todo added');
statusClass = '';
persist().then(render);
}
var fields = [
el('label', { className: 'todo-field wide' }, ['Title', titleInput]),
el('label', { className: 'todo-field wide' }, ['Description', descriptionInput]),
el('label', { className: 'todo-field' }, ['Priority', priorityInput]),
el('label', { className: 'todo-field' }, ['Due date', dueInput]),
el('label', { className: 'todo-field' }, ['Reminder', reminderInput])
el('label', { className: 'todo-field wide' }, [tr('ui.field.title', null, 'Title'), titleInput]),
el('label', { className: 'todo-field wide' }, [tr('ui.field.description', null, 'Description'), descriptionInput]),
el('label', { className: 'todo-field' }, [tr('ui.field.priority', null, 'Priority'), priorityInput]),
el('label', { className: 'todo-field' }, [tr('ui.field.due', null, 'Due date'), dueInput]),
el('label', { className: 'todo-field' }, [tr('ui.field.reminder', null, 'Reminder'), reminderInput])
];
if (workspaceInput) fields.push(el('label', { className: 'todo-field' }, ['Workspace', workspaceInput]));
else fields.push(el('div', { className: 'todo-field', textContent: 'Workspace: ' + scope.workspaceRoot }));
if (workspaceInput) fields.push(el('label', { className: 'todo-field' }, [tr('ui.field.workspace', null, 'Workspace'), workspaceInput]));
else fields.push(el('div', { className: 'todo-field', textContent: tr('ui.workspaceValue', { workspace: scope.workspaceRoot }, 'Workspace: ' + scope.workspaceRoot) }));
modalHost.innerHTML = '';
if (typeof modalHost.removeAttribute === 'function') modalHost.removeAttribute('hidden');
@ -448,11 +453,11 @@
if (event.target === event.currentTarget) closeTodoModal();
} }, [
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-modal-actions' }, [
el('button', { className: 'todo-btn', type: 'button', textContent: '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', type: 'button', textContent: tr('ui.cancel', null, 'Cancel'), onClick: closeTodoModal }),
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
});
});
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 = '';
persist().then(render);
}
function deleteTodo(todo) {
todos = todos.filter(function (item) { return item.id !== todo.id; });
statusText = 'Todo deleted';
statusText = tr('ui.deleted', null, 'Todo deleted');
statusClass = '';
persist().then(render);
}
@ -513,11 +518,11 @@
var workspace = cleanWorkspace(todo.workspaceRootPath);
var due = dueState(todo);
var reminderDue = reminderIsDue(todo);
if (scope.mode === 'global') meta.push(el('span', { className: 'todo-badge', textContent: workspace || 'Unassigned' }));
meta.push(el('span', { className: 'todo-badge ' + todo.priority, textContent: todo.priority + ' priority' }));
meta.push(el('span', { className: 'todo-badge', textContent: 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.reminderAt) meta.push(el('span', { className: 'todo-badge ' + (reminderDue ? 'reminder-due' : ''), textContent: (reminderDue ? 'Reminder due ' : 'Reminder ') + formatDate(todo.reminderAt) }));
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: tr('ui.priorityValue', { priority: tr('ui.priority.' + todo.priority, null, todo.priority) }, todo.priority + ' priority') }));
meta.push(el('span', { className: 'todo-badge', textContent: tr('ui.status.' + todo.status, null, todo.status) }));
if (todo.dueAt) meta.push(el('span', { className: 'todo-badge ' + due, textContent: tr('ui.dueValue', { prefix: due === 'overdue' ? tr('ui.overduePrefix', null, 'Overdue · ') : (due === 'due-soon' ? tr('ui.dueSoonPrefix', null, 'Due soon · ') : ''), date: formatDate(todo.dueAt) }, (due === 'overdue' ? 'Overdue · ' : (due === 'due-soon' ? 'Due soon · ' : '')) + 'Due ' + formatDate(todo.dueAt)) }));
if (todo.reminderAt) meta.push(el('span', { className: 'todo-badge ' + (reminderDue ? 'reminder-due' : ''), textContent: tr(reminderDue ? 'ui.reminderDueValue' : 'ui.reminderValue', { date: formatDate(todo.reminderAt) }, (reminderDue ? 'Reminder due ' : 'Reminder ') + formatDate(todo.reminderAt)) }));
return el('div', { className: 'todo-row-meta' }, meta);
}
@ -527,30 +532,30 @@
if (!visible.length) {
listEl.appendChild(el('div', {
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;
}
visible.forEach(function (todo) {
var actionButtons = [];
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') {
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': 'cancel', textContent: 'Cancel', onClick: function () { setTodoStatus(todo, 'cancelled'); } }));
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: tr('ui.cancel', null, 'Cancel'), onClick: function () { setTodoStatus(todo, 'cancelled'); } }));
} 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') {
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 danger', type: 'button', 'data-todo-action': 'delete', textContent: 'Delete', onClick: function () { deleteTodo(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: 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 }, [
el('div', { className: 'todo-row-main' }, [
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,
renderTodoMeta(todo)
@ -563,8 +568,8 @@
function render() {
var visible = visibleTodos();
countEl.textContent = visible.length === todos.length
? todos.length + ' todo' + (todos.length === 1 ? '' : 's')
: visible.length + ' of ' + todos.length + ' todos';
? tr('ui.count', { count: todos.length }, todos.length + ' todo' + (todos.length === 1 ? '' : 's'))
: tr('ui.filteredCount', { visible: visible.length, count: todos.length }, visible.length + ' of ' + todos.length + ' todos');
statusFilterEl.value = statusFilter;
sortEl.value = sortMode;
searchInput.value = searchQuery;
@ -578,6 +583,14 @@
Promise.all([loadStored(), loadWorkspaceOptions()]).then(function () {
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) {

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",
"apiVersion": "0.1.0",
"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",
"icon": "list-todo",
"provides": [

View File

@ -152,6 +152,10 @@
statusError: 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() {
var values = {};
@ -170,18 +174,18 @@
}
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;
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() {
var entry = state.entries.find(function (item) { return item.trashId === state.confirmingId; });
if (!entry) return null;
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('h3', {}, ['Delete permanently?']),
el('p', {}, ['This cannot be undone.']),
el('section', { className: 'trash-confirm', role: 'dialog', 'aria-modal': 'true', 'aria-label': tr('ui.deletePermanently', null, 'Delete permanently') }, [
el('h3', {}, [tr('ui.deleteQuestion', null, 'Delete permanently?')]),
el('p', {}, [tr('ui.cannotUndo', null, 'This cannot be undone.')]),
el('p', { className: 'trash-confirm-path' }, [entry.originalPath || nameFor(entry)]),
el('div', { className: 'trash-confirm-actions' }, [
el('button', {
@ -189,14 +193,14 @@
type: 'button',
'data-trash-confirm-cancel': entry.trashId,
onClick: function () { state.confirmingId = ''; render(); }
}, ['Cancel']),
}, [tr('ui.cancel', null, 'Cancel')]),
el('button', {
className: 'trash-btn trash-btn-danger',
type: 'button',
disabled: state.busyId === entry.trashId,
'data-trash-confirm-delete': entry.trashId,
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,
'data-trash-filter-workspace': '',
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) {
workspaceSelect.appendChild(el('option', { value: workspace }, [workspace]));
});
@ -224,9 +228,9 @@
'data-trash-filter-type': '',
onChange: function (event) { state.type = event.target.value; render(); }
}, [
el('option', { value: '' }, ['All types']),
el('option', { value: 'file' }, ['Files']),
el('option', { value: 'folder' }, ['Folders'])
el('option', { value: '' }, [tr('ui.allTypes', null, 'All types')]),
el('option', { value: 'file' }, [tr('ui.files', null, 'Files')]),
el('option', { value: 'folder' }, [tr('ui.folders', null, 'Folders')])
]);
var sortSelect = el('select', {
@ -235,28 +239,28 @@
'data-trash-sort': '',
onChange: function (event) { state.sort = event.target.value; render(); }
}, [
el('option', { value: 'date-desc' }, ['Deleted: newest']),
el('option', { value: 'date-asc' }, ['Deleted: oldest']),
el('option', { value: 'name-asc' }, ['Name']),
el('option', { value: 'type-asc' }, ['Type'])
el('option', { value: 'date-desc' }, [tr('ui.newest', null, 'Deleted: newest')]),
el('option', { value: 'date-asc' }, [tr('ui.oldest', null, 'Deleted: oldest')]),
el('option', { value: 'name-asc' }, [tr('ui.name', null, 'Name')]),
el('option', { value: 'type-asc' }, [tr('ui.type', null, 'Type')])
]);
containerEl.appendChild(el('div', { className: 'trash-toolbar' }, [
el('span', { className: 'trash-title' }, ['Trash']),
el('span', { className: 'trash-count' }, [state.entries.length + ' total']),
el('span', { className: 'trash-title' }, [tr('ui.title', null, 'Trash')]),
el('span', { className: 'trash-count' }, [tr('ui.total', { count: state.entries.length }, state.entries.length + ' total')]),
el('span', { className: 'trash-spacer' }),
el('input', {
className: 'trash-control trash-search',
type: 'search',
value: state.query,
placeholder: 'Filter name or path',
placeholder: tr('ui.filter', null, 'Filter name or path'),
'data-trash-filter-search': '',
onInput: function (event) { state.query = event.target.value; render(); }
}),
workspaceSelect,
typeSelect,
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', {
className: 'trash-status' + (state.statusError ? ' error' : ''),
@ -265,17 +269,17 @@
var list = el('div', { className: 'trash-list', 'data-trash-list': '' });
list.appendChild(el('div', { className: 'trash-header' }, [
el('span', {}, ['Name']),
el('span', {}, ['Workspace']),
el('span', {}, ['Original path']),
el('span', {}, ['Deleted']),
el('span', {}, ['Type / size']),
el('span', {}, ['Actions'])
el('span', {}, [tr('ui.name', null, 'Name')]),
el('span', {}, [tr('ui.workspace', null, 'Workspace')]),
el('span', {}, [tr('ui.originalPath', null, 'Original path')]),
el('span', {}, [tr('ui.deleted', null, 'Deleted')]),
el('span', {}, [tr('ui.typeSize', null, 'Type / size')]),
el('span', {}, [tr('ui.actions', null, 'Actions')])
]));
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) {
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 {
rows.forEach(function (entry) {
var size = formatSize(entry.size);
@ -296,14 +300,14 @@
disabled: state.busyId === entry.trashId,
'data-trash-restore': entry.trashId,
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', {
className: 'trash-btn trash-btn-danger',
type: 'button',
disabled: state.busyId === entry.trashId,
'data-trash-delete': entry.trashId,
onClick: function () { state.confirmingId = entry.trashId; render(); }
}, ['Delete permanently'])
}, [tr('ui.deletePermanently', null, 'Delete permanently')])
])
]));
});
@ -386,8 +390,12 @@
}
loadEntries();
var localeUnsubscribe = api.i18n && typeof api.i18n.onDidChangeLocale === 'function'
? api.i18n.onDidChangeLocale(render)
: null;
containerEl.__trashCleanup = function () {
state.disposed = true;
if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
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",
"apiVersion": "0.1.0",
"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",
"icon": "trash",
"provides": [

View File

@ -79,7 +79,14 @@ with open(path, 'w', encoding='utf-8') as f:
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
# Find the compiled binary (same name as plugin directory)
local bin_name="$plugin_name"
@ -91,7 +98,7 @@ with open(path, 'w', encoding='utf-8') as f:
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
echo " ❌ dist package missing plugin.json"
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"
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 ""
# Guard official plugins against bypassing the v2 plugin API for note features.
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 source = fs.readFileSync(sourcePath, '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 {
constructor(tagName) {
@ -140,8 +144,10 @@ function loadComponent(document, emittedEvents) {
return component;
}
function makeApi(initialSettings = {}) {
function makeApi(initialSettings = {}, initialLocale = 'en') {
const settings = { ...initialSettings };
let locale = initialLocale;
const localeListeners = [];
return {
settings,
settingsApi: {
@ -157,8 +163,31 @@ function makeApi(initialSettings = {}) {
{ name: 'Project', relativePath: 'Project', type: 'folder' },
],
},
setLocale(nextLocale) {
locale = nextLocale;
localeListeners.slice().forEach((listener) => listener(locale));
},
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 (!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-input', 'title').value = 'Prepare project review updated';
byData(container, 'data-todo-action', 'save').click();