Compare commits

..

23 Commits

Author SHA1 Message Date
mirivlad 484d599a9f docs: use Deal terminology in official plugins 2026-07-14 23:50:59 +08:00
mirivlad 5ddc63bb8d docs: describe search as Deal scoped 2026-07-14 23:20:08 +08:00
mirivlad f456287c2e fix: use Deal terminology in trash 2026-07-14 23:19:11 +08:00
mirivlad c8b8045da1 fix: use Deal terminology in todos 2026-07-14 23:17:08 +08:00
mirivlad 3a1920d104 fix: localize journal candidate review 2026-07-14 23:12:29 +08:00
mirivlad 713b95b5a8 fix: localize activity event labels 2026-07-14 23:08:00 +08:00
mirivlad b449e9c616 fix: simplify markdown editor chrome 2026-07-14 22:56:40 +08:00
mirivlad 90ad8e8467 fix: protect secrets values and deletion 2026-07-14 22:53:37 +08:00
mirivlad aa7956dec4 fix: localize files context menu 2026-07-14 22:49:59 +08:00
mirivlad 44b39ca051 fix: use modal forms for files 2026-07-14 22:46:14 +08:00
mirivlad c83c15bced fix: use modal forms for notes 2026-07-14 22:41:17 +08:00
mirivlad 99172944a1 fix: localize Browser materials workflow 2026-07-14 22:28:53 +08:00
mirivlad a1b3c31d0d fix: localize plugin operation errors 2026-07-14 22:00:24 +08:00
mirivlad 1f03226378 fix: localize Secrets operation errors 2026-07-14 20:56:25 +08:00
mirivlad bee365ad22 fix: support typed Todo reminder times 2026-07-14 20:50:14 +08:00
mirivlad dfc53f1a5f feat: add global secrets view 2026-07-14 20:41:40 +08:00
mirivlad dd75335727 fix: add entries from global journal 2026-07-14 19:43:21 +08:00
mirivlad f23fb6e993 fix: make Todo reminder time selectable 2026-07-14 19:38:00 +08:00
mirivlad 0cd2c2a6ab test: cover activity deletion confirmation 2026-07-14 19:38:00 +08:00
mirivlad 304620952f fix: confirm activity deletion 2026-07-14 19:19:06 +08:00
mirivlad 112fb4cbec fix: apply markdown blocks to full lines 2026-07-14 19:13:56 +08:00
mirivlad 78a9dd31a3 fix: open requested secrets from workbench 2026-07-14 19:13:56 +08:00
mirivlad eb0f468463 fix: make Todo reminders and selects usable 2026-07-14 08:00:33 +08:00
61 changed files with 2215 additions and 652 deletions

View File

@ -50,7 +50,7 @@ create a GitHub Release.
deletion is a separate action. deletion is a separate action.
- Saving a browser link creates a collision-safe `.url` file. Opening a link is - Saving a browser link creates a collision-safe `.url` file. Opening a link is
handled by Verstak, so it does not depend on a Linux `.url` file association. handled by Verstak, so it does not depend on a Linux `.url` file association.
- Activity sessions can be scoped to a durable workspace identity or to an - Activity sessions can be scoped to a durable Deal identity or to an
explicit unassigned scope. Journal creation is always a user action. explicit unassigned scope. Journal creation is always a user action.
- Todo is optional: Overview uses it only when the Todo capability is present. - Todo is optional: Overview uses it only when the Todo capability is present.

View File

@ -4,22 +4,22 @@ Browser Inbox is one global queue of browser captures. New and changed records a
stored in `captures:global`; previous `captures` and `captures:workspace:*` keys stored in `captures:global`; previous `captures` and `captures:workspace:*` keys
remain readable for migration compatibility. remain readable for migration compatibility.
Each capture contains `workspaceRootPath` and `processed`. An empty Each capture contains a durable `workspaceId`, its current or historical
`workspaceRootPath` means the capture is **Unassigned**. The global view can filter `workspaceRootPath`, and `processed`. An empty `workspaceRootPath` means the
all captures by assignment, workspace, processed state, and text search. A capture capture is **Unassigned**. The global view can filter all captures by assignment,
Deal, processed state, and text search. A capture
can be assigned, reassigned, made unassigned, marked processed or unprocessed, and can be assigned, reassigned, made unassigned, marked processed or unprocessed, and
deleted. deleted.
The local browser receiver adds the currently active workspace before publishing a The local browser receiver adds the currently active Deal before publishing a
capture event. When it has no active workspace, the capture remains unassigned capture event. When it has no active Deal, the capture remains unassigned
unless an explicit existing domain binding matches it. The frontend never assigns an unless an explicit existing domain binding matches it. The frontend never assigns an
untagged capture merely because a workspace view happens to be open. untagged capture merely because a Deal view happens to be open.
Workspace assignment currently uses the top-level vault folder path as the Deal assignment uses the immutable `workspaceId` stored in Deal metadata;
identifier because the core workspace model has no separate immutable ID. In the `workspaceRootPath` is an address and display value. A rename updates the latter,
current model that path is also the displayed workspace name, so a workspace rename while a newly created folder with the old name cannot take over existing captures.
requires a later reassignment of existing captures.
Workspace Inbox and Overview show only captures whose `workspaceRootPath` exactly Deal Inbox and Overview show only captures whose `workspaceId` matches the selected
matches the selected workspace. Unassigned captures remain visible only in the Deal. Unassigned captures remain visible only in the
global Browser Inbox. global Browser Inbox.

View File

@ -2,12 +2,12 @@
Todos are stored by the official `verstak.todo` plugin under the canonical Todos are stored by the official `verstak.todo` plugin under the canonical
`todos:global` settings key. A todo is global when `workspaceRootPath` is empty; `todos:global` settings key. A todo is global when `workspaceRootPath` is empty;
otherwise that field identifies the top-level workspace folder that owns it. otherwise that field identifies the top-level Deal folder that owns it.
The global Todos view aggregates all records and supports workspace, status, text, The global Todos view aggregates all records and supports Deal, status, text,
and due/reminder/updated sorting filters. The workspace Todos view shows only and due/reminder/updated sorting filters. The Deal Todos view shows only records
records whose `workspaceRootPath` exactly matches the current workspace. This whose `workspaceRootPath` exactly matches the current Deal. This keeps unassigned
keeps unassigned and other-workspace records out of a workspace tab and its and other-Deal records out of a Deal tab and its
Overview signals. Overview signals.
## Stored fields ## Stored fields
@ -20,15 +20,15 @@ Each record has a stable `id`, `title`, optional `description`, optional
## Reminders ## Reminders
The plugin stores reminder metadata and renders clear indicators for overdue, The plugin stores reminder metadata, renders clear indicators for overdue,
due-soon, and reminder-due todos. Verstak does not yet have a notification due-soon, and reminder-due todos, and schedules native desktop notifications when
scheduler, so a reminder does not create a native desktop notification or run in the Desktop notification capability is available. Without that capability, the
the background. reminder remains visible in Todos but is not treated as an error.
## Completed Todo to Journal ## Completed Todo to Journal
From a workspace Todo tab, a completed todo exposes **Create Journal Entry**. It From a Deal Todo tab, a completed todo exposes **Create Journal Entry**. It opens
opens the current workspace Journal with a normal, editable form. The form copies the current Deal Journal with a normal, editable form. The form copies
only factual data from the todo: title, description, completion date, and zero only factual data from the todo: title, description, completion date, and zero
minutes. It never generates a summary, duration, or billable status. minutes. It never generates a summary, duration, or billable status.
@ -40,8 +40,6 @@ own record.
## Visibility ## Visibility
The Plugin Manager can globally enable or disable the Todo plugin. The current The Plugin Manager can globally enable or disable the Todo plugin. A Deal template
workspace host has no per-workspace or template-level contribution filter yet, so controls whether its Todos tab is available; disabling the plugin hides the tab
a globally enabled Todo plugin contributes its tab to every workspace. Template without affecting stored todo records.
visibility will be handled with the workspace/template model rather than by a
Todo-specific exception.

View File

@ -37,19 +37,19 @@
'activity.session.handled' 'activity.session.handled'
]; ];
var EVENT_LABELS = { var EVENT_LABELS = {
'workspace.selected': 'Workspace selected', 'workspace.selected': { key: 'ui.event.workspaceSelected', fallback: 'Deal selected' },
'case.selected': 'Workspace selected', 'case.selected': { key: 'ui.event.workspaceSelected', fallback: 'Deal selected' },
'file.opened': 'File opened', 'file.opened': { key: 'ui.event.fileOpened', fallback: 'File opened' },
'file.changed': 'File changed', 'file.changed': { key: 'ui.event.fileChanged', fallback: 'File changed' },
'note.saved': 'Note edited', 'note.saved': { key: 'ui.event.noteEdited', fallback: 'Note edited' },
'action.started': 'Work session detected', 'action.started': { key: 'ui.event.workSessionDetected', fallback: 'Work session detected' },
'browser.capture.received': 'Browser capture received', 'browser.capture.received': { key: 'ui.event.browserCaptureReceived', fallback: 'Browser capture received' },
'browser.capture.page': 'Page captured', 'browser.capture.page': { key: 'ui.event.pageCaptured', fallback: 'Page captured' },
'browser.capture.selection': 'Selection captured', 'browser.capture.selection': { key: 'ui.event.selectionCaptured', fallback: 'Selection captured' },
'browser.capture.link': 'Link captured', 'browser.capture.link': { key: 'ui.event.linkCaptured', fallback: 'Link captured' },
'browser.capture.file': 'File captured', 'browser.capture.file': { key: 'ui.event.fileCaptured', fallback: 'File captured' },
'browser.capture.converted': 'Capture converted', 'browser.capture.converted': { key: 'ui.event.captureConverted', fallback: 'Capture converted' },
'browser.activity.domain': 'Browser domain activity' 'browser.activity.domain': { key: 'ui.event.browserDomainActivity', fallback: 'Browser domain activity' }
}; };
var LOW_VALUE_EVENT_TYPES = { var LOW_VALUE_EVENT_TYPES = {
'workspace.selected': true, 'workspace.selected': true,
@ -79,6 +79,7 @@
'.activity-btn.danger{border-color:rgba(233,69,96,.42);color:#ff9a9a}', '.activity-btn.danger{border-color:rgba(233,69,96,.42);color:#ff9a9a}',
'.activity-status{font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3);white-space:nowrap}', '.activity-status{font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3);white-space:nowrap}',
'.activity-status.error{display:inline-flex;border:1px solid rgba(233,69,96,.45);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-danger-muted,rgba(233,69,96,.14));color:#ffc6ce;padding:.18rem .4rem}', '.activity-status.error{display:inline-flex;border:1px solid rgba(233,69,96,.45);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-danger-muted,rgba(233,69,96,.14));color:#ffc6ce;padding:.18rem .4rem}',
'.activity-modal-host[hidden]{display:none}.activity-modal-overlay{position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;padding:1rem;background:rgba(0,0,0,.58)}.activity-modal{width:440px;max-width:96vw;display:grid;gap:.75rem;padding:1rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-lg,8px);background:var(--vt-color-surface,#15152c);box-shadow:0 18px 44px rgba(0,0,0,.38)}.activity-modal-title{font-size:.95rem;font-weight:650}.activity-modal-copy{color:var(--vt-color-text-secondary,#b7c0d4);font-size:.84rem;line-height:1.45}.activity-modal-actions{display:flex;justify-content:flex-end;gap:.5rem}.activity-btn.destructive{background:var(--vt-color-danger,#e94560);border-color:var(--vt-color-danger,#e94560);color:#fff}',
'.activity-candidates{border-bottom:1px solid rgba(32,43,70,.72);background:var(--vt-color-surface-muted,#111629);padding:.65rem .75rem;display:grid;gap:.5rem}', '.activity-candidates{border-bottom:1px solid rgba(32,43,70,.72);background:var(--vt-color-surface-muted,#111629);padding:.65rem .75rem;display:grid;gap:.5rem}',
'.activity-candidates-title{font-size:.76rem;font-weight:600;color:var(--vt-color-text-muted,#7f8aa3);text-transform:uppercase;letter-spacing:.04em}', '.activity-candidates-title{font-size:.76rem;font-weight:600;color:var(--vt-color-text-muted,#7f8aa3);text-transform:uppercase;letter-spacing:.04em}',
'.activity-candidate{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.65rem;align-items:start;padding:.65rem .75rem;border:1px solid rgba(78,204,163,.34);border-radius:var(--vt-radius-lg,8px);background:var(--vt-color-surface,#15152c)}', '.activity-candidate{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.65rem;align-items:start;padding:.65rem .75rem;border:1px solid rgba(78,204,163,.34);border-radius:var(--vt-radius-lg,8px);background:var(--vt-color-surface,#15152c)}',
@ -153,7 +154,7 @@
var workspaceRoot = workspaceFromProps(props); var workspaceRoot = workspaceFromProps(props);
var workspaceId = text(props && (props.workspaceId || (props.workspaceNode && props.workspaceNode.workspaceId))).trim(); var workspaceId = text(props && (props.workspaceId || (props.workspaceNode && props.workspaceNode.workspaceId))).trim();
if (!workspaceRoot) { if (!workspaceRoot) {
return { mode: 'global', key: GLOBAL_KEY, label: 'All workspaces', workspaceRoot: '', workspaceId: '' }; return { mode: 'global', key: GLOBAL_KEY, label: 'All Deals', workspaceRoot: '', workspaceId: '' };
} }
return { return {
mode: 'workspace', mode: 'workspace',
@ -411,25 +412,27 @@
}).slice(0, MAX_CANDIDATES); }).slice(0, MAX_CANDIDATES);
} }
function humanEventType(type) { function humanEventType(type, translate) {
return EVENT_LABELS[text(type).toLowerCase()] || ''; var label = EVENT_LABELS[text(type).toLowerCase()];
return label ? translate(label.key, null, label.fallback) : '';
} }
function humanEventTitle(activity) { function humanEventTitle(activity, translate) {
var explicit = text(activity && activity.title).trim(); var explicit = text(activity && activity.title).trim();
var type = text(activity && activity.type).trim(); var type = text(activity && activity.type).trim();
if (explicit && explicit.toLowerCase() !== type.toLowerCase()) return explicit; var label = humanEventType(type, translate);
return humanEventType(type) || text(activity && (activity.summary || activity.activityId)).trim() || 'Activity event'; if (explicit && explicit.toLowerCase() !== type.toLowerCase()) return label ? label + ' — ' + explicit : explicit;
return label || text(activity && (activity.summary || activity.activityId)).trim() || translate('ui.event.activity', null, 'Activity event');
} }
function eventKind(activity) { function eventKind(activity, translate) {
var type = text(activity && activity.type).toLowerCase(); var type = text(activity && activity.type).toLowerCase();
if (type.indexOf('browser.capture') === 0) return 'Capture'; if (type.indexOf('browser.capture') === 0) return translate('ui.kind.capture', null, 'Capture');
if (type.indexOf('file.') === 0) return 'File'; if (type.indexOf('file.') === 0) return translate('ui.kind.file', null, 'File');
if (type.indexOf('note.') === 0) return 'Note'; if (type.indexOf('note.') === 0) return translate('ui.kind.note', null, 'Note');
if (type.indexOf('workspace') !== -1 || type.indexOf('case.') === 0) return 'Workspace'; if (type.indexOf('workspace') !== -1 || type.indexOf('case.') === 0) return translate('ui.kind.deal', null, 'Deal');
if (type.indexOf('action.') === 0) return 'Work'; if (type.indexOf('action.') === 0) return translate('ui.kind.work', null, 'Work');
return 'Activity'; return translate('ui.kind.activity', null, 'Activity');
} }
function globalEventKeys(settings) { function globalEventKeys(settings) {
@ -562,6 +565,14 @@
var disposed = false; var disposed = false;
var unsubscribers = []; var unsubscribers = [];
function reportError(key, fallback, err) {
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
console.warn('[verstak.activity] ' + key, err);
}
statusText = tr(key, null, fallback);
statusClass = 'error';
}
var toolbar = el('div', { className: 'activity-toolbar' }); var toolbar = el('div', { className: 'activity-toolbar' });
var titleEl = el('span', { className: 'activity-title', textContent: scope.mode === 'global' ? tr('ui.title', null, 'Activity') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Activity · ' + scope.label) }); var titleEl = el('span', { className: 'activity-title', textContent: scope.mode === 'global' ? tr('ui.title', null, 'Activity') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Activity · ' + scope.label) });
var countEl = el('span', { className: 'activity-count' }); var countEl = el('span', { className: 'activity-count' });
@ -570,18 +581,7 @@
className: 'activity-btn danger', className: 'activity-btn danger',
'data-activity-action': 'clear', 'data-activity-action': 'clear',
textContent: tr('ui.clear', null, 'Clear'), textContent: tr('ui.clear', null, 'Clear'),
onClick: function () { onClick: showClearConfirmation
if (scope.mode === 'global') {
clearGlobal().then(render);
return;
}
events = [];
candidateSourceEvents = candidateSourceEvents.filter(function (activity) {
return candidateWorkspace(activity) !== scope.workspaceRoot;
});
updateCandidates();
clearWorkspaceRaw(scope.workspaceRoot).then(persist).then(render);
}
}); });
toolbar.appendChild(titleEl); toolbar.appendChild(titleEl);
toolbar.appendChild(countEl); toolbar.appendChild(countEl);
@ -594,9 +594,11 @@
'data-activity-section': 'work-session-candidates' 'data-activity-section': 'work-session-candidates'
}); });
var listEl = el('div', { className: 'activity-list' }); var listEl = el('div', { className: 'activity-list' });
var modalHost = el('div', { className: 'activity-modal-host', hidden: 'hidden' });
containerEl.appendChild(toolbar); containerEl.appendChild(toolbar);
containerEl.appendChild(candidatesEl); containerEl.appendChild(candidatesEl);
containerEl.appendChild(listEl); containerEl.appendChild(listEl);
containerEl.appendChild(modalHost);
function candidatesForWorkspace(workspaceRoot) { function candidatesForWorkspace(workspaceRoot) {
return visibleCandidates(candidateSourceEvents, workspaceRoot, sessionRegistry, dismissedByWorkspace, handledSessions); return visibleCandidates(candidateSourceEvents, workspaceRoot, sessionRegistry, dismissedByWorkspace, handledSessions);
@ -642,8 +644,7 @@
? events.filter(function (item) { return !item._storageKey || item._storageKey === GLOBAL_KEY; }) ? events.filter(function (item) { return !item._storageKey || item._storageKey === GLOBAL_KEY; })
: events; : events;
return api.settings.write(scope.key, storageEvents(toStore)).then(persistSessionRegistry).then(persistCandidateCaches).catch(function (err) { return api.settings.write(scope.key, storageEvents(toStore)).then(persistSessionRegistry).then(persistCandidateCaches).catch(function (err) {
statusText = tr('ui.saveError', { error: err && err.message ? err.message : String(err) }, 'Could not save activity: ' + (err && err.message ? err.message : String(err))); reportError('ui.saveError', 'Could not save activity. Please try again.', err);
statusClass = 'error';
}); });
} }
@ -676,11 +677,61 @@
statusText = tr('ui.cleared', null, 'Activity cleared'); statusText = tr('ui.cleared', null, 'Activity cleared');
statusClass = ''; statusClass = '';
}).catch(function (err) { }).catch(function (err) {
statusText = tr('ui.clearError', { error: err && err.message ? err.message : String(err) }, 'Could not clear activity: ' + (err && err.message ? err.message : String(err))); reportError('ui.clearError', 'Could not clear activity. Please try again.', err);
statusClass = 'error';
}); });
} }
function closeClearConfirmation() {
modalHost.innerHTML = '';
modalHost.setAttribute('hidden', 'hidden');
}
function clearCurrentScope() {
if (scope.mode === 'global') {
return clearGlobal().then(render);
}
events = [];
candidateSourceEvents = candidateSourceEvents.filter(function (activity) {
return candidateWorkspace(activity) !== scope.workspaceRoot;
});
updateCandidates();
return clearWorkspaceRaw(scope.workspaceRoot).then(persist).then(render);
}
function showClearConfirmation() {
var scopeMessage = scope.mode === 'global'
? tr('ui.clearGlobalWarning', null, 'This permanently deletes all recorded activity and journal suggestions in every case.')
: tr('ui.clearWorkspaceWarning', { workspace: scope.label }, 'This permanently deletes recorded activity for ' + scope.label + '. Activity in other cases remains.');
var confirmBtn = el('button', {
className: 'activity-btn danger destructive',
type: 'button',
'data-activity-clear-confirm': '',
textContent: tr('ui.confirmClear', null, 'Clear activity'),
onClick: function () {
confirmBtn.disabled = true;
clearCurrentScope().then(closeClearConfirmation);
}
});
modalHost.innerHTML = '';
if (typeof modalHost.removeAttribute === 'function') modalHost.removeAttribute('hidden');
else delete modalHost.attributes.hidden;
modalHost.appendChild(el('div', {
className: 'activity-modal-overlay',
onClick: function (event) {
if (event.target === event.currentTarget) closeClearConfirmation();
}
}, [
el('div', { className: 'activity-modal', role: 'dialog', 'aria-modal': 'true', 'data-activity-clear-confirmation': '' }, [
el('div', { className: 'activity-modal-title', textContent: tr('ui.clearConfirmTitle', null, 'Clear activity?') }),
el('div', { className: 'activity-modal-copy', textContent: scopeMessage }),
el('div', { className: 'activity-modal-actions' }, [
el('button', { className: 'activity-btn', type: 'button', 'data-activity-clear-cancel': '', textContent: tr('ui.cancel', null, 'Cancel'), onClick: closeClearConfirmation }),
confirmBtn
])
])
]));
}
function clearWorkspaceRaw(workspaceRoot) { function clearWorkspaceRaw(workspaceRoot) {
if (!api || !api.storage || !api.storage.data || typeof api.storage.data.readNDJSON !== 'function' || typeof api.storage.data.writeNDJSON !== 'function') { if (!api || !api.storage || !api.storage.data || typeof api.storage.data.readNDJSON !== 'function' || typeof api.storage.data.writeNDJSON !== 'function') {
return Promise.resolve(); return Promise.resolve();
@ -711,14 +762,10 @@
el('div', { className: 'activity-time', textContent: formatDate(activity.occurredAt) || '-' }), el('div', { className: 'activity-time', textContent: formatDate(activity.occurredAt) || '-' }),
el('div', { className: 'activity-main' }, [ el('div', { className: 'activity-main' }, [
el('div', { className: 'activity-row-head' }, [ el('div', { className: 'activity-row-head' }, [
el('span', { className: 'activity-type', textContent: eventKind(activity) }), el('span', { className: 'activity-type', textContent: eventKind(activity, tr) }),
el('span', { className: 'activity-title-text', textContent: humanEventTitle(activity) }) el('span', { className: 'activity-title-text', textContent: humanEventTitle(activity, tr) })
]), ]),
activity.summary ? el('div', { className: 'activity-summary', textContent: activity.summary }) : null, activity.summary ? el('div', { className: 'activity-summary', textContent: activity.summary }) : null
el('details', { className: 'activity-details' }, [
el('summary', {}, [tr('ui.details', null, 'Details')]),
el('div', { className: 'activity-source', textContent: tr('ui.eventSource', { event: activity.type, source: activity.sourcePluginId ? ' · ' + activity.sourcePluginId : '' }, 'Event: ' + activity.type + (activity.sourcePluginId ? ' · Source: ' + activity.sourcePluginId : '')) })
])
]) ])
])); ]));
}); });
@ -754,8 +801,7 @@
statusText = tr('ui.dismissed', null, 'Candidate dismissed'); statusText = tr('ui.dismissed', null, 'Candidate dismissed');
statusClass = ''; statusClass = '';
}).catch(function (err) { }).catch(function (err) {
statusText = 'Could not dismiss candidate: ' + (err && err.message ? err.message : String(err)); reportError('ui.dismissError', 'Could not dismiss the suggestion. Please try again.', err);
statusClass = 'error';
}).then(render); }).then(render);
} }
@ -776,19 +822,19 @@
el('div', {}, [ el('div', {}, [
el('div', { className: 'activity-candidate-title', textContent: tr('ui.candidate', null, 'Possible journal entry') }), el('div', { className: 'activity-candidate-title', textContent: tr('ui.candidate', null, 'Possible journal entry') }),
el('div', { className: 'activity-candidate-facts' }, [ el('div', { className: 'activity-candidate-facts' }, [
el('div', { textContent: 'Workspace: ' + candidate.workspaceRootPath }), el('div', { textContent: tr('ui.candidateDeal', { deal: candidate.workspaceRootPath }, 'Deal: ' + candidate.workspaceRootPath) }),
el('div', { textContent: 'Time: ' + candidateTimeRange(candidate) }), el('div', { textContent: tr('ui.candidateTime', { time: candidateTimeRange(candidate) }, 'Time: ' + candidateTimeRange(candidate)) }),
el('div', { textContent: 'Estimated duration: ' + candidate.estimatedMinutes + ' min' }), el('div', { textContent: tr('ui.candidateDuration', { minutes: candidate.estimatedMinutes }, 'Estimated duration: ' + candidate.estimatedMinutes + ' min') }),
el('div', { textContent: 'Activities: ' + candidate.activityCount }) el('div', { textContent: tr('ui.candidateActivities', { count: candidate.activityCount }, 'Activities: ' + candidate.activityCount) })
]), ]),
el('details', { className: 'activity-candidate-activities' }, [ el('details', { className: 'activity-candidate-activities' }, [
el('summary', { textContent: 'Included activities (' + candidate.activityCount + ')' }) el('summary', { textContent: tr('ui.includedActivities', { count: candidate.activityCount }, 'Included activities (' + candidate.activityCount + ')') })
].concat(candidate.activities.map(function (activity) { ].concat(candidate.activities.map(function (activity) {
return el('div', { className: 'activity-candidate-activity', textContent: activity.occurredAt + ' · ' + activity.type + ' · ' + activity.activityId }); return el('div', { className: 'activity-candidate-activity', textContent: activity.occurredAt + ' · ' + humanEventTitle(activity, tr) });
}))) })))
]), ]),
el('div', { className: 'activity-candidate-actions' }, [ el('div', { className: 'activity-candidate-actions' }, [
el('div', { className: 'activity-candidate-duration', textContent: candidate.estimatedMinutes + ' min' }), el('div', { className: 'activity-candidate-duration', textContent: tr('ui.minutes', { count: candidate.estimatedMinutes }, candidate.estimatedMinutes + ' min') }),
el('button', { className: 'activity-btn', type: 'button', 'data-work-session-action': 'review', textContent: tr('ui.review', null, 'Review'), onClick: function () { reviewCandidate(candidate); } }), el('button', { className: 'activity-btn', type: 'button', 'data-work-session-action': 'review', textContent: tr('ui.review', null, 'Review'), onClick: function () { reviewCandidate(candidate); } }),
el('button', { className: 'activity-btn', type: 'button', 'data-work-session-action': 'dismiss', textContent: tr('ui.dismiss', null, 'Dismiss'), onClick: function () { dismissCandidate(candidate); } }) el('button', { className: 'activity-btn', type: 'button', 'data-work-session-action': 'dismiss', textContent: tr('ui.dismiss', null, 'Dismiss'), onClick: function () { dismissCandidate(candidate); } })
]) ])
@ -797,7 +843,7 @@
} }
function render() { function render() {
countEl.textContent = events.length + ' event' + (events.length === 1 ? '' : 's'); countEl.textContent = tr(events.length === 1 ? 'ui.eventCount.one' : 'ui.eventCount.many', { count: events.length }, events.length + ' event' + (events.length === 1 ? '' : 's'));
clearBtn.disabled = events.length === 0; clearBtn.disabled = events.length === 0;
statusEl.textContent = statusText; statusEl.textContent = statusText;
statusEl.className = 'activity-status' + (statusClass ? ' ' + statusClass : ''); statusEl.className = 'activity-status' + (statusClass ? ' ' + statusClass : '');
@ -827,8 +873,7 @@
updateCandidates(); updateCandidates();
return persistSessionRegistry().then(persistCandidateCaches); return persistSessionRegistry().then(persistCandidateCaches);
}).catch(function (err) { }).catch(function (err) {
statusText = 'Could not load activity: ' + (err && err.message ? err.message : String(err)); reportError('ui.loadError', 'Could not load activity. Please try again.', err);
statusClass = 'error';
}); });
} }
@ -855,8 +900,7 @@
return api.commands.register(WORKLOG_COMMAND_ID, listWorkSessionCandidates).then(function (unregister) { return api.commands.register(WORKLOG_COMMAND_ID, listWorkSessionCandidates).then(function (unregister) {
if (typeof unregister === 'function') unsubscribers.push(unregister); if (typeof unregister === 'function') unsubscribers.push(unregister);
}).catch(function (err) { }).catch(function (err) {
statusText = 'Activity commands unavailable: ' + (err && err.message ? err.message : String(err)); reportError('ui.commandsUnavailable', 'Activity actions are unavailable. Please try again.', err);
statusClass = 'error';
}); });
} }
@ -871,11 +915,12 @@
if (typeof unsubscribe === 'function') unsubscribers.push(unsubscribe); if (typeof unsubscribe === 'function') unsubscribers.push(unsubscribe);
}); });
})).then(function () { })).then(function () {
statusText = scope.mode === 'global' ? 'Listening for all activity' : 'Listening for workspace activity'; statusText = scope.mode === 'global'
? tr('ui.listeningGlobal', null, 'Listening for all activity')
: tr('ui.listeningDeal', null, 'Listening for Deal activity');
statusClass = ''; statusClass = '';
}).catch(function (err) { }).catch(function (err) {
statusText = 'Activity subscriptions unavailable: ' + (err && err.message ? err.message : String(err)); reportError('ui.subscriptionsUnavailable', 'Activity updates are unavailable. Please try again.', err);
statusClass = 'error';
}); });
} }

View File

@ -1,6 +1,6 @@
{ {
"manifest.name": "Activity", "manifest.name": "Activity",
"manifest.description": "Workspace-scoped activity log for public plugin events.", "manifest.description": "Deal-scoped activity log for public plugin events.",
"contributions.views.verstak.activity.view.title": "Activity", "contributions.views.verstak.activity.view.title": "Activity",
"contributions.sidebarItems.verstak.activity.sidebar.title": "Activity", "contributions.sidebarItems.verstak.activity.sidebar.title": "Activity",
"contributions.workspaceItems.verstak.activity.workspace.title": "Activity", "contributions.workspaceItems.verstak.activity.workspace.title": "Activity",
@ -9,9 +9,18 @@
"ui.title": "Activity", "ui.title": "Activity",
"ui.workspaceTitle": "Activity · {workspace}", "ui.workspaceTitle": "Activity · {workspace}",
"ui.clear": "Clear", "ui.clear": "Clear",
"ui.saveError": "Could not save activity: {error}", "ui.clearConfirmTitle": "Clear activity?",
"ui.clearGlobalWarning": "This permanently deletes all recorded activity and journal suggestions in every Deal.",
"ui.clearWorkspaceWarning": "This permanently deletes recorded activity for {workspace}. Activity in other Deals remains.",
"ui.confirmClear": "Clear activity",
"ui.cancel": "Cancel",
"ui.saveError": "Could not save activity. Please try again.",
"ui.cleared": "Activity cleared", "ui.cleared": "Activity cleared",
"ui.clearError": "Could not clear activity: {error}", "ui.clearError": "Could not clear activity. Please try again.",
"ui.dismissError": "Could not dismiss the suggestion. Please try again.",
"ui.loadError": "Could not load activity. Please try again.",
"ui.commandsUnavailable": "Activity actions are unavailable. Please try again.",
"ui.subscriptionsUnavailable": "Activity updates are unavailable. Please try again.",
"ui.empty": "No activity events yet", "ui.empty": "No activity events yet",
"ui.emptyHint": "File changes, browser captures, and conversions will appear here.", "ui.emptyHint": "File changes, browser captures, and conversions will appear here.",
"ui.details": "Details", "ui.details": "Details",
@ -20,5 +29,34 @@
"ui.candidates": "Possible journal entries", "ui.candidates": "Possible journal entries",
"ui.candidate": "Possible journal entry", "ui.candidate": "Possible journal entry",
"ui.review": "Review", "ui.review": "Review",
"ui.dismiss": "Dismiss" "ui.dismiss": "Dismiss",
"ui.event.workspaceSelected": "Deal selected",
"ui.event.fileOpened": "File opened",
"ui.event.fileChanged": "File changed",
"ui.event.noteEdited": "Note edited",
"ui.event.workSessionDetected": "Work session detected",
"ui.event.browserCaptureReceived": "Browser capture received",
"ui.event.pageCaptured": "Page captured",
"ui.event.selectionCaptured": "Selection captured",
"ui.event.linkCaptured": "Link captured",
"ui.event.fileCaptured": "File captured",
"ui.event.captureConverted": "Capture converted",
"ui.event.browserDomainActivity": "Browser domain activity",
"ui.event.activity": "Activity event",
"ui.kind.capture": "Capture",
"ui.kind.file": "File",
"ui.kind.note": "Note",
"ui.kind.deal": "Deal",
"ui.kind.work": "Work",
"ui.kind.activity": "Activity",
"ui.candidateDeal": "Deal: {deal}",
"ui.candidateTime": "Time: {time}",
"ui.candidateDuration": "Estimated duration: {minutes} min",
"ui.candidateActivities": "Activities: {count}",
"ui.includedActivities": "Included activities ({count})",
"ui.minutes": "{count} min",
"ui.eventCount.one": "{count} event",
"ui.eventCount.many": "{count} events",
"ui.listeningGlobal": "Listening for all activity",
"ui.listeningDeal": "Listening for Deal activity"
} }

View File

