Compare commits

..

No commits in common. "main" and "v0.1.0-alpha.2" have entirely different histories.

67 changed files with 661 additions and 2326 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 Deal identity or to an - Activity sessions can be scoped to a durable workspace 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 a durable `workspaceId`, its current or historical Each capture contains `workspaceRootPath` and `processed`. An empty
`workspaceRootPath`, and `processed`. An empty `workspaceRootPath` means the `workspaceRootPath` means the capture is **Unassigned**. The global view can filter
capture is **Unassigned**. The global view can filter all captures by assignment, all captures by assignment, workspace, processed state, and text search. A capture
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 Deal before publishing a The local browser receiver adds the currently active workspace before publishing a
capture event. When it has no active Deal, the capture remains unassigned capture event. When it has no active workspace, 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 Deal view happens to be open. untagged capture merely because a workspace view happens to be open.
Deal assignment uses the immutable `workspaceId` stored in Deal metadata; Workspace assignment currently uses the top-level vault folder path as the
`workspaceRootPath` is an address and display value. A rename updates the latter, identifier because the core workspace model has no separate immutable ID. In the
while a newly created folder with the old name cannot take over existing captures. current model that path is also the displayed workspace name, so a workspace rename
requires a later reassignment of existing captures.
Deal Inbox and Overview show only captures whose `workspaceId` matches the selected Workspace Inbox and Overview show only captures whose `workspaceRootPath` exactly
Deal. Unassigned captures remain visible only in the matches the selected workspace. 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 Deal folder that owns it. otherwise that field identifies the top-level workspace folder that owns it.
The global Todos view aggregates all records and supports Deal, status, text, The global Todos view aggregates all records and supports workspace, status, text,
and due/reminder/updated sorting filters. The Deal Todos view shows only records and due/reminder/updated sorting filters. The workspace Todos view shows only
whose `workspaceRootPath` exactly matches the current Deal. This keeps unassigned records whose `workspaceRootPath` exactly matches the current workspace. This
and other-Deal records out of a Deal tab and its keeps unassigned and other-workspace records out of a workspace 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, renders clear indicators for overdue, The plugin stores reminder metadata and renders clear indicators for overdue,
due-soon, and reminder-due todos, and schedules native desktop notifications when due-soon, and reminder-due todos. Verstak does not yet have a notification
the Desktop notification capability is available. Without that capability, the scheduler, so a reminder does not create a native desktop notification or run in
reminder remains visible in Todos but is not treated as an error. the background.
## Completed Todo to Journal ## Completed Todo to Journal
From a Deal Todo tab, a completed todo exposes **Create Journal Entry**. It opens From a workspace Todo tab, a completed todo exposes **Create Journal Entry**. It
the current Deal Journal with a normal, editable form. The form copies opens the current workspace 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,6 +40,8 @@ own record.
## Visibility ## Visibility
The Plugin Manager can globally enable or disable the Todo plugin. A Deal template The Plugin Manager can globally enable or disable the Todo plugin. The current
controls whether its Todos tab is available; disabling the plugin hides the tab workspace host has no per-workspace or template-level contribution filter yet, so
without affecting stored todo records. a globally enabled Todo plugin contributes its tab to every workspace. Template
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': { key: 'ui.event.workspaceSelected', fallback: 'Deal selected' }, 'workspace.selected': 'Workspace selected',
'case.selected': { key: 'ui.event.workspaceSelected', fallback: 'Deal selected' }, 'case.selected': 'Workspace selected',
'file.opened': { key: 'ui.event.fileOpened', fallback: 'File opened' }, 'file.opened': 'File opened',
'file.changed': { key: 'ui.event.fileChanged', fallback: 'File changed' }, 'file.changed': 'File changed',
'note.saved': { key: 'ui.event.noteEdited', fallback: 'Note edited' }, 'note.saved': 'Note edited',
'action.started': { key: 'ui.event.workSessionDetected', fallback: 'Work session detected' }, 'action.started': 'Work session detected',
'browser.capture.received': { key: 'ui.event.browserCaptureReceived', fallback: 'Browser capture received' }, 'browser.capture.received': 'Browser capture received',
'browser.capture.page': { key: 'ui.event.pageCaptured', fallback: 'Page captured' }, 'browser.capture.page': 'Page captured',
'browser.capture.selection': { key: 'ui.event.selectionCaptured', fallback: 'Selection captured' }, 'browser.capture.selection': 'Selection captured',
'browser.capture.link': { key: 'ui.event.linkCaptured', fallback: 'Link captured' }, 'browser.capture.link': 'Link captured',
'browser.capture.file': { key: 'ui.event.fileCaptured', fallback: 'File captured' }, 'browser.capture.file': 'File captured',
'browser.capture.converted': { key: 'ui.event.captureConverted', fallback: 'Capture converted' }, 'browser.capture.converted': 'Capture converted',
'browser.activity.domain': { key: 'ui.event.browserDomainActivity', fallback: 'Browser domain activity' } 'browser.activity.domain': 'Browser domain activity'
}; };
var LOW_VALUE_EVENT_TYPES = { var LOW_VALUE_EVENT_TYPES = {
'workspace.selected': true, 'workspace.selected': true,
@ -79,7 +79,6 @@
'.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)}',
@ -154,7 +153,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 Deals', workspaceRoot: '', workspaceId: '' }; return { mode: 'global', key: GLOBAL_KEY, label: 'All workspaces', workspaceRoot: '', workspaceId: '' };
} }
return { return {
mode: 'workspace', mode: 'workspace',
@ -412,27 +411,25 @@
}).slice(0, MAX_CANDIDATES); }).slice(0, MAX_CANDIDATES);
} }
function humanEventType(type, translate) { function humanEventType(type) {
var label = EVENT_LABELS[text(type).toLowerCase()]; return EVENT_LABELS[text(type).toLowerCase()] || '';
return label ? translate(label.key, null, label.fallback) : '';
} }
function humanEventTitle(activity, translate) { function humanEventTitle(activity) {
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();
var label = humanEventType(type, translate); if (explicit && explicit.toLowerCase() !== type.toLowerCase()) return explicit;
if (explicit && explicit.toLowerCase() !== type.toLowerCase()) return label ? label + ' — ' + explicit : explicit; return humanEventType(type) || text(activity && (activity.summary || activity.activityId)).trim() || 'Activity event';
return label || text(activity && (activity.summary || activity.activityId)).trim() || translate('ui.event.activity', null, 'Activity event');
} }
function eventKind(activity, translate) { function eventKind(activity) {
var type = text(activity && activity.type).toLowerCase(); var type = text(activity && activity.type).toLowerCase();
if (type.indexOf('browser.capture') === 0) return translate('ui.kind.capture', null, 'Capture'); if (type.indexOf('browser.capture') === 0) return 'Capture';
if (type.indexOf('file.') === 0) return translate('ui.kind.file', null, 'File'); if (type.indexOf('file.') === 0) return 'File';
if (type.indexOf('note.') === 0) return translate('ui.kind.note', null, 'Note'); if (type.indexOf('note.') === 0) return 'Note';
if (type.indexOf('workspace') !== -1 || type.indexOf('case.') === 0) return translate('ui.kind.deal', null, 'Deal'); if (type.indexOf('workspace') !== -1 || type.indexOf('case.') === 0) return 'Workspace';
if (type.indexOf('action.') === 0) return translate('ui.kind.work', null, 'Work'); if (type.indexOf('action.') === 0) return 'Work';
return translate('ui.kind.activity', null, 'Activity'); return 'Activity';
} }
function globalEventKeys(settings) { function globalEventKeys(settings) {
@ -565,14 +562,6 @@
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' });
@ -581,7 +570,18 @@
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: showClearConfirmation onClick: function () {
if (scope.mode === 'global') {
clearGlobal().then(render);
return;
}
events = [];
candidateSourceEvents = candidateSourceEvents.filter(function (activity) {
return candidateWorkspace(activity) !== scope.workspaceRoot;
});
updateCandidates();
clearWorkspaceRaw(scope.workspaceRoot).then(persist).then(render);
}
}); });
toolbar.appendChild(titleEl); toolbar.appendChild(titleEl);
toolbar.appendChild(countEl); toolbar.appendChild(countEl);
@ -594,11 +594,9 @@
'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);
@ -644,7 +642,8 @@
? 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) {
reportError('ui.saveError', 'Could not save activity. Please try again.', err); statusText = tr('ui.saveError', { error: err && err.message ? err.message : String(err) }, 'Could not save activity: ' + (err && err.message ? err.message : String(err)));
statusClass = 'error';
}); });
} }
@ -677,61 +676,11 @@
statusText = tr('ui.cleared', null, 'Activity cleared'); statusText = tr('ui.cleared', null, 'Activity cleared');
statusClass = ''; statusClass = '';
}).catch(function (err) { }).catch(function (err) {
reportError('ui.clearError', 'Could not clear activity. Please try again.', err); statusText = tr('ui.clearError', { error: err && err.message ? err.message : String(err) }, 'Could not clear activity: ' + (err && err.message ? err.message : String(err)));
statusClass = 'error';
}); });
} }
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();
@ -762,10 +711,14 @@
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, tr) }), el('span', { className: 'activity-type', textContent: eventKind(activity) }),
el('span', { className: 'activity-title-text', textContent: humanEventTitle(activity, tr) }) el('span', { className: 'activity-title-text', textContent: humanEventTitle(activity) })
]), ]),
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 : '')) })
])
]) ])
])); ]));
}); });
@ -801,7 +754,8 @@
statusText = tr('ui.dismissed', null, 'Candidate dismissed'); statusText = tr('ui.dismissed', null, 'Candidate dismissed');
statusClass = ''; statusClass = '';
}).catch(function (err) { }).catch(function (err) {
reportError('ui.dismissError', 'Could not dismiss the suggestion. Please try again.', err); statusText = 'Could not dismiss candidate: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
}).then(render); }).then(render);
} }
@ -822,19 +776,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: tr('ui.candidateDeal', { deal: candidate.workspaceRootPath }, 'Deal: ' + candidate.workspaceRootPath) }), el('div', { textContent: 'Workspace: ' + candidate.workspaceRootPath }),
el('div', { textContent: tr('ui.candidateTime', { time: candidateTimeRange(candidate) }, 'Time: ' + candidateTimeRange(candidate)) }), el('div', { textContent: 'Time: ' + candidateTimeRange(candidate) }),
el('div', { textContent: tr('ui.candidateDuration', { minutes: candidate.estimatedMinutes }, 'Estimated duration: ' + candidate.estimatedMinutes + ' min') }), el('div', { textContent: 'Estimated duration: ' + candidate.estimatedMinutes + ' min' }),
el('div', { textContent: tr('ui.candidateActivities', { count: candidate.activityCount }, 'Activities: ' + candidate.activityCount) }) el('div', { textContent: 'Activities: ' + candidate.activityCount })
]), ]),
el('details', { className: 'activity-candidate-activities' }, [ el('details', { className: 'activity-candidate-activities' }, [
el('summary', { textContent: tr('ui.includedActivities', { count: candidate.activityCount }, 'Included activities (' + candidate.activityCount + ')') }) el('summary', { textContent: '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 + ' · ' + humanEventTitle(activity, tr) }); return el('div', { className: 'activity-candidate-activity', textContent: activity.occurredAt + ' · ' + activity.type + ' · ' + activity.activityId });
}))) })))
]), ]),
el('div', { className: 'activity-candidate-actions' }, [ el('div', { className: 'activity-candidate-actions' }, [
el('div', { className: 'activity-candidate-duration', textContent: tr('ui.minutes', { count: candidate.estimatedMinutes }, candidate.estimatedMinutes + ' min') }), el('div', { className: 'activity-candidate-duration', textContent: candidate.estimatedMinutes + ' min' }),
el('button', { className: 'activity-btn', type: 'button', 'data-work-session-action': 'review', textContent: 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); } })
]) ])
@ -843,7 +797,7 @@
} }
function render() { function render() {
countEl.textContent = tr(events.length === 1 ? 'ui.eventCount.one' : 'ui.eventCount.many', { count: events.length }, events.length + ' event' + (events.length === 1 ? '' : 's')); countEl.textContent = 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 : '');
@ -873,7 +827,8 @@
updateCandidates(); updateCandidates();
return persistSessionRegistry().then(persistCandidateCaches); return persistSessionRegistry().then(persistCandidateCaches);
}).catch(function (err) { }).catch(function (err) {
reportError('ui.loadError', 'Could not load activity. Please try again.', err); statusText = 'Could not load activity: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
}); });
} }
@ -900,7 +855,8 @@
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) {
reportError('ui.commandsUnavailable', 'Activity actions are unavailable. Please try again.', err); statusText = 'Activity commands unavailable: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
}); });
} }
@ -915,12 +871,11 @@
if (typeof unsubscribe === 'function') unsubscribers.push(unsubscribe); if (typeof unsubscribe === 'function') unsubscribers.push(unsubscribe);
}); });
})).then(function () { })).then(function () {
statusText = scope.mode === 'global' statusText = scope.mode === 'global' ? 'Listening for all activity' : 'Listening for workspace activity';
? tr('ui.listeningGlobal', null, 'Listening for all activity')
: tr('ui.listeningDeal', null, 'Listening for Deal activity');
statusClass = ''; statusClass = '';
}).catch(function (err) { }).catch(function (err) {
reportError('ui.subscriptionsUnavailable', 'Activity updates are unavailable. Please try again.', err); statusText = 'Activity subscriptions unavailable: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
}); });
} }

View File