@ -1,6 +1,6 @@
{ {
"manifest.name": "Активность", "manifest.name": "Активность",
"manifest.description": "Журнал публичных событий плагинов в рабочем пространстве.", "manifest.description": "Журнал публичных событий плагинов в Деле.",
"contributions.views.verstak.activity.view.title": "Активность", "contributions.views.verstak.activity.view.title": "Активность",
"contributions.sidebarItems.verstak.activity.sidebar.title": "Активность", "contributions.sidebarItems.verstak.activity.sidebar.title": "Активность",
"contributions.workspaceItems.verstak.activity.workspace.title": "Активность", "contributions.workspaceItems.verstak.activity.workspace.title": "Активность",
@ -9,9 +9,18 @@
"ui.title": "Активность", "ui.title": "Активность",
"ui.workspaceTitle": "Активность · {workspace}", "ui.workspaceTitle": "Активность · {workspace}",
"ui.clear": "Очистить", "ui.clear": "Очистить",
"ui.saveError": "Не удалось сохранить активность: {error}", "ui.clearConfirmTitle": "Очистить активность?",
"ui.clearGlobalWarning": "Будут безвозвратно удалены вся записанная активность и предложения для журнала во всех Делах.",
"ui.clearWorkspaceWarning": "Будет безвозвратно удалена записанная активность Дела «{workspace}». Активность других Дел останется.",
"ui.confirmClear": "Очистить активность",
"ui.cancel": "Отмена",
"ui.saveError": "Не удалось сохранить активность. Повторите попытку.",
"ui.cleared": "Активность очищена", "ui.cleared": "Активность очищена",
"ui.clearError": "Не удалось очистить активность: {error}", "ui.clearError": "Не удалось очистить активность. Повторите попытку.",
"ui.dismissError": "Не удалось отклонить предложение. Повторите попытку.",
"ui.loadError": "Не удалось загрузить активность. Повторите попытку.",
"ui.commandsUnavailable": "Действия с активностью недоступны. Повторите попытку.",
"ui.subscriptionsUnavailable": "Обновления активности недоступны. Повторите попытку.",
"ui.empty": "Событий активности пока нет", "ui.empty": "Событий активности пока нет",
"ui.emptyHint": "Здесь появятся изменения файлов, материалы из браузера и преобразования.", "ui.emptyHint": "Здесь появятся изменения файлов, материалы из браузера и преобразования.",
"ui.details": "Подробнее", "ui.details": "Подробнее",
@ -20,5 +29,34 @@
"ui.candidates": "Возможные записи журнала", "ui.candidates": "Возможные записи журнала",
"ui.candidate": "Возможная запись журнала", "ui.candidate": "Возможная запись журнала",
"ui.review": "Проверить", "ui.review": "Проверить",
"ui.dismiss": "Скрыть" "ui.dismiss": "Скрыть",
"ui.event.workspaceSelected": "Выбрано Дело",
"ui.event.fileOpened": "Открыт файл",
"ui.event.fileChanged": "Изменён файл",
"ui.event.noteEdited": "Изменена заметка",
"ui.event.workSessionDetected": "Обнаружена рабочая сессия",
"ui.event.browserCaptureReceived": "Получено сохранённое из браузера",
"ui.event.pageCaptured": "Сохранена страница",
"ui.event.selectionCaptured": "Сохранено выделение",
"ui.event.linkCaptured": "Сохранена ссылка",
"ui.event.fileCaptured": "Сохранён файл",
"ui.event.captureConverted": "Сохранённое преобразовано",
"ui.event.browserDomainActivity": "Активность на домене в браузере",
"ui.event.activity": "Событие активности",
"ui.kind.capture": "Сохранённое",
"ui.kind.file": "Файл",
"ui.kind.note": "Заметка",
"ui.kind.deal": "Дело",
"ui.kind.work": "Работа",
"ui.kind.activity": "Активность",
"ui.candidateDeal": "Дело: {deal}",
"ui.candidateTime": "Время: {time}",
"ui.candidateDuration": "Оценка длительности: {minutes} мин.",
"ui.candidateActivities": "Активностей: {count}",
"ui.includedActivities": "Учтённые активности ({count})",
"ui.minutes": "{count} мин.",
"ui.eventCount.one": "Событий: {count}",
"ui.eventCount.many": "Событий: {count}",
"ui.listeningGlobal": "Учёт всей активности включён",
"ui.listeningDeal": "Учёт активности Дела включён"
} }

View File

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

View File

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

View File

@ -1,25 +1,26 @@
{ {
"manifest.name": "Browser Inbox", "manifest.name": "Browser",
"manifest.description": "Global browser capture queue with explicit workspace assignment delivered through the local receiver event protocol.", "manifest.description": "Global browser materials with explicit Deal assignment delivered through the local receiver event protocol.",
"contributions.views.verstak.browser-inbox.view.title": "Browser Inbox", "contributions.views.verstak.browser-inbox.view.title": "Browser",
"contributions.sidebarItems.verstak.browser-inbox.sidebar.title": "Browser Inbox", "contributions.sidebarItems.verstak.browser-inbox.sidebar.title": "Browser",
"contributions.workspaceItems.verstak.browser-inbox.workspace.title": "Browser Inbox", "contributions.workspaceItems.verstak.browser-inbox.workspace.title": "Browser",
"contributions.settingsPanels.verstak.browser-inbox.settings.title": "Browser Inbox", "contributions.settingsPanels.verstak.browser-inbox.settings.title": "Browser",
"ui.connecting": "Connecting to receiver events...", "ui.connecting": "Connecting to receiver events...",
"ui.title": "Browser Inbox", "ui.title": "Browser",
"ui.workspaceTitle": "Browser Inbox · {workspace}", "ui.workspaceTitle": "Browser · {workspace}",
"ui.allCaptures": "All captures", "ui.allCaptures": "All captures",
"ui.allDeals": "All Deals",
"ui.unassigned": "Unassigned", "ui.unassigned": "Unassigned",
"ui.unprocessed": "Unprocessed", "ui.unprocessed": "Unprocessed",
"ui.processed": "Processed", "ui.processed": "Processed",
"ui.search": "Search captures", "ui.search": "Search captures",
"ui.clear": "Clear", "ui.clear": "Clear",
"ui.assignedHere": "Assigned to this workspace", "ui.assignedHere": "Assigned to this Deal",
"ui.selectCapture": "Select a capture to inspect it.", "ui.selectCapture": "Select a capture to inspect it.",
"ui.kind": "Kind", "ui.kind": "Kind",
"ui.domain": "Domain", "ui.domain": "Domain",
"ui.captured": "Captured", "ui.captured": "Captured",
"ui.workspace": "Workspace", "ui.workspace": "Deal",
"ui.status": "Status", "ui.status": "Status",
"ui.createNote": "Create Note", "ui.createNote": "Create Note",
"ui.createLink": "Create Link", "ui.createLink": "Create Link",
@ -35,5 +36,55 @@
"ui.copied": "{label} copied", "ui.copied": "{label} copied",
"ui.rotateConfirm": "Rotate pairing token?", "ui.rotateConfirm": "Rotate pairing token?",
"ui.rotating": "Rotating...", "ui.rotating": "Rotating...",
"ui.tokenRotated": "Token rotated" "ui.tokenRotated": "Token rotated",
"ui.saveError": "Could not update browser materials. Please try again.",
"ui.createNoteError": "Could not create the note. Please try again.",
"ui.createLinkError": "Could not create the link. Please try again.",
"ui.openLinkError": "Could not open the link. Please try again.",
"ui.createFileError": "Could not create the file. Please try again.",
"ui.loadError": "Could not load browser materials. Please try again.",
"ui.receiverError": "The browser receiver is unavailable. Please try again.",
"ui.pairingLoadError": "Could not load browser connection settings. Please try again.",
"ui.clipboardError": "Could not copy to the clipboard. Please try again.",
"ui.tokenRotateError": "Could not rotate the pairing token. Please try again.",
"ui.statusFilter": "Material status filter",
"ui.workspaceFilter": "Deal filter",
"ui.assignment": "Assign to Deal",
"ui.clearAssignment": "Clear assignment",
"ui.empty": "No browser materials yet. Send a page, selection, or link from the extension.",
"ui.emptyFiltered": "No materials match the current filters.",
"ui.untitledCapture": "Untitled material",
"ui.url": "URL",
"ui.browser": "Browser",
"ui.assignBeforeCreation": "Assign a Deal before creating a note, link, or file.",
"ui.markProcessed": "Mark processed",
"ui.markUnprocessed": "Mark unprocessed",
"ui.openLink": "Open link",
"ui.restore": "Restore to Inbox",
"ui.archive": "Archive",
"ui.deletePermanently": "Delete permanently",
"ui.captureReceived": "Material received",
"ui.captureLoadError": "Could not load the received material.",
"ui.inboxArchived": "Inbox archived",
"ui.workspaceCapturesArchived": "Deal materials archived",
"ui.assignedToWorkspace": "Material assigned to {workspace}",
"ui.captureUnassigned": "Material is unassigned",
"ui.captureArchived": "Material archived",
"ui.captureRestored": "Material restored to Inbox",
"ui.captureDeleted": "Material permanently deleted",
"ui.captureProcessed": "Material marked processed",
"ui.captureUnprocessed": "Material marked unprocessed",
"ui.creatingNote": "Creating note...",
"ui.noteCreated": "Note created: {path}",
"ui.creatingLink": "Creating link...",
"ui.linkCreated": "Link created",
"ui.creatingFile": "Creating file...",
"ui.fileCreated": "File created: {path}",
"ui.receiverReadyAll": "Receiver ready for all Deals",
"ui.receiverReadyWorkspace": "Receiver ready for this Deal",
"ui.items.one": "{count} item",
"ui.items.few": "{count} items",
"ui.items.many": "{count} items",
"ui.items.other": "{count} items",
"ui.items.filtered": "{visible} of {total}"
} }

View File

@ -1,25 +1,26 @@
{ {
"manifest.name": "Входящие из браузера", "manifest.name": "Браузер",
"manifest.description": "Общая очередь материалов из браузера с явным назначением рабочего пространства через локальный протокол приёма.", "manifest.description": "Общие материалы из браузера с явным назначением Дела через локальный протокол приёма.",
"contributions.views.verstak.browser-inbox.view.title": "Входящие из браузера", "contributions.views.verstak.browser-inbox.view.title": "Браузер",
"contributions.sidebarItems.verstak.browser-inbox.sidebar.title": "Входящие из браузера", "contributions.sidebarItems.verstak.browser-inbox.sidebar.title": "Браузер",
"contributions.workspaceItems.verstak.browser-inbox.workspace.title": "Входящие из браузера", "contributions.workspaceItems.verstak.browser-inbox.workspace.title": "Браузер",
"contributions.settingsPanels.verstak.browser-inbox.settings.title": "Входящие из браузера", "contributions.settingsPanels.verstak.browser-inbox.settings.title": "Браузер",
"ui.connecting": "Подключение к событиям приёмника...", "ui.connecting": "Подключение к событиям приёмника...",
"ui.title": "Входящие из браузера", "ui.title": "Браузер",
"ui.workspaceTitle": "Входящие из браузера · {workspace}", "ui.workspaceTitle": "Браузер · {workspace}",
"ui.allCaptures": "Все материалы", "ui.allCaptures": "Все материалы",
"ui.allDeals": "Все Дела",
"ui.unassigned": "Не назначено", "ui.unassigned": "Не назначено",
"ui.unprocessed": "Не обработано", "ui.unprocessed": "Не обработано",
"ui.processed": "Обработано", "ui.processed": "Обработано",
"ui.search": "Поиск материалов", "ui.search": "Поиск материалов",
"ui.clear": "Очистить", "ui.clear": "Очистить",
"ui.assignedHere": "Назначено этому рабочему пространству", "ui.assignedHere": "Назначено этому Делу",
"ui.selectCapture": "Выберите материал для просмотра.", "ui.selectCapture": "Выберите материал для просмотра.",
"ui.kind": "Тип", "ui.kind": "Тип",
"ui.domain": "Домен", "ui.domain": "Домен",
"ui.captured": "Получено", "ui.captured": "Получено",
"ui.workspace": "Рабочее пространство", "ui.workspace": "Дело",
"ui.status": "Состояние", "ui.status": "Состояние",
"ui.createNote": "Создать заметку", "ui.createNote": "Создать заметку",
"ui.createLink": "Создать ссылку", "ui.createLink": "Создать ссылку",
@ -35,5 +36,55 @@
"ui.copied": "{label} скопирован", "ui.copied": "{label} скопирован",
"ui.rotateConfirm": "Сменить токен сопряжения?", "ui.rotateConfirm": "Сменить токен сопряжения?",
"ui.rotating": "Смена токена...", "ui.rotating": "Смена токена...",
"ui.tokenRotated": "Токен изменён" "ui.tokenRotated": "Токен изменён",
"ui.saveError": "Не удалось обновить материалы браузера. Повторите попытку.",
"ui.createNoteError": "Не удалось создать заметку. Повторите попытку.",
"ui.createLinkError": "Не удалось создать ссылку. Повторите попытку.",
"ui.openLinkError": "Не удалось открыть ссылку. Повторите попытку.",
"ui.createFileError": "Не удалось создать файл. Повторите попытку.",
"ui.loadError": "Не удалось загрузить материалы браузера. Повторите попытку.",
"ui.receiverError": "Приёмник браузера недоступен. Повторите попытку.",
"ui.pairingLoadError": "Не удалось загрузить параметры подключения браузера. Повторите попытку.",
"ui.clipboardError": "Не удалось скопировать в буфер обмена. Повторите попытку.",
"ui.tokenRotateError": "Не удалось сменить токен сопряжения. Повторите попытку.",
"ui.statusFilter": "Фильтр состояния материала",
"ui.workspaceFilter": "Фильтр Дела",
"ui.assignment": "Назначить Делу",
"ui.clearAssignment": "Снять назначение",
"ui.empty": "Пока нет материалов из браузера. Отправьте страницу, выделенный текст или ссылку с помощью расширения.",
"ui.emptyFiltered": "Нет материалов по текущим фильтрам.",
"ui.untitledCapture": "Материал без названия",
"ui.url": "URL",
"ui.browser": "Браузер",
"ui.assignBeforeCreation": "Назначьте Дело перед созданием заметки, ссылки или файла.",
"ui.markProcessed": "Отметить обработанным",
"ui.markUnprocessed": "Отметить необработанным",
"ui.openLink": "Открыть ссылку",
"ui.restore": "Вернуть во входящие",
"ui.archive": "Архивировать",
"ui.deletePermanently": "Удалить навсегда",
"ui.captureReceived": "Материал получен",
"ui.captureLoadError": "Не удалось загрузить полученный материал.",
"ui.inboxArchived": "Входящие архивированы",
"ui.workspaceCapturesArchived": "Материалы Дела архивированы",
"ui.assignedToWorkspace": "Материал назначен Делу «{workspace}»",
"ui.captureUnassigned": "Материал не назначен",
"ui.captureArchived": "Материал архивирован",
"ui.captureRestored": "Материал возвращён во входящие",
"ui.captureDeleted": "Материал удалён навсегда",
"ui.captureProcessed": "Материал отмечен обработанным",
"ui.captureUnprocessed": "Материал отмечен необработанным",
"ui.creatingNote": "Создание заметки...",
"ui.noteCreated": "Заметка создана: {path}",
"ui.creatingLink": "Создание ссылки...",
"ui.linkCreated": "Ссылка создана",
"ui.creatingFile": "Создание файла...",
"ui.fileCreated": "Файл создан: {path}",
"ui.receiverReadyAll": "Приёмник готов для всех Дел",
"ui.receiverReadyWorkspace": "Приёмник готов для этого Дела",
"ui.items.one": "{count} материал",
"ui.items.few": "{count} материала",
"ui.items.many": "{count} материалов",
"ui.items.other": "{count} материала",
"ui.items.filtered": "{visible} из {total}"
} }

View File

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

View File

@ -18,7 +18,6 @@
'.de-root{display:flex;flex-direction:column;height:100%;min-height:0;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;color:#e0e0e0;background:#0d0d1a}', '.de-root{display:flex;flex-direction:column;height:100%;min-height:0;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;color:#e0e0e0;background:#0d0d1a}',
'.de-toolbar,.de-md-toolbar{display:flex;align-items:center;gap:.45rem;padding:.45rem .75rem;border-bottom:1px solid #16213e;flex-shrink:0;background:#12122a;flex-wrap:wrap}', '.de-toolbar,.de-md-toolbar{display:flex;align-items:center;gap:.45rem;padding:.45rem .75rem;border-bottom:1px solid #16213e;flex-shrink:0;background:#12122a;flex-wrap:wrap}',
'.de-md-toolbar{background:#101028;padding:.38rem .75rem}', '.de-md-toolbar{background:#101028;padding:.38rem .75rem}',
'.de-toolbar-mode{font-size:.75rem;color:#4ecca3;padding:.15rem .5rem;border-radius:3px;background:#1a2a3a}',
'.de-toolbar-context{font-size:.75rem;color:#a0a0bb;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}', '.de-toolbar-context{font-size:.75rem;color:#a0a0bb;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'.de-toolbar-spacer{flex:1}', '.de-toolbar-spacer{flex:1}',
'.de-toolbar-btn,.de-md-btn{font-size:.75rem;padding:.28rem .58rem;border:1px solid #333;border-radius:4px;background:#1a1a2e;color:#ccc;cursor:pointer}', '.de-toolbar-btn,.de-md-btn{font-size:.75rem;padding:.28rem .58rem;border:1px solid #333;border-radius:4px;background:#1a1a2e;color:#ccc;cursor:pointer}',
@ -45,7 +44,6 @@
'.de-preview table{border-collapse:collapse;margin:.8rem 0;max-width:100%;display:block;overflow:auto}.de-preview th,.de-preview td{border:1px solid #333;padding:.35rem .6rem;text-align:left}.de-preview th{background:#1a1a2e}', '.de-preview table{border-collapse:collapse;margin:.8rem 0;max-width:100%;display:block;overflow:auto}.de-preview th,.de-preview td{border:1px solid #333;padding:.35rem .6rem;text-align:left}.de-preview th{background:#1a1a2e}',
'.de-preview img{max-width:100%;height:auto;border-radius:4px}.de-preview .task{margin-right:.4rem}', '.de-preview img{max-width:100%;height:auto;border-radius:4px}.de-preview .task{margin-right:.4rem}',
'.de-notes-badge{font-size:.65rem;padding:.1rem .4rem;border-radius:3px;background:#2a1a3a;color:#b388ff}', '.de-notes-badge{font-size:.65rem;padding:.1rem .4rem;border-radius:3px;background:#2a1a3a;color:#b388ff}',
'.de-notes-info{padding:.45rem .75rem;background:#111126;border-top:1px solid #16213e;font-size:.75rem;color:#8b8ba8;flex-shrink:0}',
'.de-loading,.de-error{flex:1;display:flex;align-items:center;justify-content:center;color:#777;padding:2rem}.de-error{color:#e74c3c;flex-direction:column;gap:.5rem}.de-error-msg{font-size:.85rem;color:#aaa;max-width:420px;text-align:center}', '.de-loading,.de-error{flex:1;display:flex;align-items:center;justify-content:center;color:#777;padding:2rem}.de-error{color:#e74c3c;flex-direction:column;gap:.5rem}.de-error-msg{font-size:.85rem;color:#aaa;max-width:420px;text-align:center}',
'@media(max-width:780px){.de-editor-wrap{flex-direction:column}.de-pane+.de-pane{border-left:0;border-top:1px solid #16213e}.de-toolbar-context{max-width:100%}}' '@media(max-width:780px){.de-editor-wrap{flex-direction:column}.de-pane+.de-pane{border-left:0;border-top:1px solid #16213e}.de-toolbar-context{max-width:100%}}'
].join('\n'); ].join('\n');
@ -259,11 +257,14 @@
var start = textarea.selectionStart; var start = textarea.selectionStart;
var end = textarea.selectionEnd; var end = textarea.selectionEnd;
var value = textarea.value; var value = textarea.value;
var selected = value.slice(start, end) || placeholder || ''; var lineStart = value.lastIndexOf('\n', Math.max(0, start - 1)) + 1;
var lineEnd = value.indexOf('\n', end);
if (lineEnd === -1) lineEnd = value.length;
var selected = value.slice(lineStart, lineEnd) || placeholder || '';
var replacement = selected.split('\n').map(function (line) { return prefix + line; }).join('\n'); var replacement = selected.split('\n').map(function (line) { return prefix + line; }).join('\n');
textarea.value = value.slice(0, start) + replacement + value.slice(end); textarea.value = value.slice(0, lineStart) + replacement + value.slice(lineEnd);
textarea.selectionStart = start; textarea.selectionStart = lineStart;
textarea.selectionEnd = start + replacement.length; textarea.selectionEnd = lineStart + replacement.length;
textarea.dispatchEvent(new Event('input', { bubbles: true })); textarea.dispatchEvent(new Event('input', { bubbles: true }));
textarea.focus(); textarea.focus();
} }
@ -300,9 +301,8 @@
containerEl.setAttribute('data-resource-path', resourcePath); containerEl.setAttribute('data-resource-path', resourcePath);
containerEl.setAttribute('data-request-mode', requestedMode); containerEl.setAttribute('data-request-mode', requestedMode);
var modeLabel = el('span', { className: 'de-toolbar-mode' }, [editorMode]);
var contextLabel = el('span', { className: 'de-toolbar-context', title: resourcePath }, [resourcePath || fileName(resourcePath)]); var contextLabel = el('span', { className: 'de-toolbar-context', title: resourcePath }, [resourcePath || fileName(resourcePath)]);
var notesBadge = editorMode === 'notes-markdown' ? el('span', { className: 'de-notes-badge', 'data-notes-badge': '' }, [tr('ui.notesContext', null, 'notes context')]) : null; var notesBadge = editorMode === 'notes-markdown' ? el('span', { className: 'de-notes-badge', 'data-notes-badge': '' }, [tr('ui.note', null, 'Note')]) : null;
var spacer = el('span', { className: 'de-toolbar-spacer' }); var spacer = el('span', { className: 'de-toolbar-spacer' });
var editBtn = isMarkdown ? el('button', { className: 'de-toolbar-btn', 'data-editor-mode-button': 'edit' }, [tr('ui.edit', null, 'Edit')]) : null; var editBtn = isMarkdown ? el('button', { className: 'de-toolbar-btn', 'data-editor-mode-button': 'edit' }, [tr('ui.edit', null, 'Edit')]) : null;
var previewBtn = isMarkdown ? el('button', { className: 'de-toolbar-btn', 'data-editor-mode-button': 'preview' }, [tr('ui.preview', null, 'Preview')]) : null; var previewBtn = isMarkdown ? el('button', { className: 'de-toolbar-btn', 'data-editor-mode-button': 'preview' }, [tr('ui.preview', null, 'Preview')]) : null;
@ -310,7 +310,7 @@
var reloadBtn = el('button', { className: 'de-toolbar-btn', 'data-editor-action': 'reload' }, [tr('ui.reload', null, 'Reload')]); var reloadBtn = el('button', { className: 'de-toolbar-btn', 'data-editor-action': 'reload' }, [tr('ui.reload', null, 'Reload')]);
var saveBtn = el('button', { className: 'de-toolbar-btn', 'data-editor-action': 'save' }, [tr('ui.save', null, 'Save')]); var saveBtn = el('button', { className: 'de-toolbar-btn', 'data-editor-action': 'save' }, [tr('ui.save', null, 'Save')]);
var statusEl = el('span', { className: 'de-status', 'data-save-state': '' }); var statusEl = el('span', { className: 'de-status', 'data-save-state': '' });
var toolbarChildren = [modeLabel, contextLabel]; var toolbarChildren = [contextLabel];
if (notesBadge) toolbarChildren.push(notesBadge); if (notesBadge) toolbarChildren.push(notesBadge);
toolbarChildren.push(spacer); toolbarChildren.push(spacer);
[editBtn, previewBtn, splitBtn, reloadBtn, saveBtn, statusEl].forEach(function (node) { if (node) toolbarChildren.push(node); }); [editBtn, previewBtn, splitBtn, reloadBtn, saveBtn, statusEl].forEach(function (node) { if (node) toolbarChildren.push(node); });
@ -320,18 +320,18 @@
if (isMarkdown) { if (isMarkdown) {
mdToolbar = el('div', { className: 'de-md-toolbar' }); mdToolbar = el('div', { className: 'de-md-toolbar' });
[ [
['heading', 'H', 'Heading'], ['heading', 'H', 'ui.md.heading', 'Heading'],
['bold', 'B', 'Bold'], ['bold', 'B', 'ui.md.bold', 'Bold'],
['italic', 'I', 'Italic'], ['italic', 'I', 'ui.md.italic', 'Italic'],
['link', 'Link', 'Link'], ['link', 'Link', 'ui.md.link', 'Link'],
['code', 'Code', 'Inline code'], ['code', 'Code', 'ui.md.inlineCode', 'Inline code'],
['code-block', '```', 'Code block'], ['code-block', '```', 'ui.md.codeBlock', 'Code block'],
['bullet', '• List', 'Bullet list'], ['bullet', '• List', 'ui.md.bulletList', 'Bullet list'],
['numbered', '1. List', 'Numbered list'], ['numbered', '1. List', 'ui.md.numberedList', 'Numbered list'],
['quote', 'Quote', 'Quote'], ['quote', 'Quote', 'ui.md.quote', 'Quote'],
['task', 'Task', 'Task item'] ['task', 'Task', 'ui.md.taskItem', 'Task item']
].forEach(function (item) { ].forEach(function (item) {
mdToolbar.appendChild(el('button', { className: 'de-md-btn', 'data-md-action': item[0], title: item[2] }, [item[1]])); mdToolbar.appendChild(el('button', { className: 'de-md-btn', type: 'button', 'data-md-action': item[0], title: tr(item[2], null, item[3]), 'aria-label': tr(item[2], null, item[3]) }, [item[1]]));
}); });
containerEl.appendChild(mdToolbar); containerEl.appendChild(mdToolbar);
} }
@ -339,10 +339,6 @@
var editorWrap = el('div', { className: 'de-editor-wrap' }); var editorWrap = el('div', { className: 'de-editor-wrap' });
containerEl.appendChild(editorWrap); containerEl.appendChild(editorWrap);
if (editorMode === 'notes-markdown') {
containerEl.appendChild(el('div', { className: 'de-notes-info' }, [tr('ui.notesInfo', null, 'Notes context active. Note actions, backlinks, and graph tools are reserved for the future Notes plugin.')]));
}
function updateLineNumbers() { function updateLineNumbers() {
if (!linesEl || !textarea) return; if (!linesEl || !textarea) return;
var count = textarea.value.split('\n').length; var count = textarea.value.split('\n').length;
@ -492,10 +488,10 @@
rebuildEditorArea(); rebuildEditorArea();
}).catch(function (err) { }).catch(function (err) {
if (disposed) return; if (disposed) return;
console.warn('[default-editor] load error:', err);
editorWrap.innerHTML = ''; editorWrap.innerHTML = '';
editorWrap.appendChild(el('div', { className: 'de-error' }, [ editorWrap.appendChild(el('div', { className: 'de-error' }, [
el('div', {}, [tr('ui.loadFailed', null, 'Failed to load file')]), el('div', {}, [tr('ui.loadFailed', null, 'Could not load the file. Please try again.')])
el('div', { className: 'de-error-msg' }, [(err && err.message) ? err.message : String(err)])
])); ]));
}); });
} }
@ -540,12 +536,25 @@
reloadFromDisk(); reloadFromDisk();
var localeUnsubscribe = api.i18n && typeof api.i18n.onDidChangeLocale === 'function' var localeUnsubscribe = api.i18n && typeof api.i18n.onDidChangeLocale === 'function'
? api.i18n.onDidChangeLocale(function () { ? api.i18n.onDidChangeLocale(function () {
if (notesBadge) notesBadge.textContent = tr('ui.notesContext', null, 'notes context'); if (notesBadge) notesBadge.textContent = tr('ui.note', null, 'Note');
if (editBtn) editBtn.textContent = tr('ui.edit', null, 'Edit'); if (editBtn) editBtn.textContent = tr('ui.edit', null, 'Edit');
if (previewBtn) previewBtn.textContent = tr('ui.preview', null, 'Preview'); if (previewBtn) previewBtn.textContent = tr('ui.preview', null, 'Preview');
if (splitBtn) splitBtn.textContent = tr('ui.split', null, 'Split'); if (splitBtn) splitBtn.textContent = tr('ui.split', null, 'Split');
reloadBtn.textContent = tr('ui.reload', null, 'Reload'); reloadBtn.textContent = tr('ui.reload', null, 'Reload');
saveBtn.textContent = tr('ui.save', null, 'Save'); saveBtn.textContent = tr('ui.save', null, 'Save');
if (mdToolbar) {
[
['heading', 'ui.md.heading', 'Heading'], ['bold', 'ui.md.bold', 'Bold'], ['italic', 'ui.md.italic', 'Italic'],
['link', 'ui.md.link', 'Link'], ['code', 'ui.md.inlineCode', 'Inline code'], ['code-block', 'ui.md.codeBlock', 'Code block'],
['bullet', 'ui.md.bulletList', 'Bullet list'], ['numbered', 'ui.md.numberedList', 'Numbered list'], ['quote', 'ui.md.quote', 'Quote'], ['task', 'ui.md.taskItem', 'Task item']
].forEach(function (item) {
var button = mdToolbar.querySelector('[data-md-action="' + item[0] + '"]');
if (!button) return;
var label = tr(item[1], null, item[2]);
button.setAttribute('title', label);
button.setAttribute('aria-label', label);
});
}
updateStatus(); updateStatus();
}) })
: null; : null;

View File

@ -5,6 +5,7 @@
"contributions.openProviders.verstak.default-editor.markdown.title": "Default Markdown Editor", "contributions.openProviders.verstak.default-editor.markdown.title": "Default Markdown Editor",
"contributions.openProviders.verstak.default-editor.notes-markdown.title": "Default Notes Markdown Editor", "contributions.openProviders.verstak.default-editor.notes-markdown.title": "Default Notes Markdown Editor",
"ui.notesContext": "notes context", "ui.notesContext": "notes context",
"ui.note": "Note",
"ui.edit": "Edit", "ui.edit": "Edit",
"ui.preview": "Preview", "ui.preview": "Preview",
"ui.split": "Split", "ui.split": "Split",
@ -18,5 +19,15 @@
"ui.saved": "Saved", "ui.saved": "Saved",
"ui.discardConfirm": "Discard unsaved changes and reload from disk?", "ui.discardConfirm": "Discard unsaved changes and reload from disk?",
"ui.loading": "Loading...", "ui.loading": "Loading...",
"ui.loadFailed": "Failed to load file" "ui.loadFailed": "Could not load the file. Please try again.",
"ui.md.heading": "Heading",
"ui.md.bold": "Bold",
"ui.md.italic": "Italic",
"ui.md.link": "Link",
"ui.md.inlineCode": "Inline code",
"ui.md.codeBlock": "Code block",
"ui.md.bulletList": "Bullet list",
"ui.md.numberedList": "Numbered list",
"ui.md.quote": "Quote",
"ui.md.taskItem": "Task item"
} }

View File

@ -5,6 +5,7 @@
"contributions.openProviders.verstak.default-editor.markdown.title": "Стандартный редактор Markdown", "contributions.openProviders.verstak.default-editor.markdown.title": "Стандартный редактор Markdown",
"contributions.openProviders.verstak.default-editor.notes-markdown.title": "Стандартный редактор Markdown-заметок", "contributions.openProviders.verstak.default-editor.notes-markdown.title": "Стандартный редактор Markdown-заметок",
"ui.notesContext": "контекст заметки", "ui.notesContext": "контекст заметки",
"ui.note": "Заметка",
"ui.edit": "Редактировать", "ui.edit": "Редактировать",
"ui.preview": "Просмотр", "ui.preview": "Просмотр",
"ui.split": "Разделить", "ui.split": "Разделить",
@ -18,5 +19,15 @@
"ui.saved": "Сохранено", "ui.saved": "Сохранено",
"ui.discardConfirm": "Отменить несохранённые изменения и перечитать файл с диска?", "ui.discardConfirm": "Отменить несохранённые изменения и перечитать файл с диска?",
"ui.loading": "Загрузка...", "ui.loading": "Загрузка...",
"ui.loadFailed": "Не удалось загрузить файл" "ui.loadFailed": "Не удалось загрузить файл. Повторите попытку.",
"ui.md.heading": "Заголовок",
"ui.md.bold": "Жирный текст",
"ui.md.italic": "Курсив",
"ui.md.link": "Ссылка",
"ui.md.inlineCode": "Встроенный код",
"ui.md.codeBlock": "Блок кода",
"ui.md.bulletList": "Маркированный список",
"ui.md.numberedList": "Нумерованный список",
"ui.md.quote": "Цитата",
"ui.md.taskItem": "Пункт задачи"
} }

View File

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

View File

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

View File

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

View File