@ -1,6 +1,6 @@
{ {
"manifest.name": "Activity", "manifest.name": "Activity",
"manifest.description": "Deal-scoped activity log for public plugin events.", "manifest.description": "Workspace-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,18 +9,9 @@
"ui.title": "Activity", "ui.title": "Activity",
"ui.workspaceTitle": "Activity · {workspace}", "ui.workspaceTitle": "Activity · {workspace}",
"ui.clear": "Clear", "ui.clear": "Clear",
"ui.clearConfirmTitle": "Clear activity?", "ui.saveError": "Could not save activity: {error}",
"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. Please try again.", "ui.clearError": "Could not clear activity: {error}",
"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",
@ -29,34 +20,5 @@
"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,18 +9,9 @@
"ui.title": "Активность", "ui.title": "Активность",
"ui.workspaceTitle": "Активность · {workspace}", "ui.workspaceTitle": "Активность · {workspace}",
"ui.clear": "Очистить", "ui.clear": "Очистить",
"ui.clearConfirmTitle": "Очистить активность?", "ui.saveError": "Не удалось сохранить активность: {error}",
"ui.clearGlobalWarning": "Будут безвозвратно удалены вся записанная активность и предложения для журнала во всех Делах.",
"ui.clearWorkspaceWarning": "Будет безвозвратно удалена записанная активность Дела «{workspace}». Активность других Дел останется.",
"ui.confirmClear": "Очистить активность",
"ui.cancel": "Отмена",
"ui.saveError": "Не удалось сохранить активность. Повторите попытку.",
"ui.cleared": "Активность очищена", "ui.cleared": "Активность очищена",
"ui.clearError": "Не удалось очистить активность. Повторите попытку.", "ui.clearError": "Не удалось очистить активность: {error}",
"ui.dismissError": "Не удалось отклонить предложение. Повторите попытку.",
"ui.loadError": "Не удалось загрузить активность. Повторите попытку.",
"ui.commandsUnavailable": "Действия с активностью недоступны. Повторите попытку.",
"ui.subscriptionsUnavailable": "Обновления активности недоступны. Повторите попытку.",
"ui.empty": "Событий активности пока нет", "ui.empty": "Событий активности пока нет",
"ui.emptyHint": "Здесь появятся изменения файлов, материалы из браузера и преобразования.", "ui.emptyHint": "Здесь появятся изменения файлов, материалы из браузера и преобразования.",
"ui.details": "Подробнее", "ui.details": "Подробнее",
@ -29,34 +20,5 @@
"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": "Deal-scoped activity log for public plugin events.", "description": "Workspace-scoped activity log for public plugin events.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } }, "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 Plugin Verstak v2 Frontend Bundle Browser Inbox 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: '', workspaceRoot: '' }; return { mode: 'global', key: GLOBAL_KEY, label: 'All workspaces', 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, fallbackTitle) { function displayTitle(capture) {
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 || fallbackTitle || ''; return capture.title || capture.url || capture.captureId || 'Untitled capture';
} }
function noteTitle(capture, fallbackTitle) { function noteTitle(capture) {
return text((capture && (capture.title || capture.domain || capture.captureId)) || fallbackTitle).trim() || text(fallbackTitle).trim(); return text((capture && (capture.title || capture.domain || capture.captureId)) || 'Browser Capture').trim() || 'Browser Capture';
} }
function safeNoteFilename(title) { function safeNoteFilename(title) {
@ -199,8 +199,8 @@
return base; return base;
} }
function captureToMarkdown(capture, fallbackTitle) { function captureToMarkdown(capture) {
var title = noteTitle(capture, fallbackTitle); var title = noteTitle(capture);
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 = ''; var statusText = 'Connecting to receiver events...';
var statusClass = ''; var statusClass = '';
var disposed = false; var disposed = false;
var unsubscribers = []; var unsubscribers = [];
@ -376,39 +376,17 @@
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') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Browser · ' + scope.label) }); var titleEl = el('span', { className: 'browser-inbox-title', textContent: scope.mode === 'global' ? tr('ui.title', null, 'Browser Inbox') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Browser Inbox · ' + scope.label) });
var countEl = el('span', { className: 'browser-inbox-count' }); var countEl = el('span', { className: 'browser-inbox-count' });
var statusEl = el('span', { className: 'browser-inbox-status' }); var statusEl = el('span', { className: 'browser-inbox-status' });
var filtersEl = el('div', { className: 'browser-inbox-filters' }); var filtersEl = el('div', { className: 'browser-inbox-filters' });
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': tr('ui.statusFilter', null, 'Material status filter'), 'aria-label': 'Capture 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 = '';
@ -424,7 +402,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': tr('ui.workspaceFilter', null, 'Deal filter'), 'aria-label': 'Workspace 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 = '';
@ -436,7 +414,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': tr('ui.search', null, 'Search captures'), 'aria-label': '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 = '';
@ -459,7 +437,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 Deal') })); filtersEl.appendChild(el('span', { className: 'browser-inbox-count', textContent: tr('ui.assignedHere', null, 'Assigned to this workspace') }));
} }
filtersEl.appendChild(searchInput); filtersEl.appendChild(searchInput);
toolbar.appendChild(filtersEl); toolbar.appendChild(filtersEl);
@ -494,7 +472,7 @@
function renderWorkspaceFilterOptions() { function renderWorkspaceFilterOptions() {
if (scope.mode !== 'global') return; if (scope.mode !== 'global') return;
workspaceFilterEl.innerHTML = ''; workspaceFilterEl.innerHTML = '';
workspaceFilterEl.appendChild(option('', tr('ui.allDeals', null, 'All Deals'))); workspaceFilterEl.appendChild(option('', 'All workspaces'));
workspaceRoots().forEach(function (root) { workspaceRoots().forEach(function (root) {
workspaceFilterEl.appendChild(option(root, root)); workspaceFilterEl.appendChild(option(root, root));
}); });
@ -518,7 +496,9 @@
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') {
reportError('ui.saveError', 'Could not update browser materials. Please try again.'); statusText = 'Could not save inbox: events API unavailable';
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 () {
@ -536,7 +516,9 @@
} }
return true; return true;
}).catch(function (err) { }).catch(function (err) {
reportError('ui.saveError', 'Could not update browser materials. Please try again.', err); statusText = 'Could not save inbox: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
render();
return false; return false;
}); });
} }
@ -563,9 +545,7 @@
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' return archiveCaptures(ids, scope.mode === 'global' ? 'Inbox archived' : 'Workspace captures archived');
? tr('ui.inboxArchived', null, 'Inbox archived')
: tr('ui.workspaceCapturesArchived', null, 'Deal materials archived'));
} }
function selectedCapture() { function selectedCapture() {
@ -583,8 +563,10 @@
}); });
if (existing) return Promise.resolve(); if (existing) return Promise.resolve();
selectedId = capture.captureId; selectedId = capture.captureId;
statusText = tr('ui.captureReceived', null, 'Material received'); statusText = 'Capture received';
statusClass = ''; statusClass = '';
statusText = 'Could not load received capture from storage';
statusClass = 'error';
render(); render();
return Promise.resolve(); return Promise.resolve();
} }
@ -610,16 +592,14 @@
}).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 statusText = workspaceRoot ? 'Capture assigned to ' + workspaceRoot : 'Capture is unassigned';
? 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], tr('ui.captureArchived', null, 'Material archived')); return archiveCaptures([captureId], 'Capture archived');
} }
function restoreCapture(captureId) { function restoreCapture(captureId) {
@ -629,7 +609,7 @@
}); });
}).then(function (saved) { }).then(function (saved) {
if (!saved) return; if (!saved) return;
statusText = tr('ui.captureRestored', null, 'Material restored to Inbox'); statusText = 'Capture restored to Inbox';
statusClass = ''; statusClass = '';
render(); render();
}); });
@ -641,7 +621,7 @@
}).then(function (saved) { }).then(function (saved) {
if (!saved) return; if (!saved) return;
if (selectedId === captureId) selectedId = ''; if (selectedId === captureId) selectedId = '';
statusText = tr('ui.captureDeleted', null, 'Material permanently deleted'); statusText = 'Capture permanently deleted';
statusClass = ''; statusClass = '';
render(); render();
}); });
@ -657,9 +637,7 @@
}); });
}).then(function (saved) { }).then(function (saved) {
if (!saved) return; if (!saved) return;
statusText = processed statusText = processed ? 'Capture marked processed' : 'Capture marked unprocessed';
? tr('ui.captureProcessed', null, 'Material marked processed')
: tr('ui.captureUnprocessed', null, 'Material marked unprocessed');
statusClass = ''; statusClass = '';
render(); render();
}); });
@ -668,15 +646,17 @@
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') {
reportError('ui.createNoteError', 'Could not create the note. Please try again.'); statusText = 'Could not create note: files API unavailable';
statusClass = 'error';
render();
return Promise.resolve(); return Promise.resolve();
} }
var title = noteTitle(capture, tr('ui.untitledCapture', null, 'Untitled material')); var title = noteTitle(capture);
var notePath = capture.workspaceRootPath + '/Notes/' + safeNoteFilename(title); var notePath = capture.workspaceRootPath + '/Notes/' + safeNoteFilename(title);
statusText = tr('ui.creatingNote', null, 'Creating note...'); statusText = 'Creating note...';
statusClass = ''; statusClass = '';
render(); render();
return api.files.writeText(notePath, captureToMarkdown(capture, tr('ui.untitledCapture', null, 'Untitled material')), { return api.files.writeText(notePath, captureToMarkdown(capture), {
createIfMissing: true, createIfMissing: true,
overwrite: false overwrite: false
}).then(function () { }).then(function () {
@ -693,22 +673,26 @@
} }
return undefined; return undefined;
}).then(function () { }).then(function () {
statusText = tr('ui.noteCreated', { path: notePath }, 'Note created: ' + notePath); statusText = 'Created note: ' + notePath;
statusClass = ''; statusClass = '';
return archiveCapture(capture.captureId); return archiveCapture(capture.captureId);
}).catch(function (err) { }).catch(function (err) {
reportError('ui.createNoteError', 'Could not create the note. Please try again.', err); statusText = 'Could not create note: ' + (err && err.message ? err.message : String(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') {
reportError('ui.createLinkError', 'Could not create the link. Please try again.'); statusText = 'Could not create link: files API unavailable';
statusClass = 'error';
render();
return Promise.resolve(); return Promise.resolve();
} }
var title = noteTitle(capture, tr('ui.untitledCapture', null, 'Untitled material')); var title = noteTitle(capture);
statusText = tr('ui.creatingLink', null, 'Creating link...'); statusText = 'Creating link...';
statusClass = ''; statusClass = '';
render(); render();
function writeLink(number) { function writeLink(number) {
@ -741,30 +725,36 @@
} }
return linkPath; return linkPath;
}).then(function () { }).then(function () {
statusText = tr('ui.linkCreated', null, 'Link created'); statusText = 'Created link';
statusClass = ''; statusClass = '';
return archiveCapture(capture.captureId); return archiveCapture(capture.captureId);
}).catch(function (err) { }).catch(function (err) {
reportError('ui.createLinkError', 'Could not create the link. Please try again.', err); statusText = 'Could not create link: ' + (err && err.message ? err.message : String(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) {
reportError('ui.openLinkError', 'Could not open the link. Please try again.', err); statusText = 'Could not open link: ' + (err && err.message ? err.message : String(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')) {
reportError('ui.createFileError', 'Could not create the file. Please try again.'); statusText = 'Could not create file: files API unavailable';
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 = tr('ui.creatingFile', null, 'Creating file...'); statusText = 'Creating file...';
statusClass = ''; statusClass = '';
render(); render();
var writeOptions = { var writeOptions = {
@ -791,11 +781,13 @@
} }
return undefined; return undefined;
}).then(function () { }).then(function () {
statusText = tr('ui.fileCreated', { path: filePath }, 'File created: ' + filePath); statusText = 'Created file: ' + filePath;
statusClass = ''; statusClass = '';
return archiveCapture(capture.captureId); return archiveCapture(capture.captureId);
}).catch(function (err) { }).catch(function (err) {
reportError('ui.createFileError', 'Could not create the file. Please try again.', err); statusText = 'Could not create file: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
render();
}); });
} }
@ -804,8 +796,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
? tr('ui.empty', null, 'No browser materials yet. Send a page, selection, or link from the extension.') ? 'No browser captures yet. Keep this view open, then send a page, selection, or link from the extension.'
: tr('ui.emptyFiltered', null, 'No materials match the current filters.'); : 'No captures 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;
} }
@ -821,22 +813,20 @@
}, [ }, [
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, tr('ui.untitledCapture', null, 'Untitled material')) }) el('span', { className: 'browser-inbox-row-title', textContent: displayTitle(capture) })
]), ]),
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 || tr('ui.unassigned', null, 'Unassigned') textContent: workspaceRoot || 'Unassigned'
}), }),
el('span', { el('span', {
className: 'browser-inbox-badge' + (capture.processed ? ' processed' : ''), className: 'browser-inbox-badge' + (capture.processed ? ' processed' : ''),
textContent: capture.processed textContent: capture.processed ? 'Processed' : 'Unprocessed'
? tr('ui.processed', null, 'Processed')
: tr('ui.unprocessed', null, 'Unprocessed')
}), }),
capture.globalState === 'archived' ? el('span', { className: 'browser-inbox-badge', textContent: tr('ui.archive', null, 'Archive') }) : null capture.globalState === 'archived' ? el('span', { className: 'browser-inbox-badge', textContent: 'Archived' }) : 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 }));
@ -853,47 +843,45 @@
return; return;
} }
selectedId = capture.captureId; selectedId = capture.captureId;
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-detail-title', textContent: displayTitle(capture) }));
detailEl.appendChild(el('div', { className: 'browser-inbox-meta' }, [ detailEl.appendChild(el('div', { className: 'browser-inbox-meta' }, [
el('div', { className: 'browser-inbox-meta-label', textContent: 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: tr('ui.url', null, 'URL') }), el('div', { className: 'browser-inbox-meta-label', textContent: 'URL' }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.url || '-' }), el('div', { className: 'browser-inbox-meta-value', textContent: capture.url || '-' }),
el('div', { className: 'browser-inbox-meta-label', textContent: 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: tr('ui.browser', null, 'Browser') }), el('div', { className: 'browser-inbox-meta-label', textContent: 'Browser' }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.browserName || capture.source || '-' }), el('div', { className: 'browser-inbox-meta-value', textContent: capture.browserName || capture.source || '-' }),
el('div', { className: 'browser-inbox-meta-label', textContent: tr('ui.workspace', null, 'Deal') }), el('div', { className: 'browser-inbox-meta-label', textContent: tr('ui.workspace', null, 'Workspace') }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.workspaceRootPath || tr('ui.unassigned', null, 'Unassigned') }), el('div', { className: 'browser-inbox-meta-value', textContent: capture.workspaceRootPath || '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 el('div', { className: 'browser-inbox-meta-value', textContent: capture.processed ? 'Processed' : 'Unprocessed' })
? 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': tr('ui.assignment', null, 'Assign to Deal'), 'aria-label': 'Assign capture workspace',
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('', tr('ui.unassigned', null, 'Unassigned'))); assignmentSelect.appendChild(option('', '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: tr('ui.assignment', null, 'Assign to Deal') }), el('span', { className: 'browser-inbox-meta-label', textContent: 'Assign workspace' }),
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: tr('ui.clearAssignment', null, 'Clear assignment'), textContent: 'Clear assignment',
onClick: function () { onClick: function () {
assignWorkspace(capture.captureId, ''); assignWorkspace(capture.captureId, '');
} }
@ -901,7 +889,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: tr('ui.assignBeforeCreation', null, 'Assign a Deal before creating a note, link, or file.') })); detailEl.appendChild(el('div', { className: 'browser-inbox-detail-note', textContent: 'Assign a workspace 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 }));
@ -913,9 +901,7 @@
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 textContent: capture.processed ? 'Mark Unprocessed' : 'Mark 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);
} }
@ -924,7 +910,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: tr('ui.openLink', null, 'Open link'), textContent: 'Open link',
onClick: function () { onClick: function () {
openCaptureURL(capture); openCaptureURL(capture);
} }
@ -964,7 +950,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: tr('ui.restore', null, 'Restore to Inbox'), textContent: 'Restore to Inbox',
onClick: function () { onClick: function () {
restoreCapture(capture.captureId); restoreCapture(capture.captureId);
} }
@ -973,7 +959,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: tr('ui.archive', null, 'Archive'), textContent: 'Archive',
onClick: function () { onClick: function () {
archiveCapture(capture.captureId); archiveCapture(capture.captureId);
} }
@ -982,7 +968,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: tr('ui.deletePermanently', null, 'Delete permanently'), textContent: 'Delete permanently',
onClick: function () { onClick: function () {
permanentlyDeleteCapture(capture.captureId); permanentlyDeleteCapture(capture.captureId);
} }
@ -994,11 +980,8 @@
var visibleCount = visibleCaptures().length; var visibleCount = visibleCaptures().length;
var total = captures.length; var total = captures.length;
countEl.textContent = visibleCount === total countEl.textContent = visibleCount === total
? localizedItemCount(total) ? total + ' item' + (total === 1 ? '' : 's')
: tr('ui.items.filtered', { : visibleCount + ' of ' + total + ' items';
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;
@ -1051,7 +1034,8 @@
}); });
}); });
}).catch(function (err) { }).catch(function (err) {
reportError('ui.loadError', 'Could not load browser materials. Please try again.', err); statusText = 'Could not load inbox: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
}); });
} }
@ -1091,13 +1075,13 @@
}).then(function (saved) { }).then(function (saved) {
if (!saved) return; if (!saved) return;
selectedId = received.captureId; selectedId = received.captureId;
statusText = tr('ui.captureReceived', null, 'Material received'); statusText = 'Capture received';
statusClass = ''; statusClass = '';
render(); render();
}); });
} }
selectedId = received.captureId; selectedId = received.captureId;
statusText = tr('ui.captureReceived', null, 'Material received'); statusText = 'Capture received';
statusClass = ''; statusClass = '';
render(); render();
return undefined; return undefined;
@ -1106,12 +1090,11 @@
if (typeof unsubscribe === 'function') unsubscribers.push(unsubscribe); if (typeof unsubscribe === 'function') unsubscribers.push(unsubscribe);
}); });
})).then(function () { })).then(function () {
statusText = scope.mode === 'global' statusText = scope.mode === 'global' ? 'Receiver ready for all workspaces' : 'Receiver ready for workspace';
? 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) {
reportError('ui.receiverError', 'The browser receiver is unavailable. Please try again.', err); statusText = 'Receiver unavailable: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
}); });
} }
@ -1125,11 +1108,8 @@
}); });
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') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Browser · ' + scope.label); titleEl.textContent = scope.mode === 'global' ? tr('ui.title', null, 'Browser Inbox') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Browser Inbox · ' + scope.label);
searchInput.setAttribute('placeholder', tr('ui.search', null, 'Search captures')); 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();
}); });
@ -1221,13 +1201,6 @@
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;
@ -1253,7 +1226,7 @@
applyPairing(pairing); applyPairing(pairing);
setStatus('', false); setStatus('', false);
}).catch(function (err) { }).catch(function (err) {
reportError('ui.pairingLoadError', 'Could not load browser connection settings. Please try again.', err); setStatus(text(err && err.message ? err.message : err), true);
}).then(function () { }).then(function () {
setBusy(false); setBusy(false);
}); });
@ -1268,7 +1241,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) {
reportError('ui.clipboardError', 'Could not copy to the clipboard. Please try again.', err); setStatus(text(err && err.message ? err.message : err), true);
}); });
} }
@ -1288,7 +1261,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) {
reportError('ui.tokenRotateError', 'Could not rotate the pairing token. Please try again.', err); setStatus(text(err && err.message ? err.message : err), true);
}).then(function () { }).then(function () {
setBusy(false); setBusy(false);
}); });

View File

@ -1,26 +1,25 @@
{ {
"manifest.name": "Browser", "manifest.name": "Browser Inbox",
"manifest.description": "Global browser materials with explicit Deal assignment delivered through the local receiver event protocol.", "manifest.description": "Global browser capture queue with explicit workspace assignment delivered through the local receiver event protocol.",
"contributions.views.verstak.browser-inbox.view.title": "Browser", "contributions.views.verstak.browser-inbox.view.title": "Browser Inbox",
"contributions.sidebarItems.verstak.browser-inbox.sidebar.title": "Browser", "contributions.sidebarItems.verstak.browser-inbox.sidebar.title": "Browser Inbox",
"contributions.workspaceItems.verstak.browser-inbox.workspace.title": "Browser", "contributions.workspaceItems.verstak.browser-inbox.workspace.title": "Browser Inbox",
"contributions.settingsPanels.verstak.browser-inbox.settings.title": "Browser", "contributions.settingsPanels.verstak.browser-inbox.settings.title": "Browser Inbox",
"ui.connecting": "Connecting to receiver events...", "ui.connecting": "Connecting to receiver events...",
"ui.title": "Browser", "ui.title": "Browser Inbox",
"ui.workspaceTitle": "Browser · {workspace}", "ui.workspaceTitle": "Browser Inbox · {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 Deal", "ui.assignedHere": "Assigned to this workspace",
"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": "Deal", "ui.workspace": "Workspace",
"ui.status": "Status", "ui.status": "Status",
"ui.createNote": "Create Note", "ui.createNote": "Create Note",
"ui.createLink": "Create Link", "ui.createLink": "Create Link",
@ -36,55 +35,5 @@
"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,26 +1,25 @@
{ {
"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": "Создать ссылку",
@ -36,55 +35,5 @@
"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", "name": "Browser Inbox",
"version": "0.1.0", "version": "0.1.0",
"apiVersion": "0.1.0", "apiVersion": "0.1.0",
"description": "Global browser materials with explicit Deal assignment delivered through the local receiver event protocol.", "description": "Global browser capture queue with explicit workspace assignment delivered through the local receiver event protocol.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } }, "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", "title": "Browser Inbox",
"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", "title": "Browser Inbox",
"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", "title": "Browser Inbox",
"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", "title": "Browser Inbox",
"component": "BrowserInboxSettings" "component": "BrowserInboxSettings"
} }
] ]

View File

@ -18,6 +18,7 @@
'.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}',
@ -44,6 +45,7 @@
'.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');
@ -257,14 +259,11 @@
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 lineStart = value.lastIndexOf('\n', Math.max(0, start - 1)) + 1; var selected = value.slice(start, end) || placeholder || '';
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, lineStart) + replacement + value.slice(lineEnd); textarea.value = value.slice(0, start) + replacement + value.slice(end);
textarea.selectionStart = lineStart; textarea.selectionStart = start;
textarea.selectionEnd = lineStart + replacement.length; textarea.selectionEnd = start + replacement.length;
textarea.dispatchEvent(new Event('input', { bubbles: true })); textarea.dispatchEvent(new Event('input', { bubbles: true }));
textarea.focus(); textarea.focus();
} }
@ -301,8 +300,9 @@
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.note', null, 'Note')]) : null; var notesBadge = editorMode === 'notes-markdown' ? el('span', { className: 'de-notes-badge', 'data-notes-badge': '' }, [tr('ui.notesContext', null, 'notes context')]) : null;
var spacer = el('span', { className: 'de-toolbar-spacer' }); var spacer = el('span', { className: 'de-toolbar-spacer' });
var editBtn = isMarkdown ? el('button', { className: 'de-toolbar-btn', 'data-editor-mode-button': 'edit' }, [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 = [contextLabel]; var toolbarChildren = [modeLabel, 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', 'ui.md.heading', 'Heading'], ['heading', 'H', 'Heading'],
['bold', 'B', 'ui.md.bold', 'Bold'], ['bold', 'B', 'Bold'],
['italic', 'I', 'ui.md.italic', 'Italic'], ['italic', 'I', 'Italic'],
['link', 'Link', 'ui.md.link', 'Link'], ['link', 'Link', 'Link'],
['code', 'Code', 'ui.md.inlineCode', 'Inline code'], ['code', 'Code', 'Inline code'],
['code-block', '```', 'ui.md.codeBlock', 'Code block'], ['code-block', '```', 'Code block'],
['bullet', '• List', 'ui.md.bulletList', 'Bullet list'], ['bullet', '• List', 'Bullet list'],
['numbered', '1. List', 'ui.md.numberedList', 'Numbered list'], ['numbered', '1. List', 'Numbered list'],
['quote', 'Quote', 'ui.md.quote', 'Quote'], ['quote', 'Quote', 'Quote'],
['task', 'Task', 'ui.md.taskItem', 'Task item'] ['task', 'Task', 'Task item']
].forEach(function (item) { ].forEach(function (item) {
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]])); mdToolbar.appendChild(el('button', { className: 'de-md-btn', 'data-md-action': item[0], title: item[2] }, [item[1]]));
}); });
containerEl.appendChild(mdToolbar); containerEl.appendChild(mdToolbar);
} }
@ -339,6 +339,10 @@
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;
@ -488,10 +492,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, 'Could not load the file. Please try again.')]) el('div', {}, [tr('ui.loadFailed', null, 'Failed to load file')]),
el('div', { className: 'de-error-msg' }, [(err && err.message) ? err.message : String(err)])
])); ]));
}); });
} }
@ -536,25 +540,12 @@
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.note', null, 'Note'); if (notesBadge) notesBadge.textContent = tr('ui.notesContext', null, 'notes context');
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,7 +5,6 @@
"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",
@ -19,15 +18,5 @@
"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": "Could not load the file. Please try again.", "ui.loadFailed": "Failed to load file"
"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,7 +5,6 @@
"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": "Разделить",
@ -19,15 +18,5 @@
"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,9 +143,8 @@
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', null, 'Could not preview this file. Please try again.'); body.textContent = tr('ui.error', { error: err && err.message ? err.message : String(err) }, 'Preview error: ' + (err && err.message ? err.message : String(err)));
}); });
if (api.i18n && typeof api.i18n.onDidChangeLocale === 'function') { 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": "Could not preview this file. Please try again." "ui.error": "Preview error: {error}"
} }

View File