@ -34,10 +34,10 @@
'.files-breadcrumb-item:hover{background:var(--vt-color-accent-muted,rgba(78,204,163,.14))}', '.files-breadcrumb-item:hover{background:var(--vt-color-accent-muted,rgba(78,204,163,.14))}',
'.files-breadcrumb-current{color:var(--vt-color-text-primary,#f4f7fb);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}', '.files-breadcrumb-current{color:var(--vt-color-text-primary,#f4f7fb);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
'.files-breadcrumb-sep{color:var(--vt-color-text-muted,#7f8aa3)}', '.files-breadcrumb-sep{color:var(--vt-color-text-muted,#7f8aa3)}',
'.files-filter,.files-sort,.files-create-input,.files-rename-input{font-size:.78rem;padding:.32rem .5rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);outline:none}', '.files-filter,.files-sort{font-size:.78rem;padding:.32rem .5rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);outline:none}',
'.files-filter{width:11rem}', '.files-filter{width:11rem}',
'.files-sort{width:9.5rem;appearance:none;background-color:#0f1424;background-image:linear-gradient(45deg,transparent 50%,#8b8ba8 50%),linear-gradient(135deg,#8b8ba8 50%,transparent 50%);background-position:calc(100% - 14px) 50%,calc(100% - 9px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:1.6rem}', '.files-sort{width:9.5rem;appearance:none;background-color:#0f1424;background-image:linear-gradient(45deg,transparent 50%,#8b8ba8 50%),linear-gradient(135deg,#8b8ba8 50%,transparent 50%);background-position:calc(100% - 14px) 50%,calc(100% - 9px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:1.6rem}.files-sort option{background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb)}',
'.files-filter:focus,.files-sort:focus,.files-create-input:focus,.files-rename-input:focus{border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}', '.files-filter:focus,.files-sort:focus{border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.files-list{flex:1;overflow:auto;min-height:0}', '.files-list{flex:1;overflow:auto;min-height:0}',
'.files-header,.files-item{display:grid;grid-template-columns:minmax(160px,1fr) 90px 90px 150px 220px;align-items:center;gap:.5rem;padding:.38rem .75rem;border-bottom:1px solid rgba(22,33,62,.55)}', '.files-header,.files-item{display:grid;grid-template-columns:minmax(160px,1fr) 90px 90px 150px 220px;align-items:center;gap:.5rem;padding:.38rem .75rem;border-bottom:1px solid rgba(22,33,62,.55)}',
'.files-header{position:sticky;top:0;background:var(--vt-color-surface-muted,#111629);color:var(--vt-color-text-muted,#7f8aa3);font-size:.7rem;text-transform:uppercase;letter-spacing:.04em;z-index:1}', '.files-header{position:sticky;top:0;background:var(--vt-color-surface-muted,#111629);color:var(--vt-color-text-muted,#7f8aa3);font-size:.7rem;text-transform:uppercase;letter-spacing:.04em;z-index:1}',
@ -60,10 +60,6 @@
'.files-empty-btn svg{width:15px;height:15px;display:block;fill:currentColor}', '.files-empty-btn svg{width:15px;height:15px;display:block;fill:currentColor}',
'.files-error{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--vt-color-danger,#e94560);gap:.5rem;padding:1rem}', '.files-error{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--vt-color-danger,#e94560);gap:.5rem;padding:1rem}',
'.files-error-msg{font-size:.85rem;color:var(--vt-color-text-secondary,#b7c0d4);max-width:420px;text-align:center}', '.files-error-msg{font-size:.85rem;color:var(--vt-color-text-secondary,#b7c0d4);max-width:420px;text-align:center}',
'.files-panel{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;border-top:1px solid var(--vt-color-border,#202b46);flex-shrink:0;background:var(--vt-color-surface-muted,#111629)}',
'.files-field-stack{display:flex;flex:1;min-width:160px;flex-direction:column;gap:.25rem}',
'.files-panel-error{display:none;color:#ff9aaa;font-size:.72rem;line-height:1.2}',
'.files-create-input,.files-rename-input{flex:1;min-width:160px}',
'@media(max-width:760px){.files-header,.files-item{grid-template-columns:minmax(130px,1fr) 70px 0 0 150px}.files-header span:nth-child(3),.files-header span:nth-child(4),.files-item-meta.hide-narrow{display:none}.files-toolbar{align-items:stretch}.files-filter,.files-sort{width:100%}}', '@media(max-width:760px){.files-header,.files-item{grid-template-columns:minmax(130px,1fr) 70px 0 0 150px}.files-header span:nth-child(3),.files-header span:nth-child(4),.files-item-meta.hide-narrow{display:none}.files-toolbar{align-items:stretch}.files-filter,.files-sort{width:100%}}',
'.files-ctx-menu{position:fixed;z-index:9999;min-width:180px;background:var(--vt-color-surface,#15152c);border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-md,6px);padding:6px 0;box-shadow:var(--vt-elevation-menu,0 14px 32px rgba(0,0,0,.42));font-size:.84rem;color:var(--vt-color-text-primary,#f4f7fb);user-select:none}', '.files-ctx-menu{position:fixed;z-index:9999;min-width:180px;background:var(--vt-color-surface,#15152c);border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-md,6px);padding:6px 0;box-shadow:var(--vt-elevation-menu,0 14px 32px rgba(0,0,0,.42));font-size:.84rem;color:var(--vt-color-text-primary,#f4f7fb);user-select:none}',
'.files-ctx-menu-item{padding:6px 16px;cursor:pointer;white-space:nowrap;display:flex;align-items:center;gap:.5rem}', '.files-ctx-menu-item{padding:6px 16px;cursor:pointer;white-space:nowrap;display:flex;align-items:center;gap:.5rem}',
@ -75,6 +71,12 @@
'.files-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:10000;display:flex;align-items:center;justify-content:center}', '.files-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:10000;display:flex;align-items:center;justify-content:center}',
'.files-modal{width:400px;max-width:90vw;padding:24px;background:#1a1a2e;border:1px solid #333;border-radius:12px;color:#e0e0e0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;box-shadow:0 12px 40px rgba(0,0,0,.5)}', '.files-modal{width:400px;max-width:90vw;padding:24px;background:#1a1a2e;border:1px solid #333;border-radius:12px;color:#e0e0e0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;box-shadow:0 12px 40px rgba(0,0,0,.5)}',
'.files-modal-title{font-size:.95rem;line-height:1.5;margin-bottom:20px;word-wrap:break-word}', '.files-modal-title{font-size:.95rem;line-height:1.5;margin-bottom:20px;word-wrap:break-word}',
'.files-modal-form{display:grid;gap:.65rem}',
'.files-modal-form .files-modal-title{margin-bottom:0}',
'.files-modal-input{width:100%;font:inherit;font-size:.86rem;padding:.52rem .65rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);outline:none}',
'.files-modal-input:focus{border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.files-modal-input[aria-invalid="true"]{border-color:var(--vt-color-danger,#e94560)}',
'.files-modal-error{display:none;color:#ff9aaa;font-size:.78rem;line-height:1.35}',
'.files-modal-actions{display:flex;justify-content:flex-end;gap:8px}', '.files-modal-actions{display:flex;justify-content:flex-end;gap:8px}',
'.files-modal-btn{font-size:.82rem;padding:.4rem 1rem;border:1px solid #333;border-radius:6px;cursor:pointer;font-family:inherit}', '.files-modal-btn{font-size:.82rem;padding:.4rem 1rem;border:1px solid #333;border-radius:6px;cursor:pointer;font-family:inherit}',
'.files-modal-btn.cancel{background:#2a2a4e;color:#ccc;border-color:#444}', '.files-modal-btn.cancel{background:#2a2a4e;color:#ccc;border-color:#444}',
@ -320,15 +322,16 @@
return Promise.reject(new Error('clipboard unavailable')); return Promise.reject(new Error('clipboard unavailable'));
} }
function showExternalFallback(entry, mode, reason) { function showExternalFallback(entry, mode, labels) {
if (!entry) return; if (!entry) return;
labels = labels || {};
var pathToShow = entry.relativePath; var pathToShow = entry.relativePath;
if (mode === 'explorer' && entry.type !== 'folder') { if (mode === 'explorer' && entry.type !== 'folder') {
pathToShow = parentPath(entry.relativePath) || entry.relativePath; pathToShow = parentPath(entry.relativePath) || entry.relativePath;
} }
var title = mode === 'explorer' ? 'Show in Explorer' : 'Open External'; var title = mode === 'explorer' ? (labels.showInFolder || 'Show in folder') : (labels.openExternal || 'Open externally');
var message = title + ' failed.\n' + (reason ? String(reason) + '\n' : '') + 'Vault-relative path:\n' + pathToShow; var message = (labels.externalFailed || '{action} failed.').replace('{action}', title) + '\n' + (labels.vaultRelativePath || 'Vault-relative path:') + '\n' + pathToShow;
confirmModal(message, { confirmText: 'Copy Path', cancelText: 'Close' }).then(function (copy) { confirmModal(message, { confirmText: labels.copyPath || 'Copy path', cancelText: labels.close || 'Close' }).then(function (copy) {
if (!copy) return; if (!copy) return;
copyTextToClipboard(pathToShow).catch(function (err) { copyTextToClipboard(pathToShow).catch(function (err) {
console.error('[files] copy path failed:', err); console.error('[files] copy path failed:', err);
@ -346,7 +349,7 @@
var workspaceNode = props && props.workspaceNode; var workspaceNode = props && props.workspaceNode;
var workspaceRoot = cleanPath(props && (props.workspaceRootPath || (workspaceNode && (workspaceNode.rootPath || workspaceNode.name || workspaceNode.id))) || ''); var workspaceRoot = cleanPath(props && (props.workspaceRootPath || (workspaceNode && (workspaceNode.rootPath || workspaceNode.name || workspaceNode.id))) || '');
var workspaceName = workspaceRoot || (workspaceNode && (workspaceNode.name || workspaceNode.title || workspaceNode.id)) || 'Workspace'; var workspaceName = workspaceRoot || (workspaceNode && (workspaceNode.name || workspaceNode.title || workspaceNode.id)) || '';
window.__filesHistoryByWorkspace = window.__filesHistoryByWorkspace || {}; window.__filesHistoryByWorkspace = window.__filesHistoryByWorkspace || {};
var historyKey = workspaceRoot || workspaceName; var historyKey = workspaceRoot || workspaceName;
var savedHistory = window.__filesHistoryByWorkspace[historyKey] || { stack: [''], index: 0, currentPath: '' }; var savedHistory = window.__filesHistoryByWorkspace[historyKey] || { stack: [''], index: 0, currentPath: '' };
@ -358,6 +361,12 @@
var sortMode = 'folder-name'; var sortMode = 'folder-name';
var createMode = ''; var createMode = '';
var renameTarget = null; var renameTarget = null;
var createModal = null;
var createInput = null;
var createError = null;
var renameModal = null;
var renameInput = null;
var renameError = null;
var disposed = false; var disposed = false;
var fileActions = []; var fileActions = [];
var contextMenuEntries = []; var contextMenuEntries = [];
@ -374,6 +383,13 @@
return fallback || key; return fallback || key;
} }
function reportError(key, fallback, err) {
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
console.warn('[verstak.files] ' + key, err);
}
return tr(key, null, fallback);
}
function scopedPath(local) { function scopedPath(local) {
local = cleanPath(local); local = cleanPath(local);
return workspaceRoot ? (local ? workspaceRoot + '/' + local : workspaceRoot) : local; return workspaceRoot ? (local ? workspaceRoot + '/' + local : workspaceRoot) : local;
@ -431,32 +447,6 @@
var listContainer = el('div', { className: 'files-list', 'data-files-list': '' }); var listContainer = el('div', { className: 'files-list', 'data-files-list': '' });
containerEl.appendChild(listContainer); containerEl.appendChild(listContainer);
var createPanel = el('div', { className: 'files-panel', style: { display: 'none' } });
var createField = el('div', { className: 'files-field-stack' });
var createInput = el('input', { className: 'files-create-input', 'data-files-create-input': '' });
var createError = el('div', { className: 'files-panel-error', 'data-files-create-error': '', role: 'alert' });
var createConfirm = el('button', { className: 'files-toolbar-btn', 'data-files-create-confirm': '' }, [tr('ui.create', null, 'Create')]);
var createCancel = el('button', { className: 'files-toolbar-btn' }, [tr('ui.cancel', null, 'Cancel')]);
createField.appendChild(createInput);
createField.appendChild(createError);
createPanel.appendChild(createField);
createPanel.appendChild(createConfirm);
createPanel.appendChild(createCancel);
containerEl.appendChild(createPanel);
var renamePanel = el('div', { className: 'files-panel', style: { display: 'none' } });
var renameField = el('div', { className: 'files-field-stack' });
var renameInput = el('input', { className: 'files-rename-input', 'data-files-rename-input': '' });
var renameError = el('div', { className: 'files-panel-error', 'data-files-rename-error': '', role: 'alert' });
var renameConfirm = el('button', { className: 'files-toolbar-btn', 'data-files-rename-confirm': '' }, [tr('ui.rename', null, 'Rename')]);
var renameCancel = el('button', { className: 'files-toolbar-btn' }, [tr('ui.cancel', null, 'Cancel')]);
renameField.appendChild(renameInput);
renameField.appendChild(renameError);
renamePanel.appendChild(renameField);
renamePanel.appendChild(renameConfirm);
renamePanel.appendChild(renameCancel);
containerEl.appendChild(renamePanel);
function selectedEntry() { function selectedEntry() {
var keys = Object.keys(selectedPaths); var keys = Object.keys(selectedPaths);
if (keys.length === 0) return null; if (keys.length === 0) return null;
@ -714,10 +704,10 @@
renderList(); renderList();
}).catch(function (err) { }).catch(function (err) {
if (disposed) return; if (disposed) return;
console.warn('[verstak.files] load error:', err);
listContainer.innerHTML = ''; listContainer.innerHTML = '';
listContainer.appendChild(el('div', { className: 'files-error' }, [ listContainer.appendChild(el('div', { className: 'files-error' }, [
el('div', {}, [tr('ui.loadFailed', null, 'Failed to load files')]), el('div', {}, [tr('ui.loadFailed', null, 'Could not load files. Please try again.')])
el('div', { className: 'files-error-msg' }, [(err && err.message) ? err.message : String(err)])
])); ]));
}); });
} }
@ -813,35 +803,131 @@
}).catch(function (err) { console.error('[files] openResource error:', err); }); }).catch(function (err) { console.error('[files] openResource error:', err); });
} }
function showFileFormModal(mode, details) {
var isRename = mode === 'rename';
var overlay = el('div', {
className: 'files-modal-overlay',
role: 'presentation'
});
overlay.setAttribute('data-files-' + mode + '-modal', '');
var input = el('input', {
className: 'files-modal-input',
type: 'text',
value: details.value || '',
placeholder: details.placeholder || '',
autocomplete: 'off',
'aria-invalid': 'false'
});
input.setAttribute('data-files-' + mode + '-input', '');
input.value = details.value || '';
var error = el('div', {
className: 'files-modal-error',
role: 'alert'
});
error.setAttribute('data-files-' + mode + '-error', '');
function closeForm() {
if (isRename) cancelRename();
else cancelCreate();
}
function confirmForm() {
if (isRename) confirmRename();
else confirmCreate();
}
input.addEventListener('input', function () {
if (isRename) setRenameError('');
else setCreateError('');
});
input.addEventListener('keydown', function (event) {
if (event.key === 'Enter') {
if (event.preventDefault) event.preventDefault();
confirmForm();
}
if (event.key === 'Escape') {
if (event.preventDefault) event.preventDefault();
closeForm();
}
});
var modal = el('div', {
className: 'files-modal files-modal-form',
role: 'dialog',
'aria-modal': 'true',
'aria-label': details.title
}, [
el('div', { className: 'files-modal-title', textContent: details.title }),
input,
error,
el('div', { className: 'files-modal-actions' }, [
el('button', {
className: 'files-modal-btn cancel',
type: 'button',
textContent: tr('ui.cancel', null, 'Cancel'),
onClick: closeForm
}),
el('button', {
className: 'files-modal-btn confirm',
type: 'button',
textContent: details.confirmLabel,
onClick: confirmForm
})
])
]);
overlay.appendChild(modal);
document.body.appendChild(overlay);
if (isRename) {
renameInput = input;
renameError = error;
} else {
createInput = input;
createError = error;
}
input.focus();
if (isRename && input.select) input.select();
return overlay;
}
function startCreate(mode) { function startCreate(mode) {
cancelCreate();
createMode = mode; createMode = mode;
createInput.value = ''; createModal = showFileFormModal('create', {
createInput.placeholder = mode === 'folder' ? 'Folder name' : (mode === 'markdown' ? 'Markdown file name' : 'Text file name'); title: mode === 'folder'
setCreateError(''); ? tr('ui.createFolderTitle', null, 'Create folder')
createPanel.style.display = 'flex'; : (mode === 'markdown'
createInput.focus(); ? tr('ui.createMarkdownTitle', null, 'Create Markdown file')
: tr('ui.createTextTitle', null, 'Create text file')),
placeholder: mode === 'folder'
? tr('ui.folderName', null, 'Folder name')
: (mode === 'markdown'
? tr('ui.markdownFileName', null, 'Markdown file name')
: tr('ui.textFileName', null, 'Text file name')),
confirmLabel: tr('ui.create', null, 'Create')
});
} }
function setCreateError(message) { function setCreateError(message) {
if (!createError || !createInput) return;
createError.textContent = message || ''; createError.textContent = message || '';
createError.style.display = message ? 'block' : 'none'; createError.style.display = message ? 'block' : 'none';
createInput.setAttribute('aria-invalid', message ? 'true' : 'false'); createInput.setAttribute('aria-invalid', message ? 'true' : 'false');
} }
function validateCreateName(name) { function validateCreateName(name) {
if (!name) return 'Name is required'; if (!name) return tr('ui.nameRequired', null, 'Enter a name.');
if (/[\\/:*?"<>|\x00-\x1f]/.test(name)) return 'Invalid characters in name'; if (/[\\/:*?"<>|\x00-\x1f]/.test(name)) return tr('ui.invalidCharacters', null, 'The name contains invalid characters.');
if (name === '.' || name === '..' || name[0] === ' ' || name[name.length - 1] === ' ' || name[name.length - 1] === '.') return 'Invalid name'; if (name === '.' || name === '..' || name[0] === ' ' || name[name.length - 1] === ' ' || name[name.length - 1] === '.') return tr('ui.invalidName', null, 'Enter a valid name.');
return ''; return '';
} }
function cancelCreate() { function cancelCreate() {
createMode = ''; createMode = '';
setCreateError(''); if (createModal && typeof createModal.remove === 'function') createModal.remove();
createPanel.style.display = 'none'; else if (createModal && createModal.parentNode) createModal.parentNode.removeChild(createModal);
createModal = null;
createInput = null;
createError = null;
} }
function confirmCreate() { function confirmCreate() {
if (!createInput || !createMode) return;
var name = createInput.value.trim(); var name = createInput.value.trim();
var validationError = validateCreateName(name); var validationError = validateCreateName(name);
if (validationError) { if (validationError) {
@ -863,46 +949,56 @@
api.workbench.openResource({ kind: 'vault-file', path: full, mode: 'edit', extension: ext ? '.' + ext : '', context: { sourcePluginId: 'verstak.files', sourceView: 'files' } }).catch(function () {}); api.workbench.openResource({ kind: 'vault-file', path: full, mode: 'edit', extension: ext ? '.' + ext : '', context: { sourcePluginId: 'verstak.files', sourceView: 'files' } }).catch(function () {});
} }
}).catch(function (err) { }).catch(function (err) {
setCreateError('Error: ' + ((err && err.message) ? err.message : String(err))); setCreateError(reportError('ui.createError', 'Could not create this item. Please try again.', err));
}); });
} }
function beginRename(entry) { function beginRename(entry) {
entry = entry || selectedEntry(); entry = entry || selectedEntry();
if (!entry) return; if (!entry) return;
cancelRename();
renameTarget = entry; renameTarget = entry;
renameInput.value = entry.name; renameModal = showFileFormModal('rename', {
setRenameError(''); title: tr('ui.renameTitle', { name: entry.name }, 'Rename ' + entry.name),
renamePanel.style.display = 'flex'; placeholder: tr('ui.fileName', null, 'File or folder name'),
renameInput.focus(); value: entry.name,
renameInput.select(); confirmLabel: tr('ui.rename', null, 'Rename')
});
} }
function cancelRename() { function cancelRename() {
renameTarget = null; renameTarget = null;
setRenameError(''); if (renameModal && typeof renameModal.remove === 'function') renameModal.remove();
renamePanel.style.display = 'none'; else if (renameModal && renameModal.parentNode) renameModal.parentNode.removeChild(renameModal);
renameModal = null;
renameInput = null;
renameError = null;
} }
function setRenameError(message) { function setRenameError(message) {
if (!renameError || !renameInput) return;
renameError.textContent = message || ''; renameError.textContent = message || '';
renameError.style.display = message ? 'block' : 'none'; renameError.style.display = message ? 'block' : 'none';
renameInput.setAttribute('aria-invalid', message ? 'true' : 'false'); renameInput.setAttribute('aria-invalid', message ? 'true' : 'false');
} }
function confirmRename() { function confirmRename() {
if (!renameTarget) return; if (!renameTarget || !renameInput) return;
var newName = renameInput.value.trim(); var newName = renameInput.value.trim();
if (!newName || newName === renameTarget.name) { if (!newName) {
setRenameError(tr('ui.nameRequired', null, 'Enter a name.'));
return;
}
if (newName === renameTarget.name) {
cancelRename(); cancelRename();
return; return;
} }
if (/[\\/:*?"<>|\x00-\x1f]/.test(newName)) { if (/[\\/:*?"<>|\x00-\x1f]/.test(newName)) {
setRenameError('Invalid characters in name'); setRenameError(tr('ui.invalidCharacters', null, 'The name contains invalid characters.'));
return; return;
} }
if (newName === '.' || newName === '..' || newName[0] === ' ' || newName[newName.length - 1] === ' ' || newName[newName.length - 1] === '.') { if (newName === '.' || newName === '..' || newName[0] === ' ' || newName[newName.length - 1] === ' ' || newName[newName.length - 1] === '.') {
setRenameError('Invalid name'); setRenameError(tr('ui.invalidName', null, 'Enter a valid name.'));
return; return;
} }
var from = renameTarget.relativePath; var from = renameTarget.relativePath;
@ -910,20 +1006,20 @@
var to = targetParent ? targetParent + '/' + newName : newName; var to = targetParent ? targetParent + '/' + newName : newName;
api.files.metadata(to).then(function () { api.files.metadata(to).then(function () {
if (to.toLowerCase() === from.toLowerCase() && to !== from) { if (to.toLowerCase() === from.toLowerCase() && to !== from) {
setRenameError('Name differs only by case'); setRenameError(tr('ui.nameDiffersOnlyByCase', null, 'The name differs only by letter case.'));
return; return;
} }
setRenameError('A file with that name already exists'); setRenameError(tr('ui.nameConflict', null, 'An item with that name already exists.'));
}, function () { }, function () {
api.files.move(from, to, { overwrite: false }).then(function () { api.files.move(from, to, { overwrite: false }).then(function () {
cancelRename(); cancelRename();
loadEntries(); loadEntries();
}).catch(function (err) { }).catch(function (err) {
if (isConflictError(err)) { if (isConflictError(err)) {
setRenameError('A file with that name already exists'); setRenameError(tr('ui.nameConflict', null, 'An item with that name already exists.'));
return; return;
} }
setRenameError('Error: ' + ((err && err.message) ? err.message : String(err))); setRenameError(reportError('ui.renameError', 'Could not rename this item. Please try again.', err));
}); });
}); });
} }
@ -931,14 +1027,24 @@
function trashEntry(entry) { function trashEntry(entry) {
var count = selectedCount(); var count = selectedCount();
if (entry) { if (entry) {
confirmModal('Move "' + entry.name + '" to trash?', { danger: true }).then(function (ok) { confirmModal(tr('ui.trashConfirm', { name: entry.name }, 'Move "' + entry.name + '" to trash?'), {
danger: true,
confirmText: tr('ui.trash', null, 'Move to trash'),
cancelText: tr('ui.cancel', null, 'Cancel')
}).then(function (ok) {
if (!ok) return; if (!ok) return;
api.files.trash(entry.relativePath).then(function () { api.files.trash(entry.relativePath).then(function () {
loadEntries(); loadEntries();
}).catch(function (err) { window.alert((err && err.message) ? err.message : String(err)); }); }).catch(function (err) {
window.alert(reportError('ui.trashError', 'Could not move this item to trash. Please try again.', err));
});
}); });
} else if (count > 1) { } else if (count > 1) {
confirmModal('Move ' + count + ' items to trash?', { danger: true }).then(function (ok) { confirmModal(tr('ui.trashManyConfirm', { count: count }, 'Move ' + count + ' items to trash?'), {
danger: true,
confirmText: tr('ui.trash', null, 'Move to trash'),
cancelText: tr('ui.cancel', null, 'Cancel')
}).then(function (ok) {
if (!ok) return; if (!ok) return;
var paths = Object.keys(selectedPaths); var paths = Object.keys(selectedPaths);
Promise.allSettled(paths.map(function (p) { return api.files.trash(p); })).then(function () { Promise.allSettled(paths.map(function (p) { return api.files.trash(p); })).then(function () {
@ -954,14 +1060,6 @@
filterInput.addEventListener('input', function () { filterText = filterInput.value; renderList(); }); filterInput.addEventListener('input', function () { filterText = filterInput.value; renderList(); });
sortSelect.addEventListener('change', function () { sortMode = sortSelect.value; renderList(); }); sortSelect.addEventListener('change', function () { sortMode = sortSelect.value; renderList(); });
createConfirm.addEventListener('click', confirmCreate);
createCancel.addEventListener('click', cancelCreate);
renameConfirm.addEventListener('click', confirmRename);
renameCancel.addEventListener('click', cancelRename);
createInput.addEventListener('input', function () { setCreateError(''); });
createInput.addEventListener('keydown', function (event) { if (event.key === 'Enter') confirmCreate(); if (event.key === 'Escape') cancelCreate(); });
renameInput.addEventListener('input', function () { setRenameError(''); });
renameInput.addEventListener('keydown', function (event) { if (event.key === 'Enter') confirmRename(); if (event.key === 'Escape') cancelRename(); });
/* --- Context menu --- */ /* --- Context menu --- */
var ctxMenu = el('div', { className: 'files-ctx-menu', style: { display: 'none' } }); var ctxMenu = el('div', { className: 'files-ctx-menu', style: { display: 'none' } });
document.body.appendChild(ctxMenu); document.body.appendChild(ctxMenu);
@ -991,14 +1089,23 @@
function openExternalEntry(entry, mode) { function openExternalEntry(entry, mode) {
if (!entry) return; if (!entry) return;
var fallbackLabels = {
openExternal: tr('ui.openExternal', null, 'Open externally'),
showInFolder: tr('ui.showInFolder', null, 'Show in folder'),
externalFailed: tr('ui.externalFailed', null, '{action} failed.'),
vaultRelativePath: tr('ui.vaultRelativePath', null, 'Vault-relative path:'),
copyPath: tr('ui.copyPath', null, 'Copy path'),
close: tr('ui.close', null, 'Close')
};
var filesApi = api && api.files; var filesApi = api && api.files;
var action = mode === 'explorer' ? filesApi && filesApi.showInFolder : filesApi && filesApi.openExternal; var action = mode === 'explorer' ? filesApi && filesApi.showInFolder : filesApi && filesApi.openExternal;
if (typeof action !== 'function') { if (typeof action !== 'function') {
showExternalFallback(entry, mode, 'files external-open API is unavailable.'); showExternalFallback(entry, mode, fallbackLabels);
return; return;
} }
action(entry.relativePath).catch(function (err) { action(entry.relativePath).catch(function (err) {
showExternalFallback(entry, mode, err && err.message ? err.message : err); console.warn('[verstak.files] external open error:', err);
showExternalFallback(entry, mode, fallbackLabels);
}); });
} }
@ -1044,27 +1151,27 @@
renderList(); renderList();
} }
var isFolder = entry.type === 'folder'; var isFolder = entry.type === 'folder';
ctxMenu.appendChild(ctxItem(isFolder ? 'Open Folder' : 'Open', '', function () { openEntry(entry); }, 'open', 'open')); ctxMenu.appendChild(ctxItem(isFolder ? tr('ui.openFolder', null, 'Open folder') : tr('ui.open', null, 'Open'), '', function () { openEntry(entry); }, 'open', 'open'));
ctxMenu.appendChild(ctxItem('Open External', '', function () { openExternalEntry(entry, 'external'); }, 'open-external', 'external')); ctxMenu.appendChild(ctxItem(tr('ui.openExternal', null, 'Open externally'), '', function () { openExternalEntry(entry, 'external'); }, 'open-external', 'external'));
ctxMenu.appendChild(ctxItem('Show in Explorer', '', function () { openExternalEntry(entry, 'explorer'); }, 'show-in-explorer', 'explorer')); ctxMenu.appendChild(ctxItem(tr('ui.showInFolder', null, 'Show in folder'), '', function () { openExternalEntry(entry, 'explorer'); }, 'show-in-explorer', 'explorer'));
appendContributionMenuItems(entry); appendContributionMenuItems(entry);
ctxMenu.appendChild(ctxSep()); ctxMenu.appendChild(ctxSep());
ctxMenu.appendChild(ctxItem('Rename', '', function () { beginRename(entry); }, 'rename', 'rename')); ctxMenu.appendChild(ctxItem(tr('ui.rename', null, 'Rename'), '', function () { beginRename(entry); }, 'rename', 'rename'));
if (entry.type !== 'folder') { if (entry.type !== 'folder') {
ctxMenu.appendChild(ctxItem('Duplicate', '', function () { duplicateEntry(entry); }, 'duplicate', 'duplicate')); ctxMenu.appendChild(ctxItem(tr('ui.duplicate', null, 'Duplicate'), '', function () { duplicateEntry(entry); }, 'duplicate', 'duplicate'));
} }
ctxMenu.appendChild(ctxSep()); ctxMenu.appendChild(ctxSep());
ctxMenu.appendChild(ctxItem('Cut', '', function () { cutSelection(); }, 'cut', 'cut')); ctxMenu.appendChild(ctxItem(tr('ui.cut', null, 'Cut'), '', function () { cutSelection(); }, 'cut', 'cut'));
ctxMenu.appendChild(ctxItem('Copy', '', function () { copySelection(); }, 'copy', 'copy')); ctxMenu.appendChild(ctxItem(tr('ui.copy', null, 'Copy'), '', function () { copySelection(); }, 'copy', 'copy'));
ctxMenu.appendChild(ctxSep()); ctxMenu.appendChild(ctxSep());
ctxMenu.appendChild(ctxItem('Move to Trash', 'danger', function () { trashEntry(); }, 'trash', 'trash')); ctxMenu.appendChild(ctxItem(tr('ui.trash', null, 'Move to trash'), 'danger', function () { trashEntry(); }, 'trash', 'trash'));
} else { } else {
ctxMenu.appendChild(ctxItem('New Folder', '', function () { startCreate('folder'); }, 'new-folder', 'folderAdd')); ctxMenu.appendChild(ctxItem(tr('ui.newFolder', null, 'New folder'), '', function () { startCreate('folder'); }, 'new-folder', 'folderAdd'));
ctxMenu.appendChild(ctxItem('New Markdown', '', function () { startCreate('markdown'); }, 'new-markdown', 'markdownAdd')); ctxMenu.appendChild(ctxItem(tr('ui.newMarkdown', null, 'New Markdown file'), '', function () { startCreate('markdown'); }, 'new-markdown', 'markdownAdd'));
ctxMenu.appendChild(ctxItem('New Text', '', function () { startCreate('text'); }, 'new-text', 'textAdd')); ctxMenu.appendChild(ctxItem(tr('ui.newText', null, 'New text file'), '', function () { startCreate('text'); }, 'new-text', 'textAdd'));
if (window.__filesClipboard && window.__filesClipboard.items && window.__filesClipboard.items.length) { if (window.__filesClipboard && window.__filesClipboard.items && window.__filesClipboard.items.length) {
ctxMenu.appendChild(ctxSep()); ctxMenu.appendChild(ctxSep());
ctxMenu.appendChild(ctxItem('Paste', '', function () { pasteEntry(); }, 'paste', 'paste')); ctxMenu.appendChild(ctxItem(tr('ui.paste', null, 'Paste'), '', function () { pasteEntry(); }, 'paste', 'paste'));
} }
} }
ctxMenu.style.display = 'block'; ctxMenu.style.display = 'block';
@ -1157,7 +1264,7 @@
var clip = window.__filesClipboard; var clip = window.__filesClipboard;
if (!clip || !clip.items || clip.items.length === 0) return; if (!clip || !clip.items || clip.items.length === 0) return;
if (clip.workspaceRoot && clip.workspaceRoot !== workspaceRoot) { if (clip.workspaceRoot && clip.workspaceRoot !== workspaceRoot) {
window.alert('Clipboard items belong to another workspace.'); window.alert(tr('ui.clipboardDifferentDeal', null, 'Clipboard items belong to another Deal.'));
return; return;
} }
var destinationDir = scopedPath(currentPath); var destinationDir = scopedPath(currentPath);
@ -1498,10 +1605,6 @@
item[0].setAttribute('aria-label', label); item[0].setAttribute('aria-label', label);
}); });
filterInput.setAttribute('placeholder', tr('ui.filter', null, 'Filter current folder')); filterInput.setAttribute('placeholder', tr('ui.filter', null, 'Filter current folder'));
createConfirm.textContent = tr('ui.create', null, 'Create');
createCancel.textContent = tr('ui.cancel', null, 'Cancel');
renameConfirm.textContent = tr('ui.rename', null, 'Rename');
renameCancel.textContent = tr('ui.cancel', null, 'Cancel');
renderList(); renderList();
}); });
} }
@ -1520,6 +1623,8 @@
containerEl.__filesCleanup = function () { containerEl.__filesCleanup = function () {
disposed = true; disposed = true;
cancelCreate();
cancelRename();
if (typeof localeUnsubscribe === 'function') localeUnsubscribe(); if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
if (typeof fileChangedUnsubscribe === 'function') fileChangedUnsubscribe(); if (typeof fileChangedUnsubscribe === 'function') fileChangedUnsubscribe();
document.removeEventListener('click', onDocClick); document.removeEventListener('click', onDocClick);

View File

@ -1,6 +1,6 @@
{ {
"manifest.name": "Files", "manifest.name": "Files",
"manifest.description": "Workspace-scoped vault file explorer with create, rename, trash, filtering, sorting, and Workbench openResource integration.", "manifest.description": "Deal-scoped vault file explorer with create, rename, trash, filtering, sorting, and Workbench openResource integration.",
"contributions.workspaceItems.verstak.files.workspace.title": "Files", "contributions.workspaceItems.verstak.files.workspace.title": "Files",
"ui.back": "Back", "ui.back": "Back",
"ui.forward": "Forward", "ui.forward": "Forward",
@ -10,7 +10,11 @@
"ui.newMarkdown": "New markdown file", "ui.newMarkdown": "New markdown file",
"ui.newText": "New text file", "ui.newText": "New text file",
"ui.open": "Open", "ui.open": "Open",
"ui.openFolder": "Open folder",
"ui.openExternal": "Open externally",
"ui.showInFolder": "Show in folder",
"ui.rename": "Rename", "ui.rename": "Rename",
"ui.duplicate": "Duplicate",
"ui.trash": "Move to trash", "ui.trash": "Move to trash",
"ui.cut": "Cut", "ui.cut": "Cut",
"ui.copy": "Copy", "ui.copy": "Copy",
@ -24,9 +28,32 @@
"ui.column.actions": "Actions", "ui.column.actions": "Actions",
"ui.create": "Create", "ui.create": "Create",
"ui.cancel": "Cancel", "ui.cancel": "Cancel",
"ui.createFolderTitle": "Create folder",
"ui.createMarkdownTitle": "Create Markdown file",
"ui.createTextTitle": "Create text file",
"ui.renameTitle": "Rename {name}",
"ui.folderName": "Folder name",
"ui.markdownFileName": "Markdown file name",
"ui.textFileName": "Text file name",
"ui.fileName": "File or folder name",
"ui.nameRequired": "Enter a name.",
"ui.invalidCharacters": "The name contains invalid characters.",
"ui.invalidName": "Enter a valid name.",
"ui.nameDiffersOnlyByCase": "The name differs only by letter case.",
"ui.nameConflict": "An item with that name already exists.",
"ui.externalFailed": "{action} failed.",
"ui.vaultRelativePath": "Vault-relative path:",
"ui.copyPath": "Copy path",
"ui.close": "Close",
"ui.trashConfirm": "Move \"{name}\" to trash?",
"ui.trashManyConfirm": "Move {count} items to trash?",
"ui.clipboardDifferentDeal": "Clipboard items belong to another Deal.",
"ui.emptyFolder": "Empty folder", "ui.emptyFolder": "Empty folder",
"ui.noMatches": "No matches", "ui.noMatches": "No matches",
"ui.clearFilter": "Clear filter", "ui.clearFilter": "Clear filter",
"ui.loading": "Loading...", "ui.loading": "Loading...",
"ui.loadFailed": "Failed to load files" "ui.loadFailed": "Could not load files. Please try again.",
"ui.createError": "Could not create this item. Please try again.",
"ui.renameError": "Could not rename this item. Please try again.",
"ui.trashError": "Could not move this item to trash. Please try again."
} }

View File

@ -1,6 +1,6 @@
{ {
"manifest.name": "Файлы", "manifest.name": "Файлы",
"manifest.description": "Файловый менеджер рабочего пространства с созданием, переименованием, корзиной, фильтрацией и сортировкой.", "manifest.description": "Файловый менеджер Дела с созданием, переименованием, корзиной, фильтрацией и сортировкой.",
"contributions.workspaceItems.verstak.files.workspace.title": "Файлы", "contributions.workspaceItems.verstak.files.workspace.title": "Файлы",
"ui.back": "Назад", "ui.back": "Назад",
"ui.forward": "Вперёд", "ui.forward": "Вперёд",
@ -10,7 +10,11 @@
"ui.newMarkdown": "Новый Markdown-файл", "ui.newMarkdown": "Новый Markdown-файл",
"ui.newText": "Новый текстовый файл", "ui.newText": "Новый текстовый файл",
"ui.open": "Открыть", "ui.open": "Открыть",
"ui.openFolder": "Открыть папку",
"ui.openExternal": "Открыть во внешнем приложении",
"ui.showInFolder": "Показать в папке",
"ui.rename": "Переименовать", "ui.rename": "Переименовать",
"ui.duplicate": "Создать копию",
"ui.trash": "Переместить в корзину", "ui.trash": "Переместить в корзину",
"ui.cut": "Вырезать", "ui.cut": "Вырезать",
"ui.copy": "Копировать", "ui.copy": "Копировать",
@ -24,9 +28,32 @@
"ui.column.actions": "Действия", "ui.column.actions": "Действия",
"ui.create": "Создать", "ui.create": "Создать",
"ui.cancel": "Отмена", "ui.cancel": "Отмена",
"ui.createFolderTitle": "Создать папку",
"ui.createMarkdownTitle": "Создать Markdown-файл",
"ui.createTextTitle": "Создать текстовый файл",
"ui.renameTitle": "Переименовать {name}",
"ui.folderName": "Название папки",
"ui.markdownFileName": "Название Markdown-файла",
"ui.textFileName": "Название текстового файла",
"ui.fileName": "Название файла или папки",
"ui.nameRequired": "Введите название.",
"ui.invalidCharacters": "В названии есть недопустимые символы.",
"ui.invalidName": "Введите корректное название.",
"ui.nameDiffersOnlyByCase": "Название отличается только регистром букв.",
"ui.nameConflict": "Элемент с таким названием уже существует.",
"ui.externalFailed": "Не удалось выполнить действие «{action}».",
"ui.vaultRelativePath": "Путь относительно хранилища:",
"ui.copyPath": "Скопировать путь",
"ui.close": "Закрыть",
"ui.trashConfirm": "Переместить «{name}» в корзину?",
"ui.trashManyConfirm": "Переместить в корзину {count} элементов?",
"ui.clipboardDifferentDeal": "Элементы буфера обмена принадлежат другому Делу.",
"ui.emptyFolder": "Папка пуста", "ui.emptyFolder": "Папка пуста",
"ui.noMatches": "Совпадений нет", "ui.noMatches": "Совпадений нет",
"ui.clearFilter": "Сбросить фильтр", "ui.clearFilter": "Сбросить фильтр",
"ui.loading": "Загрузка...", "ui.loading": "Загрузка...",
"ui.loadFailed": "Не удалось загрузить файлы" "ui.loadFailed": "Не удалось загрузить файлы. Повторите попытку.",
"ui.createError": "Не удалось создать этот элемент. Повторите попытку.",
"ui.renameError": "Не удалось переименовать этот элемент. Повторите попытку.",
"ui.trashError": "Не удалось переместить элемент в корзину. Повторите попытку."
} }

View File

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

View File

@ -31,6 +31,7 @@
'.journal-status.error{display:inline-flex;border:1px solid rgba(233,69,96,.45);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-danger-muted,rgba(233,69,96,.14));color:#ffc6ce;padding:.18rem .4rem}', '.journal-status.error{display:inline-flex;border:1px solid rgba(233,69,96,.45);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-danger-muted,rgba(233,69,96,.14));color:#ffc6ce;padding:.18rem .4rem}',
'.journal-input{font-size:.8rem;padding:.38rem .5rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);min-width:0;font-family:inherit}', '.journal-input{font-size:.8rem;padding:.38rem .5rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);min-width:0;font-family:inherit}',
'.journal-input.textarea{min-height:7rem;resize:vertical;line-height:1.4}', '.journal-input.textarea{min-height:7rem;resize:vertical;line-height:1.4}',
'.journal-input.journal-select{appearance:none;background-color:#0f1424;background-image:linear-gradient(45deg,transparent 50%,var(--vt-color-text-muted,#7f8aa3) 50%),linear-gradient(135deg,var(--vt-color-text-muted,#7f8aa3) 50%,transparent 50%);background-position:calc(100% - 14px) 50%,calc(100% - 9px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:1.7rem}.journal-input.journal-select option{background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb)}',
'.journal-input:focus{outline:none;border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}', '.journal-input:focus{outline:none;border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.journal-billable{display:flex;align-items:center;gap:.25rem;font-size:.74rem;color:var(--vt-color-text-secondary,#b7c0d4);white-space:nowrap}', '.journal-billable{display:flex;align-items:center;gap:.25rem;font-size:.74rem;color:var(--vt-color-text-secondary,#b7c0d4);white-space:nowrap}',
'.journal-list{flex:1;min-height:0;overflow:auto;background:var(--vt-color-background,#101020)}', '.journal-list{flex:1;min-height:0;overflow:auto;background:var(--vt-color-background,#101020)}',
@ -107,7 +108,7 @@
function scopeFromProps(props) { function scopeFromProps(props) {
var workspaceRoot = workspaceFromProps(props || {}); var workspaceRoot = workspaceFromProps(props || {});
if (!workspaceRoot) return { mode: 'global', key: '', label: 'All workspaces', workspaceRoot: '' }; if (!workspaceRoot) return { mode: 'global', key: '', label: 'All Deals', workspaceRoot: '' };
return { return {
mode: 'workspace', mode: 'workspace',
key: WORKLOG_PREFIX + encodeKey(workspaceRoot), key: WORKLOG_PREFIX + encodeKey(workspaceRoot),
@ -275,6 +276,7 @@
var scope = scopeFromProps(props || {}); var scope = scopeFromProps(props || {});
var entries = []; var entries = [];
var workspaceOptions = [];
function tr(key, params, fallback) { function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback); if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key; return fallback || key;
@ -283,6 +285,14 @@
var statusClass = ''; var statusClass = '';
var modalHost = el('div', { className: 'journal-modal-host', hidden: 'hidden' }); var modalHost = el('div', { className: 'journal-modal-host', hidden: 'hidden' });
function reportError(key, fallback, err) {
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
console.warn('[verstak.journal] ' + key, err);
}
statusText = tr(key, null, fallback);
statusClass = 'error';
}
var toolbar = el('div', { className: 'journal-toolbar' }); var toolbar = el('div', { className: 'journal-toolbar' });
var titleEl = el('span', { className: 'journal-title', textContent: scope.mode === 'global' ? tr('ui.title', null, 'Journal') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Journal · ' + scope.label) }); var titleEl = el('span', { className: 'journal-title', textContent: scope.mode === 'global' ? tr('ui.title', null, 'Journal') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Journal · ' + scope.label) });
var countEl = el('span', { className: 'journal-count' }); var countEl = el('span', { className: 'journal-count' });
@ -304,12 +314,23 @@
containerEl.appendChild(listEl); containerEl.appendChild(listEl);
containerEl.appendChild(modalHost); containerEl.appendChild(modalHost);
function persist() { function persist(workspaceRoot, values) {
if (scope.mode !== 'workspace') return Promise.resolve();
if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve(); if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
return api.settings.write(scope.key, storageEntries(entries)).catch(function (err) { var target = cleanWorkspace(workspaceRoot || scope.workspaceRoot);
statusText = tr('ui.saveError', { error: err && err.message ? err.message : String(err) }, 'Could not save journal: ' + (err && err.message ? err.message : String(err))); if (!target) return Promise.resolve();
statusClass = 'error'; return api.settings.write(WORKLOG_PREFIX + encodeKey(target), storageEntries(values || entries)).catch(function (err) {
reportError('ui.saveError', 'Could not save journal. Please try again.', err);
});
}
function loadWorkspaceOptions() {
if (!api || !api.files || typeof api.files.list !== 'function') return Promise.resolve();
return api.files.list('').then(function (items) {
workspaceOptions = (Array.isArray(items) ? items : []).filter(function (item) {
return text(item && item.type).toLowerCase() === 'folder';
}).map(function (item) { return cleanWorkspace(item.relativePath || item.name); }).filter(function (value) {
return value && value.indexOf('/') === -1;
});
}); });
} }
@ -325,8 +346,7 @@
statusText = tr('ui.aggregating', null, 'Aggregating worklogs'); statusText = tr('ui.aggregating', null, 'Aggregating worklogs');
statusClass = ''; statusClass = '';
}).catch(function (err) { }).catch(function (err) {
statusText = tr('ui.loadError', { error: err && err.message ? err.message : String(err) }, 'Could not load journal: ' + (err && err.message ? err.message : String(err))); reportError('ui.loadError', 'Could not load journal. Please try again.', err);
statusClass = 'error';
}); });
} }
return api.settings.read(scope.key).then(function (stored) { return api.settings.read(scope.key).then(function (stored) {
@ -334,8 +354,7 @@
statusText = tr('ui.ready', null, 'Ready'); statusText = tr('ui.ready', null, 'Ready');
statusClass = ''; statusClass = '';
}).catch(function (err) { }).catch(function (err) {
statusText = tr('ui.loadError', { error: err && err.message ? err.message : String(err) }, 'Could not load journal: ' + (err && err.message ? err.message : String(err))); reportError('ui.loadError', 'Could not load journal. Please try again.', err);
statusClass = 'error';
}); });
} }
@ -344,8 +363,33 @@
modalHost.setAttribute('hidden', 'hidden'); modalHost.setAttribute('hidden', 'hidden');
} }
function activityLabel(activity) {
var type = text(activity && activity.type);
var labels = {
'browser.capture.link': ['ui.activity.captureLink', 'Link captured'],
'browser.capture.selection': ['ui.activity.captureSelection', 'Selection captured'],
'browser.capture.page': ['ui.activity.capturePage', 'Page captured'],
'file.created': ['ui.activity.fileCreated', 'File created'],
'file.updated': ['ui.activity.fileUpdated', 'File updated'],
'file.deleted': ['ui.activity.fileDeleted', 'File deleted'],
'note.saved': ['ui.activity.noteSaved', 'Note saved'],
'workspace.created': ['ui.activity.dealCreated', 'Deal created'],
'workspace.renamed': ['ui.activity.dealRenamed', 'Deal renamed']
};
var label = labels[type] || ['ui.activity.generic', 'Activity'];
return tr(label[0], null, label[1]);
}
function entryMeta(entry) {
var facts = [entry.workspaceRootPath, entry.billable ? tr('ui.meta.billable', null, 'billable') : tr('ui.meta.nonBillable', null, 'non-billable')];
if (entry.activityIds.length) {
facts.push(tr(entry.activityIds.length === 1 ? 'ui.meta.activities.one' : 'ui.meta.activities.many', { count: entry.activityIds.length }, entry.activityIds.length + ' linked activities'));
}
if (entry.sourceTodoId) facts.push(tr('ui.meta.todo', null, 'linked todo'));
return facts.join(' · ');
}
function showEntryModal(existingEntry, candidate, completedTodo) { function showEntryModal(existingEntry, candidate, completedTodo) {
if (scope.mode !== 'workspace') return;
var editing = !!existingEntry; var editing = !!existingEntry;
var reviewingCandidate = !editing && !!candidate; var reviewingCandidate = !editing && !!candidate;
var reviewingTodo = !editing && !!completedTodo; var reviewingTodo = !editing && !!completedTodo;
@ -356,6 +400,13 @@
var minutesInput = el('input', { className: 'journal-input', type: 'number', min: '0', step: '1', value: editing ? existingEntry.minutes : (reviewingCandidate ? candidate.estimatedMinutes : (reviewingTodo ? '0' : '30')), 'data-journal-input': 'minutes' }); var minutesInput = el('input', { className: 'journal-input', type: 'number', min: '0', step: '1', value: editing ? existingEntry.minutes : (reviewingCandidate ? candidate.estimatedMinutes : (reviewingTodo ? '0' : '30')), 'data-journal-input': 'minutes' });
var billableInput = el('input', { type: 'checkbox', 'data-journal-input': 'billable' }); var billableInput = el('input', { type: 'checkbox', 'data-journal-input': 'billable' });
billableInput.checked = editing ? existingEntry.billable === true : false; billableInput.checked = editing ? existingEntry.billable === true : false;
var workspaceInput = null;
if (scope.mode === 'global') {
workspaceInput = el('select', { className: 'journal-input journal-select', 'data-journal-input': 'workspaceRootPath' });
workspaceOptions.forEach(function (workspace) {
workspaceInput.appendChild(el('option', { value: workspace, textContent: workspace }));
});
}
var activityInputs = reviewingCandidate ? candidate.activities.map(function (activity) { var activityInputs = reviewingCandidate ? candidate.activities.map(function (activity) {
var input = el('input', { type: 'checkbox', value: activity.activityId, checked: 'checked', 'data-journal-candidate-activity': activity.activityId }); var input = el('input', { type: 'checkbox', value: activity.activityId, checked: 'checked', 'data-journal-candidate-activity': activity.activityId });
input.checked = true; input.checked = true;
@ -369,6 +420,7 @@
summary: summaryInput.value, summary: summaryInput.value,
minutes: minutesInput.value, minutes: minutesInput.value,
billable: billableInput.checked === true, billable: billableInput.checked === true,
workspaceRootPath: workspaceInput ? workspaceInput.value : scope.workspaceRoot,
sourceCandidateId: reviewingCandidate ? candidate.candidateId : (existingEntry ? existingEntry.sourceCandidateId : ''), sourceCandidateId: reviewingCandidate ? candidate.candidateId : (existingEntry ? existingEntry.sourceCandidateId : ''),
sessionId: reviewingCandidate ? candidate.sessionId : '', sessionId: reviewingCandidate ? candidate.sessionId : '',
handledThrough: reviewingCandidate ? candidate.handledThrough : '', handledThrough: reviewingCandidate ? candidate.handledThrough : '',
@ -380,22 +432,22 @@
} }
var candidateContext = reviewingCandidate ? el('div', { className: 'journal-candidate-context', 'data-journal-candidate': candidate.candidateId }, [ var candidateContext = reviewingCandidate ? el('div', { className: 'journal-candidate-context', 'data-journal-candidate': candidate.candidateId }, [
el('strong', { textContent: 'Possible journal entry' }), el('strong', { textContent: tr('ui.candidate.title', null, 'Possible journal entry') }),
el('div', { textContent: 'Workspace: ' + candidate.workspaceRootPath }), el('div', { textContent: tr('ui.candidate.deal', { deal: candidate.workspaceRootPath }, 'Deal: ' + candidate.workspaceRootPath) }),
el('div', { textContent: 'Time: ' + candidateTime(candidate.startedAt) + ' - ' + candidateTime(candidate.endedAt) }), el('div', { textContent: tr('ui.candidate.time', { start: candidateTime(candidate.startedAt), end: candidateTime(candidate.endedAt) }, 'Time: ' + candidateTime(candidate.startedAt) + ' ' + candidateTime(candidate.endedAt)) }),
el('div', { textContent: 'Estimated duration: ' + candidate.estimatedMinutes + ' min' }), el('div', { textContent: tr('ui.candidate.duration', { minutes: candidate.estimatedMinutes }, 'Estimated duration: ' + candidate.estimatedMinutes + ' min') }),
el('div', { textContent: 'Activities: ' + candidate.activityCount }) el('div', { textContent: tr('ui.candidate.activities', { count: candidate.activityCount }, 'Activities: ' + candidate.activityCount) })
]) : null; ]) : null;
var candidateActivities = reviewingCandidate ? el('fieldset', { className: 'journal-candidate-activities' }, [ var candidateActivities = reviewingCandidate ? el('fieldset', { className: 'journal-candidate-activities' }, [
el('legend', { textContent: 'Linked activities' }) el('legend', { textContent: tr('ui.candidate.linkedActivities', null, 'Linked activities') })
].concat(activityInputs.map(function (item) { ].concat(activityInputs.map(function (item) {
var detail = (item.activity.occurredAt ? candidateTime(item.activity.occurredAt) + ' · ' : '') + item.activity.type + ' · ' + item.activity.activityId; var detail = (item.activity.occurredAt ? candidateTime(item.activity.occurredAt) + ' · ' : '') + activityLabel(item.activity);
return el('label', { className: 'journal-candidate-activity' }, [item.input, detail]); return el('label', { className: 'journal-candidate-activity' }, [item.input, detail]);
}))) : null; }))) : null;
var todoContext = reviewingTodo ? el('div', { className: 'journal-candidate-context', 'data-journal-todo': completedTodo.id }, [ var todoContext = reviewingTodo ? el('div', { className: 'journal-candidate-context', 'data-journal-todo': completedTodo.id }, [
el('strong', { textContent: 'Completed todo' }), el('strong', { textContent: tr('ui.todo.title', null, 'Completed todo') }),
el('div', { textContent: 'Workspace: ' + completedTodo.workspaceRootPath }), el('div', { textContent: tr('ui.candidate.deal', { deal: completedTodo.workspaceRootPath }, 'Deal: ' + completedTodo.workspaceRootPath) }),
completedTodo.completedAt ? el('div', { textContent: 'Completed: ' + candidateTime(completedTodo.completedAt) }) : null completedTodo.completedAt ? el('div', { textContent: tr('ui.todo.completed', { time: candidateTime(completedTodo.completedAt) }, 'Completed: ' + candidateTime(completedTodo.completedAt)) }) : null
]) : null; ]) : null;
modalHost.innerHTML = ''; modalHost.innerHTML = '';
@ -411,6 +463,7 @@
el('div', { className: 'journal-modal-grid' }, [ el('div', { className: 'journal-modal-grid' }, [
el('label', { className: 'journal-field' }, [tr('ui.date', null, 'Date'), dateInput]), el('label', { className: 'journal-field' }, [tr('ui.date', null, 'Date'), dateInput]),
el('label', { className: 'journal-field' }, [tr('ui.minutes', null, 'Minutes'), minutesInput]), el('label', { className: 'journal-field' }, [tr('ui.minutes', null, 'Minutes'), minutesInput]),
workspaceInput ? el('label', { className: 'journal-field wide' }, [tr('ui.workspace', null, 'Deal'), workspaceInput]) : null,
el('label', { className: 'journal-field wide' }, [tr('ui.fieldTitle', null, 'Title'), titleInput]), el('label', { className: 'journal-field wide' }, [tr('ui.fieldTitle', null, 'Title'), titleInput]),
el('label', { className: 'journal-field wide' }, [tr('ui.body', null, 'Body'), summaryInput]), el('label', { className: 'journal-field wide' }, [tr('ui.body', null, 'Body'), summaryInput]),
el('label', { className: 'journal-billable' }, [billableInput, tr('ui.billable', null, 'Billable')]) el('label', { className: 'journal-billable' }, [billableInput, tr('ui.billable', null, 'Billable')])
@ -426,7 +479,6 @@
} }
function addOrUpdateEntry(existingEntry, formValue) { function addOrUpdateEntry(existingEntry, formValue) {
if (scope.mode !== 'workspace') return;
var title = text(formValue && formValue.title).trim(); var title = text(formValue && formValue.title).trim();
if (!title) { if (!title) {
statusText = tr('ui.titleRequired', null, 'Title is required'); statusText = tr('ui.titleRequired', null, 'Title is required');
@ -434,25 +486,27 @@
render(); render();
return; return;
} }
var workspaceRoot = cleanWorkspace(formValue && formValue.workspaceRootPath || scope.workspaceRoot);
if (!workspaceRoot) return;
var sourceCandidateId = text(formValue && formValue.sourceCandidateId || (existingEntry && existingEntry.sourceCandidateId)).trim(); var sourceCandidateId = text(formValue && formValue.sourceCandidateId || (existingEntry && existingEntry.sourceCandidateId)).trim();
var sessionID = text(formValue && formValue.sessionId).trim(); var sessionID = text(formValue && formValue.sessionId).trim();
var handledThrough = text(formValue && formValue.handledThrough).trim(); var handledThrough = text(formValue && formValue.handledThrough).trim();
var sourceTodoId = text(formValue && formValue.sourceTodoId || (existingEntry && existingEntry.sourceTodoId)).trim(); var sourceTodoId = text(formValue && formValue.sourceTodoId || (existingEntry && existingEntry.sourceTodoId)).trim();
if (!existingEntry && sourceCandidateId && entries.some(function (entry) { return entry.sourceCandidateId === sourceCandidateId; })) { if (!existingEntry && sourceCandidateId && entries.some(function (entry) { return entry.sourceCandidateId === sourceCandidateId; })) {
statusText = 'A journal entry already references this candidate'; statusText = tr('ui.candidate.duplicate', null, 'A journal entry already references this candidate');
statusClass = 'error'; statusClass = 'error';
render(); render();
return; return;
} }
if (!existingEntry && sourceTodoId && entries.some(function (entry) { return entry.sourceTodoId === sourceTodoId; })) { if (!existingEntry && sourceTodoId && entries.some(function (entry) { return entry.sourceTodoId === sourceTodoId; })) {
statusText = 'A journal entry already references this todo'; statusText = tr('ui.todo.duplicate', null, 'A journal entry already references this todo');
statusClass = 'error'; statusClass = 'error';
render(); render();
return; return;
} }
var entry = normalizeEntry({ var entry = normalizeEntry({
entryId: existingEntry ? existingEntry.entryId : entryId(scope.workspaceRoot, formValue.date || today(), title), entryId: existingEntry ? existingEntry.entryId : entryId(workspaceRoot, formValue.date || today(), title),
workspaceRootPath: scope.workspaceRoot, workspaceRootPath: workspaceRoot,
date: formValue.date || today(), date: formValue.date || today(),
title: title, title: title,
summary: formValue.summary, summary: formValue.summary,
@ -470,10 +524,11 @@
entries = [entry].concat(entries); entries = [entry].concat(entries);
} }
entries = sortEntries(entries); entries = sortEntries(entries);
var targetEntries = entries.filter(function (item) { return item.workspaceRootPath === workspaceRoot; });
closeEntryModal(); closeEntryModal();
statusText = existingEntry ? 'Entry updated' : 'Entry added'; statusText = existingEntry ? tr('ui.updated', null, 'Entry updated') : tr('ui.added', null, 'Entry added');
statusClass = ''; statusClass = '';
persist().then(function () { persist(workspaceRoot, targetEntries).then(function () {
if (!sessionID || !handledThrough || !api || !api.events || typeof api.events.publish !== 'function') return undefined; if (!sessionID || !handledThrough || !api || !api.events || typeof api.events.publish !== 'function') return undefined;
return api.events.publish('activity.session.handled', { return api.events.publish('activity.session.handled', {
sessionId: sessionID, sessionId: sessionID,
@ -506,9 +561,9 @@
el('div', { className: 'journal-main' }, [ el('div', { className: 'journal-main' }, [
el('div', { className: 'journal-entry-title', textContent: entry.title }), el('div', { className: 'journal-entry-title', textContent: entry.title }),
entry.summary ? el('div', { className: 'journal-summary', textContent: entry.summary }) : null, entry.summary ? el('div', { className: 'journal-summary', textContent: entry.summary }) : null,
el('div', { className: 'journal-meta', textContent: entry.workspaceRootPath + (entry.billable ? ' · billable' : ' · non-billable') + (entry.activityIds.length ? ' · ' + entry.activityIds.length + ' linked activities' : '') + (entry.sourceTodoId ? ' · linked todo' : '') }) el('div', { className: 'journal-meta', textContent: entryMeta(entry) })
]), ]),
el('div', { className: 'journal-minutes', textContent: entry.minutes + ' min' }), el('div', { className: 'journal-minutes', textContent: tr('ui.minutesValue', { minutes: entry.minutes }, entry.minutes + ' min') }),
scope.mode === 'workspace' ? el('div', { className: 'journal-row-actions' }, [ scope.mode === 'workspace' ? el('div', { className: 'journal-row-actions' }, [
el('button', { className: 'journal-icon-btn', type: 'button', title: 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', 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); } }) 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); } })
@ -525,12 +580,12 @@
); );
statusEl.textContent = statusText; statusEl.textContent = statusText;
statusEl.className = 'journal-status' + (statusClass ? ' ' + statusClass : ''); statusEl.className = 'journal-status' + (statusClass ? ' ' + statusClass : '');
addBtn.disabled = scope.mode !== 'workspace'; addBtn.disabled = false;
renderList(); renderList();
} }
render(); render();
loadStored().then(function () { Promise.all([loadStored(), loadWorkspaceOptions()]).then(function () {
render(); render();
var candidate = candidateFromRequest(props && props.toolRequest, scope.workspaceRoot); var candidate = candidateFromRequest(props && props.toolRequest, scope.workspaceRoot);
var completedTodo = completedTodoFromRequest(props && props.toolRequest, scope.workspaceRoot); var completedTodo = completedTodoFromRequest(props && props.toolRequest, scope.workspaceRoot);

View File

@ -1,6 +1,6 @@
{ {
"manifest.name": "Journal", "manifest.name": "Journal",
"manifest.description": "Workspace-scoped journal with user-authored entries and optional Activity links.", "manifest.description": "Deal-scoped journal with user-authored entries and optional Activity links.",
"contributions.views.verstak.journal.view.title": "Journal", "contributions.views.verstak.journal.view.title": "Journal",
"contributions.sidebarItems.verstak.journal.sidebar.title": "Journal", "contributions.sidebarItems.verstak.journal.sidebar.title": "Journal",
"contributions.workspaceItems.verstak.journal.workspace.title": "Journal", "contributions.workspaceItems.verstak.journal.workspace.title": "Journal",
@ -8,9 +8,9 @@
"ui.title": "Journal", "ui.title": "Journal",
"ui.workspaceTitle": "Journal · {workspace}", "ui.workspaceTitle": "Journal · {workspace}",
"ui.add": "Add", "ui.add": "Add",
"ui.saveError": "Could not save journal: {error}", "ui.saveError": "Could not save journal. Please try again.",
"ui.aggregating": "Aggregating worklogs", "ui.aggregating": "Aggregating worklogs",
"ui.loadError": "Could not load journal: {error}", "ui.loadError": "Could not load journal. Please try again.",
"ui.ready": "Ready", "ui.ready": "Ready",
"ui.workItem": "Work item", "ui.workItem": "Work item",
"ui.body": "Body", "ui.body": "Body",
@ -31,5 +31,33 @@
"ui.edit": "Edit", "ui.edit": "Edit",
"ui.delete": "Delete", "ui.delete": "Delete",
"ui.entryCount.one": "{count} entry", "ui.entryCount.one": "{count} entry",
"ui.entryCount.many": "{count} entries" "ui.entryCount.many": "{count} entries",
"ui.candidate.title": "Possible journal entry",
"ui.candidate.deal": "Deal: {deal}",
"ui.candidate.time": "Time: {start} {end}",
"ui.candidate.duration": "Estimated duration: {minutes} min",
"ui.candidate.activities": "Activities: {count}",
"ui.candidate.linkedActivities": "Linked activities",
"ui.candidate.duplicate": "A journal entry already references this candidate",
"ui.todo.title": "Completed todo",
"ui.todo.completed": "Completed: {time}",
"ui.todo.duplicate": "A journal entry already references this todo",
"ui.activity.captureLink": "Link captured",
"ui.activity.captureSelection": "Selection captured",
"ui.activity.capturePage": "Page captured",
"ui.activity.fileCreated": "File created",
"ui.activity.fileUpdated": "File updated",
"ui.activity.fileDeleted": "File deleted",
"ui.activity.noteSaved": "Note saved",
"ui.activity.dealCreated": "Deal created",
"ui.activity.dealRenamed": "Deal renamed",
"ui.activity.generic": "Activity",
"ui.updated": "Entry updated",
"ui.added": "Entry added",
"ui.minutesValue": "{minutes} min",
"ui.meta.billable": "billable",
"ui.meta.nonBillable": "non-billable",
"ui.meta.activities.one": "{count} linked activity",
"ui.meta.activities.many": "{count} linked activities",
"ui.meta.todo": "linked todo"
} }

View File

@ -1,6 +1,6 @@
{ {
"manifest.name": "Журнал", "manifest.name": "Журнал",
"manifest.description": "Журнал рабочего пространства с ручными записями и необязательными связями с активностью.", "manifest.description": "Журнал Дела с ручными записями и необязательными связями с активностью.",
"contributions.views.verstak.journal.view.title": "Журнал", "contributions.views.verstak.journal.view.title": "Журнал",
"contributions.sidebarItems.verstak.journal.sidebar.title": "Журнал", "contributions.sidebarItems.verstak.journal.sidebar.title": "Журнал",
"contributions.workspaceItems.verstak.journal.workspace.title": "Журнал", "contributions.workspaceItems.verstak.journal.workspace.title": "Журнал",
@ -8,9 +8,9 @@
"ui.title": "Журнал", "ui.title": "Журнал",
"ui.workspaceTitle": "Журнал · {workspace}", "ui.workspaceTitle": "Журнал · {workspace}",
"ui.add": "Добавить", "ui.add": "Добавить",
"ui.saveError": "Не удалось сохранить журнал: {error}", "ui.saveError": "Не удалось сохранить журнал. Повторите попытку.",
"ui.aggregating": "Сбор записей о работе", "ui.aggregating": "Сбор записей о работе",
"ui.loadError": "Не удалось загрузить журнал: {error}", "ui.loadError": "Не удалось загрузить журнал. Повторите попытку.",
"ui.ready": "Готово", "ui.ready": "Готово",
"ui.workItem": "Выполненная работа", "ui.workItem": "Выполненная работа",
"ui.body": "Описание", "ui.body": "Описание",
@ -31,5 +31,33 @@
"ui.edit": "Изменить", "ui.edit": "Изменить",
"ui.delete": "Удалить", "ui.delete": "Удалить",
"ui.entryCount.one": "{count} запись", "ui.entryCount.one": "{count} запись",
"ui.entryCount.many": "Записей: {count}" "ui.entryCount.many": "Записей: {count}",
"ui.candidate.title": "Возможная запись журнала",
"ui.candidate.deal": "Дело: {deal}",
"ui.candidate.time": "Время: {start} {end}",
"ui.candidate.duration": "Расчётная длительность: {minutes} мин",
"ui.candidate.activities": "Активностей: {count}",
"ui.candidate.linkedActivities": "Связанные активности",
"ui.candidate.duplicate": "Запись журнала уже ссылается на этого кандидата",
"ui.todo.title": "Выполненная задача",
"ui.todo.completed": "Выполнена: {time}",
"ui.todo.duplicate": "Запись журнала уже ссылается на эту задачу",
"ui.activity.captureLink": "Сохранена ссылка",
"ui.activity.captureSelection": "Захвачено выделение",
"ui.activity.capturePage": "Сохранена страница",
"ui.activity.fileCreated": "Создан файл",
"ui.activity.fileUpdated": "Изменён файл",
"ui.activity.fileDeleted": "Удалён файл",
"ui.activity.noteSaved": "Сохранена заметка",
"ui.activity.dealCreated": "Создано Дело",
"ui.activity.dealRenamed": "Переименовано Дело",
"ui.activity.generic": "Активность",
"ui.updated": "Запись обновлена",
"ui.added": "Запись добавлена",
"ui.minutesValue": "{minutes} мин",
"ui.meta.billable": "оплачиваемая",
"ui.meta.nonBillable": "неоплачиваемая",
"ui.meta.activities.one": "{count} связанная активность",
"ui.meta.activities.many": "Связанных активностей: {count}",
"ui.meta.todo": "связанная задача"
} }

View File

@ -4,7 +4,7 @@
"name": "Journal", "name": "Journal",
"version": "0.1.0", "version": "0.1.0",
"apiVersion": "0.1.0", "apiVersion": "0.1.0",
"description": "Workspace-scoped journal with user-authored entries and optional Activity links.", "description": "Deal-scoped journal with user-authored entries and optional Activity links.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } }, "localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official", "source": "official",
"icon": "book-open", "icon": "book-open",
@ -15,6 +15,7 @@
], ],
"permissions": [ "permissions": [
"events.publish", "events.publish",
"files.read",
"storage.namespace", "storage.namespace",
"ui.register" "ui.register"
], ],

View File

@ -25,7 +25,7 @@
'.notes-btn.primary:hover{background:#63d9b3;color:#101827}', '.notes-btn.primary:hover{background:#63d9b3;color:#101827}',
'.notes-filter,.notes-sort{font-size:.78rem;padding:.32rem .5rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);outline:none}', '.notes-filter,.notes-sort{font-size:.78rem;padding:.32rem .5rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);outline:none}',
'.notes-filter{width:11rem}', '.notes-filter{width:11rem}',
'.notes-sort{width:8rem;appearance:none;background-color:#0f1424;background-image:linear-gradient(45deg,transparent 50%,#8b8ba8 50%),linear-gradient(135deg,#8b8ba8 50%,transparent 50%);background-position:calc(100% - 14px) 50%,calc(100% - 9px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:1.6rem}', '.notes-sort{width:8rem;appearance:none;background-color:#0f1424;background-image:linear-gradient(45deg,transparent 50%,#8b8ba8 50%),linear-gradient(135deg,#8b8ba8 50%,transparent 50%);background-position:calc(100% - 14px) 50%,calc(100% - 9px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:1.6rem}.notes-sort option{background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb)}',
'.notes-filter:focus,.notes-sort:focus{border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}', '.notes-filter:focus,.notes-sort:focus{border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.notes-list{flex:1;overflow:auto;min-height:0}', '.notes-list{flex:1;overflow:auto;min-height:0}',
'.notes-item{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;border-bottom:1px solid rgba(32,43,70,.72);cursor:pointer;font-size:.85rem}', '.notes-item{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;border-bottom:1px solid rgba(32,43,70,.72);cursor:pointer;font-size:.85rem}',
@ -43,14 +43,16 @@
'.notes-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--vt-color-text-muted,#7f8aa3);font-size:.85rem;padding:2rem;gap:.5rem;text-align:center}', '.notes-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--vt-color-text-muted,#7f8aa3);font-size:.85rem;padding:2rem;gap:.5rem;text-align:center}',
'.notes-empty-hint{font-size:.75rem;color:var(--vt-color-text-muted,#7f8aa3)}', '.notes-empty-hint{font-size:.75rem;color:var(--vt-color-text-muted,#7f8aa3)}',
'.notes-error{flex:1;display:flex;align-items:center;justify-content:center;color:var(--vt-color-danger,#e94560);padding:1rem;font-size:.85rem}', '.notes-error{flex:1;display:flex;align-items:center;justify-content:center;color:var(--vt-color-danger,#e94560);padding:1rem;font-size:.85rem}',
'.notes-panel{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;border-top:1px solid var(--vt-color-border,#202b46);flex-shrink:0;background:var(--vt-color-surface-muted,#111629)}',
'.notes-input{flex:1;font-size:.78rem;padding:.32rem .5rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);outline:none;min-width:120px}',
'.notes-input:focus{border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.notes-title-bar{padding:.4rem .75rem;font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3);background:var(--vt-color-surface-muted,#111629);border-bottom:1px solid var(--vt-color-border,#202b46);text-transform:uppercase;letter-spacing:.04em}', '.notes-title-bar{padding:.4rem .75rem;font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3);background:var(--vt-color-surface-muted,#111629);border-bottom:1px solid var(--vt-color-border,#202b46);text-transform:uppercase;letter-spacing:.04em}',
'.notes-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:10000;display:flex;align-items:center;justify-content:center}', '.notes-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:10000;display:flex;align-items:center;justify-content:center}',
'.notes-modal{width:380px;max-width:90vw;padding:20px;background:#1a1a2e;border:1px solid #333;border-radius:10px;color:#e0e0e0;font-family:inherit;box-shadow:0 12px 40px rgba(0,0,0,.5)}', '.notes-modal{width:380px;max-width:90vw;padding:20px;background:#1a1a2e;border:1px solid #333;border-radius:10px;color:#e0e0e0;font-family:inherit;box-shadow:0 12px 40px rgba(0,0,0,.5)}',
'.notes-modal-title{font-size:.9rem;font-weight:600;margin-bottom:12px}', '.notes-modal-title{font-size:.9rem;font-weight:600;margin-bottom:12px}',
'.notes-modal-msg{font-size:.82rem;color:#aaa;margin-bottom:16px;word-wrap:break-word}', '.notes-modal-msg{font-size:.82rem;color:#aaa;margin-bottom:16px;word-wrap:break-word}',
'.notes-modal-form{display:grid;gap:.55rem}',
'.notes-modal-input{width:100%;font:inherit;font-size:.84rem;padding:.5rem .6rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);outline:none}',
'.notes-modal-input:focus{border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.notes-modal-input[aria-invalid="true"]{border-color:var(--vt-color-danger,#e94560)}',
'.notes-modal-error{display:none;font-size:.78rem;line-height:1.35;color:#ffc6ce}',
'.notes-modal-actions{display:flex;justify-content:flex-end;gap:8px}', '.notes-modal-actions{display:flex;justify-content:flex-end;gap:8px}',
'.notes-modal-btn{font-size:.8rem;padding:.35rem .9rem;border:1px solid #333;border-radius:5px;cursor:pointer;font-family:inherit}', '.notes-modal-btn{font-size:.8rem;padding:.35rem .9rem;border:1px solid #333;border-radius:5px;cursor:pointer;font-family:inherit}',
'.notes-modal-btn.cancel{background:#2a2a4e;color:#ccc}', '.notes-modal-btn.cancel{background:#2a2a4e;color:#ccc}',
@ -171,7 +173,7 @@
var workspaceNode = props && props.workspaceNode; var workspaceNode = props && props.workspaceNode;
var workspaceRoot = (workspaceNode && (workspaceNode.rootPath || workspaceNode.name || workspaceNode.id)) || ''; var workspaceRoot = (workspaceNode && (workspaceNode.rootPath || workspaceNode.name || workspaceNode.id)) || '';
var workspaceName = workspaceRoot || (workspaceNode && (workspaceNode.name || workspaceNode.title)) || 'Workspace'; var workspaceName = workspaceRoot || (workspaceNode && (workspaceNode.name || workspaceNode.title)) || '';
var notes = []; var notes = [];
var selectedPath = ''; var selectedPath = '';
@ -287,23 +289,12 @@
var listContainer = el('div', { className: 'notes-list', 'data-notes-list': '' }); var listContainer = el('div', { className: 'notes-list', 'data-notes-list': '' });
containerEl.appendChild(listContainer); containerEl.appendChild(listContainer);
var createPanel = el('div', { className: 'notes-panel', style: { display: 'none' } }); var createModal = null;
var createInput = el('input', { className: 'notes-input', 'data-notes-create-input': '', placeholder: tr('ui.noteTitle', null, 'Note title') }); var createInput = null;
var createConfirm = el('button', { className: 'notes-btn', textContent: tr('ui.create', null, 'Create') }); var createError = null;
var createCancel = el('button', { className: 'notes-btn', textContent: tr('ui.cancel', null, 'Cancel') }); var renameModal = null;
createPanel.appendChild(createInput); var renameInput = null;
createPanel.appendChild(createConfirm); var renameError = null;
createPanel.appendChild(createCancel);
containerEl.appendChild(createPanel);
var renamePanel = el('div', { className: 'notes-panel', style: { display: 'none' } });
var renameInput = el('input', { className: 'notes-input', 'data-notes-rename-input': '', placeholder: tr('ui.newTitle', null, 'New title') });
var renameConfirm = el('button', { className: 'notes-btn', textContent: tr('ui.rename', null, 'Rename') });
var renameCancel = el('button', { className: 'notes-btn', textContent: tr('ui.cancel', null, 'Cancel') });
renamePanel.appendChild(renameInput);
renamePanel.appendChild(renameConfirm);
renamePanel.appendChild(renameCancel);
containerEl.appendChild(renamePanel);
// ─── Core Functions ───────────────────────────────────── // ─── Core Functions ─────────────────────────────────────
@ -324,6 +315,13 @@
} }
} }
function userFacingError(key, fallback, err) {
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
console.warn('[verstak.notes] ' + key, err);
}
return tr(key, null, fallback);
}
function loadNotes() { function loadNotes() {
listContainer.innerHTML = ''; listContainer.innerHTML = '';
listContainer.appendChild(el('div', { className: 'notes-empty' }, [tr('ui.loading', null, 'Loading...')])); listContainer.appendChild(el('div', { className: 'notes-empty' }, [tr('ui.loading', null, 'Loading...')]));
@ -335,7 +333,7 @@
renderList(); renderList();
}).catch(function (err) { }).catch(function (err) {
if (disposed) return; if (disposed) return;
renderEmpty('Error: ' + (err.message || err)); renderEmpty(userFacingError('ui.loadError', 'Could not load notes. Please try again.', err));
}); });
} }
@ -379,8 +377,7 @@
}).then(function () { }).then(function () {
if (!disposed) setStatus(action.label + ' complete', 'success'); if (!disposed) setStatus(action.label + ' complete', 'success');
}).catch(function (err) { }).catch(function (err) {
console.error('[notes] contribution action failed:', err); if (!disposed) setStatus(userFacingError('ui.actionError', 'Could not complete this note action. Please try again.', err), 'error');
if (!disposed) setStatus('Error: ' + (err && err.message ? err.message : err), 'error');
}); });
} }
@ -489,28 +486,134 @@
}).catch(function (err) { console.error('[notes] openResource:', err); }); }).catch(function (err) { console.error('[notes] openResource:', err); });
} }
function conflictMessage(title, existingPath) {
return [
tr('ui.conflictMessage', { title: title }, 'A note with the title "' + title + '" already exists.'),
existingPath ? tr('ui.existingFile', { path: existingPath }, ' Existing file: ' + existingPath + '.') : '',
tr('ui.chooseDifferent', null, ' Please choose a different title.')
].join('');
}
function showNoteFormModal(mode, note) {
var isRename = mode === 'rename';
var modalTitle = isRename
? tr('ui.renameTitle', null, 'Rename note')
: tr('ui.createTitle', null, 'Create note');
var overlay = el('div', {
className: 'notes-modal-overlay',
role: 'presentation'
});
overlay.setAttribute('data-notes-' + mode + '-modal', '');
var input = el('input', {
className: 'notes-modal-input',
type: 'text',
value: isRename ? (note.title || fileName(note.path)) : '',
placeholder: isRename
? tr('ui.newTitle', null, 'New title')
: tr('ui.noteTitle', null, 'Note title'),
autocomplete: 'off',
'aria-invalid': 'false'
});
input.setAttribute('data-notes-' + mode + '-input', '');
input.value = isRename ? (note.title || fileName(note.path)) : '';
var error = el('div', {
className: 'notes-modal-error',
role: 'alert'
});
error.setAttribute('data-notes-' + mode + '-error', '');
var confirm = el('button', {
className: 'notes-modal-btn confirm',
type: 'button',
textContent: isRename ? tr('ui.rename', null, 'Rename') : tr('ui.create', null, 'Create'),
onClick: function () {
if (isRename) confirmRename();
else confirmCreate();
}
});
var cancel = el('button', {
className: 'notes-modal-btn cancel',
type: 'button',
textContent: tr('ui.cancel', null, 'Cancel'),
onClick: function () {
if (isRename) hideRename();
else hideCreate();
}
});
function handleFormKeydown(event) {
if (event.key === 'Enter') {
if (event.preventDefault) event.preventDefault();
if (isRename) confirmRename();
else confirmCreate();
}
if (event.key === 'Escape') {
if (event.preventDefault) event.preventDefault();
if (isRename) hideRename();
else hideCreate();
}
}
input.addEventListener('keydown', handleFormKeydown);
var modal = el('div', {
className: 'notes-modal notes-modal-form',
role: 'dialog',
'aria-modal': 'true',
'aria-label': modalTitle
}, [
el('div', { className: 'notes-modal-title', textContent: modalTitle }),
input,
error,
el('div', { className: 'notes-modal-actions' }, [cancel, confirm])
]);
overlay.appendChild(modal);
document.body.appendChild(overlay);
if (isRename) {
renameInput = input;
renameError = error;
} else {
createInput = input;
createError = error;
}
input.focus();
if (isRename && input.select) input.select();
return overlay;
}
// ─── Create ──────────────────────────────────────────── // ─── Create ────────────────────────────────────────────
function showCreate() { function showCreate() {
createInput.value = ''; hideCreate();
createPanel.style.display = 'flex'; createModal = showNoteFormModal('create');
createInput.focus();
} }
function hideCreate() { function hideCreate() {
createPanel.style.display = 'none'; if (createModal) createModal.remove();
createModal = null;
createInput = null;
createError = null;
}
function setCreateError(message) {
if (!createError || !createInput) return;
createError.textContent = message || '';
createError.style.display = message ? 'block' : 'none';
createInput.setAttribute('aria-invalid', message ? 'true' : 'false');
} }
function confirmCreate() { function confirmCreate() {
if (!createInput) return;
var title = createInput.value.trim(); var title = createInput.value.trim();
if (!title) return; if (!title) {
setCreateError(tr('ui.titleRequired', null, 'Enter a note title.'));
createInput.focus();
return;
}
setStatus(tr('ui.creating', null, 'Creating note...'), 'loading'); setStatus(tr('ui.creating', null, 'Creating note...'), 'loading');
var parent = notesParent(); var parent = notesParent();
createNote(parent, title).then(function (data) { createNote(parent, title).then(function (data) {
if (disposed) return; if (disposed) return;
data = data || {}; data = data || {};
if (data.conflict) { if (data.conflict) {
showConflictModal(title, data.path, createInput); setCreateError(conflictMessage(title, data.path));
createInput.focus();
return; return;
} }
hideCreate(); hideCreate();
@ -533,42 +636,55 @@
}).catch(function () {}); }).catch(function () {});
} }
}).catch(function (err) { }).catch(function (err) {
setStatus('Error: ' + (err.message || err), 'error'); setCreateError(userFacingError('ui.createError', 'Could not create the note. Please try again.', err));
}); });
} }
// ─── Rename ───────────────────────────────────────────── // ─── Rename ─────────────────────────────────────────────
function beginRename(note) { function beginRename(note) {
hideRename();
renameTarget = note; renameTarget = note;
renameInput.value = note.title || fileName(note.path); renameModal = showNoteFormModal('rename', note);
renamePanel.style.display = 'flex';
renameInput.focus();
renameInput.select();
} }
function hideRename() { function hideRename() {
renameTarget = null; renameTarget = null;
renamePanel.style.display = 'none'; if (renameModal) renameModal.remove();
renameModal = null;
renameInput = null;
renameError = null;
}
function setRenameError(message) {
if (!renameError || !renameInput) return;
renameError.textContent = message || '';
renameError.style.display = message ? 'block' : 'none';
renameInput.setAttribute('aria-invalid', message ? 'true' : 'false');
} }
function confirmRename() { function confirmRename() {
if (!renameTarget) return; if (!renameTarget || !renameInput) return;
var newTitle = renameInput.value.trim(); var newTitle = renameInput.value.trim();
if (!newTitle) return; if (!newTitle) {
setRenameError(tr('ui.titleRequired', null, 'Enter a note title.'));
renameInput.focus();
return;
}
setStatus(tr('ui.renaming', null, 'Renaming...'), 'loading'); setStatus(tr('ui.renaming', null, 'Renaming...'), 'loading');
renameNote(renameTarget.path, newTitle).then(function (data) { renameNote(renameTarget.path, newTitle).then(function (data) {
if (disposed) return; if (disposed) return;
data = data || {}; data = data || {};
if (data.conflict) { if (data.conflict) {
showConflictModal(newTitle, data.path, renameInput); setRenameError(conflictMessage(newTitle, data.path));
renameInput.focus();
return; return;
} }
hideRename(); hideRename();
setStatus(tr('ui.renamed', null, 'Note renamed'), 'success'); setStatus(tr('ui.renamed', null, 'Note renamed'), 'success');
loadNotes(); loadNotes();
}).catch(function (err) { }).catch(function (err) {
setStatus('Error: ' + (err.message || err), 'error'); setRenameError(userFacingError('ui.renameError', 'Could not rename the note. Please try again.', err));
}); });
} }
@ -585,30 +701,11 @@
setStatus(tr('ui.trashed', null, 'Note moved to trash'), 'success'); setStatus(tr('ui.trashed', null, 'Note moved to trash'), 'success');
loadNotes(); loadNotes();
}).catch(function (err) { }).catch(function (err) {
setStatus('Error: ' + (err.message || err), 'error'); setStatus(userFacingError('ui.trashError', 'Could not move the note to trash. Please try again.', err), 'error');
}); });
}); });
} }
// ─── Conflict Modal ─────────────────────────────────────
function showConflictModal(title, existingPath, focusTarget) {
var overlay = el('div', { className: 'notes-modal-overlay' });
var modal = el('div', { className: 'notes-modal' }, [
el('div', { className: 'notes-modal-title' }, [tr('ui.conflictTitle', null, 'Name Conflict')]),
el('div', { className: 'notes-modal-msg' }, [
tr('ui.conflictMessage', { title: title }, 'A note with the title "' + title + '" already exists.'),
existingPath ? tr('ui.existingFile', { path: existingPath }, ' Existing file: ' + existingPath + '.') : '',
tr('ui.chooseDifferent', null, ' Please choose a different title.')
].join('')),
el('div', { className: 'notes-modal-actions' }, [
el('button', { className: 'notes-modal-btn confirm', textContent: 'OK', onClick: function () { overlay.remove(); (focusTarget || createInput).focus(); } })
])
]);
overlay.appendChild(modal);
document.body.appendChild(overlay);
}
function showTrashModal(note) { function showTrashModal(note) {
return new Promise(function (resolve) { return new Promise(function (resolve) {
var overlay = el('div', { className: 'notes-modal-overlay' }); var overlay = el('div', { className: 'notes-modal-overlay' });
@ -642,18 +739,6 @@
sortMode = sortSelect.value || 'title-asc'; sortMode = sortSelect.value || 'title-asc';
renderList(); renderList();
}); });
createConfirm.addEventListener('click', confirmCreate);
createCancel.addEventListener('click', hideCreate);
renameConfirm.addEventListener('click', confirmRename);
renameCancel.addEventListener('click', hideRename);
createInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') confirmCreate();
if (e.key === 'Escape') hideCreate();
});
renameInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') confirmRename();
if (e.key === 'Escape') hideRename();
});
// ─── Init ─────────────────────────────────────────────── // ─── Init ───────────────────────────────────────────────
@ -666,12 +751,6 @@
createBtn.innerHTML = iconSvg('add') + ' ' + tr('ui.newNote', null, 'New Note'); createBtn.innerHTML = iconSvg('add') + ' ' + tr('ui.newNote', null, 'New Note');
filterInput.setAttribute('placeholder', tr('ui.filter', null, 'Filter notes')); filterInput.setAttribute('placeholder', tr('ui.filter', null, 'Filter notes'));
titleBar.textContent = tr('ui.title', { workspace: workspaceName }, 'Notes in ' + workspaceName); titleBar.textContent = tr('ui.title', { workspace: workspaceName }, 'Notes in ' + workspaceName);
createInput.setAttribute('placeholder', tr('ui.noteTitle', null, 'Note title'));
renameInput.setAttribute('placeholder', tr('ui.newTitle', null, 'New title'));
createConfirm.textContent = tr('ui.create', null, 'Create');
createCancel.textContent = tr('ui.cancel', null, 'Cancel');
renameConfirm.textContent = tr('ui.rename', null, 'Rename');
renameCancel.textContent = tr('ui.cancel', null, 'Cancel');
renderList(); renderList();
}); });
} }
@ -690,6 +769,8 @@
containerEl.__notesCleanup = function () { containerEl.__notesCleanup = function () {
disposed = true; disposed = true;
hideCreate();
hideRename();
if (typeof localeUnsubscribe === 'function') localeUnsubscribe(); if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
if (typeof fileChangedUnsubscribe === 'function') fileChangedUnsubscribe(); if (typeof fileChangedUnsubscribe === 'function') fileChangedUnsubscribe();
}; };