@ -5,5 +5,5 @@
"ui.openExternal": "Открыть во внешнем приложении", "ui.openExternal": "Открыть во внешнем приложении",
"ui.preview": "Просмотр", "ui.preview": "Просмотр",
"ui.loading": "Загрузка...", "ui.loading": "Загрузка...",
"ui.error": "Не удалось открыть предварительный просмотр файла. Повторите попытку." "ui.error": "Ошибка просмотра: {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{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,.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{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 option{background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb)}', '.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-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-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-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,6 +60,10 @@
'.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}',
@ -71,12 +75,6 @@
'.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}',
@ -224,7 +222,7 @@
return EXT_MAP[ext] || 'generic'; return EXT_MAP[ext] || 'generic';
} }
function fileIconLabel(category, translate) { function fileIconLabel(category) {
var labels = { var labels = {
folder: 'Folder', folder: 'Folder',
markdown: 'Markdown file', markdown: 'Markdown file',
@ -244,8 +242,7 @@
json: 'JSON file', json: 'JSON file',
generic: 'File' generic: 'File'
}; };
var label = labels[category] || labels.generic; return labels[category] || labels.generic;
return typeof translate === 'function' ? translate('ui.icon.' + (labels[category] ? category : 'generic'), null, label) : label;
} }
function fileIcon(entry) { function fileIcon(entry) {
@ -268,8 +265,8 @@
return date.toLocaleString(undefined, { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }); return date.toLocaleString(undefined, { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' });
} }
function typeLabel(entry, folderLabel) { function typeLabel(entry) {
if (entry.type === 'folder') return folderLabel || 'Folder'; if (entry.type === 'folder') return 'folder';
return (entry.extension || extension(entry.name) || 'file').toLowerCase(); return (entry.extension || extension(entry.name) || 'file').toLowerCase();
} }
@ -323,16 +320,15 @@
return Promise.reject(new Error('clipboard unavailable')); return Promise.reject(new Error('clipboard unavailable'));
} }
function showExternalFallback(entry, mode, labels) { function showExternalFallback(entry, mode, reason) {
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' ? (labels.showInFolder || 'Show in folder') : (labels.openExternal || 'Open externally'); var title = mode === 'explorer' ? 'Show in Explorer' : 'Open External';
var message = (labels.externalFailed || '{action} failed.').replace('{action}', title) + '\n' + (labels.vaultRelativePath || 'Vault-relative path:') + '\n' + pathToShow; var message = title + ' failed.\n' + (reason ? String(reason) + '\n' : '') + 'Vault-relative path:\n' + pathToShow;
confirmModal(message, { confirmText: labels.copyPath || 'Copy path', cancelText: labels.close || 'Close' }).then(function (copy) { confirmModal(message, { confirmText: 'Copy Path', cancelText: '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);
@ -350,7 +346,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)) || ''; var workspaceName = workspaceRoot || (workspaceNode && (workspaceNode.name || workspaceNode.title || workspaceNode.id)) || 'Workspace';
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: '' };
@ -362,12 +358,6 @@
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 = [];
@ -384,17 +374,6 @@
return fallback || key; return fallback || key;
} }
function displayType(entry) {
return typeLabel(entry, tr('ui.folderType', null, 'Folder'));
}
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;
@ -452,6 +431,32 @@
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;
@ -528,7 +533,7 @@
if (a.type !== 'folder' && b.type === 'folder') return 1; if (a.type !== 'folder' && b.type === 'folder') return 1;
} }
if (sortMode === 'type') { if (sortMode === 'type') {
var typeCmp = displayType(a).localeCompare(displayType(b)); var typeCmp = typeLabel(a).localeCompare(typeLabel(b));
if (typeCmp) return typeCmp; if (typeCmp) return typeCmp;
} }
if (sortMode === 'modified-desc') { if (sortMode === 'modified-desc') {
@ -650,7 +655,7 @@
shown.forEach(function (entry) { shown.forEach(function (entry) {
var iconCategory = fileIconCategory(entry); var iconCategory = fileIconCategory(entry);
var iconLabel = fileIconLabel(iconCategory, tr); var iconLabel = fileIconLabel(iconCategory);
var row = el('div', { var row = el('div', {
className: 'files-item' + (selectedPaths[entry.relativePath] ? ' selected' : ''), className: 'files-item' + (selectedPaths[entry.relativePath] ? ' selected' : ''),
'data-file-name': entry.name, 'data-file-name': entry.name,
@ -683,7 +688,7 @@
el('span', { className: 'files-item-icon', 'data-file-icon': iconCategory, title: iconLabel, 'aria-label': iconLabel, innerHTML: fileIcon(entry) }), el('span', { className: 'files-item-icon', 'data-file-icon': iconCategory, title: iconLabel, 'aria-label': iconLabel, innerHTML: fileIcon(entry) }),
el('span', { className: 'files-item-name', textContent: entry.name, title: entry.name }) el('span', { className: 'files-item-name', textContent: entry.name, title: entry.name })
]), ]),
el('span', { className: 'files-item-meta' }, [displayType(entry)]), el('span', { className: 'files-item-meta' }, [typeLabel(entry)]),
el('span', { className: 'files-item-meta hide-narrow' }, [entry.type === 'folder' ? '' : formatSize(entry.size)]), el('span', { className: 'files-item-meta hide-narrow' }, [entry.type === 'folder' ? '' : formatSize(entry.size)]),
el('span', { className: 'files-item-meta hide-narrow' }, [formatDate(entry.modifiedAt)]), el('span', { className: 'files-item-meta hide-narrow' }, [formatDate(entry.modifiedAt)]),
el('div', { className: 'files-row-actions' }, [ el('div', { className: 'files-row-actions' }, [
@ -709,10 +714,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, 'Could not load files. Please try again.')]) el('div', {}, [tr('ui.loadFailed', null, 'Failed to load files')]),
el('div', { className: 'files-error-msg' }, [(err && err.message) ? err.message : String(err)])
])); ]));
}); });
} }
@ -808,131 +813,35 @@
}).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;
createModal = showFileFormModal('create', { createInput.value = '';
title: mode === 'folder' createInput.placeholder = mode === 'folder' ? 'Folder name' : (mode === 'markdown' ? 'Markdown file name' : 'Text file name');
? tr('ui.createFolderTitle', null, 'Create folder') setCreateError('');
: (mode === 'markdown' createPanel.style.display = 'flex';
? tr('ui.createMarkdownTitle', null, 'Create Markdown file') createInput.focus();
: 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 tr('ui.nameRequired', null, 'Enter a name.'); if (!name) return 'Name is required';
if (/[\\/:*?"<>|\x00-\x1f]/.test(name)) return tr('ui.invalidCharacters', null, 'The name contains invalid characters.'); if (/[\\/:*?"<>|\x00-\x1f]/.test(name)) return 'Invalid characters in name';
if (name === '.' || name === '..' || name[0] === ' ' || name[name.length - 1] === ' ' || name[name.length - 1] === '.') return tr('ui.invalidName', null, 'Enter a valid name.'); if (name === '.' || name === '..' || name[0] === ' ' || name[name.length - 1] === ' ' || name[name.length - 1] === '.') return 'Invalid name';
return ''; return '';
} }
function cancelCreate() { function cancelCreate() {
createMode = ''; createMode = '';
if (createModal && typeof createModal.remove === 'function') createModal.remove(); setCreateError('');
else if (createModal && createModal.parentNode) createModal.parentNode.removeChild(createModal); createPanel.style.display = 'none';
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) {
@ -954,56 +863,46 @@
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(reportError('ui.createError', 'Could not create this item. Please try again.', err)); setCreateError('Error: ' + ((err && err.message) ? err.message : String(err)));
}); });
} }
function beginRename(entry) { function beginRename(entry) {
entry = entry || selectedEntry(); entry = entry || selectedEntry();
if (!entry) return; if (!entry) return;
cancelRename();
renameTarget = entry; renameTarget = entry;
renameModal = showFileFormModal('rename', { renameInput.value = entry.name;
title: tr('ui.renameTitle', { name: entry.name }, 'Rename ' + entry.name), setRenameError('');
placeholder: tr('ui.fileName', null, 'File or folder name'), renamePanel.style.display = 'flex';
value: entry.name, renameInput.focus();
confirmLabel: tr('ui.rename', null, 'Rename') renameInput.select();
});
} }
function cancelRename() { function cancelRename() {
renameTarget = null; renameTarget = null;
if (renameModal && typeof renameModal.remove === 'function') renameModal.remove(); setRenameError('');
else if (renameModal && renameModal.parentNode) renameModal.parentNode.removeChild(renameModal); renamePanel.style.display = 'none';
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 || !renameInput) return; if (!renameTarget) return;
var newName = renameInput.value.trim(); var newName = renameInput.value.trim();
if (!newName) { if (!newName || newName === renameTarget.name) {
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(tr('ui.invalidCharacters', null, 'The name contains invalid characters.')); setRenameError('Invalid characters in name');
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(tr('ui.invalidName', null, 'Enter a valid name.')); setRenameError('Invalid name');
return; return;
} }
var from = renameTarget.relativePath; var from = renameTarget.relativePath;
@ -1011,20 +910,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(tr('ui.nameDiffersOnlyByCase', null, 'The name differs only by letter case.')); setRenameError('Name differs only by case');
return; return;
} }
setRenameError(tr('ui.nameConflict', null, 'An item with that name already exists.')); setRenameError('A file 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(tr('ui.nameConflict', null, 'An item with that name already exists.')); setRenameError('A file with that name already exists');
return; return;
} }
setRenameError(reportError('ui.renameError', 'Could not rename this item. Please try again.', err)); setRenameError('Error: ' + ((err && err.message) ? err.message : String(err)));
}); });
}); });
} }
@ -1032,24 +931,14 @@
function trashEntry(entry) { function trashEntry(entry) {
var count = selectedCount(); var count = selectedCount();
if (entry) { if (entry) {
confirmModal(tr('ui.trashConfirm', { name: entry.name }, 'Move "' + entry.name + '" to trash?'), { confirmModal('Move "' + entry.name + '" to trash?', { danger: true }).then(function (ok) {
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) { }).catch(function (err) { window.alert((err && err.message) ? err.message : String(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(tr('ui.trashManyConfirm', { count: count }, 'Move ' + count + ' items to trash?'), { confirmModal('Move ' + count + ' items to trash?', { danger: true }).then(function (ok) {
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 () {
@ -1065,6 +954,14 @@
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);
@ -1094,23 +991,14 @@
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, fallbackLabels); showExternalFallback(entry, mode, 'files external-open API is unavailable.');
return; return;
} }
action(entry.relativePath).catch(function (err) { action(entry.relativePath).catch(function (err) {
console.warn('[verstak.files] external open error:', err); showExternalFallback(entry, mode, err && err.message ? err.message : err);
showExternalFallback(entry, mode, fallbackLabels);
}); });
} }
@ -1156,27 +1044,27 @@
renderList(); renderList();
} }
var isFolder = entry.type === 'folder'; var isFolder = entry.type === 'folder';
ctxMenu.appendChild(ctxItem(isFolder ? tr('ui.openFolder', null, 'Open folder') : tr('ui.open', null, 'Open'), '', function () { openEntry(entry); }, 'open', 'open')); ctxMenu.appendChild(ctxItem(isFolder ? 'Open Folder' : 'Open', '', function () { openEntry(entry); }, 'open', 'open'));
ctxMenu.appendChild(ctxItem(tr('ui.openExternal', null, 'Open externally'), '', function () { openExternalEntry(entry, 'external'); }, 'open-external', 'external')); ctxMenu.appendChild(ctxItem('Open External', '', function () { openExternalEntry(entry, 'external'); }, 'open-external', 'external'));
ctxMenu.appendChild(ctxItem(tr('ui.showInFolder', null, 'Show in folder'), '', function () { openExternalEntry(entry, 'explorer'); }, 'show-in-explorer', 'explorer')); ctxMenu.appendChild(ctxItem('Show in Explorer', '', function () { openExternalEntry(entry, 'explorer'); }, 'show-in-explorer', 'explorer'));
appendContributionMenuItems(entry); appendContributionMenuItems(entry);
ctxMenu.appendChild(ctxSep()); ctxMenu.appendChild(ctxSep());
ctxMenu.appendChild(ctxItem(tr('ui.rename', null, 'Rename'), '', function () { beginRename(entry); }, 'rename', 'rename')); ctxMenu.appendChild(ctxItem('Rename', '', function () { beginRename(entry); }, 'rename', 'rename'));
if (entry.type !== 'folder') { if (entry.type !== 'folder') {
ctxMenu.appendChild(ctxItem(tr('ui.duplicate', null, 'Duplicate'), '', function () { duplicateEntry(entry); }, 'duplicate', 'duplicate')); ctxMenu.appendChild(ctxItem('Duplicate', '', function () { duplicateEntry(entry); }, 'duplicate', 'duplicate'));
} }
ctxMenu.appendChild(ctxSep()); ctxMenu.appendChild(ctxSep());
ctxMenu.appendChild(ctxItem(tr('ui.cut', null, 'Cut'), '', function () { cutSelection(); }, 'cut', 'cut')); ctxMenu.appendChild(ctxItem('Cut', '', function () { cutSelection(); }, 'cut', 'cut'));
ctxMenu.appendChild(ctxItem(tr('ui.copy', null, 'Copy'), '', function () { copySelection(); }, 'copy', 'copy')); ctxMenu.appendChild(ctxItem('Copy', '', function () { copySelection(); }, 'copy', 'copy'));
ctxMenu.appendChild(ctxSep()); ctxMenu.appendChild(ctxSep());
ctxMenu.appendChild(ctxItem(tr('ui.trash', null, 'Move to trash'), 'danger', function () { trashEntry(); }, 'trash', 'trash')); ctxMenu.appendChild(ctxItem('Move to Trash', 'danger', function () { trashEntry(); }, 'trash', 'trash'));
} else { } else {
ctxMenu.appendChild(ctxItem(tr('ui.newFolder', null, 'New folder'), '', function () { startCreate('folder'); }, 'new-folder', 'folderAdd')); ctxMenu.appendChild(ctxItem('New Folder', '', function () { startCreate('folder'); }, 'new-folder', 'folderAdd'));
ctxMenu.appendChild(ctxItem(tr('ui.newMarkdown', null, 'New Markdown file'), '', function () { startCreate('markdown'); }, 'new-markdown', 'markdownAdd')); ctxMenu.appendChild(ctxItem('New Markdown', '', function () { startCreate('markdown'); }, 'new-markdown', 'markdownAdd'));
ctxMenu.appendChild(ctxItem(tr('ui.newText', null, 'New text file'), '', function () { startCreate('text'); }, 'new-text', 'textAdd')); ctxMenu.appendChild(ctxItem('New Text', '', 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(tr('ui.paste', null, 'Paste'), '', function () { pasteEntry(); }, 'paste', 'paste')); ctxMenu.appendChild(ctxItem('Paste', '', function () { pasteEntry(); }, 'paste', 'paste'));
} }
} }
ctxMenu.style.display = 'block'; ctxMenu.style.display = 'block';
@ -1269,7 +1157,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(tr('ui.clipboardDifferentDeal', null, 'Clipboard items belong to another Deal.')); window.alert('Clipboard items belong to another workspace.');
return; return;
} }
var destinationDir = scopedPath(currentPath); var destinationDir = scopedPath(currentPath);
@ -1610,6 +1498,10 @@
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();
}); });
} }
@ -1628,8 +1520,6 @@
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": "Deal-scoped vault file explorer with create, rename, trash, filtering, sorting, and Workbench openResource integration.", "manifest.description": "Workspace-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,11 +10,7 @@
"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",
@ -26,52 +22,11 @@
"ui.column.modified": "Modified", "ui.column.modified": "Modified",
"ui.column.size": "Size", "ui.column.size": "Size",
"ui.column.actions": "Actions", "ui.column.actions": "Actions",
"ui.folderType": "Folder",
"ui.icon.folder": "Folder",
"ui.icon.markdown": "Markdown file",
"ui.icon.image": "Image file",
"ui.icon.video": "Video file",
"ui.icon.audio": "Audio file",
"ui.icon.archive": "Archive file",
"ui.icon.pdf": "PDF file",
"ui.icon.text": "Text file",
"ui.icon.document": "Document file",
"ui.icon.spreadsheet": "Spreadsheet file",
"ui.icon.presentation": "Presentation file",
"ui.icon.code": "Code file",
"ui.icon.database": "Database file",
"ui.icon.font": "Font file",
"ui.icon.config": "Config file",
"ui.icon.json": "JSON file",
"ui.icon.generic": "File",
"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": "Could not load files. Please try again.", "ui.loadFailed": "Failed to load files"
"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,11 +10,7 @@
"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": "Копировать",
@ -26,52 +22,11 @@
"ui.column.modified": "Изменён", "ui.column.modified": "Изменён",
"ui.column.size": "Размер", "ui.column.size": "Размер",
"ui.column.actions": "Действия", "ui.column.actions": "Действия",
"ui.folderType": "Папка",
"ui.icon.folder": "Папка",
"ui.icon.markdown": "Файл Markdown",
"ui.icon.image": "Изображение",
"ui.icon.video": "Видео",
"ui.icon.audio": "Аудиофайл",
"ui.icon.archive": "Архив",
"ui.icon.pdf": "PDF-файл",
"ui.icon.text": "Текстовый файл",
"ui.icon.document": "Документ",
"ui.icon.spreadsheet": "Таблица",
"ui.icon.presentation": "Презентация",
"ui.icon.code": "Исходный код",
"ui.icon.database": "База данных",
"ui.icon.font": "Шрифт",
"ui.icon.config": "Файл конфигурации",
"ui.icon.json": "JSON-файл",
"ui.icon.generic": "Файл",
"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": "Deal-scoped vault file explorer with create, rename, trash, filtering, sorting, and Workbench openResource integration.", "description": "Workspace-scoped vault file explorer with create, rename, trash, filtering, sorting, and Workbench openResource integration.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } }, "localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official", "source": "official",
"icon": "folder", "icon": "folder",

View File

@ -31,7 +31,6 @@
'.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)}',
@ -108,7 +107,7 @@
function scopeFromProps(props) { function scopeFromProps(props) {
var workspaceRoot = workspaceFromProps(props || {}); var workspaceRoot = workspaceFromProps(props || {});
if (!workspaceRoot) return { mode: 'global', key: '', label: 'All Deals', workspaceRoot: '' }; if (!workspaceRoot) return { mode: 'global', key: '', label: 'All workspaces', workspaceRoot: '' };
return { return {
mode: 'workspace', mode: 'workspace',
key: WORKLOG_PREFIX + encodeKey(workspaceRoot), key: WORKLOG_PREFIX + encodeKey(workspaceRoot),
@ -276,7 +275,6 @@
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;
@ -285,14 +283,6 @@
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' });
@ -314,23 +304,12 @@
containerEl.appendChild(listEl); containerEl.appendChild(listEl);
containerEl.appendChild(modalHost); containerEl.appendChild(modalHost);
function persist(workspaceRoot, values) { function persist() {
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();
var target = cleanWorkspace(workspaceRoot || scope.workspaceRoot); return api.settings.write(scope.key, storageEntries(entries)).catch(function (err) {
if (!target) return Promise.resolve(); statusText = tr('ui.saveError', { error: err && err.message ? err.message : String(err) }, 'Could not save journal: ' + (err && err.message ? err.message : String(err)));
return api.settings.write(WORKLOG_PREFIX + encodeKey(target), storageEntries(values || entries)).catch(function (err) { statusClass = 'error';
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;
});
}); });
} }
@ -346,7 +325,8 @@
statusText = tr('ui.aggregating', null, 'Aggregating worklogs'); statusText = tr('ui.aggregating', null, 'Aggregating worklogs');
statusClass = ''; statusClass = '';
}).catch(function (err) { }).catch(function (err) {
reportError('ui.loadError', 'Could not load journal. Please try again.', err); statusText = tr('ui.loadError', { error: err && err.message ? err.message : String(err) }, 'Could not load journal: ' + (err && err.message ? err.message : String(err)));
statusClass = 'error';
}); });
} }
return api.settings.read(scope.key).then(function (stored) { return api.settings.read(scope.key).then(function (stored) {
@ -354,7 +334,8 @@
statusText = tr('ui.ready', null, 'Ready'); statusText = tr('ui.ready', null, 'Ready');
statusClass = ''; statusClass = '';
}).catch(function (err) { }).catch(function (err) {
reportError('ui.loadError', 'Could not load journal. Please try again.', err); statusText = tr('ui.loadError', { error: err && err.message ? err.message : String(err) }, 'Could not load journal: ' + (err && err.message ? err.message : String(err)));
statusClass = 'error';
}); });
} }
@ -363,33 +344,8 @@
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;
@ -400,13 +356,6 @@
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;
@ -420,7 +369,6 @@
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 : '',
@ -432,22 +380,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: tr('ui.candidate.title', null, 'Possible journal entry') }), el('strong', { textContent: 'Possible journal entry' }),
el('div', { textContent: tr('ui.candidate.deal', { deal: candidate.workspaceRootPath }, 'Deal: ' + candidate.workspaceRootPath) }), el('div', { textContent: 'Workspace: ' + candidate.workspaceRootPath }),
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: 'Time: ' + candidateTime(candidate.startedAt) + ' - ' + candidateTime(candidate.endedAt) }),
el('div', { textContent: tr('ui.candidate.duration', { minutes: candidate.estimatedMinutes }, 'Estimated duration: ' + candidate.estimatedMinutes + ' min') }), el('div', { textContent: 'Estimated duration: ' + candidate.estimatedMinutes + ' min' }),
el('div', { textContent: tr('ui.candidate.activities', { count: candidate.activityCount }, 'Activities: ' + candidate.activityCount) }) el('div', { textContent: '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: tr('ui.candidate.linkedActivities', null, 'Linked activities') }) el('legend', { textContent: 'Linked activities' })
].concat(activityInputs.map(function (item) { ].concat(activityInputs.map(function (item) {
var detail = (item.activity.occurredAt ? candidateTime(item.activity.occurredAt) + ' · ' : '') + activityLabel(item.activity); var detail = (item.activity.occurredAt ? candidateTime(item.activity.occurredAt) + ' · ' : '') + item.activity.type + ' · ' + item.activity.activityId;
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: tr('ui.todo.title', null, 'Completed todo') }), el('strong', { textContent: 'Completed todo' }),
el('div', { textContent: tr('ui.candidate.deal', { deal: completedTodo.workspaceRootPath }, 'Deal: ' + completedTodo.workspaceRootPath) }), el('div', { textContent: 'Workspace: ' + completedTodo.workspaceRootPath }),
completedTodo.completedAt ? el('div', { textContent: tr('ui.todo.completed', { time: candidateTime(completedTodo.completedAt) }, 'Completed: ' + candidateTime(completedTodo.completedAt)) }) : null completedTodo.completedAt ? el('div', { textContent: 'Completed: ' + candidateTime(completedTodo.completedAt) }) : null
]) : null; ]) : null;
modalHost.innerHTML = ''; modalHost.innerHTML = '';
@ -463,7 +411,6 @@
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')])
@ -479,6 +426,7 @@
} }
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');
@ -486,27 +434,25 @@
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 = tr('ui.candidate.duplicate', null, 'A journal entry already references this candidate'); statusText = '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 = tr('ui.todo.duplicate', null, 'A journal entry already references this todo'); statusText = '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(workspaceRoot, formValue.date || today(), title), entryId: existingEntry ? existingEntry.entryId : entryId(scope.workspaceRoot, formValue.date || today(), title),
workspaceRootPath: workspaceRoot, workspaceRootPath: scope.workspaceRoot,
date: formValue.date || today(), date: formValue.date || today(),
title: title, title: title,
summary: formValue.summary, summary: formValue.summary,
@ -524,11 +470,10 @@
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 ? tr('ui.updated', null, 'Entry updated') : tr('ui.added', null, 'Entry added'); statusText = existingEntry ? 'Entry updated' : 'Entry added';
statusClass = ''; statusClass = '';
persist(workspaceRoot, targetEntries).then(function () { persist().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,
@ -561,9 +506,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: entryMeta(entry) }) 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-minutes', textContent: tr('ui.minutesValue', { minutes: entry.minutes }, entry.minutes + ' min') }), el('div', { className: 'journal-minutes', textContent: entry.minutes + ' min' }),
scope.mode === 'workspace' ? el('div', { className: 'journal-row-actions' }, [ scope.mode === 'workspace' ? el('div', { className: 'journal-row-actions' }, [
el('button', { className: 'journal-icon-btn', type: 'button', title: 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); } })
@ -580,12 +525,12 @@
); );
statusEl.textContent = statusText; statusEl.textContent = statusText;
statusEl.className = 'journal-status' + (statusClass ? ' ' + statusClass : ''); statusEl.className = 'journal-status' + (statusClass ? ' ' + statusClass : '');
addBtn.disabled = false; addBtn.disabled = scope.mode !== 'workspace';
renderList(); renderList();
} }
render(); render();
Promise.all([loadStored(), loadWorkspaceOptions()]).then(function () { loadStored().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": "Deal-scoped journal with user-authored entries and optional Activity links.", "manifest.description": "Workspace-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. Please try again.", "ui.saveError": "Could not save journal: {error}",
"ui.aggregating": "Aggregating worklogs", "ui.aggregating": "Aggregating worklogs",
"ui.loadError": "Could not load journal. Please try again.", "ui.loadError": "Could not load journal: {error}",
"ui.ready": "Ready", "ui.ready": "Ready",
"ui.workItem": "Work item", "ui.workItem": "Work item",
"ui.body": "Body", "ui.body": "Body",
@ -31,33 +31,5 @@
"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": "Не удалось сохранить журнал. Повторите попытку.", "ui.saveError": "Не удалось сохранить журнал: {error}",
"ui.aggregating": "Сбор записей о работе", "ui.aggregating": "Сбор записей о работе",
"ui.loadError": "Не удалось загрузить журнал. Повторите попытку.", "ui.loadError": "Не удалось загрузить журнал: {error}",
"ui.ready": "Готово", "ui.ready": "Готово",
"ui.workItem": "Выполненная работа", "ui.workItem": "Выполненная работа",
"ui.body": "Описание", "ui.body": "Описание",
@ -31,33 +31,5 @@
"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": "Deal-scoped journal with user-authored entries and optional Activity links.", "description": "Workspace-scoped journal with user-authored entries and optional Activity links.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } }, "localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official", "source": "official",
"icon": "book-open", "icon": "book-open",
@ -15,7 +15,6 @@
], ],
"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 option{background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb)}', '.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-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,16 +43,14 @@
'.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}',
@ -173,7 +171,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)) || ''; var workspaceName = workspaceRoot || (workspaceNode && (workspaceNode.name || workspaceNode.title)) || 'Workspace';
var notes = []; var notes = [];
var selectedPath = ''; var selectedPath = '';
@ -289,12 +287,23 @@
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 createModal = null; var createPanel = el('div', { className: 'notes-panel', style: { display: 'none' } });
var createInput = null; var createInput = el('input', { className: 'notes-input', 'data-notes-create-input': '', placeholder: tr('ui.noteTitle', null, 'Note title') });
var createError = null; var createConfirm = el('button', { className: 'notes-btn', textContent: tr('ui.create', null, 'Create') });
var renameModal = null; var createCancel = el('button', { className: 'notes-btn', textContent: tr('ui.cancel', null, 'Cancel') });
var renameInput = null; createPanel.appendChild(createInput);
var renameError = null; createPanel.appendChild(createConfirm);
createPanel.appendChild(createCancel);
containerEl.appendChild(createPanel);
var renamePanel = el('div', { className: 'notes-panel', style: { display: 'none' } });
var renameInput = el('input', { className: 'notes-input', 'data-notes-rename-input': '', placeholder: 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 ─────────────────────────────────────
@ -315,13 +324,6 @@
} }
} }
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...')]));
@ -333,7 +335,7 @@
renderList(); renderList();
}).catch(function (err) { }).catch(function (err) {
if (disposed) return; if (disposed) return;
renderEmpty(userFacingError('ui.loadError', 'Could not load notes. Please try again.', err)); renderEmpty('Error: ' + (err.message || err));
}); });
} }
@ -377,7 +379,8 @@
}).then(function () { }).then(function () {
if (!disposed) setStatus(action.label + ' complete', 'success'); if (!disposed) setStatus(action.label + ' complete', 'success');
}).catch(function (err) { }).catch(function (err) {
if (!disposed) setStatus(userFacingError('ui.actionError', 'Could not complete this note action. Please try again.', err), 'error'); console.error('[notes] contribution action failed:', err);
if (!disposed) setStatus('Error: ' + (err && err.message ? err.message : err), 'error');
}); });
} }
@ -486,134 +489,28 @@
}).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() {
hideCreate(); createInput.value = '';
createModal = showNoteFormModal('create'); createPanel.style.display = 'flex';
createInput.focus();
} }
function hideCreate() { function hideCreate() {
if (createModal) createModal.remove(); createPanel.style.display = 'none';
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) { if (!title) return;
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) {
setCreateError(conflictMessage(title, data.path)); showConflictModal(title, data.path, createInput);
createInput.focus();
return; return;
} }
hideCreate(); hideCreate();
@ -636,55 +533,42 @@
}).catch(function () {}); }).catch(function () {});
} }
}).catch(function (err) { }).catch(function (err) {
setCreateError(userFacingError('ui.createError', 'Could not create the note. Please try again.', err)); setStatus('Error: ' + (err.message || err), 'error');
}); });
} }
// ─── Rename ───────────────────────────────────────────── // ─── Rename ─────────────────────────────────────────────
function beginRename(note) { function beginRename(note) {
hideRename();
renameTarget = note; renameTarget = note;
renameModal = showNoteFormModal('rename', note); renameInput.value = note.title || fileName(note.path);
renamePanel.style.display = 'flex';
renameInput.focus();
renameInput.select();
} }
function hideRename() { function hideRename() {
renameTarget = null; renameTarget = null;
if (renameModal) renameModal.remove(); renamePanel.style.display = 'none';
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 || !renameInput) return; if (!renameTarget) return;
var newTitle = renameInput.value.trim(); var newTitle = renameInput.value.trim();
if (!newTitle) { if (!newTitle) return;
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) {
setRenameError(conflictMessage(newTitle, data.path)); showConflictModal(newTitle, data.path, renameInput);
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) {
setRenameError(userFacingError('ui.renameError', 'Could not rename the note. Please try again.', err)); setStatus('Error: ' + (err.message || err), 'error');
}); });
} }
@ -701,11 +585,30 @@
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(userFacingError('ui.trashError', 'Could not move the note to trash. Please try again.', err), 'error'); setStatus('Error: ' + (err.message || 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' });
@ -739,6 +642,18 @@
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 ───────────────────────────────────────────────
@ -751,6 +666,12 @@
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();
}); });
} }
@ -769,8 +690,6 @@
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,14 +1,11 @@
{ {
"manifest.name": "Notes", "manifest.name": "Notes",
"manifest.description": "Deal-scoped notes manager for Markdown files in canonical Notes folders with create, rename, trash, and Workbench integration.", "manifest.description": "Workspace-scoped notes manager for Markdown files in canonical Notes folders with create, rename, trash, and Workbench integration.",
"contributions.workspaceItems.verstak.notes.workspace.title": "Notes", "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",
@ -31,10 +28,5 @@
"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,14 +1,11 @@
{ {
"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": "Новое название",
@ -31,10 +28,5 @@
"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": "Deal-scoped notes manager for Markdown files in canonical Notes folders with create, rename, trash, and Workbench integration.", "description": "Workspace-scoped notes manager for Markdown files in canonical Notes folders with create, rename, trash, and Workbench integration.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } }, "localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official", "source": "official",
"icon": "edit", "icon": "edit",

View File

@ -277,8 +277,7 @@
try { try {
providers = await api.contributions.list('searchProviders'); providers = await api.contributions.list('searchProviders');
} catch (err) { } catch (err) {
console.warn('[verstak.search] list search providers:', err); output.errors.push(err && err.message ? err.message : String(err));
output.errors.push(true);
return output; return output;
} }
providers = Array.isArray(providers) ? providers : []; providers = Array.isArray(providers) ? providers : [];
@ -295,8 +294,7 @@
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) {
console.warn('[verstak.search] provider search:', err); output.errors.push(err && err.message ? err.message : String(err));
output.errors.push(true);
} }
} }
return output; return output;
@ -373,9 +371,7 @@
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.map(function () { el('div', {}, [state.providerErrors.join('; ')])
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');
@ -464,9 +460,8 @@
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 = tr('ui.searchError', null, 'Could not search the vault. Please try again.'); state.error = err && err.message ? err.message : String(err);
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": "Deal-scoped vault text search provider.", "manifest.description": "Workspace-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,8 +10,6 @@
"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,8 +10,6 @@
"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": "Deal-scoped vault text search provider.", "description": "Workspace-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,7 +23,6 @@
'.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}',
@ -45,11 +44,10 @@
'.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,.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-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-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{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,.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-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-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}',
@ -60,12 +58,7 @@
'.secrets-table th{width:9rem;color:var(--vt-color-text-muted,#7f8aa3);font-weight:500;background:var(--vt-color-surface-muted,#111629)}', '.secrets-table 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}',
'.secrets-modal-overlay{position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.6)}', '@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{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) {
@ -102,30 +95,29 @@
|| (node && (node.rootPath || node.name || node.id))); || (node && (node.rootPath || node.name || node.id)));
} }
function scopeLabel(record, translate) { function scopeLabel(record) {
var scope = record && record.scope || {}; var scope = record && record.scope || {};
if (scope.kind === ScopeWorkspace) return cleanWorkspace(scope.workspaceRootPath) || translate('ui.deal', null, 'Deal'); if (scope.kind === ScopeWorkspace) return cleanWorkspace(scope.workspaceRootPath) || 'Workspace';
return translate('ui.global', null, 'Global'); return 'Global';
} }
function selectedIDFromProps(props) { function selectedIDFromProps(props) {
var resource = props && props.resource || {}; var resource = props && props.resource || {};
var request = props && props.request || {}; var path = text(resource.path || props && props.secretId);
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, translate) { function groupRecords(records) {
var groups = {}; var groups = {};
records.forEach(function (record) { records.forEach(function (record) {
var label = scopeLabel(record, translate); var label = scopeLabel(record);
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 === translate('ui.global', null, 'Global')) return -1; if (a === 'Global') return -1;
if (b === translate('ui.global', null, 'Global')) return 1; if (b === '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) {
@ -152,13 +144,8 @@
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 = '';
@ -174,92 +161,6 @@
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',
@ -289,7 +190,7 @@
return loadRecords(); return loadRecords();
}).catch(function (err) { }).catch(function (err) {
unlockBtn.disabled = false; unlockBtn.disabled = false;
setUserFacingError('unlock', err); setStatus((err && err.message) ? err.message : String(err), true);
}); });
} }
}, [tr('ui.unlock', null, 'Unlock')]); }, [tr('ui.unlock', null, 'Unlock')]);
@ -325,48 +226,19 @@
} }
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(visibleRecords.length)]), el('span', { className: 'secrets-count' }, [String(records.length)]),
el('span', { className: 'secrets-spacer' }), el('span', { className: 'secrets-spacer' }),
el('button', { className: 'secrets-btn', type: 'button', onClick: showNewSecret }, [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 (!visibleRecords.length) { if (!records.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(visibleRecords, tr).forEach(function (group) { groupRecords(records).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;
@ -387,19 +259,16 @@
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, tr)), fieldRow(tr('ui.group', null, 'Group'), scopeLabel(selectedRecord)),
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'), valueDisplay, isValueVisible && valueIsAvailable ? 'secrets-secret-value' : 'secrets-hidden-value'), fieldRow(tr('ui.password', null, 'Password'), selectedValue ? selectedValue : '••••••••••••', selectedValue ? '' : 'secrets-hidden-value'),
fieldRow(tr('ui.updated', null, 'Updated'), selectedRecord.updatedAt || '') fieldRow(tr('ui.updated', null, 'Updated'), selectedRecord.updatedAt || '')
]) ])
]), ]),
@ -410,24 +279,6 @@
'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',
@ -465,26 +316,9 @@
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 }, [tr('ui.deal', null, 'Deal')]) el('option', { value: ScopeWorkspace }, [workspaceRoot || tr('ui.workspace', null, 'Workspace')])
]); ]);
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' }, [
@ -492,7 +326,6 @@
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', {
@ -501,24 +334,19 @@
'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: targetWorkspace } : { kind: ScopeGlobal } scope: scope.value === ScopeWorkspace ? { kind: ScopeWorkspace, workspaceRootPath: workspaceRoot } : { 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) {
setUserFacingError('save', err); setStatus((err && err.message) ? err.message : String(err), true);
}); });
} }
}, [tr('ui.save', null, 'Save')]), }, [tr('ui.save', null, 'Save')]),
@ -553,20 +381,11 @@
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);
}); });
} }
@ -575,7 +394,6 @@
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) {
@ -585,7 +403,7 @@
render(); render();
}).catch(function (err) { }).catch(function (err) {
if (disposed) return; if (disposed) return;
setUserFacingError('read', err); setStatus((err && err.message) ? err.message : String(err), true);
}); });
} }
@ -605,68 +423,14 @@
} }
function deleteSecret(id) { function deleteSecret(id) {
if (!id) return; if (!id || !window.confirm(tr('ui.deleteConfirm', null, 'Delete this secret?'))) 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) {
setUserFacingError('delete', err); setStatus((err && err.message) ? err.message : String(err), true);
});
}
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);
}); });
} }
@ -676,17 +440,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) {
setUserFacingError('copyLink', err); setStatus((err && err.message) ? err.message : String(err), true);
}); });
} }
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 loadWorkspaceOptions().then(loadRecords); if (unlocked) return loadRecords();
render(); render();
}).catch(function (err) { }).catch(function (err) {
statusText = userFacingError('status', err); statusText = (err && err.message) ? err.message : String(err);
statusError = true; statusError = true;
renderLocked(); renderLocked();
}); });
@ -697,7 +461,6 @@
containerEl.__secretsCleanup = function () { containerEl.__secretsCleanup = function () {
disposed = true; disposed = true;
removeDeleteModal();
if (typeof localeUnsubscribe === 'function') localeUnsubscribe(); if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
}; };
}, },