View File

@ -1,11 +1,14 @@
{ {
"manifest.name": "Notes", "manifest.name": "Notes",
"manifest.description": "Workspace-scoped notes manager for Markdown files in canonical Notes folders with create, rename, trash, and Workbench integration.", "manifest.description": "Deal-scoped notes manager for Markdown files in canonical Notes folders with create, rename, trash, and Workbench integration.",
"contributions.workspaceItems.verstak.notes.workspace.title": "Notes", "contributions.workspaceItems.verstak.notes.workspace.title": "Notes",
"ui.newNote": "New Note", "ui.newNote": "New Note",
"ui.filter": "Filter notes", "ui.filter": "Filter notes",
"ui.title": "Notes in {workspace}", "ui.title": "Notes in {workspace}",
"ui.noteTitle": "Note title", "ui.noteTitle": "Note title",
"ui.createTitle": "Create note",
"ui.renameTitle": "Rename note",
"ui.titleRequired": "Enter a note title.",
"ui.create": "Create", "ui.create": "Create",
"ui.cancel": "Cancel", "ui.cancel": "Cancel",
"ui.newTitle": "New title", "ui.newTitle": "New title",
@ -28,5 +31,10 @@
"ui.existingFile": " Existing file: {path}.", "ui.existingFile": " Existing file: {path}.",
"ui.chooseDifferent": " Please choose a different title.", "ui.chooseDifferent": " Please choose a different title.",
"ui.trashTitle": "Move Note to Trash", "ui.trashTitle": "Move Note to Trash",
"ui.trashConfirm": "Move \"{title}\" to trash?" "ui.trashConfirm": "Move \"{title}\" to trash?",
"ui.loadError": "Could not load notes. Please try again.",
"ui.actionError": "Could not complete this note action. Please try again.",
"ui.createError": "Could not create the note. Please try again.",
"ui.renameError": "Could not rename the note. Please try again.",
"ui.trashError": "Could not move the note to trash. Please try again."
} }

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{ {
"manifest.name": "Search", "manifest.name": "Search",
"manifest.description": "Workspace-scoped vault text search provider.", "manifest.description": "Deal-scoped vault text search provider.",
"contributions.workspaceItems.verstak.search.workspace.title": "Search", "contributions.workspaceItems.verstak.search.workspace.title": "Search",
"contributions.commands.verstak.search.searchVaultText.title": "Search Vault Text", "contributions.commands.verstak.search.searchVaultText.title": "Search Vault Text",
"contributions.searchProviders.verstak.search.vault-text.label": "Vault Text Search", "contributions.searchProviders.verstak.search.vault-text.label": "Vault Text Search",
@ -10,6 +10,8 @@
"ui.searching": "Searching...", "ui.searching": "Searching...",
"ui.search": "Search", "ui.search": "Search",
"ui.providersFailed": "Some plugin search providers did not respond", "ui.providersFailed": "Some plugin search providers did not respond",
"ui.providerUnavailable": "A search provider is unavailable.",
"ui.searchError": "Could not search the vault. Please try again.",
"ui.noResults": "No results", "ui.noResults": "No results",
"ui.open": "Open", "ui.open": "Open",
"ui.count": "{count} result(s)" "ui.count": "{count} result(s)"

View File

@ -1,6 +1,6 @@
{ {
"manifest.name": "Поиск", "manifest.name": "Поиск",
"manifest.description": "Поиск текста в хранилище в пределах рабочего пространства.", "manifest.description": "Поиск текста в хранилище в пределах Дела.",
"contributions.workspaceItems.verstak.search.workspace.title": "Поиск", "contributions.workspaceItems.verstak.search.workspace.title": "Поиск",
"contributions.commands.verstak.search.searchVaultText.title": "Искать текст в хранилище", "contributions.commands.verstak.search.searchVaultText.title": "Искать текст в хранилище",
"contributions.searchProviders.verstak.search.vault-text.label": "Поиск по тексту хранилища", "contributions.searchProviders.verstak.search.vault-text.label": "Поиск по тексту хранилища",
@ -10,6 +10,8 @@
"ui.searching": "Поиск...", "ui.searching": "Поиск...",
"ui.search": "Искать", "ui.search": "Искать",
"ui.providersFailed": "Некоторые поставщики поиска не ответили", "ui.providersFailed": "Некоторые поставщики поиска не ответили",
"ui.providerUnavailable": "Один из источников поиска недоступен.",
"ui.searchError": "Не удалось выполнить поиск в хранилище. Повторите попытку.",
"ui.noResults": "Ничего не найдено", "ui.noResults": "Ничего не найдено",
"ui.open": "Открыть", "ui.open": "Открыть",
"ui.count": "Результатов: {count}" "ui.count": "Результатов: {count}"

View File

@ -4,7 +4,7 @@
"name": "Search", "name": "Search",
"version": "0.1.0", "version": "0.1.0",
"apiVersion": "0.1.0", "apiVersion": "0.1.0",
"description": "Workspace-scoped vault text search provider.", "description": "Deal-scoped vault text search provider.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } }, "localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official", "source": "official",
"icon": "search", "icon": "search",

View File

@ -23,6 +23,7 @@
'.secrets-panel{min-height:0;overflow:auto;border-right:1px solid var(--vt-color-border,#202b46);background:var(--vt-color-surface-muted,#111629)}', '.secrets-panel{min-height:0;overflow:auto;border-right:1px solid var(--vt-color-border,#202b46);background:var(--vt-color-surface-muted,#111629)}',
'.secrets-main{min-width:0;min-height:0;overflow:auto;padding:1rem;background:var(--vt-color-background,#101020)}', '.secrets-main{min-width:0;min-height:0;overflow:auto;padding:1rem;background:var(--vt-color-background,#101020)}',
'.secrets-toolbar{display:flex;align-items:center;gap:.5rem;min-height:2.75rem;padding:.65rem .75rem;border-bottom:1px solid var(--vt-color-border,#202b46)}', '.secrets-toolbar{display:flex;align-items:center;gap:.5rem;min-height:2.75rem;padding:.65rem .75rem;border-bottom:1px solid var(--vt-color-border,#202b46)}',
'.secrets-filters{display:grid;grid-template-columns:minmax(0,1fr) minmax(8rem,.8fr);gap:.4rem;padding:.5rem .55rem;border-bottom:1px solid var(--vt-color-border,#202b46)}',
'.secrets-title{font-weight:600;font-size:.88rem}', '.secrets-title{font-weight:600;font-size:.88rem}',
'.secrets-count{color:var(--vt-color-text-muted,#7f8aa3);font-size:.76rem}', '.secrets-count{color:var(--vt-color-text-muted,#7f8aa3);font-size:.76rem}',
'.secrets-spacer{flex:1}', '.secrets-spacer{flex:1}',
@ -44,10 +45,11 @@
'.secrets-form{display:grid;gap:.65rem;border:1px solid var(--vt-color-border,#202b46);border-radius:var(--vt-radius-lg,8px);padding:.9rem;background:var(--vt-color-surface,#15152c)}', '.secrets-form{display:grid;gap:.65rem;border:1px solid var(--vt-color-border,#202b46);border-radius:var(--vt-radius-lg,8px);padding:.9rem;background:var(--vt-color-surface,#15152c)}',
'.secrets-row{display:grid;grid-template-columns:8rem minmax(0,1fr);gap:.65rem;align-items:center}', '.secrets-row{display:grid;grid-template-columns:8rem minmax(0,1fr);gap:.65rem;align-items:center}',
'.secrets-label{font-size:.78rem;color:var(--vt-color-text-muted,#7f8aa3)}', '.secrets-label{font-size:.78rem;color:var(--vt-color-text-muted,#7f8aa3)}',
'.secrets-input,.secrets-textarea,.secrets-select{width:100%;box-sizing:border-box;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);font:inherit;font-size:.84rem;padding:.45rem .55rem;outline:none}', '.secrets-input,.secrets-textarea,.secrets-select,.secrets-search{width:100%;box-sizing:border-box;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);font:inherit;font-size:.84rem;padding:.45rem .55rem;outline:none}',
'.secrets-select{appearance:none;background-color:#0d1117;background-image:linear-gradient(45deg,transparent 50%,#8b949e 50%),linear-gradient(135deg,#8b949e 50%,transparent 50%);background-position:calc(100% - 16px) 50%,calc(100% - 11px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:2rem}', '.secrets-search{font-size:.76rem}',
'.secrets-select{appearance:none;background-color:#0d1117;background-image:linear-gradient(45deg,transparent 50%,#8b949e 50%),linear-gradient(135deg,#8b949e 50%,transparent 50%);background-position:calc(100% - 16px) 50%,calc(100% - 11px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:2rem}.secrets-select option{background:#0d1117;color:var(--vt-color-text-primary,#f4f7fb)}',
'.secrets-textarea{min-height:6rem;resize:vertical;font-family:ui-monospace,SFMono-Regular,Consolas,monospace}', '.secrets-textarea{min-height:6rem;resize:vertical;font-family:ui-monospace,SFMono-Regular,Consolas,monospace}',
'.secrets-input:focus,.secrets-textarea:focus,.secrets-select:focus{border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}', '.secrets-input:focus,.secrets-textarea:focus,.secrets-select:focus,.secrets-search:focus{border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.secrets-actions{display:flex;gap:.5rem;flex-wrap:wrap}', '.secrets-actions{display:flex;gap:.5rem;flex-wrap:wrap}',
'.secrets-status{font-size:.78rem;color:var(--vt-color-text-muted,#7f8aa3);min-height:1rem}', '.secrets-status{font-size:.78rem;color:var(--vt-color-text-muted,#7f8aa3);min-height:1rem}',
'.secrets-status.error{color:#ffc6ce}', '.secrets-status.error{color:#ffc6ce}',
@ -58,7 +60,12 @@
'.secrets-table th{width:9rem;color:var(--vt-color-text-muted,#7f8aa3);font-weight:500;background:var(--vt-color-surface-muted,#111629)}', '.secrets-table th{width:9rem;color:var(--vt-color-text-muted,#7f8aa3);font-weight:500;background:var(--vt-color-surface-muted,#111629)}',
'.secrets-table td{color:var(--vt-color-text-primary,#f4f7fb);overflow-wrap:anywhere}', '.secrets-table td{color:var(--vt-color-text-primary,#f4f7fb);overflow-wrap:anywhere}',
'.secrets-table tr:last-child th,.secrets-table tr:last-child td{border-bottom:0}', '.secrets-table tr:last-child th,.secrets-table tr:last-child td{border-bottom:0}',
'@media(max-width:780px){.secrets-root{grid-template-columns:1fr}.secrets-panel{border-right:0;border-bottom:1px solid #252b36;max-height:45vh}.secrets-row{grid-template-columns:1fr}}' '.secrets-modal-overlay{position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.6)}',
'.secrets-modal{width:24rem;max-width:90vw;display:grid;gap:.8rem;padding:1rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-lg,8px);background:var(--vt-color-surface,#15152c);box-shadow:var(--vt-elevation-menu,0 14px 32px rgba(0,0,0,.42))}',
'.secrets-modal-title{margin:0;font-size:.96rem}',
'.secrets-modal-message{margin:0;color:var(--vt-color-text-secondary,#b7c0d4);font-size:.84rem;line-height:1.45}',
'.secrets-modal-actions{display:flex;justify-content:flex-end;gap:.5rem}',
'@media(max-width:780px){.secrets-root{grid-template-columns:1fr}.secrets-panel{border-right:0;border-bottom:1px solid #252b36;max-height:45vh}.secrets-row,.secrets-filters{grid-template-columns:1fr}}'
].join('\n'); ].join('\n');
function el(tag, attrs, children) { function el(tag, attrs, children) {
@ -95,29 +102,30 @@
|| (node && (node.rootPath || node.name || node.id))); || (node && (node.rootPath || node.name || node.id)));
} }
function scopeLabel(record) { function scopeLabel(record, translate) {
var scope = record && record.scope || {}; var scope = record && record.scope || {};
if (scope.kind === ScopeWorkspace) return cleanWorkspace(scope.workspaceRootPath) || 'Workspace'; if (scope.kind === ScopeWorkspace) return cleanWorkspace(scope.workspaceRootPath) || translate('ui.deal', null, 'Deal');
return 'Global'; return translate('ui.global', null, 'Global');
} }
function selectedIDFromProps(props) { function selectedIDFromProps(props) {
var resource = props && props.resource || {}; var resource = props && props.resource || {};
var path = text(resource.path || props && props.secretId); var request = props && props.request || {};
var path = text(resource.path || request.path || props && props.secretId);
if (path.indexOf('verstak-secret://') === 0) return decodeURIComponent(path.slice('verstak-secret://'.length)); if (path.indexOf('verstak-secret://') === 0) return decodeURIComponent(path.slice('verstak-secret://'.length));
return decodeURIComponent(path.replace(/^\/+/, '')); return decodeURIComponent(path.replace(/^\/+/, ''));
} }
function groupRecords(records) { function groupRecords(records, translate) {
var groups = {}; var groups = {};
records.forEach(function (record) { records.forEach(function (record) {
var label = scopeLabel(record); var label = scopeLabel(record, translate);
groups[label] = groups[label] || []; groups[label] = groups[label] || [];
groups[label].push(record); groups[label].push(record);
}); });
return Object.keys(groups).sort(function (a, b) { return Object.keys(groups).sort(function (a, b) {
if (a === 'Global') return -1; if (a === translate('ui.global', null, 'Global')) return -1;
if (b === 'Global') return 1; if (b === translate('ui.global', null, 'Global')) return 1;
return a.localeCompare(b); return a.localeCompare(b);
}).map(function (label) { }).map(function (label) {
groups[label].sort(function (a, b) { groups[label].sort(function (a, b) {
@ -144,8 +152,13 @@
var workspaceRoot = workspaceFromProps(props || {}); var workspaceRoot = workspaceFromProps(props || {});
var selectedID = selectedIDFromProps(props || {}); var selectedID = selectedIDFromProps(props || {});
var records = []; var records = [];
var workspaceOptions = [];
var scopeFilter = 'all';
var searchQuery = '';
var selectedRecord = null; var selectedRecord = null;
var selectedValue = ''; var selectedValue = '';
var isValueVisible = false;
var deleteModal = null;
var initialized = false; var initialized = false;
var unlocked = false; var unlocked = false;
var statusText = ''; var statusText = '';
@ -161,6 +174,92 @@
render(); render();
} }
function userFacingError(action, err) {
var raw = (err && err.message) ? err.message : String(err || '');
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
console.warn('[verstak.secrets] ' + action + ' failed', err);
}
if (action === 'unlock') {
var minimumLength = raw.match(/master password must be at least (\d+) characters/i);
if (minimumLength) {
return tr('ui.masterPasswordMinLength', { count: minimumLength[1] }, 'Master password must be at least ' + minimumLength[1] + ' characters.');
}
if (/master password is empty/i.test(raw)) {
return tr('ui.masterPasswordRequired', null, 'Enter a master password.');
}
if (/invalid master password/i.test(raw)) {
return tr('ui.masterPasswordInvalid', null, 'The master password is incorrect.');
}
}
return tr('ui.' + action + 'Error', null, {
status: 'Could not check the secret storage. Please try again.',
load: 'Could not load secrets. Please try again.',
read: 'Could not open this secret. Please try again.',
save: 'Could not save the secret. Please try again.',
delete: 'Could not delete the secret. Please try again.',
copyValue: 'Could not copy the secret value. Please try again.',
copyLink: 'Could not copy the secret link. Please try again.',
unlock: 'Could not unlock secrets. Check the master password and try again.'
}[action] || 'Could not complete this action. Please try again.');
}
function setUserFacingError(action, err) {
setStatus(userFacingError(action, err), true);
}
function recordWorkspaceRoot(record) {
var scope = record && record.scope || {};
return scope.kind === ScopeWorkspace ? cleanWorkspace(scope.workspaceRootPath) : '';
}
function workspaceRoots() {
var seen = {};
var values = [];
workspaceOptions.concat(records.map(recordWorkspaceRoot)).forEach(function (value) {
value = cleanWorkspace(value);
if (!value || seen[value]) return;
seen[value] = true;
values.push(value);
});
return values.sort(function (a, b) { return a.localeCompare(b); });
}
function filteredRecords() {
var query = text(searchQuery).trim().toLowerCase();
return records.filter(function (record) {
var recordWorkspace = recordWorkspaceRoot(record);
if (scopeFilter === ScopeGlobal && recordWorkspace) return false;
if (scopeFilter.indexOf(ScopeWorkspace + ':') === 0 && recordWorkspace !== scopeFilter.slice((ScopeWorkspace + ':').length)) return false;
if (!query) return true;
return [record.title, record.id, record.username, recordWorkspace].some(function (value) {
return text(value).toLowerCase().indexOf(query) !== -1;
});
});
}
function clearHiddenSelection() {
if (!selectedRecord) return;
var visible = filteredRecords();
if (visible.some(function (record) { return record.id === selectedRecord.id; })) return;
selectedRecord = null;
selectedValue = '';
}
function loadWorkspaceOptions() {
if (!api || !api.files || typeof api.files.list !== 'function') return Promise.resolve();
return api.files.list('').then(function (items) {
workspaceOptions = (Array.isArray(items) ? items : []).filter(function (item) {
return text(item && item.type).toLowerCase() === 'folder';
}).map(function (item) {
return cleanWorkspace(item.relativePath || item.name);
}).filter(function (value) {
return value && value.indexOf('/') === -1;
});
}).catch(function () {
workspaceOptions = [];
});
}
function renderLocked() { function renderLocked() {
var passwordInput = el('input', { var passwordInput = el('input', {
className: 'secrets-input', className: 'secrets-input',
@ -190,7 +289,7 @@
return loadRecords(); return loadRecords();
}).catch(function (err) { }).catch(function (err) {
unlockBtn.disabled = false; unlockBtn.disabled = false;
setStatus((err && err.message) ? err.message : String(err), true); setUserFacingError('unlock', err);
}); });
} }
}, [tr('ui.unlock', null, 'Unlock')]); }, [tr('ui.unlock', null, 'Unlock')]);
@ -226,19 +325,48 @@
} }
function renderList() { function renderList() {
var visibleRecords = filteredRecords();
var scopeSelect = el('select', {
className: 'secrets-select',
'data-secret-scope-filter': '',
onChange: function (event) {
scopeFilter = text(event.target.value) || 'all';
clearHiddenSelection();
render();
}
}, [
el('option', { value: 'all' }, [tr('ui.scopeAll', null, 'All scopes')]),
el('option', { value: ScopeGlobal }, [tr('ui.global', null, 'Global')])
].concat(workspaceRoots().map(function (workspace) {
return el('option', { value: ScopeWorkspace + ':' + workspace }, [workspace]);
})));
scopeSelect.value = scopeFilter;
var searchInput = el('input', {
className: 'secrets-search',
type: 'search',
'data-secret-search': '',
placeholder: tr('ui.search', null, 'Search secrets'),
value: searchQuery,
onInput: function (event) {
searchQuery = text(event.target.value);
clearHiddenSelection();
render();
}
});
var children = [ var children = [
el('div', { className: 'secrets-toolbar' }, [ el('div', { className: 'secrets-toolbar' }, [
el('span', { className: 'secrets-title' }, [tr('ui.title', null, 'Secrets')]), el('span', { className: 'secrets-title' }, [tr('ui.title', null, 'Secrets')]),
el('span', { className: 'secrets-count' }, [String(records.length)]), el('span', { className: 'secrets-count' }, [String(visibleRecords.length)]),
el('span', { className: 'secrets-spacer' }), el('span', { className: 'secrets-spacer' }),
el('button', { className: 'secrets-btn', type: 'button', onClick: showNewSecret }, [tr('ui.new', null, 'New')]) el('button', { className: 'secrets-btn', type: 'button', onClick: showNewSecret }, [tr('ui.new', null, 'New')])
]) ]),
el('div', { className: 'secrets-filters' }, [searchInput, scopeSelect])
]; ];
if (!records.length) { if (!visibleRecords.length) {
children.push(el('div', { className: 'secrets-empty' }, [tr('ui.empty', null, 'No secrets')])); children.push(el('div', { className: 'secrets-empty' }, [tr('ui.empty', null, 'No secrets')]));
return children; return children;
} }
groupRecords(records).forEach(function (group) { groupRecords(visibleRecords, tr).forEach(function (group) {
children.push(el('div', { className: 'secrets-group' }, [group.label])); children.push(el('div', { className: 'secrets-group' }, [group.label]));
children.push(el('div', { className: 'secrets-list' }, group.records.map(function (record) { children.push(el('div', { className: 'secrets-list' }, group.records.map(function (record) {
var active = selectedRecord && selectedRecord.id === record.id; var active = selectedRecord && selectedRecord.id === record.id;
@ -259,16 +387,19 @@
function renderSelected() { function renderSelected() {
if (!selectedRecord) return el('div', { className: 'secrets-card' }, [ if (!selectedRecord) return el('div', { className: 'secrets-card' }, [
el('h2', {}, [tr('ui.select', null, 'Select a secret')]) el('h2', {}, [tr('ui.select', null, 'Select a secret')]),
el('div', { className: statusError ? 'secrets-status error' : 'secrets-status' }, [statusText])
]); ]);
var valueIsAvailable = !!selectedValue;
var valueDisplay = valueIsAvailable && isValueVisible ? selectedValue : '••••••••••••';
return el('div', { className: 'secrets-card' }, [ return el('div', { className: 'secrets-card' }, [
el('h2', {}, [selectedRecord.title || selectedRecord.id]), el('h2', {}, [selectedRecord.title || selectedRecord.id]),
el('table', { className: 'secrets-table' }, [ el('table', { className: 'secrets-table' }, [
el('tbody', {}, [ el('tbody', {}, [
fieldRow(tr('ui.group', null, 'Group'), scopeLabel(selectedRecord)), fieldRow(tr('ui.group', null, 'Group'), scopeLabel(selectedRecord, tr)),
fieldRow('ID', selectedRecord.id), fieldRow('ID', selectedRecord.id),
fieldRow(tr('ui.username', null, 'Username'), selectedRecord.username || ''), fieldRow(tr('ui.username', null, 'Username'), selectedRecord.username || ''),
fieldRow(tr('ui.password', null, 'Password'), selectedValue ? selectedValue : '••••••••••••', selectedValue ? '' : 'secrets-hidden-value'), fieldRow(tr('ui.password', null, 'Password'), valueDisplay, isValueVisible && valueIsAvailable ? 'secrets-secret-value' : 'secrets-hidden-value'),
fieldRow(tr('ui.updated', null, 'Updated'), selectedRecord.updatedAt || '') fieldRow(tr('ui.updated', null, 'Updated'), selectedRecord.updatedAt || '')
]) ])
]), ]),
@ -279,6 +410,24 @@
'data-secret-copy-link': selectedRecord.id, 'data-secret-copy-link': selectedRecord.id,
onClick: function () { copySecretLink(selectedRecord.id); } onClick: function () { copySecretLink(selectedRecord.id); }
}, [tr('ui.copyLink', null, 'Copy secret link')]), }, [tr('ui.copyLink', null, 'Copy secret link')]),
el('button', {
className: 'secrets-btn',
type: 'button',
disabled: !valueIsAvailable,
'data-secret-copy-value': selectedRecord.id,
onClick: function () { copySecretValue(selectedValue); }
}, [tr('ui.copyValue', null, 'Copy value')]),
el('button', {
className: 'secrets-btn',
type: 'button',
disabled: !valueIsAvailable,
'data-secret-toggle-value': selectedRecord.id,
onClick: function () {
if (!selectedValue) return;
isValueVisible = !isValueVisible;
render();
}
}, [isValueVisible ? tr('ui.hideValue', null, 'Hide value') : tr('ui.showValue', null, 'Show value')]),
el('button', { el('button', {
className: 'secrets-btn', className: 'secrets-btn',
type: 'button', type: 'button',
@ -316,9 +465,26 @@
value.value = isEdit ? selectedValue : ''; value.value = isEdit ? selectedValue : '';
var scope = el('select', { className: 'secrets-select' }, [ var scope = el('select', { className: 'secrets-select' }, [
el('option', { value: ScopeGlobal }, [tr('ui.global', null, 'Global')]), el('option', { value: ScopeGlobal }, [tr('ui.global', null, 'Global')]),
el('option', { value: ScopeWorkspace }, [workspaceRoot || tr('ui.workspace', null, 'Workspace')]) el('option', { value: ScopeWorkspace }, [tr('ui.deal', null, 'Deal')])
]); ]);
scope.setAttribute('data-secret-scope', '');
var existingWorkspace = existing ? recordWorkspaceRoot(existing) : '';
var defaultWorkspace = existingWorkspace || workspaceRoot || workspaceRoots()[0] || '';
var workspace = el('select', { className: 'secrets-select', 'data-secret-workspace': '' }, [
el('option', { value: '' }, [tr('ui.chooseWorkspace', null, 'Choose a Deal')])
].concat(workspaceRoots().map(function (item) {
return el('option', { value: item }, [item]);
})));
workspace.value = defaultWorkspace;
var workspaceRow = el('div', { className: 'secrets-row' }, [
el('label', { className: 'secrets-label' }, [tr('ui.deal', null, 'Deal')]), workspace
]);
function updateWorkspaceVisibility() {
workspaceRow.hidden = scope.value !== ScopeWorkspace;
}
scope.addEventListener('change', updateWorkspaceVisibility);
scope.value = existing && existing.scope && existing.scope.kind ? existing.scope.kind : (workspaceRoot ? ScopeWorkspace : ScopeGlobal); scope.value = existing && existing.scope && existing.scope.kind ? existing.scope.kind : (workspaceRoot ? ScopeWorkspace : ScopeGlobal);
updateWorkspaceVisibility();
return el('div', { className: 'secrets-card' }, [ return el('div', { className: 'secrets-card' }, [
el('h2', {}, [isEdit ? tr('ui.editSecret', null, 'Edit secret') : tr('ui.newSecret', null, 'New secret')]), el('h2', {}, [isEdit ? tr('ui.editSecret', null, 'Edit secret') : tr('ui.newSecret', null, 'New secret')]),
el('div', { className: 'secrets-form' }, [ el('div', { className: 'secrets-form' }, [
@ -326,6 +492,7 @@
el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, ['ID']), id]), el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, ['ID']), id]),
el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, [tr('ui.username', null, 'Username')]), username]), el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, [tr('ui.username', null, 'Username')]), username]),
el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, [tr('ui.scope', null, 'Scope')]), scope]), el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, [tr('ui.scope', null, 'Scope')]), scope]),
workspaceRow,
el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, [tr('ui.value', null, 'Value')]), value]), el('div', { className: 'secrets-row' }, [el('label', { className: 'secrets-label' }, [tr('ui.value', null, 'Value')]), value]),
el('div', { className: 'secrets-actions' }, [ el('div', { className: 'secrets-actions' }, [
el('button', { el('button', {
@ -334,19 +501,24 @@
'data-secret-save': '', 'data-secret-save': '',
onClick: function () { onClick: function () {
var nextID = text(id.value).trim() || text(title.value).trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '.').replace(/^\.+|\.+$/g, ''); var nextID = text(id.value).trim() || text(title.value).trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '.').replace(/^\.+|\.+$/g, '');
var targetWorkspace = cleanWorkspace(workspace.value);
if (scope.value === ScopeWorkspace && !targetWorkspace) {
setStatus(tr('ui.workspaceRequired', null, 'Choose a Deal for this secret.'), true);
return;
}
api.secrets.write({ api.secrets.write({
id: nextID, id: nextID,
title: text(title.value).trim() || nextID, title: text(title.value).trim() || nextID,
username: text(username.value).trim(), username: text(username.value).trim(),
value: text(value.value), value: text(value.value),
scope: scope.value === ScopeWorkspace ? { kind: ScopeWorkspace, workspaceRootPath: workspaceRoot } : { kind: ScopeGlobal } scope: scope.value === ScopeWorkspace ? { kind: ScopeWorkspace, workspaceRootPath: targetWorkspace } : { kind: ScopeGlobal }
}).then(function (record) { }).then(function (record) {
selectedID = record.id; selectedID = record.id;
selectedRecord = record; selectedRecord = record;
selectedValue = ''; selectedValue = '';
return loadRecords(); return loadRecords();
}).catch(function (err) { }).catch(function (err) {
setStatus((err && err.message) ? err.message : String(err), true); setUserFacingError('save', err);
}); });
} }
}, [tr('ui.save', null, 'Save')]), }, [tr('ui.save', null, 'Save')]),
@ -381,11 +553,20 @@
if (wanted) { if (wanted) {
var found = records.find(function (record) { return record.id === wanted; }); var found = records.find(function (record) { return record.id === wanted; });
if (found) return selectRecord(found.id); if (found) return selectRecord(found.id);
selectedRecord = null;
selectedValue = '';
mode = 'selected';
statusText = tr('ui.requestedUnavailable', null, 'The requested secret is unavailable.');
statusError = true;
render();
return;
} }
selectedRecord = records[0] || null; selectedRecord = records[0] || null;
selectedValue = ''; selectedValue = '';
mode = 'selected'; mode = 'selected';
render(); render();
}).catch(function (err) {
setUserFacingError('load', err);
}); });
} }
@ -394,6 +575,7 @@
selectedID = id; selectedID = id;
selectedRecord = records.find(function (record) { return record.id === id; }) || null; selectedRecord = records.find(function (record) { return record.id === id; }) || null;
selectedValue = ''; selectedValue = '';
isValueVisible = false;
render(); render();
if (!id) return Promise.resolve(); if (!id) return Promise.resolve();
return api.secrets.read(id).then(function (record) { return api.secrets.read(id).then(function (record) {
@ -403,7 +585,7 @@
render(); render();
}).catch(function (err) { }).catch(function (err) {
if (disposed) return; if (disposed) return;
setStatus((err && err.message) ? err.message : String(err), true); setUserFacingError('read', err);
}); });
} }
@ -423,14 +605,68 @@
} }
function deleteSecret(id) { function deleteSecret(id) {
if (!id || !window.confirm(tr('ui.deleteConfirm', null, 'Delete this secret?'))) return; if (!id) return;
removeDeleteModal();
var title = selectedRecord && selectedRecord.id === id ? selectedRecord.title || selectedRecord.id : id;
var overlay = el('div', {
className: 'secrets-modal-overlay',
'data-secret-delete-modal': '',
role: 'presentation'
});
var cancel = el('button', {
className: 'secrets-btn',
type: 'button',
'data-secret-delete-cancel': '',
onClick: removeDeleteModal
}, [tr('ui.cancel', null, 'Cancel')]);
var confirm = el('button', {
className: 'secrets-btn danger',
type: 'button',
'data-secret-delete-confirm': '',
onClick: function () {
removeDeleteModal();
confirmDeleteSecret(id);
}
}, [tr('ui.delete', null, 'Delete')]);
overlay.appendChild(el('div', {
className: 'secrets-modal',
role: 'dialog',
'aria-modal': 'true',
'aria-label': tr('ui.deleteSecretTitle', null, 'Delete secret')
}, [
el('h2', { className: 'secrets-modal-title' }, [tr('ui.deleteSecretTitle', null, 'Delete secret')]),
el('p', { className: 'secrets-modal-message' }, [tr('ui.deleteConfirm', { title: title }, 'Delete "' + title + '"?')]),
el('div', { className: 'secrets-modal-actions' }, [cancel, confirm])
]));
document.body.appendChild(overlay);
deleteModal = overlay;
if (cancel.focus) cancel.focus();
}
function removeDeleteModal() {
if (deleteModal && typeof deleteModal.remove === 'function') deleteModal.remove();
else if (deleteModal && deleteModal.parentNode) deleteModal.parentNode.removeChild(deleteModal);
deleteModal = null;
}
function confirmDeleteSecret(id) {
api.secrets.delete(id).then(function () { api.secrets.delete(id).then(function () {
selectedID = ''; selectedID = '';
selectedRecord = null; selectedRecord = null;
selectedValue = ''; selectedValue = '';
isValueVisible = false;
return loadRecords(); return loadRecords();
}).catch(function (err) { }).catch(function (err) {
setStatus((err && err.message) ? err.message : String(err), true); setUserFacingError('delete', err);
});
}
function copySecretValue(value) {
if (!value) return;
writeClipboard(api, value).then(function () {
setStatus(tr('ui.valueCopied', null, 'Secret value copied'), false);
}).catch(function (err) {
setUserFacingError('copyValue', err);
}); });
} }
@ -440,17 +676,17 @@
setStatus(tr('ui.linkCopied', null, 'Secret link copied'), false); setStatus(tr('ui.linkCopied', null, 'Secret link copied'), false);
}); });
}).catch(function (err) { }).catch(function (err) {
setStatus((err && err.message) ? err.message : String(err), true); setUserFacingError('copyLink', err);
}); });
} }
api.secrets.status().then(function (status) { api.secrets.status().then(function (status) {
initialized = !!(status && status.initialized); initialized = !!(status && status.initialized);
unlocked = !!(status && status.unlocked); unlocked = !!(status && status.unlocked);
if (unlocked) return loadRecords(); if (unlocked) return loadWorkspaceOptions().then(loadRecords);
render(); render();
}).catch(function (err) { }).catch(function (err) {
statusText = (err && err.message) ? err.message : String(err); statusText = userFacingError('status', err);
statusError = true; statusError = true;
renderLocked(); renderLocked();
}); });
@ -461,6 +697,7 @@
containerEl.__secretsCleanup = function () { containerEl.__secretsCleanup = function () {
disposed = true; disposed = true;
removeDeleteModal();
if (typeof localeUnsubscribe === 'function') localeUnsubscribe(); if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
}; };
}, },

View File

@ -1,12 +1,17 @@
{ {
"manifest.name": "Secrets", "manifest.name": "Secrets",
"manifest.description": "Encrypted global and workspace-scoped secret manager.", "manifest.description": "Encrypted global and Deal-scoped secret manager.",
"contributions.views.verstak.secrets.view.title": "Secrets",
"contributions.sidebarItems.verstak.secrets.sidebar.title": "Secrets",
"contributions.openProviders.verstak.secrets.secret.title": "Secrets", "contributions.openProviders.verstak.secrets.secret.title": "Secrets",
"contributions.settingsPanels.verstak.secrets.settings.title": "Secrets", "contributions.settingsPanels.verstak.secrets.settings.title": "Secrets",
"contributions.workspaceItems.verstak.secrets.workspace.title": "Secrets", "contributions.workspaceItems.verstak.secrets.workspace.title": "Secrets",
"ui.masterPassword": "Master password", "ui.masterPassword": "Master password",
"ui.repeatPassword": "Repeat master password", "ui.repeatPassword": "Repeat master password",
"ui.passwordMismatch": "Master passwords do not match", "ui.passwordMismatch": "Master passwords do not match",
"ui.masterPasswordMinLength": "Master password must be at least {count} characters.",
"ui.masterPasswordRequired": "Enter a master password.",
"ui.masterPasswordInvalid": "The master password is incorrect.",
"ui.unlock": "Unlock", "ui.unlock": "Unlock",
"ui.createMaster": "Create master password", "ui.createMaster": "Create master password",
"ui.password": "Password", "ui.password": "Password",
@ -16,23 +21,42 @@
"ui.new": "New", "ui.new": "New",
"ui.empty": "No secrets", "ui.empty": "No secrets",
"ui.select": "Select a secret", "ui.select": "Select a secret",
"ui.requestedUnavailable": "The requested secret is unavailable.",
"ui.group": "Group", "ui.group": "Group",
"ui.username": "Username", "ui.username": "Username",
"ui.updated": "Updated", "ui.updated": "Updated",
"ui.copyLink": "Copy secret link", "ui.copyLink": "Copy secret link",
"ui.edit": "Edit", "ui.edit": "Edit",
"ui.delete": "Delete", "ui.delete": "Delete",
"ui.copyValue": "Copy value",
"ui.showValue": "Show value",
"ui.hideValue": "Hide value",
"ui.fieldTitle": "Title", "ui.fieldTitle": "Title",
"ui.optionalUsername": "optional username", "ui.optionalUsername": "optional username",
"ui.secretValue": "Secret value", "ui.secretValue": "Secret value",
"ui.global": "Global", "ui.global": "Global",
"ui.workspace": "Workspace", "ui.workspace": "Deal",
"ui.editSecret": "Edit secret", "ui.editSecret": "Edit secret",
"ui.newSecret": "New secret", "ui.newSecret": "New secret",
"ui.scope": "Scope", "ui.scope": "Scope",
"ui.deal": "Deal",
"ui.scopeAll": "All scopes",
"ui.search": "Search secrets",
"ui.chooseWorkspace": "Choose a Deal",
"ui.workspaceRequired": "Choose a Deal for this secret.",
"ui.statusError": "Could not check the secret storage. Please try again.",
"ui.loadError": "Could not load secrets. Please try again.",
"ui.readError": "Could not open this secret. Please try again.",
"ui.saveError": "Could not save the secret. Please try again.",
"ui.deleteError": "Could not delete the secret. Please try again.",
"ui.copyValueError": "Could not copy the secret value. Please try again.",
"ui.copyLinkError": "Could not copy the secret link. Please try again.",
"ui.unlockError": "Could not unlock secrets. Check the master password and try again.",
"ui.value": "Value", "ui.value": "Value",
"ui.save": "Save", "ui.save": "Save",
"ui.cancel": "Cancel", "ui.cancel": "Cancel",
"ui.deleteConfirm": "Delete this secret?", "ui.deleteSecretTitle": "Delete secret",
"ui.linkCopied": "Secret link copied" "ui.deleteConfirm": "Delete \"{title}\"? This cannot be undone.",
"ui.linkCopied": "Secret link copied",
"ui.valueCopied": "Secret value copied"
} }