View File

@ -1,17 +1,12 @@
{ {
"manifest.name": "Secrets", "manifest.name": "Secrets",
"manifest.description": "Encrypted global and Deal-scoped secret manager.", "manifest.description": "Encrypted global and workspace-scoped secret manager.",
"contributions.views.verstak.secrets.view.title": "Secrets",
"contributions.sidebarItems.verstak.secrets.sidebar.title": "Secrets",
"contributions.openProviders.verstak.secrets.secret.title": "Secrets", "contributions.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",
@ -21,42 +16,23 @@
"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": "Deal", "ui.workspace": "Workspace",
"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.deleteSecretTitle": "Delete secret", "ui.deleteConfirm": "Delete this secret?",
"ui.deleteConfirm": "Delete \"{title}\"? This cannot be undone.", "ui.linkCopied": "Secret link copied"
"ui.linkCopied": "Secret link copied",
"ui.valueCopied": "Secret value copied"
} }

View File

@ -1,17 +1,12 @@
{ {
"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": "Пароль",
@ -21,42 +16,23 @@
"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.deleteSecretTitle": "Удалить секрет", "ui.deleteConfirm": "Удалить этот секрет?",
"ui.deleteConfirm": "Удалить «{title}»? Это действие нельзя отменить.", "ui.linkCopied": "Ссылка на секрет скопирована"
"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 Deal-scoped secret manager.", "description": "Encrypted global and workspace-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,7 +14,6 @@
"secrets.write-ui" "secrets.write-ui"
], ],
"permissions": [ "permissions": [
"files.read",
"secrets.read", "secrets.read",
"secrets.write", "secrets.write",
"ui.register" "ui.register"
@ -23,23 +22,6 @@
"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,9 +28,11 @@
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 reportError(key, fallback, error) { function sanitizeError(msg) {
console.warn('[verstak.sync] operation failed:', error) if (!msg) return tr('ui.unknownError', null, 'Unknown error')
return tr(key, null, fallback) let s = String(msg).replace(/<[^>]+>/g, '')
if (s.length > 200) s = s.substring(0, 200) + '...'
return s
} }
function syncAPI() { function syncAPI() {
@ -49,21 +51,15 @@
syncInterval = saved.syncInterval || 5 syncInterval = saved.syncInterval || 5
} }
} }
} catch (error) { } catch (_) {}
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()
@ -84,7 +80,7 @@
resultMsg = tr('ui.settingsSaved', null, 'Settings saved.') resultMsg = tr('ui.settingsSaved', null, 'Settings saved.')
resultKind = '' resultKind = ''
} catch (e) { } catch (e) {
errorMsg = reportError('ui.saveFailed', 'Could not save sync settings. Please try again.', e) errorMsg = sanitizeError(e.message || e)
} }
loading = false loading = false
} }
@ -101,7 +97,7 @@
connectionResult = tr('ui.connectionSuccessful', null, 'Connection successful.') connectionResult = tr('ui.connectionSuccessful', null, 'Connection successful.')
} catch (e) { } catch (e) {
connectionOk = false connectionOk = false
connectionResult = reportError('ui.connectionFailed', 'Could not connect. Check the server address and credentials.', e) connectionResult = tr('ui.connectionFailed', { error: sanitizeError(e.message || e) }, 'Connection failed: {error}')
} }
loading = false loading = false
} }
@ -119,7 +115,7 @@
password = '' password = ''
await load() await load()
} catch (e) { } catch (e) {
errorMsg = reportError('ui.connectFailed', 'Could not connect this device. Please try again.', e) errorMsg = sanitizeError(e.message || e)
} }
loading = false loading = false
} }
@ -133,8 +129,23 @@
return parts.join(' · ') return parts.join(' · ')
} }
function formatSyncConflict() { function conflictField(conflict, keys) {
return tr('ui.syncConflictItem', null, 'A synchronization conflict needs attention.') for (const key of keys) {
const value = conflict && conflict[key]
if (value != null && String(value).trim()) return String(value)
}
return ''
}
function formatSyncConflict(conflict) {
const entityType = conflictField(conflict, ['entity_type', 'entityType']) || 'item'
const entityId = conflictField(conflict, ['entity_id', 'entityId', 'path']) || 'unknown'
const opId = conflictField(conflict, ['op_id', 'opId'])
const reason = conflictField(conflict, ['reason', 'message'])
const parts = [entityType + ': ' + entityId]
if (opId) parts.push('op ' + opId)
if (reason) parts.push(reason)
return parts.join(' · ')
} }
async function runSyncNow() { async function runSyncNow() {
@ -152,7 +163,7 @@
resultKind = warning ? 'warning' : '' resultKind = warning ? 'warning' : ''
await load() await load()
} catch (e) { } catch (e) {
errorMsg = reportError('ui.syncFailed', 'Could not synchronize. Please try again.', e) errorMsg = sanitizeError(e.message || e)
} }
loading = false loading = false
} }
@ -183,7 +194,7 @@
settings = null settings = null
await load() await load()
} catch (e) { } catch (e) {
errorMsg = reportError('ui.disconnectFailed', 'Could not disconnect from the server. Please try again.', e) errorMsg = sanitizeError(e.message || e)
} }
loading = false loading = false
} }
@ -198,7 +209,7 @@
resultKind = '' resultKind = ''
await load() await load()
} catch (e) { } catch (e) {
errorMsg = reportError('ui.resetKeyFailed', 'Could not reset the sync key. Please try again.', e) errorMsg = sanitizeError(e.message || e)
} }
loading = false loading = false
} }
@ -232,7 +243,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', null, 'The last synchronization did not finish. Try again.')} {tr('ui.lastSyncError', { error: sanitizeError(settings.lastError) }, 'Last sync error: {error}')}
</div> </div>
{/if} {/if}