View File

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

View File

@ -4,7 +4,7 @@
"name": "Secrets", "name": "Secrets",
"version": "0.1.0", "version": "0.1.0",
"apiVersion": "0.1.0", "apiVersion": "0.1.0",
"description": "Encrypted global and workspace-scoped secret manager.", "description": "Encrypted global and Deal-scoped secret manager.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } }, "localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official", "source": "official",
"icon": "key-round", "icon": "key-round",
@ -14,6 +14,7 @@
"secrets.write-ui" "secrets.write-ui"
], ],
"permissions": [ "permissions": [
"files.read",
"secrets.read", "secrets.read",
"secrets.write", "secrets.write",
"ui.register" "ui.register"
@ -22,6 +23,23 @@
"entry": "frontend/src/index.js" "entry": "frontend/src/index.js"
}, },
"contributes": { "contributes": {
"views": [
{
"id": "verstak.secrets.view",
"title": "Secrets",
"icon": "key-round",
"component": "SecretsView"
}
],
"sidebarItems": [
{
"id": "verstak.secrets.sidebar",
"title": "Secrets",
"icon": "key-round",
"view": "verstak.secrets.view",
"position": 45
}
],
"openProviders": [ "openProviders": [
{ {
"id": "verstak.secrets.secret", "id": "verstak.secrets.secret",

View File

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

View File

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

View File

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

View File

@ -17,8 +17,8 @@
'.todo-toolbar{display:flex;align-items:center;gap:.5rem;min-height:2.75rem;padding:.5rem .75rem;border-bottom:1px solid var(--vt-color-border,#202b46);background:var(--vt-color-surface-muted,#111629);flex-wrap:wrap;flex-shrink:0}', '.todo-toolbar{display:flex;align-items:center;gap:.5rem;min-height:2.75rem;padding:.5rem .75rem;border-bottom:1px solid var(--vt-color-border,#202b46);background:var(--vt-color-surface-muted,#111629);flex-wrap:wrap;flex-shrink:0}',
'.todo-title{font-size:.86rem;font-weight:600}.todo-count,.todo-status,.todo-scope{font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3)}.todo-spacer{flex:1}', '.todo-title{font-size:.86rem;font-weight:600}.todo-count,.todo-status,.todo-scope{font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3)}.todo-spacer{flex:1}',
'.todo-filters{display:flex;align-items:center;gap:.35rem;min-width:0;flex:1;flex-wrap:wrap}', '.todo-filters{display:flex;align-items:center;gap:.35rem;min-width:0;flex:1;flex-wrap:wrap}',
'.todo-input,.todo-select{box-sizing:border-box;min-height:1.9rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-surface,#15152c);color:var(--vt-color-text-primary,#f4f7fb);font:inherit;font-size:.78rem;padding:.32rem .45rem}', '.todo-input,.todo-select{box-sizing:border-box;min-height:1.9rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-surface,#15152c);color:var(--vt-color-text-primary,#f4f7fb);color-scheme:dark;font:inherit;font-size:.78rem;padding:.32rem .45rem}',
'.todo-input.search{width:min(15rem,100%)}.todo-input.textarea{min-height:6.5rem;resize:vertical;line-height:1.4}.todo-select{max-width:12rem}', '.todo-input.search{width:min(15rem,100%)}.todo-input.textarea{min-height:6.5rem;resize:vertical;line-height:1.4}.todo-input.todo-time-input{width:100%;font-variant-numeric:tabular-nums}.todo-select{max-width:12rem;appearance:none;background-color:var(--vt-color-surface,#15152c);background-image:linear-gradient(45deg,transparent 50%,var(--vt-color-text-muted,#7f8aa3) 50%),linear-gradient(135deg,var(--vt-color-text-muted,#7f8aa3) 50%,transparent 50%);background-position:calc(100% - 14px) 50%,calc(100% - 9px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:1.7rem}.todo-select option{background:var(--vt-color-surface,#15152c);color:var(--vt-color-text-primary,#f4f7fb)}',
'.todo-btn{min-height:1.9rem;padding:.32rem .62rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-md,6px);background:var(--vt-color-surface-hover,#1b2440);color:var(--vt-color-text-secondary,#b7c0d4);font-size:.78rem;cursor:pointer}.todo-btn:hover{border-color:var(--vt-color-accent,#4ecca3);color:var(--vt-color-text-primary,#f4f7fb)}.todo-btn.primary{background:var(--vt-color-accent,#4ecca3);border-color:var(--vt-color-accent,#4ecca3);color:#101827}.todo-btn.danger{border-color:rgba(233,69,96,.5);color:#ff9a9a}.todo-btn:disabled{opacity:.45;cursor:default}', '.todo-btn{min-height:1.9rem;padding:.32rem .62rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-md,6px);background:var(--vt-color-surface-hover,#1b2440);color:var(--vt-color-text-secondary,#b7c0d4);font-size:.78rem;cursor:pointer}.todo-btn:hover{border-color:var(--vt-color-accent,#4ecca3);color:var(--vt-color-text-primary,#f4f7fb)}.todo-btn.primary{background:var(--vt-color-accent,#4ecca3);border-color:var(--vt-color-accent,#4ecca3);color:#101827}.todo-btn.danger{border-color:rgba(233,69,96,.5);color:#ff9a9a}.todo-btn:disabled{opacity:.45;cursor:default}',
'.todo-list{flex:1;min-height:0;overflow:auto;padding:.5rem .75rem .85rem}.todo-empty{height:100%;display:flex;align-items:center;justify-content:center;padding:2rem;text-align:center;color:var(--vt-color-text-muted,#7f8aa3);font-size:.86rem}', '.todo-list{flex:1;min-height:0;overflow:auto;padding:.5rem .75rem .85rem}.todo-empty{height:100%;display:flex;align-items:center;justify-content:center;padding:2rem;text-align:center;color:var(--vt-color-text-muted,#7f8aa3);font-size:.86rem}',
'.todo-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.75rem;align-items:start;margin-top:.5rem;padding:.75rem .85rem;border:1px solid var(--vt-color-border,#202b46);border-radius:var(--vt-radius-lg,8px);background:var(--vt-color-surface,#15152c)}.todo-row:hover{background:var(--vt-color-surface-hover,#1b2440)}.todo-row.done .todo-row-title{text-decoration:line-through;color:var(--vt-color-text-muted,#7f8aa3)}', '.todo-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.75rem;align-items:start;margin-top:.5rem;padding:.75rem .85rem;border:1px solid var(--vt-color-border,#202b46);border-radius:var(--vt-radius-lg,8px);background:var(--vt-color-surface,#15152c)}.todo-row:hover{background:var(--vt-color-surface-hover,#1b2440)}.todo-row.done .todo-row-title{text-decoration:line-through;color:var(--vt-color-text-muted,#7f8aa3)}',
@ -78,7 +78,7 @@
var workspaceRoot = workspaceFromProps(props || {}); var workspaceRoot = workspaceFromProps(props || {});
return workspaceRoot return workspaceRoot
? { mode: 'workspace', workspaceRoot: workspaceRoot, label: workspaceRoot } ? { mode: 'workspace', workspaceRoot: workspaceRoot, label: workspaceRoot }
: { mode: 'global', workspaceRoot: '', label: 'All workspaces' }; : { mode: 'global', workspaceRoot: '', label: 'All Deals' };
} }
function now() { function now() {
@ -96,7 +96,28 @@
} }
function cleanDate(value) { function cleanDate(value) {
return /^\d{4}-\d{2}-\d{2}$/.test(text(value).trim()) ? text(value).trim() : ''; value = text(value).trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
var match = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(value);
var day;
var month;
var year;
if (match) {
month = Number(match[1]);
day = Number(match[2]);
year = Number(match[3]);
} else {
match = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(value);
if (!match) return '';
day = Number(match[1]);
month = Number(match[2]);
year = Number(match[3]);
}
var date = new Date(Date.UTC(year, month - 1, day));
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return '';
return String(year).padStart(4, '0') + '-' + String(month).padStart(2, '0') + '-' + String(day).padStart(2, '0');
} }
function cleanDateTime(value) { function cleanDateTime(value) {
@ -106,6 +127,27 @@
return isNaN(date.getTime()) ? '' : value; return isNaN(date.getTime()) ? '' : value;
} }
function splitReminderDateTime(value) {
var match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(cleanDateTime(value));
return match ? { date: match[1], time: match[2] } : { date: '', time: '' };
}
function cleanReminderTime(value) {
var match = /^(\d{1,2}):(\d{2})$/.exec(text(value).trim());
if (!match) return '';
var hour = Number(match[1]);
var minute = Number(match[2]);
if (hour > 23 || minute > 59) return '';
return String(hour).padStart(2, '0') + ':' + String(minute).padStart(2, '0');
}
function joinReminderDateTime(date, time) {
date = cleanDate(date);
time = cleanReminderTime(time);
if (!date || !time) return '';
return cleanDateTime(date + 'T' + time);
}
function todoId(workspaceRoot, title) { function todoId(workspaceRoot, title) {
return 'todo:' + (cleanWorkspace(workspaceRoot) || 'global') + ':' + Date.now() + ':' + Math.random().toString(36).slice(2, 8) + ':' + text(title).trim().slice(0, 20).replace(/\s+/g, '-'); return 'todo:' + (cleanWorkspace(workspaceRoot) || 'global') + ':' + Date.now() + ':' + Math.random().toString(36).slice(2, 8) + ':' + text(title).trim().slice(0, 20).replace(/\s+/g, '-');
} }
@ -113,6 +155,8 @@
function normalizeTodo(value) { function normalizeTodo(value) {
value = value || {}; value = value || {};
var status = cleanStatus(value.status); var status = cleanStatus(value.status);
var reminderAt = cleanDateTime(value.reminderAt || value.reminderDateTime);
var reminder = splitReminderDateTime(reminderAt);
var createdAt = text(value.createdAt).trim() || now(); var createdAt = text(value.createdAt).trim() || now();
var completedAt = status === 'done' ? (text(value.completedAt).trim() || createdAt) : ''; var completedAt = status === 'done' ? (text(value.completedAt).trim() || createdAt) : '';
return { return {
@ -124,7 +168,8 @@
status: status, status: status,
priority: cleanPriority(value.priority), priority: cleanPriority(value.priority),
dueAt: cleanDate(value.dueAt || value.dueDate), dueAt: cleanDate(value.dueAt || value.dueDate),
reminderAt: cleanDateTime(value.reminderAt || value.reminderDateTime), reminderDate: cleanDate(value.reminderDate) || reminder.date,
reminderAt: reminderAt,
createdAt: createdAt, createdAt: createdAt,
updatedAt: text(value.updatedAt).trim() || createdAt, updatedAt: text(value.updatedAt).trim() || createdAt,
completedAt: completedAt, completedAt: completedAt,
@ -153,6 +198,7 @@
status: todo.status, status: todo.status,
priority: todo.priority, priority: todo.priority,
dueAt: todo.dueAt, dueAt: todo.dueAt,
reminderDate: todo.reminderDate,
reminderAt: todo.reminderAt, reminderAt: todo.reminderAt,
createdAt: todo.createdAt, createdAt: todo.createdAt,
updatedAt: todo.updatedAt, updatedAt: todo.updatedAt,
@ -314,7 +360,7 @@
function renderWorkspaceFilterOptions() { function renderWorkspaceFilterOptions() {
if (scope.mode !== 'global') return; if (scope.mode !== 'global') return;
workspaceFilterEl.innerHTML = ''; workspaceFilterEl.innerHTML = '';
workspaceFilterEl.appendChild(option('', tr('ui.allWorkspaces', null, 'All workspaces'))); workspaceFilterEl.appendChild(option('', tr('ui.allWorkspaces', null, 'All Deals')));
workspaceFilterEl.appendChild(option('__unassigned__', tr('ui.unassigned', null, 'Unassigned'))); workspaceFilterEl.appendChild(option('__unassigned__', tr('ui.unassigned', null, 'Unassigned')));
workspaceRoots().forEach(function (workspace) { workspaceRoots().forEach(function (workspace) {
workspaceFilterEl.appendChild(option(workspace, workspace)); workspaceFilterEl.appendChild(option(workspace, workspace));
@ -356,11 +402,18 @@
}).filter(function (item) { return item !== null; }); }).filter(function (item) { return item !== null; });
} }
function reportError(key, fallback, err) {
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
console.warn('[verstak.todo] ' + key, err);
}
statusText = tr(key, null, fallback);
statusClass = 'error';
}
function syncNotifications() { function syncNotifications() {
if (!api || !api.notifications || typeof api.notifications.replace !== 'function') return Promise.resolve(); if (!api || !api.notifications || typeof api.notifications.replace !== 'function') return Promise.resolve();
return api.notifications.replace(notificationRequests()).catch(function (err) { return api.notifications.replace(notificationRequests()).catch(function (err) {
statusText = tr('ui.notificationError', { error: err && err.message ? err.message : String(err) }, 'Could not schedule reminders: ' + (err && err.message ? err.message : String(err))); reportError('ui.notificationError', 'Could not schedule reminders. Please try again.', err);
statusClass = 'error';
}); });
} }
@ -369,8 +422,7 @@
return api.settings.write(GLOBAL_KEY, storageTodos(sortTodos(todos))).then(function () { return api.settings.write(GLOBAL_KEY, storageTodos(sortTodos(todos))).then(function () {
return syncNotifications(); return syncNotifications();
}).catch(function (err) { }).catch(function (err) {
statusText = tr('ui.saveError', { error: err && err.message ? err.message : String(err) }, 'Could not save todos: ' + (err && err.message ? err.message : String(err))); reportError('ui.saveError', 'Could not save tasks. Please try again.', err);
statusClass = 'error';
}); });
} }
@ -380,8 +432,7 @@
todos = sortTodos(normalizeTodos((settings || {})[GLOBAL_KEY])); todos = sortTodos(normalizeTodos((settings || {})[GLOBAL_KEY]));
return syncNotifications(); return syncNotifications();
}).catch(function (err) { }).catch(function (err) {
statusText = tr('ui.loadError', { error: err && err.message ? err.message : String(err) }, 'Could not load todos: ' + (err && err.message ? err.message : String(err))); reportError('ui.loadError', 'Could not load tasks. Please try again.', err);
statusClass = 'error';
}); });
} }
@ -413,7 +464,9 @@
var priorityInput = el('select', { className: 'todo-select', 'data-todo-input': 'priority' }, [option('low', tr('ui.priority.low', null, 'Low')), option('normal', tr('ui.priority.normal', null, 'Normal')), option('high', tr('ui.priority.high', null, 'High'))]); var priorityInput = el('select', { className: 'todo-select', 'data-todo-input': 'priority' }, [option('low', tr('ui.priority.low', null, 'Low')), option('normal', tr('ui.priority.normal', null, 'Normal')), option('high', tr('ui.priority.high', null, 'High'))]);
priorityInput.value = editing ? existingTodo.priority : 'normal'; priorityInput.value = editing ? existingTodo.priority : 'normal';
var dueInput = el('input', { className: 'todo-input', type: 'date', value: editing ? existingTodo.dueAt : '', 'data-todo-input': 'dueAt' }); var dueInput = el('input', { className: 'todo-input', type: 'date', value: editing ? existingTodo.dueAt : '', 'data-todo-input': 'dueAt' });
var reminderInput = el('input', { className: 'todo-input', type: 'datetime-local', value: editing ? existingTodo.reminderAt : '', 'data-todo-input': 'reminderAt' }); var reminder = splitReminderDateTime(editing ? existingTodo.reminderAt : '');
var reminderDateInput = el('input', { className: 'todo-input', type: 'date', value: editing ? existingTodo.reminderDate || reminder.date : '', 'data-todo-input': 'reminderDate' });
var reminderTimeInput = el('input', { className: 'todo-input todo-time-input', type: 'text', inputmode: 'numeric', maxlength: '5', placeholder: tr('ui.reminderTimePlaceholder', null, '14:30'), value: reminder.time, 'data-todo-input': 'reminderTime', 'aria-label': tr('ui.field.reminderTime', null, 'Reminder time') });
var workspaceInput = null; var workspaceInput = null;
var workspace = editing ? existingTodo.workspaceRootPath : scope.workspaceRoot; var workspace = editing ? existingTodo.workspaceRootPath : scope.workspaceRoot;
if (scope.mode === 'global') { if (scope.mode === 'global') {
@ -434,6 +487,20 @@
return; return;
} }
var workspaceRoot = scope.mode === 'workspace' ? scope.workspaceRoot : cleanWorkspace(workspaceInput && workspaceInput.value); var workspaceRoot = scope.mode === 'workspace' ? scope.workspaceRoot : cleanWorkspace(workspaceInput && workspaceInput.value);
var reminderDate = cleanDate(reminderDateInput.value);
var reminderTime = cleanReminderTime(reminderTimeInput.value);
if (text(reminderTimeInput.value).trim() && !reminderTime) {
statusText = tr('ui.reminderTimeInvalid', null, 'Enter a valid reminder time (HH:MM)');
statusClass = 'error';
render();
return;
}
if (reminderTime && !reminderDate) {
statusText = tr('ui.reminderDateRequired', null, 'Choose a reminder date before setting its time');
statusClass = 'error';
render();
return;
}
var timestamp = now(); var timestamp = now();
var next = normalizeTodo({ var next = normalizeTodo({
id: editing ? existingTodo.id : todoId(workspaceRoot, title), id: editing ? existingTodo.id : todoId(workspaceRoot, title),
@ -444,7 +511,8 @@
status: editing ? existingTodo.status : 'open', status: editing ? existingTodo.status : 'open',
priority: priorityInput.value, priority: priorityInput.value,
dueAt: dueInput.value, dueAt: dueInput.value,
reminderAt: reminderInput.value, reminderDate: reminderDate,
reminderAt: joinReminderDateTime(reminderDate, reminderTime),
createdAt: editing ? existingTodo.createdAt : timestamp, createdAt: editing ? existingTodo.createdAt : timestamp,
updatedAt: timestamp, updatedAt: timestamp,
completedAt: editing ? existingTodo.completedAt : '', completedAt: editing ? existingTodo.completedAt : '',
@ -469,10 +537,11 @@
el('label', { className: 'todo-field wide' }, [tr('ui.field.description', null, 'Description'), descriptionInput]), el('label', { className: 'todo-field wide' }, [tr('ui.field.description', null, 'Description'), descriptionInput]),
el('label', { className: 'todo-field' }, [tr('ui.field.priority', null, 'Priority'), priorityInput]), el('label', { className: 'todo-field' }, [tr('ui.field.priority', null, 'Priority'), priorityInput]),
el('label', { className: 'todo-field' }, [tr('ui.field.due', null, 'Due date'), dueInput]), el('label', { className: 'todo-field' }, [tr('ui.field.due', null, 'Due date'), dueInput]),
el('label', { className: 'todo-field' }, [tr('ui.field.reminder', null, 'Reminder'), reminderInput]) el('label', { className: 'todo-field' }, [tr('ui.field.reminderDate', null, 'Reminder date'), reminderDateInput]),
el('label', { className: 'todo-field' }, [tr('ui.field.reminderTime', null, 'Reminder time'), reminderTimeInput])
]; ];
if (workspaceInput) fields.push(el('label', { className: 'todo-field' }, [tr('ui.field.workspace', null, 'Workspace'), workspaceInput])); if (workspaceInput) fields.push(el('label', { className: 'todo-field' }, [tr('ui.field.workspace', null, 'Deal'), workspaceInput]));
else fields.push(el('div', { className: 'todo-field', textContent: tr('ui.workspaceValue', { workspace: scope.workspaceRoot }, 'Workspace: ' + scope.workspaceRoot) })); else fields.push(el('div', { className: 'todo-field', textContent: tr('ui.workspaceValue', { workspace: scope.workspaceRoot }, 'Deal: ' + scope.workspaceRoot) }));
modalHost.innerHTML = ''; modalHost.innerHTML = '';
if (typeof modalHost.removeAttribute === 'function') modalHost.removeAttribute('hidden'); if (typeof modalHost.removeAttribute === 'function') modalHost.removeAttribute('hidden');
@ -550,6 +619,7 @@
meta.push(el('span', { className: 'todo-badge', textContent: tr('ui.status.' + todo.status, null, todo.status) })); meta.push(el('span', { className: 'todo-badge', textContent: tr('ui.status.' + todo.status, null, todo.status) }));
if (todo.dueAt) meta.push(el('span', { className: 'todo-badge ' + due, textContent: tr('ui.dueValue', { prefix: due === 'overdue' ? tr('ui.overduePrefix', null, 'Overdue · ') : (due === 'due-soon' ? tr('ui.dueSoonPrefix', null, 'Due soon · ') : ''), date: formatDate(todo.dueAt) }, (due === 'overdue' ? 'Overdue · ' : (due === 'due-soon' ? 'Due soon · ' : '')) + 'Due ' + formatDate(todo.dueAt)) })); if (todo.dueAt) meta.push(el('span', { className: 'todo-badge ' + due, textContent: tr('ui.dueValue', { prefix: due === 'overdue' ? tr('ui.overduePrefix', null, 'Overdue · ') : (due === 'due-soon' ? tr('ui.dueSoonPrefix', null, 'Due soon · ') : ''), date: formatDate(todo.dueAt) }, (due === 'overdue' ? 'Overdue · ' : (due === 'due-soon' ? 'Due soon · ' : '')) + 'Due ' + formatDate(todo.dueAt)) }));
if (todo.reminderAt) meta.push(el('span', { className: 'todo-badge ' + (reminderDue ? 'reminder-due' : ''), textContent: tr(reminderDue ? 'ui.reminderDueValue' : 'ui.reminderValue', { date: formatDate(todo.reminderAt) }, (reminderDue ? 'Reminder due ' : 'Reminder ') + formatDate(todo.reminderAt)) })); if (todo.reminderAt) meta.push(el('span', { className: 'todo-badge ' + (reminderDue ? 'reminder-due' : ''), textContent: tr(reminderDue ? 'ui.reminderDueValue' : 'ui.reminderValue', { date: formatDate(todo.reminderAt) }, (reminderDue ? 'Reminder due ' : 'Reminder ') + formatDate(todo.reminderAt)) }));
else if (todo.reminderDate) meta.push(el('span', { className: 'todo-badge', textContent: tr('ui.reminderDateValue', { date: formatDate(todo.reminderDate) }, 'Reminder date ' + formatDate(todo.reminderDate)) }));
return el('div', { className: 'todo-row-meta' }, meta); return el('div', { className: 'todo-row-meta' }, meta);
} }
@ -566,7 +636,7 @@
visible.forEach(function (todo) { visible.forEach(function (todo) {
var actionButtons = []; var actionButtons = [];
if (scope.mode === 'global' && todo.workspaceRootPath) { if (scope.mode === 'global' && todo.workspaceRootPath) {
actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'open-workspace', textContent: tr('ui.openWorkspace', null, '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 Deal'), onClick: function () { openWorkspace(todo); } }));
} }
if (todo.status === 'open') { if (todo.status === 'open') {
actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'mark-done', textContent: tr('ui.status.done', null, 'Done'), onClick: function () { setTodoStatus(todo, 'done'); } })); actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'mark-done', textContent: tr('ui.status.done', null, 'Done'), onClick: function () { setTodoStatus(todo, 'done'); } }));

View File

@ -1,6 +1,6 @@
{ {
"manifest.name": "Todos", "manifest.name": "Todos",
"manifest.description": "Global and workspace todo tracking with due and reminder metadata.", "manifest.description": "Global and Deal-scoped todo tracking with due and reminder metadata.",
"contributions.views.verstak.todo.view.title": "Todos", "contributions.views.verstak.todo.view.title": "Todos",
"contributions.sidebarItems.verstak.todo.sidebar.title": "Todos", "contributions.sidebarItems.verstak.todo.sidebar.title": "Todos",
"contributions.workspaceItems.verstak.todo.workspace.title": "Todos", "contributions.workspaceItems.verstak.todo.workspace.title": "Todos",
@ -15,11 +15,11 @@
"ui.sort.updated": "Sort by updated", "ui.sort.updated": "Sort by updated",
"ui.search": "Search todos", "ui.search": "Search todos",
"ui.add": "Add Todo", "ui.add": "Add Todo",
"ui.allWorkspaces": "All workspaces", "ui.allWorkspaces": "All Deals",
"ui.unassigned": "Unassigned", "ui.unassigned": "Unassigned",
"ui.saveError": "Could not save todos: {error}", "ui.saveError": "Could not save tasks. Please try again.",
"ui.loadError": "Could not load todos: {error}", "ui.loadError": "Could not load tasks. Please try again.",
"ui.notificationError": "Could not schedule reminders: {error}", "ui.notificationError": "Could not schedule reminders. Please try again.",
"ui.notificationTitle": "Todo reminder", "ui.notificationTitle": "Todo reminder",
"ui.notificationBody": "{title}", "ui.notificationBody": "{title}",
"ui.titlePlaceholder": "Todo title", "ui.titlePlaceholder": "Todo title",
@ -35,8 +35,13 @@
"ui.field.priority": "Priority", "ui.field.priority": "Priority",
"ui.field.due": "Due date", "ui.field.due": "Due date",
"ui.field.reminder": "Reminder", "ui.field.reminder": "Reminder",
"ui.field.workspace": "Workspace", "ui.field.reminderDate": "Reminder date",
"ui.workspaceValue": "Workspace: {workspace}", "ui.field.reminderTime": "Reminder time",
"ui.reminderTimePlaceholder": "14:30",
"ui.reminderTimeInvalid": "Enter a valid reminder time (HH:MM)",
"ui.reminderDateRequired": "Choose a reminder date before setting its time",
"ui.field.workspace": "Deal",
"ui.workspaceValue": "Deal: {workspace}",
"ui.edit": "Edit Todo", "ui.edit": "Edit Todo",
"ui.cancel": "Cancel", "ui.cancel": "Cancel",
"ui.saveChanges": "Save changes", "ui.saveChanges": "Save changes",
@ -50,9 +55,10 @@
"ui.dueValue": "{prefix}Due {date}", "ui.dueValue": "{prefix}Due {date}",
"ui.reminderDueValue": "Reminder due {date}", "ui.reminderDueValue": "Reminder due {date}",
"ui.reminderValue": "Reminder {date}", "ui.reminderValue": "Reminder {date}",
"ui.reminderDateValue": "Reminder date {date}",
"ui.noMatches": "No todos match the current filters.", "ui.noMatches": "No todos match the current filters.",
"ui.empty": "No todos yet.", "ui.empty": "No todos yet.",
"ui.openWorkspace": "Open workspace", "ui.openWorkspace": "Open Deal",
"ui.reopen": "Reopen", "ui.reopen": "Reopen",
"ui.createJournal": "Create Journal Entry", "ui.createJournal": "Create Journal Entry",
"ui.editAction": "Edit", "ui.editAction": "Edit",

View File

@ -1,6 +1,6 @@
{ {
"manifest.name": "Задачи", "manifest.name": "Задачи",
"manifest.description": "Общие задачи и задачи рабочих пространств со сроками и напоминаниями.", "manifest.description": "Общие задачи и задачи Дел со сроками и напоминаниями.",
"contributions.views.verstak.todo.view.title": "Задачи", "contributions.views.verstak.todo.view.title": "Задачи",
"contributions.sidebarItems.verstak.todo.sidebar.title": "Задачи", "contributions.sidebarItems.verstak.todo.sidebar.title": "Задачи",
"contributions.workspaceItems.verstak.todo.workspace.title": "Задачи", "contributions.workspaceItems.verstak.todo.workspace.title": "Задачи",
@ -15,11 +15,11 @@
"ui.sort.updated": "Сортировать по обновлению", "ui.sort.updated": "Сортировать по обновлению",
"ui.search": "Поиск задач", "ui.search": "Поиск задач",
"ui.add": "Добавить задачу", "ui.add": "Добавить задачу",
"ui.allWorkspaces": "Все рабочие пространства", "ui.allWorkspaces": "Все Дела",
"ui.unassigned": "Не назначено", "ui.unassigned": "Не назначено",
"ui.saveError": "Не удалось сохранить задачи: {error}", "ui.saveError": "Не удалось сохранить задачи. Повторите попытку.",
"ui.loadError": "Не удалось загрузить задачи: {error}", "ui.loadError": "Не удалось загрузить задачи. Повторите попытку.",
"ui.notificationError": "Не удалось запланировать напоминания: {error}", "ui.notificationError": "Не удалось запланировать напоминания. Повторите попытку.",
"ui.notificationTitle": "Напоминание о задаче", "ui.notificationTitle": "Напоминание о задаче",
"ui.notificationBody": "{title}", "ui.notificationBody": "{title}",
"ui.titlePlaceholder": "Название задачи", "ui.titlePlaceholder": "Название задачи",
@ -35,8 +35,13 @@
"ui.field.priority": "Приоритет", "ui.field.priority": "Приоритет",
"ui.field.due": "Срок", "ui.field.due": "Срок",
"ui.field.reminder": "Напоминание", "ui.field.reminder": "Напоминание",
"ui.field.workspace": "Рабочее пространство", "ui.field.reminderDate": "Дата напоминания",
"ui.workspaceValue": "Рабочее пространство: {workspace}", "ui.field.reminderTime": "Время напоминания",
"ui.reminderTimePlaceholder": "14:30",
"ui.reminderTimeInvalid": "Введите корректное время напоминания (ЧЧ:ММ)",
"ui.reminderDateRequired": "Сначала выберите дату напоминания",
"ui.field.workspace": "Дело",
"ui.workspaceValue": "Дело: {workspace}",
"ui.edit": "Изменить задачу", "ui.edit": "Изменить задачу",
"ui.cancel": "Отмена", "ui.cancel": "Отмена",
"ui.saveChanges": "Сохранить изменения", "ui.saveChanges": "Сохранить изменения",
@ -50,9 +55,10 @@
"ui.dueValue": "{prefix}Срок: {date}", "ui.dueValue": "{prefix}Срок: {date}",
"ui.reminderDueValue": "Пора напомнить: {date}", "ui.reminderDueValue": "Пора напомнить: {date}",
"ui.reminderValue": "Напоминание: {date}", "ui.reminderValue": "Напоминание: {date}",
"ui.reminderDateValue": "Дата напоминания: {date}",
"ui.noMatches": "Нет задач, соответствующих фильтрам.", "ui.noMatches": "Нет задач, соответствующих фильтрам.",
"ui.empty": "Задач пока нет.", "ui.empty": "Задач пока нет.",
"ui.openWorkspace": "Открыть рабочее пространство", "ui.openWorkspace": "Открыть Дело",
"ui.reopen": "Открыть снова", "ui.reopen": "Открыть снова",
"ui.createJournal": "Создать запись журнала", "ui.createJournal": "Создать запись журнала",
"ui.editAction": "Изменить", "ui.editAction": "Изменить",

View File

@ -4,7 +4,7 @@
"name": "Todos", "name": "Todos",
"version": "0.1.0", "version": "0.1.0",
"apiVersion": "0.1.0", "apiVersion": "0.1.0",
"description": "Global and workspace todo tracking with due and reminder metadata.", "description": "Global and Deal-scoped todo tracking with due and reminder metadata.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } }, "localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official", "source": "official",
"icon": "list-todo", "icon": "list-todo",

View File

@ -16,7 +16,7 @@
'.trash-spacer{flex:1}', '.trash-spacer{flex:1}',
'.trash-control{min-height:2rem;box-sizing:border-box;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-surface,#15152c);color:var(--vt-color-text-primary,#f4f7fb);font:inherit;font-size:.78rem;padding:.35rem .5rem}', '.trash-control{min-height:2rem;box-sizing:border-box;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-surface,#15152c);color:var(--vt-color-text-primary,#f4f7fb);font:inherit;font-size:.78rem;padding:.35rem .5rem}',
'.trash-search{min-width:12rem;flex:1 1 14rem}', '.trash-search{min-width:12rem;flex:1 1 14rem}',
'.trash-select{max-width:11rem}', '.trash-select{max-width:11rem;appearance:none;background-color:var(--vt-color-surface,#15152c);background-image:linear-gradient(45deg,transparent 50%,var(--vt-color-text-muted,#7f8aa3) 50%),linear-gradient(135deg,var(--vt-color-text-muted,#7f8aa3) 50%,transparent 50%);background-position:calc(100% - 14px) 50%,calc(100% - 9px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:1.7rem}.trash-select option{background:var(--vt-color-surface,#15152c);color:var(--vt-color-text-primary,#f4f7fb)}.trash-control:focus{outline:none;border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.trash-btn{min-height:2rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-surface-hover,#1b2440);color:var(--vt-color-text-secondary,#b7c0d4);font:inherit;font-size:.78rem;padding:.35rem .6rem;cursor:pointer;white-space:nowrap}', '.trash-btn{min-height:2rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-surface-hover,#1b2440);color:var(--vt-color-text-secondary,#b7c0d4);font:inherit;font-size:.78rem;padding:.35rem .6rem;cursor:pointer;white-space:nowrap}',
'.trash-btn:hover:not(:disabled){border-color:var(--vt-color-accent,#4ecca3);color:var(--vt-color-text-primary,#f4f7fb)}', '.trash-btn:hover:not(:disabled){border-color:var(--vt-color-accent,#4ecca3);color:var(--vt-color-text-primary,#f4f7fb)}',
'.trash-btn:disabled{opacity:.48;cursor:default}', '.trash-btn:disabled{opacity:.48;cursor:default}',
@ -217,7 +217,7 @@
value: state.workspace, value: state.workspace,
'data-trash-filter-workspace': '', 'data-trash-filter-workspace': '',
onChange: function (event) { state.workspace = event.target.value; render(); } onChange: function (event) { state.workspace = event.target.value; render(); }
}, [el('option', { value: '' }, [tr('ui.allWorkspaces', null, 'All workspaces')])]); }, [el('option', { value: '' }, [tr('ui.allWorkspaces', null, 'All Deals')])]);
workspaceOptions().forEach(function (workspace) { workspaceOptions().forEach(function (workspace) {
workspaceSelect.appendChild(el('option', { value: workspace }, [workspace])); workspaceSelect.appendChild(el('option', { value: workspace }, [workspace]));
}); });
@ -270,7 +270,7 @@
var list = el('div', { className: 'trash-list', 'data-trash-list': '' }); var list = el('div', { className: 'trash-list', 'data-trash-list': '' });
list.appendChild(el('div', { className: 'trash-header' }, [ list.appendChild(el('div', { className: 'trash-header' }, [
el('span', {}, [tr('ui.name', null, 'Name')]), el('span', {}, [tr('ui.name', null, 'Name')]),
el('span', {}, [tr('ui.workspace', null, 'Workspace')]), el('span', {}, [tr('ui.workspace', null, 'Deal')]),
el('span', {}, [tr('ui.originalPath', null, 'Original path')]), el('span', {}, [tr('ui.originalPath', null, 'Original path')]),
el('span', {}, [tr('ui.deleted', null, 'Deleted')]), el('span', {}, [tr('ui.deleted', null, 'Deleted')]),
el('span', {}, [tr('ui.typeSize', null, 'Type / size')]), el('span', {}, [tr('ui.typeSize', null, 'Type / size')]),

View File

@ -10,7 +10,7 @@
"ui.cannotUndo": "This cannot be undone.", "ui.cannotUndo": "This cannot be undone.",
"ui.cancel": "Cancel", "ui.cancel": "Cancel",
"ui.deleting": "Deleting...", "ui.deleting": "Deleting...",
"ui.allWorkspaces": "All workspaces", "ui.allWorkspaces": "All Deals",
"ui.allTypes": "All types", "ui.allTypes": "All types",
"ui.files": "Files", "ui.files": "Files",
"ui.folders": "Folders", "ui.folders": "Folders",
@ -22,7 +22,7 @@
"ui.total": "{count} total", "ui.total": "{count} total",
"ui.filter": "Filter name or path", "ui.filter": "Filter name or path",
"ui.refresh": "Refresh", "ui.refresh": "Refresh",
"ui.workspace": "Workspace", "ui.workspace": "Deal",
"ui.originalPath": "Original path", "ui.originalPath": "Original path",
"ui.deleted": "Deleted", "ui.deleted": "Deleted",
"ui.typeSize": "Type / size", "ui.typeSize": "Type / size",

View File

@ -10,7 +10,7 @@
"ui.cannotUndo": "Это действие нельзя отменить.", "ui.cannotUndo": "Это действие нельзя отменить.",
"ui.cancel": "Отмена", "ui.cancel": "Отмена",
"ui.deleting": "Удаление...", "ui.deleting": "Удаление...",
"ui.allWorkspaces": "Все рабочие пространства", "ui.allWorkspaces": "Все Дела",
"ui.allTypes": "Все типы", "ui.allTypes": "Все типы",
"ui.files": "Файлы", "ui.files": "Файлы",
"ui.folders": "Папки", "ui.folders": "Папки",
@ -22,7 +22,7 @@
"ui.total": "Всего: {count}", "ui.total": "Всего: {count}",
"ui.filter": "Фильтр по имени или пути", "ui.filter": "Фильтр по имени или пути",
"ui.refresh": "Обновить", "ui.refresh": "Обновить",
"ui.workspace": "Рабочее пространство", "ui.workspace": "Дело",
"ui.originalPath": "Исходный путь", "ui.originalPath": "Исходный путь",
"ui.deleted": "Удалён", "ui.deleted": "Удалён",
"ui.typeSize": "Тип / размер", "ui.typeSize": "Тип / размер",

View File

@ -0,0 +1,35 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
const checks = [
['plugins/todo/frontend/src/index.js', '.todo-select'],
['plugins/files/frontend/src/index.js', '.files-sort'],
['plugins/notes/frontend/src/index.js', '.notes-sort'],
['plugins/browser-inbox/frontend/src/index.js', '.browser-inbox-select'],
['plugins/journal/frontend/src/index.js', '.journal-input.journal-select'],
['plugins/trash/frontend/src/index.js', '.trash-select'],
['plugins/secrets/frontend/src/index.js', '.secrets-select'],
];
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
let failed = false;
for (const [relativePath, selector] of checks) {
const source = fs.readFileSync(path.join(root, relativePath), 'utf8');
const escaped = escapeRegExp(selector);
if (!new RegExp(`${escaped}\\{[^}]*appearance:none`).test(source)) {
console.error(`${relativePath}: ${selector} must hide the native select arrow`);
failed = true;
}
if (!new RegExp(`${escaped} option\\{[^}]*background`).test(source)) {
console.error(`${relativePath}: ${selector} options must use the application surface`);
failed = true;
}
}
if (failed) process.exit(1);
console.log('official plugin select styles are complete');

View File

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

View File

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

View File

@ -280,13 +280,16 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
} }
if (api.storedEvents(globalKey).length !== 0) throw new Error('workspace activity leaked into global storage'); if (api.storedEvents(globalKey).length !== 0) throw new Error('workspace activity leaked into global storage');
if (!container.textContent.includes('Example Article')) throw new Error('browser capture title was not rendered'); if (!container.textContent.includes('Example Article')) throw new Error('browser capture title was not rendered');
if (!container.textContent.includes('browser.capture.selection')) throw new Error('event type was not rendered'); if (!container.textContent.includes('Selection captured')) throw new Error('browser capture label was not rendered');
if (!container.textContent.includes('browser.capture.converted')) throw new Error('conversion event type was not rendered'); if (!container.textContent.includes('Capture converted')) throw new Error('conversion label was not rendered');
if (container.textContent.includes('browser.capture.selection') || container.textContent.includes('browser.capture.converted') || container.textContent.includes('verstak.browser-inbox')) {
throw new Error('Activity must not expose technical event or plugin identifiers in its UI');
}
if (!container.textContent.includes('Possible journal entries')) throw new Error('work session candidate section was not rendered'); if (!container.textContent.includes('Possible journal entries')) throw new Error('work session candidate section was not rendered');
if (container.textContent.includes('Project work on 2026-06-27')) throw new Error('candidate must not invent a worklog title'); if (container.textContent.includes('Project work on 2026-06-27')) throw new Error('candidate must not invent a worklog title');
const candidateNode = walk(container, (node) => node.getAttribute && node.getAttribute('data-work-session-candidate')); const candidateNode = walk(container, (node) => node.getAttribute && node.getAttribute('data-work-session-candidate'));
if (!candidateNode) throw new Error('work session candidate data attribute was not rendered'); if (!candidateNode) throw new Error('work session candidate data attribute was not rendered');
if (!candidateNode.textContent.includes('Workspace: Project')) throw new Error('candidate workspace was not rendered'); if (!candidateNode.textContent.includes('Deal: Project')) throw new Error('candidate Deal was not rendered');
if (!candidateNode.textContent.includes('Estimated duration: 20 min')) throw new Error('candidate duration was not rendered'); if (!candidateNode.textContent.includes('Estimated duration: 20 min')) throw new Error('candidate duration was not rendered');
if (!candidateNode.textContent.includes('Activities: 3')) throw new Error('candidate activity count was not rendered'); if (!candidateNode.textContent.includes('Activities: 3')) throw new Error('candidate activity count was not rendered');
if (!walk(candidateNode, (node) => node.getAttribute && node.getAttribute('data-work-session-action') === 'review')) throw new Error('candidate review action was not rendered'); if (!walk(candidateNode, (node) => node.getAttribute && node.getAttribute('data-work-session-action') === 'review')) throw new Error('candidate review action was not rendered');
@ -361,6 +364,13 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
if (!clearButton) throw new Error('clear activity button not found'); if (!clearButton) throw new Error('clear activity button not found');
clearButton.click(); clearButton.click();
await flush(); await flush();
if (api.storedEvents(projectKey).length === 0) throw new Error('clear action removed activity before confirmation');
const clearConfirmation = walk(container, (node) => node.getAttribute && node.getAttribute('data-activity-clear-confirmation') === '');
if (!clearConfirmation) throw new Error('clear activity confirmation was not rendered');
const confirmClear = walk(clearConfirmation, (node) => node.getAttribute && node.getAttribute('data-activity-clear-confirm') === '');
if (!confirmClear) throw new Error('clear activity confirmation button was not rendered');
confirmClear.click();
await flush();
if (api.storedEvents(projectKey).length !== 0) throw new Error('clear action did not remove activity events'); if (api.storedEvents(projectKey).length !== 0) throw new Error('clear action did not remove activity events');
if (api.storedEvents('work-session-candidates:workspace:Project').length !== 0) throw new Error('clear action did not remove cached candidates'); if (api.storedEvents('work-session-candidates:workspace:Project').length !== 0) throw new Error('clear action did not remove cached candidates');
@ -531,6 +541,13 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
const rawClear = walk(rawView.container, (node) => node.getAttribute && node.getAttribute('data-activity-action') === 'clear'); const rawClear = walk(rawView.container, (node) => node.getAttribute && node.getAttribute('data-activity-action') === 'clear');
rawClear.click(); rawClear.click();
await flush(); await flush();
if (rawApi.storedData('activity-events').length === 0) throw new Error('append-only activity was removed before confirmation');
const rawClearConfirmation = walk(rawView.container, (node) => node.getAttribute && node.getAttribute('data-activity-clear-confirmation') === '');
if (!rawClearConfirmation) throw new Error('append-only activity confirmation was not rendered');
const rawConfirmClear = walk(rawClearConfirmation, (node) => node.getAttribute && node.getAttribute('data-activity-clear-confirm') === '');
if (!rawConfirmClear) throw new Error('append-only activity confirmation button was not rendered');
rawConfirmClear.click();
await flush();
if (rawApi.storedData('activity-events').length !== 0) throw new Error('clear activity did not replace append-only data'); if (rawApi.storedData('activity-events').length !== 0) throw new Error('clear activity did not replace append-only data');
component.unmount && component.unmount(rawView.container); component.unmount && component.unmount(rawView.container);

View File

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

View File

@ -120,7 +120,7 @@ async function flush() {
} }
} }
async function mountEditor(secretProviderEnabled) { async function mountEditor(secretProviderEnabled, translations) {
const document = makeDocument(); const document = makeDocument();
const component = loadComponent(document); const component = loadComponent(document);
const opened = []; const opened = [];
@ -146,6 +146,15 @@ async function mountEditor(secretProviderEnabled) {
return { status: 'opened', request }; return { status: 'opened', request };
}, },
}, },
i18n: {
t: (key, params, fallback) => {
let value = translations && translations[key] ? translations[key] : (fallback || key);
Object.entries(params || {}).forEach(([name, replacement]) => {
value = value.replace(`{${name}}`, String(replacement));
});
return value;
},
},
}; };
const container = document.createElement('div'); const container = document.createElement('div');
component.mount(container, { component.mount(container, {
@ -162,6 +171,12 @@ async function mountEditor(secretProviderEnabled) {
if (disabledPreview.innerHTML.includes('data-secret-id')) throw new Error('secret link rendered without secrets provider'); if (disabledPreview.innerHTML.includes('data-secret-id')) throw new Error('secret link rendered without secrets provider');
const enabled = await mountEditor(true); const enabled = await mountEditor(true);
if (enabled.container.textContent.includes('notes-markdown') || enabled.container.textContent.includes('generic-markdown')) {
throw new Error('editor must not expose the technical editor mode in its toolbar');
}
if (enabled.container.textContent.includes('Notes context active')) {
throw new Error('editor must not show an implementation roadmap in the notes UI');
}
const preview = walk(enabled.container, (node) => node.className === 'de-preview'); const preview = walk(enabled.container, (node) => node.className === 'de-preview');
if (!preview) throw new Error('enabled preview missing'); if (!preview) throw new Error('enabled preview missing');
if (!preview.innerHTML.includes('data-secret-id="client-a.db"')) throw new Error('secret link did not render with provider'); if (!preview.innerHTML.includes('data-secret-id="client-a.db"')) throw new Error('secret link did not render with provider');
@ -182,6 +197,12 @@ async function mountEditor(secretProviderEnabled) {
throw new Error('secret link did not open through workbench'); throw new Error('secret link did not open through workbench');
} }
const russian = await mountEditor(true, { 'ui.md.heading': 'Заголовок' });
const headingButton = walk(russian.container, (node) => node.getAttribute && node.getAttribute('data-md-action') === 'heading');
if (!headingButton || headingButton.getAttribute('title') !== 'Заголовок') {
throw new Error('Markdown toolbar titles must use the locale catalog');
}
console.log('default editor smoke passed'); console.log('default editor smoke passed');
})().catch((err) => { })().catch((err) => {
console.error(err); console.error(err);

View File

@ -195,9 +195,11 @@ function loadFilesComponent(document) {
return { component, clipboard: sandbox.navigator.clipboard }; return { component, clipboard: sandbox.navigator.clipboard };
} }
function makeApi() { function makeApi(options = {}) {
const externalCalls = []; const externalCalls = [];
const contributionCalls = []; const contributionCalls = [];
const created = [];
const moved = [];
const eventHandlers = {}; const eventHandlers = {};
let restored = false; let restored = false;
let externalVisible = false; let externalVisible = false;
@ -212,6 +214,8 @@ function makeApi() {
return { return {
externalCalls, externalCalls,
contributionCalls, contributionCalls,
created,
moved,
emitFileChanged(payload) { emitFileChanged(payload) {
(eventHandlers['file.changed'] || []).forEach((handler) => handler({ (eventHandlers['file.changed'] || []).forEach((handler) => handler({
name: 'file.changed', name: 'file.changed',
@ -253,9 +257,15 @@ function makeApi() {
}, },
metadata: async () => { throw new Error('not-found'); }, metadata: async () => { throw new Error('not-found'); },
readText: async () => '# Readme\n', readText: async () => '# Readme\n',
writeText: async () => undefined, writeText: async (relativePath, content, options) => {
createFolder: async () => undefined, created.push({ type: 'file', relativePath, content, options });
move: async () => undefined, },
createFolder: async (relativePath) => {
created.push({ type: 'folder', relativePath });
},
move: async (fromRelativePath, toRelativePath) => {
moved.push({ fromRelativePath, toRelativePath });
},
trash: async () => undefined, trash: async () => undefined,
listTrash: async () => trashEntries.slice(), listTrash: async () => trashEntries.slice(),
restoreTrash: async (trashId) => { restoreTrash: async (trashId) => {
@ -307,6 +317,15 @@ function makeApi() {
}; };
}, },
}, },
i18n: {
t: (key, params, fallback) => {
let value = options.translations && options.translations[key] ? options.translations[key] : (fallback || key);
Object.entries(params || {}).forEach(([name, replacement]) => {
value = value.replace(`{${name}}`, String(replacement));
});
return value;
},
},
showExternalFile() { showExternalFile() {
externalVisible = true; externalVisible = true;
}, },
@ -328,6 +347,62 @@ async function flush() {
const row = walk(container, (node) => node.getAttribute && node.getAttribute('data-file-path') === 'Docs/readme.md'); const row = walk(container, (node) => node.getAttribute && node.getAttribute('data-file-path') === 'Docs/readme.md');
if (!row) throw new Error('file row not rendered'); if (!row) throw new Error('file row not rendered');
const newFolder = walk(container, (node) => node.getAttribute && node.getAttribute('data-files-action') === 'new-folder');
if (!newFolder) throw new Error('new folder button not found');
newFolder.click();
const createModal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-files-create-modal') !== undefined);
if (!createModal) throw new Error('create file modal not found');
const createInput = walk(createModal, (node) => node.getAttribute && node.getAttribute('data-files-create-input') !== undefined);
if (!createInput) throw new Error('create file input not found');
const createConfirm = walk(createModal, (node) => node.tagName === 'BUTTON' && node.textContent === 'Create');
if (!createConfirm) throw new Error('create file confirm button not found');
createConfirm.click();
const createError = walk(createModal, (node) => node.getAttribute && node.getAttribute('data-files-create-error') !== undefined);
if (!createError || !createError.textContent.includes('Enter a name') || createInput.getAttribute('aria-invalid') !== 'true') {
throw new Error('create file modal does not show a validation error for an empty name');
}
createInput.value = 'New folder';
createInput.dispatchEvent('keydown', { key: 'Enter' });
await flush();
if (!api.created.some((entry) => entry.type === 'folder' && entry.relativePath === 'New folder')) {
throw new Error(`new folder was not created: ${JSON.stringify(api.created)}`);
}
if (walk(document.body, (node) => node.getAttribute && node.getAttribute('data-files-create-modal') !== undefined)) {
throw new Error('create file modal should close after a successful create');
}
const newMarkdown = walk(container, (node) => node.getAttribute && node.getAttribute('data-files-action') === 'new-markdown');
if (!newMarkdown) throw new Error('new markdown button not found');
newMarkdown.click();
const markdownModal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-files-create-modal') !== undefined);
const markdownInput = walk(markdownModal, (node) => node.getAttribute && node.getAttribute('data-files-create-input') !== undefined);
markdownInput.value = 'Readme';
markdownInput.dispatchEvent('keydown', { key: 'Enter' });
await flush();
if (!api.created.some((entry) => entry.type === 'file' && entry.relativePath === 'Readme.md')) {
throw new Error(`new markdown file was not created: ${JSON.stringify(api.created)}`);
}
const rename = walk(container, (node) => node.getAttribute && node.getAttribute('data-files-action') === 'row-rename');
if (!rename) throw new Error('rename file button not found');
rename.click();
const renameModal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-files-rename-modal') !== undefined);
if (!renameModal) throw new Error('rename file modal not found');
const renameInput = walk(renameModal, (node) => node.getAttribute && node.getAttribute('data-files-rename-input') !== undefined);
if (!renameInput || renameInput.value !== 'readme.md') throw new Error('rename file input should keep the current filename and extension');
renameInput.value = '';
renameInput.dispatchEvent('keydown', { key: 'Enter' });
const renameError = walk(renameModal, (node) => node.getAttribute && node.getAttribute('data-files-rename-error') !== undefined);
if (!renameError || !renameError.textContent.includes('Enter a name') || renameInput.getAttribute('aria-invalid') !== 'true') {
throw new Error('rename file modal does not show a validation error for an empty name');
}
renameInput.value = 'renamed.md';
renameInput.dispatchEvent('keydown', { key: 'Enter' });
await flush();
if (!api.moved.some((entry) => entry.fromRelativePath === 'Docs/readme.md' && entry.toRelativePath === 'Docs/renamed.md')) {
throw new Error(`rename did not preserve the extension: ${JSON.stringify(api.moved)}`);
}
const list = walk(container, (node) => node.getAttribute && node.getAttribute('data-files-list') !== undefined); const list = walk(container, (node) => node.getAttribute && node.getAttribute('data-files-list') !== undefined);
if (!list) throw new Error('files list not rendered'); if (!list) throw new Error('files list not rendered');
list.dispatchEvent('contextmenu', { target: row, clientX: 20, clientY: 20 }); list.dispatchEvent('contextmenu', { target: row, clientX: 20, clientY: 20 });
@ -378,6 +453,31 @@ async function flush() {
throw new Error(`external file row not rendered after file.changed: ${container.textContent}`); throw new Error(`external file row not rendered after file.changed: ${container.textContent}`);
} }
const russianDocument = makeDocument();
const { component: russianComponent } = loadFilesComponent(russianDocument);
const russianApi = makeApi({
translations: {
'ui.open': 'Открыть',
'ui.openExternal': 'Открыть во внешнем приложении',
'ui.showInFolder': 'Показать в папке',
'ui.rename': 'Переименовать',
'ui.duplicate': 'Создать копию',
'ui.cut': 'Вырезать',
'ui.copy': 'Копировать',
'ui.trash': 'Переместить в корзину',
},
});
const russianContainer = new FakeNode('div');
russianComponent.mount(russianContainer, {}, russianApi);
await flush();
const russianList = walk(russianContainer, (node) => node.getAttribute && node.getAttribute('data-files-list') !== undefined);
const russianRow = walk(russianContainer, (node) => node.getAttribute && node.getAttribute('data-file-path') === 'Docs/readme.md');
russianList.dispatchEvent('contextmenu', { target: russianRow, clientX: 20, clientY: 20 });
const russianMenuText = russianDocument.body.textContent;
['Открыть во внешнем приложении', 'Показать в папке', 'Переименовать', 'Создать копию', 'Вырезать', 'Копировать', 'Переместить в корзину'].forEach((label) => {
if (!russianMenuText.includes(label)) throw new Error(`context menu label was not localized: ${label}`);
});
console.log('files frontend smoke passed'); console.log('files frontend smoke passed');
})().catch((err) => { })().catch((err) => {
console.error(err); console.error(err);

View File

@ -6,12 +6,14 @@ const vm = require('vm');
const root = path.resolve(__dirname, '..'); const root = path.resolve(__dirname, '..');
const manifestPath = path.join(root, 'plugins', 'journal', 'plugin.json'); const manifestPath = path.join(root, 'plugins', 'journal', 'plugin.json');
const sourcePath = path.join(root, 'plugins', 'journal', 'frontend', 'src', 'index.js'); const sourcePath = path.join(root, 'plugins', 'journal', 'frontend', 'src', 'index.js');
const russianLocalePath = path.join(root, 'plugins', 'journal', 'locales', 'ru.json');
if (!fs.existsSync(manifestPath)) throw new Error('journal plugin manifest missing'); if (!fs.existsSync(manifestPath)) throw new Error('journal plugin manifest missing');
if (!fs.existsSync(sourcePath)) throw new Error('journal frontend entry missing'); if (!fs.existsSync(sourcePath)) throw new Error('journal frontend entry missing');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const source = fs.readFileSync(sourcePath, 'utf8'); const source = fs.readFileSync(sourcePath, 'utf8');
const russianLocale = JSON.parse(fs.readFileSync(russianLocalePath, 'utf8'));
class FakeNode { class FakeNode {
constructor(tagName) { constructor(tagName) {
@ -131,7 +133,7 @@ function loadComponent(document) {
return component; return component;
} }
function makeApi(initialSettings = {}) { function makeApi(initialSettings = {}, locale = null) {
const settings = { ...initialSettings }; const settings = { ...initialSettings };
const publishedEvents = []; const publishedEvents = [];
return { return {
@ -148,6 +150,17 @@ function makeApi(initialSettings = {}) {
publishedEvents.push({ name, payload }); publishedEvents.push({ name, payload });
}, },
}, },
files: {
list: async () => [
{ type: 'folder', relativePath: 'Project', name: 'Project' },
{ type: 'folder', relativePath: 'Client', name: 'Client' },
],
},
i18n: locale ? {
t(key, params, fallback) {
return String(locale[key] || fallback || key).replace(/\{(\w+)\}/g, (_match, name) => String((params || {})[name] ?? ''));
},
} : null,
storedEntries(key) { storedEntries(key) {
return settings[key] || []; return settings[key] || [];
}, },
@ -239,8 +252,11 @@ function byData(container, attr, value) {
toolRequest: { type: 'work-session-candidate', candidate }, toolRequest: { type: 'work-session-candidate', candidate },
}); });
if (!candidateView.container.textContent.includes('Review possible journal entry')) throw new Error('candidate review modal was not opened'); if (!candidateView.container.textContent.includes('Review possible journal entry')) throw new Error('candidate review modal was not opened');
if (!candidateView.container.textContent.includes('Workspace: Project')) throw new Error('candidate workspace was not shown for review'); if (!candidateView.container.textContent.includes('Deal: Project')) throw new Error('candidate Deal was not shown for review');
if (!candidateView.container.textContent.includes('Estimated duration: 51 min')) throw new Error('candidate duration was not shown for review'); if (!candidateView.container.textContent.includes('Estimated duration: 51 min')) throw new Error('candidate duration was not shown for review');
if (candidateView.container.textContent.includes('browser.capture.selection') || candidateView.container.textContent.includes('verstak.browser-inbox') || candidateView.container.textContent.includes('capture-1')) {
throw new Error('candidate review exposed technical Activity identifiers');
}
if (byData(candidateView.container, 'data-journal-input', 'title').value !== '') throw new Error('candidate review must start with an empty title'); if (byData(candidateView.container, 'data-journal-input', 'title').value !== '') throw new Error('candidate review must start with an empty title');
if (byData(candidateView.container, 'data-journal-input', 'summary').value !== '') throw new Error('candidate review must start with an empty body'); if (byData(candidateView.container, 'data-journal-input', 'summary').value !== '') throw new Error('candidate review must start with an empty body');
if (byData(candidateView.container, 'data-journal-input', 'minutes').value !== '51') throw new Error('candidate review must prefill the factual duration'); if (byData(candidateView.container, 'data-journal-input', 'minutes').value !== '51') throw new Error('candidate review must prefill the factual duration');
@ -267,6 +283,15 @@ function byData(container, attr, value) {
throw new Error('journal rows must not navigate to Activity by default'); throw new Error('journal rows must not navigate to Activity by default');
} }
const russianCandidateView = await mountWithApi(makeApi({}, russianLocale), {
workspaceNode: { name: 'Project' },
workspaceRootPath: 'Project',
toolRequest: { type: 'work-session-candidate', candidate },
});
if (!russianCandidateView.container.textContent.includes('Дело: Project') || !russianCandidateView.container.textContent.includes('Захвачено выделение')) {
throw new Error('candidate review was not localized');
}
byData(candidateView.container, 'data-journal-action', 'delete').click(); byData(candidateView.container, 'data-journal-action', 'delete').click();
await flush(); await flush();
if (api.storedEntries(projectKey).length !== 1) throw new Error('journal entry was not deleted'); if (api.storedEntries(projectKey).length !== 1) throw new Error('journal entry was not deleted');
@ -325,9 +350,26 @@ function byData(container, attr, value) {
if (!globalView.container.textContent.includes('Review research capture') && !globalView.container.textContent.includes('Draft brief updated')) { if (!globalView.container.textContent.includes('Review research capture') && !globalView.container.textContent.includes('Draft brief updated')) {
throw new Error('global journal did not aggregate remaining entries'); throw new Error('global journal did not aggregate remaining entries');
} }
const globalAdd = byData(globalView.container, 'data-journal-action', 'add');
if (globalAdd.disabled) throw new Error('global Journal Add must be available');
globalAdd.click();
await flush();
const globalWorkspace = byData(globalView.container, 'data-journal-input', 'workspaceRootPath');
if (!globalWorkspace || globalWorkspace.tagName !== 'SELECT') throw new Error('global Journal form did not render the Deal selector');
globalWorkspace.value = 'Client';
byData(globalView.container, 'data-journal-input', 'title').value = 'Prepare client summary';
byData(globalView.container, 'data-journal-input', 'minutes').value = '30';
byData(globalView.container, 'data-journal-action', 'save-entry').click();
await flush();
const clientKey = 'worklog:workspace:Client';
if (api.storedEntries(clientKey).length !== 1 || api.storedEntries(clientKey)[0].title !== 'Prepare client summary') {
throw new Error('global Journal entry was not stored under the selected Deal');
}
if (!globalView.container.textContent.includes('Prepare client summary')) throw new Error('global Journal did not render the created entry');
component.unmount && component.unmount(container); component.unmount && component.unmount(container);
component.unmount && component.unmount(candidateView.container); component.unmount && component.unmount(candidateView.container);
component.unmount && component.unmount(russianCandidateView.container);
component.unmount && component.unmount(todoView.container); component.unmount && component.unmount(todoView.container);
component.unmount && component.unmount(duplicateTodoView.container); component.unmount && component.unmount(duplicateTodoView.container);
component.unmount && component.unmount(globalView.container); component.unmount && component.unmount(globalView.container);

View File

@ -264,12 +264,19 @@ async function mountNotes(api) {
const createButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-action') === 'create'); const createButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-action') === 'create');
if (!createButton) throw new Error('create button not found'); if (!createButton) throw new Error('create button not found');
createButton.click(); createButton.click();
const input = walk(container, (node) => node.getAttribute && node.getAttribute('data-notes-create-input') !== undefined); const createModal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-notes-create-modal') !== undefined);
if (!createModal) throw new Error('create modal not found');
const input = walk(createModal, (node) => node.getAttribute && node.getAttribute('data-notes-create-input') !== undefined);
if (!input) throw new Error('create input not found'); if (!input) throw new Error('create input not found');
input.value = 'First Note'; const confirm = walk(createModal, (node) => node.tagName === 'BUTTON' && node.textContent === 'Create');
const confirm = walk(container, (node) => node.tagName === 'BUTTON' && node.textContent === 'Create');
if (!confirm) throw new Error('create confirm button not found'); if (!confirm) throw new Error('create confirm button not found');
confirm.click(); confirm.click();
const createError = walk(createModal, (node) => node.getAttribute && node.getAttribute('data-notes-create-error') !== undefined);
if (!createError || !createError.textContent.includes('Enter a note title') || input.getAttribute('aria-invalid') !== 'true') {
throw new Error('create modal does not show a validation error for an empty title');
}
input.value = 'First Note';
input.dispatchEvent('keydown', { key: 'Enter' });
await flush(); await flush();
const created = createApi.entries.get('Project/Notes/First_Note.md'); const created = createApi.entries.get('Project/Notes/First_Note.md');
@ -319,21 +326,25 @@ async function mountNotes(api) {
const renameButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-note-action') === 'rename'); const renameButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-note-action') === 'rename');
if (!renameButton) throw new Error('rename note button not found'); if (!renameButton) throw new Error('rename note button not found');
renameButton.click(); renameButton.click();
const renameInput = walk(container, (node) => node.getAttribute && node.getAttribute('data-notes-rename-input') !== undefined); const renameModal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-notes-rename-modal') !== undefined);
if (!renameModal) throw new Error('rename modal not found');
const renameInput = walk(renameModal, (node) => node.getAttribute && node.getAttribute('data-notes-rename-input') !== undefined);
if (!renameInput) throw new Error('rename input not found'); if (!renameInput) throw new Error('rename input not found');
renameInput.value = 'Second Note'; renameInput.value = 'Second Note';
const renameConfirm = walk(container, (node) => node.tagName === 'BUTTON' && node.textContent === 'Rename'); renameInput.dispatchEvent('keydown', { key: 'Enter' });
if (!renameConfirm) throw new Error('rename confirm button not found');
renameConfirm.click();
await flush(); await flush();
const conflictModal = walk(document.body, (node) => node.className === 'notes-modal-msg'); const renameError = walk(renameModal, (node) => node.getAttribute && node.getAttribute('data-notes-rename-error') !== undefined);
if (!conflictModal || !conflictModal.textContent.includes('Project/Notes/Second_Note.md')) { if (!renameError || !renameError.textContent.includes('Project/Notes/Second_Note.md')) {
throw new Error(`rename conflict modal should include existing path, got ${conflictModal && conflictModal.textContent}`); throw new Error(`rename conflict should stay in the form modal, got ${renameError && renameError.textContent}`);
} }
const conflictOk = walk(document.body, (node) => node.tagName === 'BUTTON' && node.textContent === 'OK'); if (walk(document.body, (node) => node.className === 'notes-modal-msg')) {
if (!conflictOk) throw new Error('rename conflict OK button not found'); throw new Error('rename conflict opened a second modal instead of reporting in the form');
conflictOk.click(); }
renameInput.dispatchEvent('keydown', { key: 'Escape' });
await flush(); await flush();
if (walk(document.body, (node) => node.getAttribute && node.getAttribute('data-notes-rename-modal') !== undefined)) {
throw new Error('Escape did not close the rename modal');
}
const providerAction = walk(container, (node) => node.getAttribute && node.getAttribute('data-note-contribution-action') === 'provider.note.action'); const providerAction = walk(container, (node) => node.getAttribute && node.getAttribute('data-note-contribution-action') === 'provider.note.action');
if (!providerAction) throw new Error('provider note action button not found'); if (!providerAction) throw new Error('provider note action button not found');

View File

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

View File

@ -28,6 +28,16 @@ class FakeNode {
return node; return node;
} }
removeChild(node) {
this.children = this.children.filter((child) => child !== node);
node.parentNode = null;
return node;
}
remove() {
if (this.parentNode) this.parentNode.removeChild(this);
}
setAttribute(name, value) { setAttribute(name, value) {
this.attributes[name] = String(value); this.attributes[name] = String(value);
} }
@ -96,10 +106,18 @@ function makeDocument() {
}; };
} }
function loadComponent(document) { function loadComponent(document, errorLog) {
const registry = {}; const registry = {};
vm.runInNewContext(source, { vm.runInNewContext(source, {
console, console: {
...console,
warn(...args) {
errorLog.push(args.map((value) => String(value)).join(' '));
},
error(...args) {
errorLog.push(args.map((value) => String(value)).join(' '));
},
},
document, document,
window: { window: {
VerstakPluginRegister(pluginId, bundle) { VerstakPluginRegister(pluginId, bundle) {
@ -129,19 +147,24 @@ async function flush() {
if (!manifest.provides.includes('secrets.write-ui')) throw new Error('secrets manifest must provide secrets.write-ui'); if (!manifest.provides.includes('secrets.write-ui')) throw new Error('secrets manifest must provide secrets.write-ui');
if (!manifest.permissions.includes('secrets.read')) throw new Error('secrets manifest must request secrets.read'); if (!manifest.permissions.includes('secrets.read')) throw new Error('secrets manifest must request secrets.read');
if (!manifest.permissions.includes('secrets.write')) throw new Error('secrets manifest must request secrets.write'); if (!manifest.permissions.includes('secrets.write')) throw new Error('secrets manifest must request secrets.write');
if (!manifest.permissions.includes('files.read')) throw new Error('secrets manifest must request files.read for the Deal selector');
if (!manifest.permissions.includes('ui.register')) throw new Error('secrets manifest must request ui.register'); if (!manifest.permissions.includes('ui.register')) throw new Error('secrets manifest must request ui.register');
if (!(manifest.contributes.openProviders || []).some((item) => (item.supports || []).some((support) => support.kind === 'secret'))) throw new Error('secrets secret open provider missing'); if (!(manifest.contributes.openProviders || []).some((item) => (item.supports || []).some((support) => support.kind === 'secret'))) throw new Error('secrets secret open provider missing');
if (!(manifest.contributes.views || []).some((item) => item.component === 'SecretsView')) throw new Error('secrets global view missing');
if (!(manifest.contributes.sidebarItems || []).some((item) => item.view === 'verstak.secrets.view')) throw new Error('secrets global sidebar item missing');
if (!(manifest.contributes.workspaceItems || []).some((item) => item.component === 'SecretsView')) throw new Error('secrets workspace item missing'); if (!(manifest.contributes.workspaceItems || []).some((item) => item.component === 'SecretsView')) throw new Error('secrets workspace item missing');
if (!(manifest.contributes.settingsPanels || []).some((item) => item.component === 'SecretsView')) throw new Error('secrets settings panel missing'); if (!(manifest.contributes.settingsPanels || []).some((item) => item.component === 'SecretsView')) throw new Error('secrets settings panel missing');
const document = makeDocument(); const document = makeDocument();
const component = loadComponent(document); const errorLog = [];
const component = loadComponent(document, errorLog);
const records = [ const records = [
{ id: 'global.server', title: 'Global Server', username: 'root', scope: { kind: 'global' }, updatedAt: '2026-06-29T00:00:00Z' }, { id: 'global.server', title: 'Global Server', username: 'root', scope: { kind: 'global' }, updatedAt: '2026-06-29T00:00:00Z' },
{ id: 'client-a.db', title: 'Client A DB', username: 'app', scope: { kind: 'workspace', workspaceRootPath: 'ClientA' }, updatedAt: '2026-06-29T00:00:00Z' }, { id: 'client-a.db', title: 'Client A DB', username: 'app', scope: { kind: 'workspace', workspaceRootPath: 'ClientA' }, updatedAt: '2026-06-29T00:00:00Z' },
]; ];
let initialized = false; let initialized = false;
let unlocked = false; let unlocked = false;
let unlockError = '';
const readCalls = []; const readCalls = [];
const copied = []; const copied = [];
const deleted = []; const deleted = [];
@ -149,6 +172,7 @@ async function flush() {
secrets: { secrets: {
status: async () => ({ initialized, unlocked }), status: async () => ({ initialized, unlocked }),
unlock: async (password) => { unlock: async (password) => {
if (unlockError) throw new Error(unlockError);
if (password !== 'master-password') throw new Error('bad password'); if (password !== 'master-password') throw new Error('bad password');
initialized = true; initialized = true;
unlocked = true; unlocked = true;
@ -177,6 +201,12 @@ async function flush() {
clipboard: { clipboard: {
writeText: async (text) => copied.push(text), writeText: async (text) => copied.push(text),
}, },
files: {
list: async () => [
{ type: 'folder', relativePath: 'ClientA' },
{ type: 'folder', relativePath: 'ClientB' },
],
},
}; };
const container = document.createElement('div'); const container = document.createElement('div');
@ -188,6 +218,21 @@ async function flush() {
const confirmInput = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-master-password-confirm') === ''); const confirmInput = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-master-password-confirm') === '');
const unlockButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-unlock') === ''); const unlockButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-unlock') === '');
if (!passwordInput || !confirmInput || !unlockButton) throw new Error('setup controls missing'); if (!passwordInput || !confirmInput || !unlockButton) throw new Error('setup controls missing');
unlockError = '[plugin:verstak.secrets] secrets.unlock failed: master password must be at least 8 characters';
passwordInput.value = 'short';
confirmInput.value = 'short';
unlockButton.click();
await flush();
if (!container.textContent.includes('Master password must be at least 8 characters')) {
throw new Error('weak master password error was not explained clearly');
}
if (container.textContent.includes('[plugin:') || container.textContent.includes('secrets.unlock')) {
throw new Error('technical unlock details leaked into the Secrets UI');
}
if (!errorLog.some((entry) => entry.includes('[plugin:verstak.secrets] secrets.unlock failed'))) {
throw new Error('technical unlock details were not retained in the console log');
}
unlockError = '';
passwordInput.value = 'master-password'; passwordInput.value = 'master-password';
confirmInput.value = 'master-password'; confirmInput.value = 'master-password';
unlockButton.click(); unlockButton.click();
@ -200,7 +245,17 @@ async function flush() {
if (!container.textContent.includes('Group')) throw new Error('secret field table missing Group row'); if (!container.textContent.includes('Group')) throw new Error('secret field table missing Group row');
if (!container.textContent.includes('Username')) throw new Error('secret field table missing Username row'); if (!container.textContent.includes('Username')) throw new Error('secret field table missing Username row');
if (!container.textContent.includes('Password')) throw new Error('secret field table missing Password row'); if (!container.textContent.includes('Password')) throw new Error('secret field table missing Password row');
if (!container.textContent.includes('secret-value')) throw new Error('secret value was not shown in the field table'); if (container.textContent.includes('secret-value')) throw new Error('secret value must stay hidden until the user explicitly reveals it');
const copyValueButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-copy-value') === 'client-a.db');
if (!copyValueButton) throw new Error('copy value button missing');
copyValueButton.click();
await flush();
if (!copied.includes('secret-value')) throw new Error('secret value was not copied');
const showValueButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-toggle-value') === 'client-a.db');
if (!showValueButton) throw new Error('show value button missing');
showValueButton.click();
await flush();
if (!container.textContent.includes('secret-value')) throw new Error('secret value was not revealed after explicit request');
const copyButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-copy-link') === 'client-a.db'); const copyButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-copy-link') === 'client-a.db');
if (!copyButton) throw new Error('copy link button missing'); if (!copyButton) throw new Error('copy link button missing');
@ -226,8 +281,69 @@ async function flush() {
if (!deleteButton) throw new Error('delete button missing'); if (!deleteButton) throw new Error('delete button missing');
deleteButton.click(); deleteButton.click();
await flush(); await flush();
if (deleted.includes('client-a.db')) throw new Error('secret delete must wait for confirmation');
const deleteConfirmModal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-secret-delete-modal') === '');
if (!deleteConfirmModal) throw new Error('secret delete confirmation modal missing');
const deleteCancelButton = walk(deleteConfirmModal, (node) => node.getAttribute && node.getAttribute('data-secret-delete-cancel') === '');
if (!deleteCancelButton) throw new Error('secret delete cancel button missing');
deleteCancelButton.click();
await flush();
if (deleted.includes('client-a.db')) throw new Error('secret delete ran after cancelling the confirmation');
deleteButton.click();
await flush();
const deleteConfirmButton = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-secret-delete-confirm') === '');
if (!deleteConfirmButton) throw new Error('secret delete confirm button missing');
deleteConfirmButton.click();
await flush();
if (!deleted.includes('client-a.db')) throw new Error('secret delete was not called'); if (!deleted.includes('client-a.db')) throw new Error('secret delete was not called');
records.push({ id: 'client-a.global-view', title: 'Client A Global View', username: 'app', scope: { kind: 'workspace', workspaceRootPath: 'ClientA' }, updatedAt: '2026-06-29T00:00:00Z' });
const globalContainer = document.createElement('div');
component.mount(globalContainer, {}, api);
await flush();
if (!globalContainer.textContent.includes('Global Server') || !globalContainer.textContent.includes('Client A Global View')) {
throw new Error('global secrets view did not show global and Deal-scoped records');
}
const scopeFilter = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-scope-filter') === '');
if (!scopeFilter) throw new Error('global secrets scope filter missing');
scopeFilter.value = 'workspace:ClientA';
scopeFilter.dispatchEvent('change');
await flush();
if (!globalContainer.textContent.includes('Client A Global View') || globalContainer.textContent.includes('Global Server')) {
throw new Error('global secrets scope filter did not isolate the selected Deal');
}
const searchInput = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-search') === '');
if (!searchInput) throw new Error('global secrets search missing');
searchInput.value = 'no-match';
searchInput.dispatchEvent('input');
await flush();
if (!globalContainer.textContent.includes('No secrets')) throw new Error('global secrets search did not filter records');
scopeFilter.value = 'all';
scopeFilter.dispatchEvent('change');
await flush();
const newButton = walk(globalContainer, (node) => node.tagName === 'BUTTON' && node.textContent === 'New');
if (!newButton) throw new Error('global secrets new button missing');
newButton.click();
await flush();
const globalTitleInput = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-title') === '');
const globalValueInput = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-value') === '');
const scopeInput = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-scope') === '');
const workspaceInput = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-workspace') === '');
const globalSaveButton = walk(globalContainer, (node) => node.getAttribute && node.getAttribute('data-secret-save') === '');
if (!globalTitleInput || !globalValueInput || !scopeInput || !workspaceInput || !globalSaveButton) throw new Error('global Deal-scoped secret form controls missing');
globalTitleInput.value = 'Client B API';
globalValueInput.value = 'client-b-api-value';
scopeInput.value = 'workspace';
scopeInput.dispatchEvent('change');
workspaceInput.value = 'ClientB';
globalSaveButton.click();
await flush();
if (!records.some((record) => record.title === 'Client B API' && record.scope && record.scope.workspaceRootPath === 'ClientB')) {
throw new Error('global secrets form did not create a Deal-scoped secret');
}
console.log('secrets plugin smoke passed'); console.log('secrets plugin smoke passed');
})().catch((err) => { })().catch((err) => {
console.error(err); console.error(err);

View File

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

View File

@ -231,8 +231,13 @@ async function mountWithApi(apiState, props, emittedEvents = [], document = make
byData(container, 'data-todo-input', 'title').value = 'Prepare project review'; byData(container, 'data-todo-input', 'title').value = 'Prepare project review';
byData(container, 'data-todo-input', 'description').value = 'Collect factual review notes.'; byData(container, 'data-todo-input', 'description').value = 'Collect factual review notes.';
byData(container, 'data-todo-input', 'priority').value = 'high'; byData(container, 'data-todo-input', 'priority').value = 'high';
byData(container, 'data-todo-input', 'dueAt').value = '2000-01-01'; byData(container, 'data-todo-input', 'dueAt').value = '01/02/2000';
byData(container, 'data-todo-input', 'reminderAt').value = '2000-01-01T09:00'; const reminderDate = byData(container, 'data-todo-input', 'reminderDate');
const reminderTime = byData(container, 'data-todo-input', 'reminderTime');
if (!reminderDate || reminderDate.getAttribute('type') !== 'date') throw new Error('Todo reminder date input was not rendered');
if (!reminderTime || reminderTime.getAttribute('type') !== 'text') throw new Error('Todo reminder time must be a keyboard-editable text input');
reminderDate.value = '01/02/2000';
reminderTime.value = '09:30';
byData(container, 'data-todo-action', 'save').click(); byData(container, 'data-todo-action', 'save').click();
await flush(); await flush();
@ -241,7 +246,7 @@ async function mountWithApi(apiState, props, emittedEvents = [], document = make
const createdTodo = storedAfterCreate[0]; const createdTodo = storedAfterCreate[0];
if (createdTodo.workspaceRootPath !== 'Project') throw new Error('workspace Todo did not keep the Project root path'); if (createdTodo.workspaceRootPath !== 'Project') throw new Error('workspace Todo did not keep the Project root path');
if (createdTodo.status !== 'open' || createdTodo.priority !== 'high') throw new Error('Todo status or priority was not stored'); if (createdTodo.status !== 'open' || createdTodo.priority !== 'high') throw new Error('Todo status or priority was not stored');
if (createdTodo.dueAt !== '2000-01-01' || createdTodo.reminderAt !== '2000-01-01T09:00') throw new Error('Todo due/reminder metadata was not stored'); if (createdTodo.dueAt !== '2000-01-02' || createdTodo.reminderDate !== '2000-01-02' || createdTodo.reminderAt !== '2000-01-02T09:30') throw new Error('Todo due/reminder metadata was not stored');
if (!container.textContent.includes('Overdue') || !container.textContent.includes('Reminder due')) throw new Error('due/reminder indicators were not rendered'); if (!container.textContent.includes('Overdue') || !container.textContent.includes('Reminder due')) throw new Error('due/reminder indicators were not rendered');
const scheduledAfterCreate = apiState.notificationCalls.at(-1) || []; const scheduledAfterCreate = apiState.notificationCalls.at(-1) || [];
if (scheduledAfterCreate.length !== 1 if (scheduledAfterCreate.length !== 1
@ -256,14 +261,32 @@ async function mountWithApi(apiState, props, emittedEvents = [], document = make
if (!container.textContent.includes('Задачи · Project') || !container.textContent.includes('Просрочено')) { if (!container.textContent.includes('Задачи · Project') || !container.textContent.includes('Просрочено')) {
throw new Error('Todo view did not update to Russian without remounting'); throw new Error('Todo view did not update to Russian without remounting');
} }
byData(container, 'data-todo-action', 'add').click();
if (!container.textContent.includes('Дело: Project')) {
throw new Error('Todo editor did not use Deal terminology in Russian');
}
const cancelModal = walk(container, (node) => node.tagName === 'BUTTON' && node.textContent === 'Отмена' && !node.getAttribute('data-todo-action'));
if (!cancelModal) throw new Error('Todo editor cancel action was not rendered in Russian');
cancelModal.click();
if ((apiState.settings['todos:global'] || []).length !== 1) throw new Error('locale change lost Todo state'); if ((apiState.settings['todos:global'] || []).length !== 1) throw new Error('locale change lost Todo state');
apiState.setLocale('en'); apiState.setLocale('en');
byData(container, 'data-todo-action', 'edit').click(); byData(container, 'data-todo-action', 'edit').click();
byData(container, 'data-todo-input', 'title').value = 'Prepare project review updated'; byData(container, 'data-todo-input', 'title').value = 'Prepare project review updated';
byData(container, 'data-todo-input', 'reminderTime').value = '29:30';
byData(container, 'data-todo-action', 'save').click();
await flush();
if (apiState.settings['todos:global'][0].title !== 'Prepare project review' || !container.textContent.includes('Enter a valid reminder time')) {
throw new Error('invalid reminder time was saved instead of showing a human-readable error');
}
byData(container, 'data-todo-input', 'reminderTime').value = '';
byData(container, 'data-todo-action', 'save').click(); byData(container, 'data-todo-action', 'save').click();
await flush(); await flush();
if (apiState.settings['todos:global'][0].title !== 'Prepare project review updated') throw new Error('Todo edit was not persisted'); if (apiState.settings['todos:global'][0].title !== 'Prepare project review updated') throw new Error('Todo edit was not persisted');
if (apiState.settings['todos:global'][0].reminderDate !== '2000-01-02' || apiState.settings['todos:global'][0].reminderAt !== '') {
throw new Error('clearing reminder time did not preserve its date while cancelling the reminder');
}
if ((apiState.notificationCalls.at(-1) || []).length !== 0) throw new Error('clearing reminder time did not remove the native notification');
byData(container, 'data-todo-action', 'mark-done').click(); byData(container, 'data-todo-action', 'mark-done').click();
await flush(); await flush();
@ -306,6 +329,9 @@ async function mountWithApi(apiState, props, emittedEvents = [], document = make
if (!globalView.container.textContent.includes('Prepare project review updated') || !globalView.container.textContent.includes('Client follow-up')) { if (!globalView.container.textContent.includes('Prepare project review updated') || !globalView.container.textContent.includes('Client follow-up')) {
throw new Error('global Todo view did not aggregate workspace todos'); throw new Error('global Todo view did not aggregate workspace todos');
} }
if (!globalView.container.textContent.includes('All Deals')) {
throw new Error('global Todo filter did not use Deal terminology');
}
const workspaceFilter = byData(globalView.container, 'data-todo-filter', 'workspace'); const workspaceFilter = byData(globalView.container, 'data-todo-filter', 'workspace');
workspaceFilter.value = 'ClientA'; workspaceFilter.value = 'ClientA';
workspaceFilter.dispatchEvent('change'); workspaceFilter.dispatchEvent('change');

View File

@ -5,8 +5,10 @@ const vm = require('vm');
const root = path.resolve(__dirname, '..'); const root = path.resolve(__dirname, '..');
const sourcePath = path.join(root, 'plugins', 'trash', 'frontend', 'src', 'index.js'); const sourcePath = path.join(root, 'plugins', 'trash', 'frontend', 'src', 'index.js');
const manifestPath = path.join(root, 'plugins', 'trash', 'plugin.json'); const manifestPath = path.join(root, 'plugins', 'trash', 'plugin.json');
const russianLocalePath = path.join(root, 'plugins', 'trash', 'locales', 'ru.json');
const source = fs.readFileSync(sourcePath, 'utf8'); const source = fs.readFileSync(sourcePath, 'utf8');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const russianLocale = JSON.parse(fs.readFileSync(russianLocalePath, 'utf8'));
class FakeClassList { class FakeClassList {
constructor(node) { constructor(node) {
@ -159,7 +161,7 @@ function loadTrashComponent(document) {
return component; return component;
} }
function makeApi() { function makeApi(locale = null) {
let entries = [ let entries = [
{ {
trashId: 'old-project-file', trashId: 'old-project-file',
@ -194,6 +196,11 @@ function makeApi() {
return { return {
restoreCalls, restoreCalls,
deleteCalls, deleteCalls,
i18n: locale ? {
t(key, params, fallback) {
return String(locale[key] || fallback || key).replace(/\{(\w+)\}/g, (_match, name) => String((params || {})[name] ?? ''));
},
} : null,
files: { files: {
listTrash: async () => entries.slice(), listTrash: async () => entries.slice(),
restoreTrash: async (trashId, options) => { restoreTrash: async (trashId, options) => {
@ -237,6 +244,10 @@ function findByData(rootNode, name, value) {
component.mount(container, {}, api); component.mount(container, {}, api);
await flush(); await flush();
if (!container.textContent.includes('All Deals') || !container.textContent.includes('Deal')) {
throw new Error('global Trash did not use Deal terminology');
}
const latestRow = findByData(container, 'data-trash-row', 'latest-client-report'); const latestRow = findByData(container, 'data-trash-row', 'latest-client-report');
if (!latestRow || !latestRow.textContent.includes('ClientA') || !latestRow.textContent.includes('ClientA/Archive/report.pdf')) { if (!latestRow || !latestRow.textContent.includes('ClientA') || !latestRow.textContent.includes('ClientA/Archive/report.pdf')) {
throw new Error(`global Trash must render workspace and original path: ${container.textContent}`); throw new Error(`global Trash must render workspace and original path: ${container.textContent}`);
@ -298,6 +309,14 @@ function findByData(rootNode, name, value) {
} }
component.unmount(container); component.unmount(container);
const russianContainer = new FakeNode('div');
component.mount(russianContainer, {}, makeApi(russianLocale));
await flush();
if (!russianContainer.textContent.includes('Все Дела') || !russianContainer.textContent.includes('Дело')) {
throw new Error('global Trash did not localize Deal terminology');
}
component.unmount(russianContainer);
console.log('trash frontend smoke passed'); console.log('trash frontend smoke passed');
})().catch((err) => { })().catch((err) => {
console.error(err); console.error(err);