View File

@ -5,17 +5,12 @@
"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": "Synchronization is unavailable right now.", "ui.apiUnavailable": "Plugin API sync namespace not available",
"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": "Could not connect. Check the server address and credentials.", "ui.connectionFailed": "Connection failed: {error}",
"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)",
@ -23,8 +18,7 @@
"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": "The last synchronization did not finish. Try again.", "ui.lastSyncError": "Last sync error: {error}",
"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,17 +5,12 @@
"ui.title": "Синхронизация", "ui.title": "Синхронизация",
"ui.description": "Синхронизируйте хранилище между устройствами.", "ui.description": "Синхронизируйте хранилище между устройствами.",
"ui.unknownError": "Неизвестная ошибка", "ui.unknownError": "Неизвестная ошибка",
"ui.apiUnavailable": "Синхронизация сейчас недоступна.", "ui.apiUnavailable": "API синхронизации плагина недоступен",
"ui.intervalError": "Интервал синхронизации должен быть от 1 до 1440 минут.", "ui.intervalError": "Интервал синхронизации должен быть от 1 до 1440 минут.",
"ui.settingsSaved": "Настройки сохранены.", "ui.settingsSaved": "Настройки сохранены.",
"ui.serverRequired": "Укажите URL сервера.", "ui.serverRequired": "Укажите URL сервера.",
"ui.connectionSuccessful": "Подключение успешно.", "ui.connectionSuccessful": "Подключение успешно.",
"ui.connectionFailed": "Не удалось подключиться. Проверьте адрес сервера и учётные данные.", "ui.connectionFailed": "Не удалось подключиться: {error}",
"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}",
@ -23,8 +18,7 @@
"ui.disconnected": "Сервер отключён.", "ui.disconnected": "Сервер отключён.",
"ui.keyReset": "Ключ синхронизации сброшен. Подключитесь снова, чтобы связать устройство.", "ui.keyReset": "Ключ синхронизации сброшен. Подключитесь снова, чтобы связать устройство.",
"ui.syncConflicts": "Конфликты синхронизации", "ui.syncConflicts": "Конфликты синхронизации",
"ui.lastSyncError": "Последняя синхронизация не завершилась. Повторите попытку.", "ui.lastSyncError": "Ошибка последней синхронизации: {error}",
"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);color-scheme:dark;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);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-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-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-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 Deals' }; : { mode: 'global', workspaceRoot: '', label: 'All workspaces' };
} }
function now() { function now() {
@ -96,28 +96,7 @@
} }
function cleanDate(value) { function cleanDate(value) {
value = text(value).trim(); return /^\d{4}-\d{2}-\d{2}$/.test(text(value).trim()) ? 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) {
@ -127,27 +106,6 @@
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, '-');
} }
@ -155,8 +113,6 @@
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 {
@ -168,8 +124,7 @@
status: status, status: status,
priority: cleanPriority(value.priority), priority: cleanPriority(value.priority),
dueAt: cleanDate(value.dueAt || value.dueDate), dueAt: cleanDate(value.dueAt || value.dueDate),
reminderDate: cleanDate(value.reminderDate) || reminder.date, reminderAt: cleanDateTime(value.reminderAt || value.reminderDateTime),
reminderAt: reminderAt,
createdAt: createdAt, createdAt: createdAt,
updatedAt: text(value.updatedAt).trim() || createdAt, updatedAt: text(value.updatedAt).trim() || createdAt,
completedAt: completedAt, completedAt: completedAt,
@ -198,7 +153,6 @@
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,
@ -360,7 +314,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 Deals'))); workspaceFilterEl.appendChild(option('', tr('ui.allWorkspaces', null, 'All workspaces')));
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));
@ -402,18 +356,11 @@
}).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) {
reportError('ui.notificationError', 'Could not schedule reminders. Please try again.', err); statusText = tr('ui.notificationError', { error: err && err.message ? err.message : String(err) }, 'Could not schedule reminders: ' + (err && err.message ? err.message : String(err)));
statusClass = 'error';
}); });
} }
@ -422,7 +369,8 @@
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) {
reportError('ui.saveError', 'Could not save tasks. Please try again.', err); statusText = tr('ui.saveError', { error: err && err.message ? err.message : String(err) }, 'Could not save todos: ' + (err && err.message ? err.message : String(err)));
statusClass = 'error';
}); });
} }
@ -432,7 +380,8 @@
todos = sortTodos(normalizeTodos((settings || {})[GLOBAL_KEY])); todos = sortTodos(normalizeTodos((settings || {})[GLOBAL_KEY]));
return syncNotifications(); return syncNotifications();
}).catch(function (err) { }).catch(function (err) {
reportError('ui.loadError', 'Could not load tasks. Please try again.', err); statusText = tr('ui.loadError', { error: err && err.message ? err.message : String(err) }, 'Could not load todos: ' + (err && err.message ? err.message : String(err)));
statusClass = 'error';
}); });
} }
@ -464,9 +413,7 @@
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 reminder = splitReminderDateTime(editing ? existingTodo.reminderAt : ''); var reminderInput = el('input', { className: 'todo-input', type: 'datetime-local', value: editing ? existingTodo.reminderAt : '', 'data-todo-input': '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') {
@ -487,20 +434,6 @@
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),
@ -511,8 +444,7 @@
status: editing ? existingTodo.status : 'open', status: editing ? existingTodo.status : 'open',
priority: priorityInput.value, priority: priorityInput.value,
dueAt: dueInput.value, dueAt: dueInput.value,
reminderDate: reminderDate, reminderAt: reminderInput.value,
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 : '',
@ -537,11 +469,10 @@
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.reminderDate', null, 'Reminder date'), reminderDateInput]), el('label', { className: 'todo-field' }, [tr('ui.field.reminder', null, 'Reminder'), reminderInput])
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, 'Deal'), workspaceInput])); if (workspaceInput) fields.push(el('label', { className: 'todo-field' }, [tr('ui.field.workspace', null, 'Workspace'), workspaceInput]));
else fields.push(el('div', { className: 'todo-field', textContent: tr('ui.workspaceValue', { workspace: scope.workspaceRoot }, 'Deal: ' + scope.workspaceRoot) })); else fields.push(el('div', { className: 'todo-field', textContent: tr('ui.workspaceValue', { workspace: scope.workspaceRoot }, 'Workspace: ' + scope.workspaceRoot) }));
modalHost.innerHTML = ''; modalHost.innerHTML = '';
if (typeof modalHost.removeAttribute === 'function') modalHost.removeAttribute('hidden'); if (typeof modalHost.removeAttribute === 'function') modalHost.removeAttribute('hidden');
@ -619,7 +550,6 @@
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);
} }
@ -636,7 +566,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 Deal'), onClick: function () { openWorkspace(todo); } })); actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'open-workspace', textContent: tr('ui.openWorkspace', null, 'Open workspace'), onClick: function () { openWorkspace(todo); } }));
} }
if (todo.status === 'open') { if (todo.status === 'open') {
actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'mark-done', textContent: 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 Deal-scoped todo tracking with due and reminder metadata.", "manifest.description": "Global and workspace 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 Deals", "ui.allWorkspaces": "All workspaces",
"ui.unassigned": "Unassigned", "ui.unassigned": "Unassigned",
"ui.saveError": "Could not save tasks. Please try again.", "ui.saveError": "Could not save todos: {error}",
"ui.loadError": "Could not load tasks. Please try again.", "ui.loadError": "Could not load todos: {error}",
"ui.notificationError": "Could not schedule reminders. Please try again.", "ui.notificationError": "Could not schedule reminders: {error}",
"ui.notificationTitle": "Todo reminder", "ui.notificationTitle": "Todo reminder",
"ui.notificationBody": "{title}", "ui.notificationBody": "{title}",
"ui.titlePlaceholder": "Todo title", "ui.titlePlaceholder": "Todo title",
@ -35,13 +35,8 @@
"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.reminderDate": "Reminder date", "ui.field.workspace": "Workspace",
"ui.field.reminderTime": "Reminder time", "ui.workspaceValue": "Workspace: {workspace}",
"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",
@ -55,10 +50,9 @@
"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 Deal", "ui.openWorkspace": "Open workspace",
"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": "Не удалось сохранить задачи. Повторите попытку.", "ui.saveError": "Не удалось сохранить задачи: {error}",
"ui.loadError": "Не удалось загрузить задачи. Повторите попытку.", "ui.loadError": "Не удалось загрузить задачи: {error}",
"ui.notificationError": "Не удалось запланировать напоминания. Повторите попытку.", "ui.notificationError": "Не удалось запланировать напоминания: {error}",
"ui.notificationTitle": "Напоминание о задаче", "ui.notificationTitle": "Напоминание о задаче",
"ui.notificationBody": "{title}", "ui.notificationBody": "{title}",
"ui.titlePlaceholder": "Название задачи", "ui.titlePlaceholder": "Название задачи",
@ -35,13 +35,8 @@
"ui.field.priority": "Приоритет", "ui.field.priority": "Приоритет",
"ui.field.due": "Срок", "ui.field.due": "Срок",
"ui.field.reminder": "Напоминание", "ui.field.reminder": "Напоминание",
"ui.field.reminderDate": "Дата напоминания", "ui.field.workspace": "Рабочее пространство",
"ui.field.reminderTime": "Время напоминания", "ui.workspaceValue": "Рабочее пространство: {workspace}",
"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": "Сохранить изменения",
@ -55,10 +50,9 @@
"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 Deal-scoped todo tracking with due and reminder metadata.", "description": "Global and workspace todo tracking with due and reminder metadata.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } }, "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;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-select{max-width:11rem}',
'.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 Deals')])]); }, [el('option', { value: '' }, [tr('ui.allWorkspaces', null, 'All workspaces')])]);
workspaceOptions().forEach(function (workspace) { workspaceOptions().forEach(function (workspace) {
workspaceSelect.appendChild(el('option', { value: workspace }, [workspace])); workspaceSelect.appendChild(el('option', { value: workspace }, [workspace]));
}); });
@ -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, 'Deal')]), el('span', {}, [tr('ui.workspace', null, 'Workspace')]),
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 Deals", "ui.allWorkspaces": "All workspaces",
"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": "Deal", "ui.workspace": "Workspace",
"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

@ -1,7 +0,0 @@
## Highlights
- First public bundle of Verstak's official plugins.
- Includes Files, Notes, Journal, Todo, Secrets, Browser Inbox, Activity, Search, Trash, Sync, and supporting editors and previews.
- Adds durable browser captures, archive handling, activity sessions, and localized plugin interfaces.
**Commit history**: https://github.com/mirivlad/verstak-official-plugins/commits/v0.1.0-alpha.1

View File

@ -1,5 +0,0 @@
## Highlights
- Todo tasks can schedule native reminders through the Desktop notification capability.
**Full Changelog**: https://github.com/mirivlad/verstak-official-plugins/compare/v0.1.0-alpha.1...v0.1.0-alpha.2

View File

@ -1,7 +0,0 @@
## Highlights
- Makes Todo reminder time selection, global Secrets, and global Journal entry creation usable.
- Localizes plugin errors, browser materials, activity review, files menus, and other key workflows.
- Replaces technical terminology with “Deal” and improves modal forms and secret safety.
**Full Changelog**: https://github.com/mirivlad/verstak-official-plugins/compare/v0.1.0-alpha.2...v0.1.0-alpha.3

View File

@ -1,6 +0,0 @@
## Highlights
- Folder rows in the Files plugin now use localized labels instead of the internal `folder` type.
- File icons have localized, accessible names in both supported languages.
Install the archive matching your platform into Verstak's plugins directory, replacing the previous official plugins bundle.

View File

@ -1,35 +0,0 @@
#!/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

@ -1,51 +0,0 @@
#!/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,30 +89,6 @@ 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

@ -6,7 +6,6 @@ VERSION="${1:-}"
REPOSITORY="mirivlad/verstak-official-plugins" REPOSITORY="mirivlad/verstak-official-plugins"
RELEASE_SCRIPT="${VERSTAK_RELEASE_SCRIPT:-$ROOT/scripts/release.sh}" RELEASE_SCRIPT="${VERSTAK_RELEASE_SCRIPT:-$ROOT/scripts/release.sh}"
RELEASE_DIR="${VERSTAK_RELEASE_DIR:-$ROOT/release}" RELEASE_DIR="${VERSTAK_RELEASE_DIR:-$ROOT/release}"
RELEASE_NOTES_DIR="${VERSTAK_RELEASE_NOTES_DIR:-$ROOT/release-notes}"
GIT_BIN="${GIT_BIN:-git}" GIT_BIN="${GIT_BIN:-git}"
GH_BIN="${GH_BIN:-gh}" GH_BIN="${GH_BIN:-gh}"
@ -65,17 +64,7 @@ fi
if "$GH_BIN" release view "$VERSION" --repo "$REPOSITORY" >/dev/null 2>&1; then if "$GH_BIN" release view "$VERSION" --repo "$REPOSITORY" >/dev/null 2>&1; then
"$GH_BIN" release upload "$VERSION" "${ASSETS[@]}" --repo "$REPOSITORY" --clobber "$GH_BIN" release upload "$VERSION" "${ASSETS[@]}" --repo "$REPOSITORY" --clobber
else else
NOTES_FILE="$RELEASE_NOTES_DIR/$VERSION.md" RELEASE_OPTIONS=(--generate-notes --verify-tag)
if [[ ! -s "$NOTES_FILE" ]]; then
echo "human-readable release notes are required: $NOTES_FILE" >&2
exit 1
fi
RELEASE_OPTIONS=(--notes-file "$NOTES_FILE" --generate-notes --verify-tag)
PREVIOUS_TAG="$("$GIT_BIN" describe --tags --abbrev=0 "${HEAD}^" 2>/dev/null || true)"
if [[ -n "$PREVIOUS_TAG" ]]; then
RELEASE_OPTIONS+=(--notes-start-tag "$PREVIOUS_TAG")
fi
if [[ "$VERSION" == *-* ]]; then if [[ "$VERSION" == *-* ]]; then
RELEASE_OPTIONS+=(--prerelease) RELEASE_OPTIONS+=(--prerelease)
else else

View File

@ -280,16 +280,13 @@ 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('Selection captured')) throw new Error('browser capture label was not rendered'); if (!container.textContent.includes('browser.capture.selection')) throw new Error('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.converted')) throw new Error('conversion event type 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('Deal: Project')) throw new Error('candidate Deal was not rendered'); if (!candidateNode.textContent.includes('Workspace: Project')) throw new Error('candidate workspace 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');
@ -364,13 +361,6 @@ 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');
@ -541,13 +531,6 @@ 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,11 +6,6 @@ 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) {
@ -118,15 +113,7 @@ 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: {
@ -149,7 +136,7 @@ function loadComponent(document) {
return component; return component;
} }
function makeApi(initialSettings = {}, locale = 'en') { function makeApi(initialSettings = {}) {
const settings = { ...initialSettings }; const settings = { ...initialSettings };
const handlers = {}; const handlers = {};
const unsubscribed = []; const unsubscribed = [];
@ -166,13 +153,6 @@ function makeApi(initialSettings = {}, locale = 'en') {
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();
@ -225,11 +205,6 @@ function makeApi(initialSettings = {}, locale = 'en') {
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');
}, },
@ -323,18 +298,6 @@ 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') === '');
@ -775,12 +738,9 @@ 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 the note. Please try again.')) { if (!failedConversionView.container.textContent.includes('Could not create note')) {
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');
} }
@ -867,7 +827,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 the link. Please try again.')) { if (!failedLinkView.container.textContent.includes('Could not create link')) {
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')) {
@ -974,7 +934,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 the file. Please try again.')) { if (!failedFileView.container.textContent.includes('Could not create file')) {
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')) {
@ -982,10 +942,6 @@ 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, translations) { async function mountEditor(secretProviderEnabled) {
const document = makeDocument(); const document = makeDocument();
const component = loadComponent(document); const component = loadComponent(document);
const opened = []; const opened = [];
@ -146,15 +146,6 @@ async function mountEditor(secretProviderEnabled, translations) {
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, {
@ -171,12 +162,6 @@ async function mountEditor(secretProviderEnabled, translations) {
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');
@ -197,12 +182,6 @@ async function mountEditor(secretProviderEnabled, translations) {
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,11 +195,9 @@ function loadFilesComponent(document) {
return { component, clipboard: sandbox.navigator.clipboard }; return { component, clipboard: sandbox.navigator.clipboard };
} }
function makeApi(options = {}) { function makeApi() {
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;
@ -214,8 +212,6 @@ function makeApi(options = {}) {
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',
@ -233,15 +229,6 @@ function makeApi(options = {}) {
size: 12, size: 12,
modifiedAt: '2026-06-27T00:00:00Z', modifiedAt: '2026-06-27T00:00:00Z',
}]; }];
if (options.includeFolder) {
entries.push({
name: 'Archive',
relativePath: 'Docs/Archive',
type: 'folder',
size: 0,
modifiedAt: '2026-06-27T00:00:00Z',
});
}
if (restored) { if (restored) {
entries.push({ entries.push({
name: 'deleted.md', name: 'deleted.md',
@ -266,15 +253,9 @@ function makeApi(options = {}) {
}, },
metadata: async () => { throw new Error('not-found'); }, metadata: async () => { throw new Error('not-found'); },
readText: async () => '# Readme\n', readText: async () => '# Readme\n',
writeText: async (relativePath, content, options) => { writeText: async () => undefined,
created.push({ type: 'file', relativePath, content, options }); createFolder: async () => undefined,
}, 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) => {
@ -326,15 +307,6 @@ function makeApi(options = {}) {
}; };
}, },
}, },
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;
}, },
@ -356,62 +328,6 @@ 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 });
@ -462,41 +378,6 @@ 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({
includeFolder: true,
translations: {
'ui.open': 'Открыть',
'ui.openExternal': 'Открыть во внешнем приложении',
'ui.showInFolder': 'Показать в папке',
'ui.rename': 'Переименовать',
'ui.duplicate': 'Создать копию',
'ui.cut': 'Вырезать',
'ui.copy': 'Копировать',
'ui.trash': 'Переместить в корзину',
'ui.folderType': 'Папка',
'ui.icon.markdown': 'Файл Markdown',
},
});
const russianContainer = new FakeNode('div');
russianComponent.mount(russianContainer, {}, russianApi);
await flush();
if (!russianContainer.textContent.includes('Папка') || russianContainer.textContent.includes('folder')) {
throw new Error(`folder type was not localized: ${russianContainer.textContent}`);
}
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');
const russianIcon = walk(russianRow, (node) => node.getAttribute && node.getAttribute('data-file-icon') === 'markdown');
if (!russianIcon || russianIcon.getAttribute('title') !== 'Файл Markdown' || russianIcon.getAttribute('aria-label') !== 'Файл Markdown') {
throw new Error(`file icon label was not localized: ${russianIcon && russianIcon.getAttribute('title')}`);
}
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,14 +6,12 @@ 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) {
@ -133,7 +131,7 @@ function loadComponent(document) {
return component; return component;
} }
function makeApi(initialSettings = {}, locale = null) { function makeApi(initialSettings = {}) {
const settings = { ...initialSettings }; const settings = { ...initialSettings };
const publishedEvents = []; const publishedEvents = [];
return { return {
@ -150,17 +148,6 @@ function makeApi(initialSettings = {}, locale = null) {
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] || [];
}, },
@ -252,11 +239,8 @@ 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('Deal: Project')) throw new Error('candidate Deal was not shown for review'); if (!candidateView.container.textContent.includes('Workspace: Project')) throw new Error('candidate workspace 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');
@ -283,15 +267,6 @@ 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');
@ -350,26 +325,9 @@ 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,19 +264,12 @@ 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 createModal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-notes-create-modal') !== undefined); const input = walk(container, (node) => node.getAttribute && node.getAttribute('data-notes-create-input') !== 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');
const confirm = walk(createModal, (node) => node.tagName === 'BUTTON' && node.textContent === 'Create'); input.value = 'First Note';
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');
@ -326,25 +319,21 @@ 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 renameModal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-notes-rename-modal') !== undefined); const renameInput = walk(container, (node) => node.getAttribute && node.getAttribute('data-notes-rename-input') !== 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';
renameInput.dispatchEvent('keydown', { key: 'Enter' }); const renameConfirm = walk(container, (node) => node.tagName === 'BUTTON' && node.textContent === 'Rename');
if (!renameConfirm) throw new Error('rename confirm button not found');
renameConfirm.click();
await flush(); await flush();
const renameError = walk(renameModal, (node) => node.getAttribute && node.getAttribute('data-notes-rename-error') !== undefined); const conflictModal = walk(document.body, (node) => node.className === 'notes-modal-msg');
if (!renameError || !renameError.textContent.includes('Project/Notes/Second_Note.md')) { if (!conflictModal || !conflictModal.textContent.includes('Project/Notes/Second_Note.md')) {
throw new Error(`rename conflict should stay in the form modal, got ${renameError && renameError.textContent}`); throw new Error(`rename conflict modal should include existing path, got ${conflictModal && conflictModal.textContent}`);
} }
if (walk(document.body, (node) => node.className === 'notes-modal-msg')) { const conflictOk = walk(document.body, (node) => node.tagName === 'BUTTON' && node.textContent === 'OK');
throw new Error('rename conflict opened a second modal instead of reporting in the form'); if (!conflictOk) throw new Error('rename conflict OK button not found');
} 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,7 +8,6 @@ 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) {
@ -101,15 +100,7 @@ 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) {
@ -262,13 +253,11 @@ 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('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 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,16 +28,6 @@ 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);
} }
@ -106,18 +96,10 @@ function makeDocument() {
}; };
} }
function loadComponent(document, errorLog) { function loadComponent(document) {
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) {
@ -147,24 +129,19 @@ 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 errorLog = []; const component = loadComponent(document);
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 = [];
@ -172,7 +149,6 @@ 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;
@ -201,12 +177,6 @@ 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');
@ -218,21 +188,6 @@ 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();
@ -245,17 +200,7 @@ 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 must stay hidden until the user explicitly reveals it'); if (!container.textContent.includes('secret-value')) throw new Error('secret value was not shown in the field table');
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');
@ -281,69 +226,8 @@ 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,17 +9,14 @@ 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('function reportError')) { if (!source.includes('sanitizeError(settings.lastError)')) {
throw new Error('SyncSettings must map technical failures to localized action-specific errors'); throw new Error('SyncSettings must sanitize persisted sync errors before rendering');
} }
if (/sanitizeError\(/.test(source) || /\{ error: sanitizeError/.test(source)) { if (!source.includes('Last sync error')) {
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("tr('ui.syncConflictItem'")) { if (!source.includes('function formatSyncConflict')) {
throw new Error('SyncSettings must hide technical conflict identifiers behind a user-facing summary'); throw new Error('SyncSettings must format individual sync conflicts');
} }
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,13 +231,8 @@ 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 = '01/02/2000'; byData(container, 'data-todo-input', 'dueAt').value = '2000-01-01';
const reminderDate = byData(container, 'data-todo-input', 'reminderDate'); byData(container, 'data-todo-input', 'reminderAt').value = '2000-01-01T09:00';
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();
@ -246,7 +241,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-02' || createdTodo.reminderDate !== '2000-01-02' || createdTodo.reminderAt !== '2000-01-02T09:30') throw new Error('Todo due/reminder metadata was not stored'); if (createdTodo.dueAt !== '2000-01-01' || createdTodo.reminderAt !== '2000-01-01T09:00') throw new Error('Todo due/reminder metadata was not stored');
if (!container.textContent.includes('Overdue') || !container.textContent.includes('Reminder due')) throw new Error('due/reminder indicators were not rendered'); if (!container.textContent.includes('Overdue') || !container.textContent.includes('Reminder due')) throw new Error('due/reminder indicators were not rendered');
const scheduledAfterCreate = apiState.notificationCalls.at(-1) || []; const scheduledAfterCreate = apiState.notificationCalls.at(-1) || [];
if (scheduledAfterCreate.length !== 1 if (scheduledAfterCreate.length !== 1
@ -261,32 +256,14 @@ 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();
@ -329,9 +306,6 @@ 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,10 +5,8 @@ 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) {
@ -161,7 +159,7 @@ function loadTrashComponent(document) {
return component; return component;
} }
function makeApi(locale = null) { function makeApi() {
let entries = [ let entries = [
{ {
trashId: 'old-project-file', trashId: 'old-project-file',
@ -196,11 +194,6 @@ function makeApi(locale = null) {
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) => {
@ -244,10 +237,6 @@ 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}`);
@ -309,14 +298,6 @@ 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);

View File

@ -17,10 +17,9 @@ if [[ ! -x "$PUBLISHER" ]]; then
exit 1 exit 1
fi fi
mkdir -p "$WORK/bin" "$WORK/release" "$WORK/release-notes" "$WORK/state" mkdir -p "$WORK/bin" "$WORK/release" "$WORK/state"
LOG="$WORK/log" LOG="$WORK/log"
export LOG export LOG
printf '## Highlights\n\nHuman-readable release notes.\n' > "$WORK/release-notes/$VERSION.md"
cat > "$WORK/release.sh" <<'SCRIPT' cat > "$WORK/release.sh" <<'SCRIPT'
#!/usr/bin/env bash #!/usr/bin/env bash
@ -50,7 +49,6 @@ case "${1:-}" in
fi fi
echo test-commit echo test-commit
;; ;;
describe) echo v0.0.0-previous ;;
tag) touch "$TEST_STATE/tag"; printf 'tag:%s\n' "${3:-}" >> "$LOG" ;; tag) touch "$TEST_STATE/tag"; printf 'tag:%s\n' "${3:-}" >> "$LOG" ;;
push) printf 'push:%s:%s\n' "${2:-}" "${3:-}" >> "$LOG" ;; push) printf 'push:%s:%s\n' "${2:-}" "${3:-}" >> "$LOG" ;;
*) echo "unexpected git invocation: $*" >&2; exit 1 ;; *) echo "unexpected git invocation: $*" >&2; exit 1 ;;
@ -76,7 +74,6 @@ chmod +x "$WORK/bin/gh"
run_publisher() { run_publisher() {
VERSTAK_RELEASE_SCRIPT="$WORK/release.sh" \ VERSTAK_RELEASE_SCRIPT="$WORK/release.sh" \
VERSTAK_RELEASE_DIR="$WORK/release" \ VERSTAK_RELEASE_DIR="$WORK/release" \
VERSTAK_RELEASE_NOTES_DIR="$WORK/release-notes" \
GIT_BIN="$WORK/bin/git" \ GIT_BIN="$WORK/bin/git" \
GH_BIN="$WORK/bin/gh" \ GH_BIN="$WORK/bin/gh" \
EXPECTED_ROOT="$ROOT" \ EXPECTED_ROOT="$ROOT" \
@ -93,9 +90,6 @@ for asset in "${ASSET_NAMES[@]}"; do
grep -F "$asset" "$LOG" >/dev/null grep -F "$asset" "$LOG" >/dev/null
done done
grep -F "SHA256SUMS" "$LOG" >/dev/null grep -F "SHA256SUMS" "$LOG" >/dev/null
grep -F -- "--notes-file $WORK/release-notes/$VERSION.md" "$LOG" >/dev/null
grep -F -- "--generate-notes" "$LOG" >/dev/null
grep -F -- "--notes-start-tag v0.0.0-previous" "$LOG" >/dev/null
grep -F -- "--prerelease" "$LOG" >/dev/null grep -F -- "--prerelease" "$LOG" >/dev/null
run_publisher run_publisher