Compare commits

..

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

61 changed files with 659 additions and 2341 deletions

View File

@ -50,7 +50,7 @@ create a GitHub Release.
deletion is a separate action.
- 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.
- 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.
- 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
remain readable for migration compatibility.
Each capture contains a durable `workspaceId`, its current or historical
`workspaceRootPath`, and `processed`. An empty `workspaceRootPath` means the
capture is **Unassigned**. The global view can filter all captures by assignment,
Deal, processed state, and text search. A capture
Each capture contains `workspaceRootPath` and `processed`. An empty
`workspaceRootPath` means the capture is **Unassigned**. The global view can filter
all captures by assignment, workspace, processed state, and text search. A capture
can be assigned, reassigned, made unassigned, marked processed or unprocessed, and
deleted.
The local browser receiver adds the currently active Deal before publishing a
capture event. When it has no active Deal, the capture remains unassigned
The local browser receiver adds the currently active workspace before publishing a
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
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;
`workspaceRootPath` is an address and display value. A rename updates the latter,
while a newly created folder with the old name cannot take over existing captures.
Workspace assignment currently uses the top-level vault folder path as the
identifier because the core workspace model has no separate immutable ID. In the
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
Deal. Unassigned captures remain visible only in the
Workspace Inbox and Overview show only captures whose `workspaceRootPath` exactly
matches the selected workspace. Unassigned captures remain visible only in the
global Browser Inbox.

View File

@ -2,12 +2,12 @@
Todos are stored by the official `verstak.todo` plugin under the canonical
`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,
and due/reminder/updated sorting filters. The Deal Todos view shows only records
whose `workspaceRootPath` exactly matches the current Deal. This keeps unassigned
and other-Deal records out of a Deal tab and its
The global Todos view aggregates all records and supports workspace, status, text,
and due/reminder/updated sorting filters. The workspace Todos view shows only
records whose `workspaceRootPath` exactly matches the current workspace. This
keeps unassigned and other-workspace records out of a workspace tab and its
Overview signals.
## Stored fields
@ -20,15 +20,15 @@ Each record has a stable `id`, `title`, optional `description`, optional
## Reminders
The plugin stores reminder metadata, renders clear indicators for overdue,
due-soon, and reminder-due todos, and schedules native desktop notifications when
the Desktop notification capability is available. Without that capability, the
reminder remains visible in Todos but is not treated as an error.
The plugin stores reminder metadata and renders clear indicators for overdue,
due-soon, and reminder-due todos. Verstak does not yet have a notification
scheduler, so a reminder does not create a native desktop notification or run in
the background.
## Completed Todo to Journal
From a Deal Todo tab, a completed todo exposes **Create Journal Entry**. It opens
the current Deal Journal with a normal, editable form. The form copies
From a workspace Todo tab, a completed todo exposes **Create Journal Entry**. It
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
minutes. It never generates a summary, duration, or billable status.
@ -40,6 +40,8 @@ own record.
## Visibility
The Plugin Manager can globally enable or disable the Todo plugin. A Deal template
controls whether its Todos tab is available; disabling the plugin hides the tab
without affecting stored todo records.
The Plugin Manager can globally enable or disable the Todo plugin. The current
workspace host has no per-workspace or template-level contribution filter yet, so
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'
];
var EVENT_LABELS = {
'workspace.selected': { key: 'ui.event.workspaceSelected', fallback: 'Deal selected' },
'case.selected': { key: 'ui.event.workspaceSelected', fallback: 'Deal selected' },
'file.opened': { key: 'ui.event.fileOpened', fallback: 'File opened' },
'file.changed': { key: 'ui.event.fileChanged', fallback: 'File changed' },
'note.saved': { key: 'ui.event.noteEdited', fallback: 'Note edited' },
'action.started': { key: 'ui.event.workSessionDetected', fallback: 'Work session detected' },
'browser.capture.received': { key: 'ui.event.browserCaptureReceived', fallback: 'Browser capture received' },
'browser.capture.page': { key: 'ui.event.pageCaptured', fallback: 'Page captured' },
'browser.capture.selection': { key: 'ui.event.selectionCaptured', fallback: 'Selection captured' },
'browser.capture.link': { key: 'ui.event.linkCaptured', fallback: 'Link captured' },
'browser.capture.file': { key: 'ui.event.fileCaptured', fallback: 'File captured' },
'browser.capture.converted': { key: 'ui.event.captureConverted', fallback: 'Capture converted' },
'browser.activity.domain': { key: 'ui.event.browserDomainActivity', fallback: 'Browser domain activity' }
'workspace.selected': 'Workspace selected',
'case.selected': 'Workspace selected',
'file.opened': 'File opened',
'file.changed': 'File changed',
'note.saved': 'Note edited',
'action.started': 'Work session detected',
'browser.capture.received': 'Browser capture received',
'browser.capture.page': 'Page captured',
'browser.capture.selection': 'Selection captured',
'browser.capture.link': 'Link captured',
'browser.capture.file': 'File captured',
'browser.capture.converted': 'Capture converted',
'browser.activity.domain': 'Browser domain activity'
};
var LOW_VALUE_EVENT_TYPES = {
'workspace.selected': true,
@ -79,7 +79,6 @@
'.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.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-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)}',
@ -154,7 +153,7 @@
var workspaceRoot = workspaceFromProps(props);
var workspaceId = text(props && (props.workspaceId || (props.workspaceNode && props.workspaceNode.workspaceId))).trim();
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 {
mode: 'workspace',
@ -412,27 +411,25 @@
}).slice(0, MAX_CANDIDATES);
}
function humanEventType(type, translate) {
var label = EVENT_LABELS[text(type).toLowerCase()];
return label ? translate(label.key, null, label.fallback) : '';
function humanEventType(type) {
return EVENT_LABELS[text(type).toLowerCase()] || '';
}
function humanEventTitle(activity, translate) {
function humanEventTitle(activity) {
var explicit = text(activity && activity.title).trim();
var type = text(activity && activity.type).trim();
var label = humanEventType(type, translate);
if (explicit && explicit.toLowerCase() !== type.toLowerCase()) return label ? label + ' — ' + explicit : explicit;
return label || text(activity && (activity.summary || activity.activityId)).trim() || translate('ui.event.activity', null, 'Activity event');
if (explicit && explicit.toLowerCase() !== type.toLowerCase()) return explicit;
return humanEventType(type) || text(activity && (activity.summary || activity.activityId)).trim() || 'Activity event';
}
function eventKind(activity, translate) {
function eventKind(activity) {
var type = text(activity && activity.type).toLowerCase();
if (type.indexOf('browser.capture') === 0) return translate('ui.kind.capture', null, 'Capture');
if (type.indexOf('file.') === 0) return translate('ui.kind.file', null, 'File');
if (type.indexOf('note.') === 0) return translate('ui.kind.note', null, 'Note');
if (type.indexOf('workspace') !== -1 || type.indexOf('case.') === 0) return translate('ui.kind.deal', null, 'Deal');
if (type.indexOf('action.') === 0) return translate('ui.kind.work', null, 'Work');
return translate('ui.kind.activity', null, 'Activity');
if (type.indexOf('browser.capture') === 0) return 'Capture';
if (type.indexOf('file.') === 0) return 'File';
if (type.indexOf('note.') === 0) return 'Note';
if (type.indexOf('workspace') !== -1 || type.indexOf('case.') === 0) return 'Workspace';
if (type.indexOf('action.') === 0) return 'Work';
return 'Activity';
}
function globalEventKeys(settings) {
@ -565,14 +562,6 @@
var disposed = false;
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 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' });
@ -581,7 +570,18 @@
className: 'activity-btn danger',
'data-activity-action': '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(countEl);
@ -594,11 +594,9 @@
'data-activity-section': 'work-session-candidates'
});
var listEl = el('div', { className: 'activity-list' });
var modalHost = el('div', { className: 'activity-modal-host', hidden: 'hidden' });
containerEl.appendChild(toolbar);
containerEl.appendChild(candidatesEl);
containerEl.appendChild(listEl);
containerEl.appendChild(modalHost);
function candidatesForWorkspace(workspaceRoot) {
return visibleCandidates(candidateSourceEvents, workspaceRoot, sessionRegistry, dismissedByWorkspace, handledSessions);
@ -644,7 +642,8 @@
? events.filter(function (item) { return !item._storageKey || item._storageKey === GLOBAL_KEY; })
: events;
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');
statusClass = '';
}).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) {
if (!api || !api.storage || !api.storage.data || typeof api.storage.data.readNDJSON !== 'function' || typeof api.storage.data.writeNDJSON !== 'function') {
return Promise.resolve();
@ -762,10 +711,14 @@
el('div', { className: 'activity-time', textContent: formatDate(activity.occurredAt) || '-' }),
el('div', { className: 'activity-main' }, [
el('div', { className: 'activity-row-head' }, [
el('span', { className: 'activity-type', textContent: eventKind(activity, tr) }),
el('span', { className: 'activity-title-text', textContent: humanEventTitle(activity, tr) })
el('span', { className: 'activity-type', textContent: eventKind(activity) }),
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');
statusClass = '';
}).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);
}
@ -822,19 +776,19 @@
el('div', {}, [
el('div', { className: 'activity-candidate-title', textContent: tr('ui.candidate', null, 'Possible journal entry') }),
el('div', { className: 'activity-candidate-facts' }, [
el('div', { textContent: tr('ui.candidateDeal', { deal: candidate.workspaceRootPath }, 'Deal: ' + candidate.workspaceRootPath) }),
el('div', { textContent: tr('ui.candidateTime', { time: candidateTimeRange(candidate) }, 'Time: ' + candidateTimeRange(candidate)) }),
el('div', { textContent: tr('ui.candidateDuration', { minutes: candidate.estimatedMinutes }, 'Estimated duration: ' + candidate.estimatedMinutes + ' min') }),
el('div', { textContent: tr('ui.candidateActivities', { count: candidate.activityCount }, 'Activities: ' + candidate.activityCount) })
el('div', { textContent: 'Workspace: ' + candidate.workspaceRootPath }),
el('div', { textContent: 'Time: ' + candidateTimeRange(candidate) }),
el('div', { textContent: 'Estimated duration: ' + candidate.estimatedMinutes + ' min' }),
el('div', { textContent: 'Activities: ' + candidate.activityCount })
]),
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) {
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-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': 'dismiss', textContent: tr('ui.dismiss', null, 'Dismiss'), onClick: function () { dismissCandidate(candidate); } })
])
@ -843,7 +797,7 @@
}
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;
statusEl.textContent = statusText;
statusEl.className = 'activity-status' + (statusClass ? ' ' + statusClass : '');
@ -873,7 +827,8 @@
updateCandidates();
return persistSessionRegistry().then(persistCandidateCaches);
}).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) {
if (typeof unregister === 'function') unsubscribers.push(unregister);
}).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);
});
})).then(function () {
statusText = scope.mode === 'global'
? tr('ui.listeningGlobal', null, 'Listening for all activity')
: tr('ui.listeningDeal', null, 'Listening for Deal activity');
statusText = scope.mode === 'global' ? 'Listening for all activity' : 'Listening for workspace activity';
statusClass = '';
}).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.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.sidebarItems.verstak.activity.sidebar.title": "Activity",
"contributions.workspaceItems.verstak.activity.workspace.title": "Activity",
@ -9,18 +9,9 @@
"ui.title": "Activity",
"ui.workspaceTitle": "Activity · {workspace}",
"ui.clear": "Clear",
"ui.clearConfirmTitle": "Clear activity?",
"ui.clearGlobalWarning": "This permanently deletes all recorded activity and journal suggestions in every Deal.",
"ui.clearWorkspaceWarning": "This permanently deletes recorded activity for {workspace}. Activity in other Deals remains.",
"ui.confirmClear": "Clear activity",
"ui.cancel": "Cancel",
"ui.saveError": "Could not save activity. Please try again.",
"ui.saveError": "Could not save activity: {error}",
"ui.cleared": "Activity cleared",
"ui.clearError": "Could not clear activity. Please try again.",
"ui.dismissError": "Could not dismiss the suggestion. Please try again.",
"ui.loadError": "Could not load activity. Please try again.",
"ui.commandsUnavailable": "Activity actions are unavailable. Please try again.",
"ui.subscriptionsUnavailable": "Activity updates are unavailable. Please try again.",
"ui.clearError": "Could not clear activity: {error}",
"ui.empty": "No activity events yet",
"ui.emptyHint": "File changes, browser captures, and conversions will appear here.",
"ui.details": "Details",
@ -29,34 +20,5 @@
"ui.candidates": "Possible journal entries",
"ui.candidate": "Possible journal entry",
"ui.review": "Review",
"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"
"ui.dismiss": "Dismiss"
}

View File

@ -1,6 +1,6 @@
{
"manifest.name": "Активность",
"manifest.description": "Журнал публичных событий плагинов в Деле.",
"manifest.description": "Журнал публичных событий плагинов в рабочем пространстве.",
"contributions.views.verstak.activity.view.title": "Активность",
"contributions.sidebarItems.verstak.activity.sidebar.title": "Активность",
"contributions.workspaceItems.verstak.activity.workspace.title": "Активность",
@ -9,18 +9,9 @@
"ui.title": "Активность",
"ui.workspaceTitle": "Активность · {workspace}",
"ui.clear": "Очистить",
"ui.clearConfirmTitle": "Очистить активность?",
"ui.clearGlobalWarning": "Будут безвозвратно удалены вся записанная активность и предложения для журнала во всех Делах.",
"ui.clearWorkspaceWarning": "Будет безвозвратно удалена записанная активность Дела «{workspace}». Активность других Дел останется.",
"ui.confirmClear": "Очистить активность",
"ui.cancel": "Отмена",
"ui.saveError": "Не удалось сохранить активность. Повторите попытку.",
"ui.saveError": "Не удалось сохранить активность: {error}",
"ui.cleared": "Активность очищена",
"ui.clearError": "Не удалось очистить активность. Повторите попытку.",
"ui.dismissError": "Не удалось отклонить предложение. Повторите попытку.",
"ui.loadError": "Не удалось загрузить активность. Повторите попытку.",
"ui.commandsUnavailable": "Действия с активностью недоступны. Повторите попытку.",
"ui.subscriptionsUnavailable": "Обновления активности недоступны. Повторите попытку.",
"ui.clearError": "Не удалось очистить активность: {error}",
"ui.empty": "Событий активности пока нет",
"ui.emptyHint": "Здесь появятся изменения файлов, материалы из браузера и преобразования.",
"ui.details": "Подробнее",
@ -29,34 +20,5 @@
"ui.candidates": "Возможные записи журнала",
"ui.candidate": "Возможная запись журнала",
"ui.review": "Проверить",
"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": "Учёт активности Дела включён"
"ui.dismiss": "Скрыть"
}

View File

@ -4,7 +4,7 @@
"name": "Activity",
"version": "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" } },
"source": "official",
"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 })
=========================================================== */
@ -146,7 +146,7 @@
function scopeFromProps(props) {
var workspaceRoot = workspaceFromProps(props);
if (!workspaceRoot) {
return { mode: 'global', key: GLOBAL_KEY, label: '', workspaceRoot: '' };
return { mode: 'global', key: GLOBAL_KEY, label: 'All workspaces', workspaceRoot: '' };
}
return {
mode: 'workspace',
@ -161,13 +161,13 @@
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;
return capture.title || capture.url || capture.captureId || fallbackTitle || '';
return capture.title || capture.url || capture.captureId || 'Untitled capture';
}
function noteTitle(capture, fallbackTitle) {
return text((capture && (capture.title || capture.domain || capture.captureId)) || fallbackTitle).trim() || text(fallbackTitle).trim();
function noteTitle(capture) {
return text((capture && (capture.title || capture.domain || capture.captureId)) || 'Browser Capture').trim() || 'Browser Capture';
}
function safeNoteFilename(title) {
@ -199,8 +199,8 @@
return base;
}
function captureToMarkdown(capture, fallbackTitle) {
var title = noteTitle(capture, fallbackTitle);
function captureToMarkdown(capture) {
var title = noteTitle(capture);
var lines = ['# ' + title, ''];
if (capture && capture.url) lines.push('Source: ' + capture.url);
if (capture && capture.capturedAt) lines.push('Captured: ' + capture.capturedAt);
@ -363,7 +363,7 @@
var scope = scopeFromProps(props || {});
var captures = [];
var selectedId = '';
var statusText = '';
var statusText = 'Connecting to receiver events...';
var statusClass = '';
var disposed = false;
var unsubscribers = [];
@ -376,39 +376,17 @@
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
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...');
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 statusEl = el('span', { className: 'browser-inbox-status' });
var filtersEl = el('div', { className: 'browser-inbox-filters' });
var statusFilterEl = el('select', {
className: 'browser-inbox-select',
'data-browser-inbox-filter': 'status',
'aria-label': tr('ui.statusFilter', null, 'Material status filter'),
'aria-label': 'Capture status filter',
onChange: function (event) {
statusFilter = text(event && event.target && event.target.value) || 'all';
selectedId = '';
@ -424,7 +402,7 @@
var workspaceFilterEl = el('select', {
className: 'browser-inbox-select',
'data-browser-inbox-filter': 'workspace',
'aria-label': tr('ui.workspaceFilter', null, 'Deal filter'),
'aria-label': 'Workspace filter',
onChange: function (event) {
workspaceFilter = cleanWorkspace(event && event.target && event.target.value);
selectedId = '';
@ -436,7 +414,7 @@
type: 'search',
placeholder: tr('ui.search', null, 'Search captures'),
'data-browser-inbox-filter': 'search',
'aria-label': tr('ui.search', null, 'Search captures'),
'aria-label': 'Search captures',
onInput: function (event) {
searchQuery = text(event && event.target && event.target.value).trim().toLowerCase();
selectedId = '';
@ -459,7 +437,7 @@
if (scope.mode === 'global') {
filtersEl.appendChild(workspaceFilterEl);
} 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);
toolbar.appendChild(filtersEl);
@ -494,7 +472,7 @@
function renderWorkspaceFilterOptions() {
if (scope.mode !== 'global') return;
workspaceFilterEl.innerHTML = '';
workspaceFilterEl.appendChild(option('', tr('ui.allDeals', null, 'All Deals')));
workspaceFilterEl.appendChild(option('', 'All workspaces'));
workspaceRoots().forEach(function (root) {
workspaceFilterEl.appendChild(option(root, root));
});
@ -518,7 +496,9 @@
function publishMutation(action, payload, verify, verifySettings) {
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 api.events.publish(MUTATION_EVENT, Object.assign({ action: action }, payload || {})).then(function () {
@ -536,7 +516,9 @@
}
return true;
}).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;
});
}
@ -563,9 +545,7 @@
var ids = scope.mode === 'global'
? captures.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'
? tr('ui.inboxArchived', null, 'Inbox archived')
: tr('ui.workspaceCapturesArchived', null, 'Deal materials archived'));
return archiveCaptures(ids, scope.mode === 'global' ? 'Inbox archived' : 'Workspace captures archived');
}
function selectedCapture() {
@ -583,8 +563,10 @@
});
if (existing) return Promise.resolve();
selectedId = capture.captureId;
statusText = tr('ui.captureReceived', null, 'Material received');
statusText = 'Capture received';
statusClass = '';
statusText = 'Could not load received capture from storage';
statusClass = 'error';
render();
return Promise.resolve();
}
@ -610,16 +592,14 @@
}).then(function (saved) {
if (!saved) return;
if (workspaceRoot && workspaceOptions.indexOf(workspaceRoot) === -1) workspaceOptions.push(workspaceRoot);
statusText = workspaceRoot
? tr('ui.assignedToWorkspace', { workspace: workspaceRoot }, 'Material assigned to ' + workspaceRoot)
: tr('ui.captureUnassigned', null, 'Material is unassigned');
statusText = workspaceRoot ? 'Capture assigned to ' + workspaceRoot : 'Capture is unassigned';
statusClass = '';
render();
});
}
function archiveCapture(captureId) {
return archiveCaptures([captureId], tr('ui.captureArchived', null, 'Material archived'));
return archiveCaptures([captureId], 'Capture archived');
}
function restoreCapture(captureId) {
@ -629,7 +609,7 @@
});
}).then(function (saved) {
if (!saved) return;
statusText = tr('ui.captureRestored', null, 'Material restored to Inbox');
statusText = 'Capture restored to Inbox';
statusClass = '';
render();
});
@ -641,7 +621,7 @@
}).then(function (saved) {
if (!saved) return;
if (selectedId === captureId) selectedId = '';
statusText = tr('ui.captureDeleted', null, 'Material permanently deleted');
statusText = 'Capture permanently deleted';
statusClass = '';
render();
});
@ -657,9 +637,7 @@
});
}).then(function (saved) {
if (!saved) return;
statusText = processed
? tr('ui.captureProcessed', null, 'Material marked processed')
: tr('ui.captureUnprocessed', null, 'Material marked unprocessed');
statusText = processed ? 'Capture marked processed' : 'Capture marked unprocessed';
statusClass = '';
render();
});
@ -668,15 +646,17 @@
function createNoteFromCapture(capture) {
if (!capture || !capture.workspaceRootPath) return Promise.resolve();
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();
}
var title = noteTitle(capture, tr('ui.untitledCapture', null, 'Untitled material'));
var title = noteTitle(capture);
var notePath = capture.workspaceRootPath + '/Notes/' + safeNoteFilename(title);
statusText = tr('ui.creatingNote', null, 'Creating note...');
statusText = 'Creating note...';
statusClass = '';
render();
return api.files.writeText(notePath, captureToMarkdown(capture, tr('ui.untitledCapture', null, 'Untitled material')), {
return api.files.writeText(notePath, captureToMarkdown(capture), {
createIfMissing: true,
overwrite: false
}).then(function () {
@ -693,22 +673,26 @@
}
return undefined;
}).then(function () {
statusText = tr('ui.noteCreated', { path: notePath }, 'Note created: ' + notePath);
statusText = 'Created note: ' + notePath;
statusClass = '';
return archiveCapture(capture.captureId);
}).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) {
if (!capture || !capture.workspaceRootPath || !capture.url) return Promise.resolve();
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();
}
var title = noteTitle(capture, tr('ui.untitledCapture', null, 'Untitled material'));
statusText = tr('ui.creatingLink', null, 'Creating link...');
var title = noteTitle(capture);
statusText = 'Creating link...';
statusClass = '';
render();
function writeLink(number) {
@ -741,30 +725,36 @@
}
return linkPath;
}).then(function () {
statusText = tr('ui.linkCreated', null, 'Link created');
statusText = 'Created link';
statusClass = '';
return archiveCapture(capture.captureId);
}).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) {
if (!capture || !capture.url || !api || !api.files || typeof api.files.openURL !== 'function') return Promise.resolve();
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) {
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')) {
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();
}
var fileName = safeFileFilename(capture.fileName);
var filePath = capture.workspaceRootPath + '/Files/' + fileName;
statusText = tr('ui.creatingFile', null, 'Creating file...');
statusText = 'Creating file...';
statusClass = '';
render();
var writeOptions = {
@ -791,11 +781,13 @@
}
return undefined;
}).then(function () {
statusText = tr('ui.fileCreated', { path: filePath }, 'File created: ' + filePath);
statusText = 'Created file: ' + filePath;
statusClass = '';
return archiveCapture(capture.captureId);
}).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();
if (visible.length === 0) {
var emptyText = captures.length === 0
? tr('ui.empty', null, 'No browser materials yet. Send a page, selection, or link from the extension.')
: tr('ui.emptyFiltered', null, 'No materials match the current filters.');
? 'No browser captures yet. Keep this view open, then send a page, selection, or link from the extension.'
: 'No captures match the current filters.';
listEl.appendChild(el('div', { className: 'browser-inbox-empty', textContent: emptyText }));
return;
}
@ -821,22 +813,20 @@
}, [
el('div', { className: 'browser-inbox-row-head' }, [
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 })
]);
row.appendChild(el('div', { className: 'browser-inbox-row-meta' }, [
el('span', {
className: 'browser-inbox-badge' + (workspaceRoot ? '' : ' unassigned'),
textContent: workspaceRoot || tr('ui.unassigned', null, 'Unassigned')
textContent: workspaceRoot || 'Unassigned'
}),
el('span', {
className: 'browser-inbox-badge' + (capture.processed ? ' processed' : ''),
textContent: capture.processed
? tr('ui.processed', null, 'Processed')
: tr('ui.unprocessed', null, 'Unprocessed')
textContent: capture.processed ? 'Processed' : '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) {
row.appendChild(el('div', { className: 'browser-inbox-row-text', textContent: capture.text }));
@ -853,47 +843,45 @@
return;
}
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' }, [
el('div', { className: 'browser-inbox-meta-label', textContent: tr('ui.kind', null, 'Kind') }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.kind }),
el('div', { className: 'browser-inbox-meta-label', textContent: 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-label', textContent: tr('ui.domain', null, 'Domain') }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.domain || '-' }),
el('div', { className: 'browser-inbox-meta-label', textContent: tr('ui.captured', null, 'Captured') }),
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-label', textContent: tr('ui.workspace', null, 'Deal') }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.workspaceRootPath || tr('ui.unassigned', null, 'Unassigned') }),
el('div', { className: 'browser-inbox-meta-label', textContent: tr('ui.workspace', null, 'Workspace') }),
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-value', textContent: capture.processed
? tr('ui.processed', null, 'Processed')
: tr('ui.unprocessed', null, 'Unprocessed') })
el('div', { className: 'browser-inbox-meta-value', textContent: capture.processed ? 'Processed' : 'Unprocessed' })
]));
var assignmentSelect = el('select', {
className: 'browser-inbox-select',
'data-browser-inbox-assignment': capture.captureId,
'aria-label': tr('ui.assignment', null, 'Assign to Deal'),
'aria-label': 'Assign capture workspace',
onChange: function (event) {
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) {
assignmentSelect.appendChild(option(workspaceRoot, workspaceRoot));
});
assignmentSelect.value = capture.workspaceRootPath || '';
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
];
if (capture.workspaceRootPath) {
assignmentControls.push(el('button', {
className: 'browser-inbox-btn',
'data-browser-inbox-action': 'clear-assignment',
textContent: tr('ui.clearAssignment', null, 'Clear assignment'),
textContent: 'Clear assignment',
onClick: function () {
assignWorkspace(capture.captureId, '');
}
@ -901,7 +889,7 @@
}
detailEl.appendChild(el('div', { className: 'browser-inbox-assignment' }, assignmentControls));
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) {
detailEl.appendChild(el('div', { className: 'browser-inbox-text', textContent: capture.text }));
@ -913,9 +901,7 @@
actionButtons.push(el('button', {
className: 'browser-inbox-btn',
'data-browser-inbox-action': 'toggle-processed',
textContent: capture.processed
? tr('ui.markUnprocessed', null, 'Mark unprocessed')
: tr('ui.markProcessed', null, 'Mark processed'),
textContent: capture.processed ? 'Mark Unprocessed' : 'Mark Processed',
onClick: function () {
setProcessed(capture.captureId, !capture.processed);
}
@ -924,7 +910,7 @@
actionButtons.push(el('button', {
className: 'browser-inbox-btn',
'data-browser-inbox-action': 'open-link',
textContent: tr('ui.openLink', null, 'Open link'),
textContent: 'Open link',
onClick: function () {
openCaptureURL(capture);
}
@ -964,7 +950,7 @@
actionButtons.push(el('button', {
className: 'browser-inbox-btn',
'data-browser-inbox-action': 'restore',
textContent: tr('ui.restore', null, 'Restore to Inbox'),
textContent: 'Restore to Inbox',
onClick: function () {
restoreCapture(capture.captureId);
}
@ -973,7 +959,7 @@
actionButtons.push(el('button', {
className: 'browser-inbox-btn',
'data-browser-inbox-action': 'archive',
textContent: tr('ui.archive', null, 'Archive'),
textContent: 'Archive',
onClick: function () {
archiveCapture(capture.captureId);
}
@ -982,7 +968,7 @@
actionButtons.push(el('button', {
className: 'browser-inbox-btn danger',
'data-browser-inbox-action': 'delete-permanently',
textContent: tr('ui.deletePermanently', null, 'Delete permanently'),
textContent: 'Delete permanently',
onClick: function () {
permanentlyDeleteCapture(capture.captureId);
}
@ -994,11 +980,8 @@
var visibleCount = visibleCaptures().length;
var total = captures.length;
countEl.textContent = visibleCount === total
? localizedItemCount(total)
: tr('ui.items.filtered', {
visible: localizedItemCount(visibleCount),
total: localizedItemCount(total)
}, localizedItemCount(visibleCount) + ' of ' + localizedItemCount(total));
? total + ' item' + (total === 1 ? '' : 's')
: visibleCount + ' of ' + total + ' items';
var scopeCount = scope.mode === 'global'
? total
: captures.filter(function (capture) { return capture.workspaceRootPath === scope.workspaceRoot; }).length;
@ -1051,7 +1034,8 @@
});
});
}).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) {
if (!saved) return;
selectedId = received.captureId;
statusText = tr('ui.captureReceived', null, 'Material received');
statusText = 'Capture received';
statusClass = '';
render();
});
}
selectedId = received.captureId;
statusText = tr('ui.captureReceived', null, 'Material received');
statusText = 'Capture received';
statusClass = '';
render();
return undefined;
@ -1106,12 +1090,11 @@
if (typeof unsubscribe === 'function') unsubscribers.push(unsubscribe);
});
})).then(function () {
statusText = scope.mode === 'global'
? tr('ui.receiverReadyAll', null, 'Receiver ready for all Deals')
: tr('ui.receiverReadyWorkspace', null, 'Receiver ready for this Deal');
statusText = scope.mode === 'global' ? 'Receiver ready for all workspaces' : 'Receiver ready for workspace';
statusClass = '';
}).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') {
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'));
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');
render();
});
@ -1221,13 +1201,6 @@
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) {
copyURLButton.disabled = busy;
copyTokenButton.disabled = busy;
@ -1253,7 +1226,7 @@
applyPairing(pairing);
setStatus('', false);
}).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 () {
setBusy(false);
});
@ -1268,7 +1241,7 @@
navigator.clipboard.writeText(value).then(function () {
setStatus(tr('ui.copied', { label: label }, '{label} copied'), false);
}).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);
setStatus(tr('ui.tokenRotated', null, 'Token rotated'), false);
}).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 () {
setBusy(false);
});

View File

@ -1,26 +1,25 @@
{
"manifest.name": "Browser",
"manifest.description": "Global browser materials with explicit Deal assignment delivered through the local receiver event protocol.",
"contributions.views.verstak.browser-inbox.view.title": "Browser",
"contributions.sidebarItems.verstak.browser-inbox.sidebar.title": "Browser",
"contributions.workspaceItems.verstak.browser-inbox.workspace.title": "Browser",
"contributions.settingsPanels.verstak.browser-inbox.settings.title": "Browser",
"manifest.name": "Browser Inbox",
"manifest.description": "Global browser capture queue with explicit workspace assignment delivered through the local receiver event protocol.",
"contributions.views.verstak.browser-inbox.view.title": "Browser Inbox",
"contributions.sidebarItems.verstak.browser-inbox.sidebar.title": "Browser Inbox",
"contributions.workspaceItems.verstak.browser-inbox.workspace.title": "Browser Inbox",
"contributions.settingsPanels.verstak.browser-inbox.settings.title": "Browser Inbox",
"ui.connecting": "Connecting to receiver events...",
"ui.title": "Browser",
"ui.workspaceTitle": "Browser · {workspace}",
"ui.title": "Browser Inbox",
"ui.workspaceTitle": "Browser Inbox · {workspace}",
"ui.allCaptures": "All captures",
"ui.allDeals": "All Deals",
"ui.unassigned": "Unassigned",
"ui.unprocessed": "Unprocessed",
"ui.processed": "Processed",
"ui.search": "Search captures",
"ui.clear": "Clear",
"ui.assignedHere": "Assigned to this Deal",
"ui.assignedHere": "Assigned to this workspace",
"ui.selectCapture": "Select a capture to inspect it.",
"ui.kind": "Kind",
"ui.domain": "Domain",
"ui.captured": "Captured",
"ui.workspace": "Deal",
"ui.workspace": "Workspace",
"ui.status": "Status",
"ui.createNote": "Create Note",
"ui.createLink": "Create Link",
@ -36,55 +35,5 @@
"ui.copied": "{label} copied",
"ui.rotateConfirm": "Rotate pairing token?",
"ui.rotating": "Rotating...",
"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}"
"ui.tokenRotated": "Token rotated"
}

View File

@ -1,26 +1,25 @@
{
"manifest.name": "Браузер",
"manifest.description": "Общие материалы из браузера с явным назначением Дела через локальный протокол приёма.",
"contributions.views.verstak.browser-inbox.view.title": "Браузер",
"contributions.sidebarItems.verstak.browser-inbox.sidebar.title": "Браузер",
"contributions.workspaceItems.verstak.browser-inbox.workspace.title": "Браузер",
"contributions.settingsPanels.verstak.browser-inbox.settings.title": "Браузер",
"manifest.name": "Входящие из браузера",
"manifest.description": "Общая очередь материалов из браузера с явным назначением рабочего пространства через локальный протокол приёма.",
"contributions.views.verstak.browser-inbox.view.title": "Входящие из браузера",
"contributions.sidebarItems.verstak.browser-inbox.sidebar.title": "Входящие из браузера",
"contributions.workspaceItems.verstak.browser-inbox.workspace.title": "Входящие из браузера",
"contributions.settingsPanels.verstak.browser-inbox.settings.title": "Входящие из браузера",
"ui.connecting": "Подключение к событиям приёмника...",
"ui.title": "Браузер",
"ui.workspaceTitle": "Браузер · {workspace}",
"ui.title": "Входящие из браузера",
"ui.workspaceTitle": "Входящие из браузера · {workspace}",
"ui.allCaptures": "Все материалы",
"ui.allDeals": "Все Дела",
"ui.unassigned": "Не назначено",
"ui.unprocessed": "Не обработано",
"ui.processed": "Обработано",
"ui.search": "Поиск материалов",
"ui.clear": "Очистить",
"ui.assignedHere": "Назначено этому Делу",
"ui.assignedHere": "Назначено этому рабочему пространству",
"ui.selectCapture": "Выберите материал для просмотра.",
"ui.kind": "Тип",
"ui.domain": "Домен",
"ui.captured": "Получено",
"ui.workspace": "Дело",
"ui.workspace": "Рабочее пространство",
"ui.status": "Состояние",
"ui.createNote": "Создать заметку",
"ui.createLink": "Создать ссылку",
@ -36,55 +35,5 @@
"ui.copied": "{label} скопирован",
"ui.rotateConfirm": "Сменить токен сопряжения?",
"ui.rotating": "Смена токена...",
"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}"
"ui.tokenRotated": "Токен изменён"
}

View File

@ -1,10 +1,10 @@
{
"schemaVersion": 1,
"id": "verstak.browser-inbox",
"name": "Browser",
"name": "Browser Inbox",
"version": "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" } },
"source": "official",
"icon": "inbox",
@ -29,7 +29,7 @@
"views": [
{
"id": "verstak.browser-inbox.view",
"title": "Browser",
"title": "Browser Inbox",
"icon": "inbox",
"component": "BrowserInboxView"
}
@ -37,7 +37,7 @@
"sidebarItems": [
{
"id": "verstak.browser-inbox.sidebar",
"title": "Browser",
"title": "Browser Inbox",
"icon": "inbox",
"view": "verstak.browser-inbox.view",
"position": 30
@ -46,7 +46,7 @@
"workspaceItems": [
{
"id": "verstak.browser-inbox.workspace",
"title": "Browser",
"title": "Browser Inbox",
"icon": "inbox",
"component": "BrowserInboxView"
}
@ -54,7 +54,7 @@
"settingsPanels": [
{
"id": "verstak.browser-inbox.settings",
"title": "Browser",
"title": "Browser Inbox",
"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-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-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-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}',
@ -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 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-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}',
'@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');
@ -257,14 +259,11 @@
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
var value = textarea.value;
var lineStart = value.lastIndexOf('\n', Math.max(0, start - 1)) + 1;
var lineEnd = value.indexOf('\n', end);
if (lineEnd === -1) lineEnd = value.length;
var selected = value.slice(lineStart, lineEnd) || placeholder || '';
var selected = value.slice(start, end) || placeholder || '';
var replacement = selected.split('\n').map(function (line) { return prefix + line; }).join('\n');
textarea.value = value.slice(0, lineStart) + replacement + value.slice(lineEnd);
textarea.selectionStart = lineStart;
textarea.selectionEnd = lineStart + replacement.length;
textarea.value = value.slice(0, start) + replacement + value.slice(end);
textarea.selectionStart = start;
textarea.selectionEnd = start + replacement.length;
textarea.dispatchEvent(new Event('input', { bubbles: true }));
textarea.focus();
}
@ -301,8 +300,9 @@
containerEl.setAttribute('data-resource-path', resourcePath);
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 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 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;
@ -310,7 +310,7 @@
var reloadBtn = el('button', { className: 'de-toolbar-btn', 'data-editor-action': 'reload' }, [tr('ui.reload', null, 'Reload')]);
var saveBtn = el('button', { className: 'de-toolbar-btn', 'data-editor-action': 'save' }, [tr('ui.save', null, 'Save')]);
var statusEl = el('span', { className: 'de-status', 'data-save-state': '' });
var toolbarChildren = [contextLabel];
var toolbarChildren = [modeLabel, contextLabel];
if (notesBadge) toolbarChildren.push(notesBadge);
toolbarChildren.push(spacer);
[editBtn, previewBtn, splitBtn, reloadBtn, saveBtn, statusEl].forEach(function (node) { if (node) toolbarChildren.push(node); });
@ -320,18 +320,18 @@
if (isMarkdown) {
mdToolbar = el('div', { className: 'de-md-toolbar' });
[
['heading', 'H', 'ui.md.heading', 'Heading'],
['bold', 'B', 'ui.md.bold', 'Bold'],
['italic', 'I', 'ui.md.italic', 'Italic'],
['link', 'Link', 'ui.md.link', 'Link'],
['code', 'Code', 'ui.md.inlineCode', 'Inline code'],
['code-block', '```', 'ui.md.codeBlock', 'Code block'],
['bullet', '• List', 'ui.md.bulletList', 'Bullet list'],
['numbered', '1. List', 'ui.md.numberedList', 'Numbered list'],
['quote', 'Quote', 'ui.md.quote', 'Quote'],
['task', 'Task', 'ui.md.taskItem', 'Task item']
['heading', 'H', 'Heading'],
['bold', 'B', 'Bold'],
['italic', 'I', 'Italic'],
['link', 'Link', 'Link'],
['code', 'Code', 'Inline code'],
['code-block', '```', 'Code block'],
['bullet', '• List', 'Bullet list'],
['numbered', '1. List', 'Numbered list'],
['quote', 'Quote', 'Quote'],
['task', 'Task', 'Task 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);
}
@ -339,6 +339,10 @@
var editorWrap = el('div', { className: 'de-editor-wrap' });
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() {
if (!linesEl || !textarea) return;
var count = textarea.value.split('\n').length;
@ -488,10 +492,10 @@
rebuildEditorArea();
}).catch(function (err) {
if (disposed) return;
console.warn('[default-editor] load error:', err);
editorWrap.innerHTML = '';
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();
var localeUnsubscribe = api.i18n && typeof 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 (previewBtn) previewBtn.textContent = tr('ui.preview', null, 'Preview');
if (splitBtn) splitBtn.textContent = tr('ui.split', null, 'Split');
reloadBtn.textContent = tr('ui.reload', null, 'Reload');
saveBtn.textContent = tr('ui.save', null, 'Save');
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();
})
: null;

View File

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

View File

@ -5,7 +5,6 @@
"contributions.openProviders.verstak.default-editor.markdown.title": "Стандартный редактор Markdown",
"contributions.openProviders.verstak.default-editor.notes-markdown.title": "Стандартный редактор Markdown-заметок",
"ui.notesContext": "контекст заметки",
"ui.note": "Заметка",
"ui.edit": "Редактировать",
"ui.preview": "Просмотр",
"ui.split": "Разделить",
@ -19,15 +18,5 @@
"ui.saved": "Сохранено",
"ui.discardConfirm": "Отменить несохранённые изменения и перечитать файл с диска?",
"ui.loading": "Загрузка...",
"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": "Пункт задачи"
"ui.loadFailed": "Не удалось загрузить файл"
}

View File

@ -143,9 +143,8 @@
return null;
});
}).catch(function (err) {
console.warn('[file-preview] preview error:', err);
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') {
api.i18n.onDidChangeLocale(function () {

View File

@ -5,5 +5,5 @@
"ui.openExternal": "Open External",
"ui.preview": "Preview",
"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.preview": "Просмотр",
"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-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-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-sort{width:9.5rem;appearance:none;background-color:#0f1424;background-image:linear-gradient(45deg,transparent 50%,#8b8ba8 50%),linear-gradient(135deg,#8b8ba8 50%,transparent 50%);background-position:calc(100% - 14px) 50%,calc(100% - 9px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:1.6rem}.files-sort option{background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb)}',
'.files-filter:focus,.files-sort:focus{border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.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,.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-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}',
@ -60,6 +60,10 @@
'.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-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%}}',
'.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}',
@ -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{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-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-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}',
@ -224,7 +222,7 @@
return EXT_MAP[ext] || 'generic';
}
function fileIconLabel(category, translate) {
function fileIconLabel(category) {
var labels = {
folder: 'Folder',
markdown: 'Markdown file',
@ -244,8 +242,7 @@
json: 'JSON file',
generic: 'File'
};
var label = labels[category] || labels.generic;
return typeof translate === 'function' ? translate('ui.icon.' + (labels[category] ? category : 'generic'), null, label) : label;
return labels[category] || labels.generic;
}
function fileIcon(entry) {
@ -268,8 +265,8 @@
return date.toLocaleString(undefined, { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
function typeLabel(entry, folderLabel) {
if (entry.type === 'folder') return folderLabel || 'Folder';
function typeLabel(entry) {
if (entry.type === 'folder') return 'folder';
return (entry.extension || extension(entry.name) || 'file').toLowerCase();
}
@ -323,16 +320,15 @@
return Promise.reject(new Error('clipboard unavailable'));
}
function showExternalFallback(entry, mode, labels) {
function showExternalFallback(entry, mode, reason) {
if (!entry) return;
labels = labels || {};
var pathToShow = entry.relativePath;
if (mode === 'explorer' && entry.type !== 'folder') {
pathToShow = parentPath(entry.relativePath) || entry.relativePath;
}
var title = mode === 'explorer' ? (labels.showInFolder || 'Show in folder') : (labels.openExternal || 'Open externally');
var message = (labels.externalFailed || '{action} failed.').replace('{action}', title) + '\n' + (labels.vaultRelativePath || 'Vault-relative path:') + '\n' + pathToShow;
confirmModal(message, { confirmText: labels.copyPath || 'Copy path', cancelText: labels.close || 'Close' }).then(function (copy) {
var title = mode === 'explorer' ? 'Show in Explorer' : 'Open External';
var message = title + ' failed.\n' + (reason ? String(reason) + '\n' : '') + 'Vault-relative path:\n' + pathToShow;
confirmModal(message, { confirmText: 'Copy Path', cancelText: 'Close' }).then(function (copy) {
if (!copy) return;
copyTextToClipboard(pathToShow).catch(function (err) {
console.error('[files] copy path failed:', err);
@ -350,7 +346,7 @@
var workspaceNode = props && props.workspaceNode;
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 || {};
var historyKey = workspaceRoot || workspaceName;
var savedHistory = window.__filesHistoryByWorkspace[historyKey] || { stack: [''], index: 0, currentPath: '' };
@ -362,12 +358,6 @@
var sortMode = 'folder-name';
var createMode = '';
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 fileActions = [];
var contextMenuEntries = [];
@ -384,17 +374,6 @@
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) {
local = cleanPath(local);
return workspaceRoot ? (local ? workspaceRoot + '/' + local : workspaceRoot) : local;
@ -452,6 +431,32 @@
var listContainer = el('div', { className: 'files-list', 'data-files-list': '' });
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() {
var keys = Object.keys(selectedPaths);
if (keys.length === 0) return null;
@ -528,7 +533,7 @@
if (a.type !== 'folder' && b.type === 'folder') return 1;
}
if (sortMode === 'type') {
var typeCmp = displayType(a).localeCompare(displayType(b));
var typeCmp = typeLabel(a).localeCompare(typeLabel(b));
if (typeCmp) return typeCmp;
}
if (sortMode === 'modified-desc') {
@ -650,7 +655,7 @@
shown.forEach(function (entry) {
var iconCategory = fileIconCategory(entry);
var iconLabel = fileIconLabel(iconCategory, tr);
var iconLabel = fileIconLabel(iconCategory);
var row = el('div', {
className: 'files-item' + (selectedPaths[entry.relativePath] ? ' selected' : ''),
'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-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' }, [formatDate(entry.modifiedAt)]),
el('div', { className: 'files-row-actions' }, [
@ -709,10 +714,10 @@
renderList();
}).catch(function (err) {
if (disposed) return;
console.warn('[verstak.files] load error:', err);
listContainer.innerHTML = '';
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); });
}
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) {
cancelCreate();
createMode = mode;
createModal = showFileFormModal('create', {
title: mode === 'folder'
? tr('ui.createFolderTitle', null, 'Create folder')
: (mode === 'markdown'
? tr('ui.createMarkdownTitle', null, 'Create Markdown file')
: tr('ui.createTextTitle', null, 'Create text file')),
placeholder: mode === 'folder'
? tr('ui.folderName', null, 'Folder name')
: (mode === 'markdown'
? tr('ui.markdownFileName', null, 'Markdown file name')
: tr('ui.textFileName', null, 'Text file name')),
confirmLabel: tr('ui.create', null, 'Create')
});
createInput.value = '';
createInput.placeholder = mode === 'folder' ? 'Folder name' : (mode === 'markdown' ? 'Markdown file name' : 'Text file name');
setCreateError('');
createPanel.style.display = 'flex';
createInput.focus();
}
function setCreateError(message) {
if (!createError || !createInput) return;
createError.textContent = message || '';
createError.style.display = message ? 'block' : 'none';
createInput.setAttribute('aria-invalid', message ? 'true' : 'false');
}
function validateCreateName(name) {
if (!name) return tr('ui.nameRequired', null, 'Enter a name.');
if (/[\\/:*?"<>|\x00-\x1f]/.test(name)) return tr('ui.invalidCharacters', null, 'The name contains invalid characters.');
if (name === '.' || name === '..' || name[0] === ' ' || name[name.length - 1] === ' ' || name[name.length - 1] === '.') return tr('ui.invalidName', null, 'Enter a valid name.');
if (!name) return 'Name is required';
if (/[\\/:*?"<>|\x00-\x1f]/.test(name)) return 'Invalid characters in name';
if (name === '.' || name === '..' || name[0] === ' ' || name[name.length - 1] === ' ' || name[name.length - 1] === '.') return 'Invalid name';
return '';
}
function cancelCreate() {
createMode = '';
if (createModal && typeof createModal.remove === 'function') createModal.remove();
else if (createModal && createModal.parentNode) createModal.parentNode.removeChild(createModal);
createModal = null;
createInput = null;
createError = null;
setCreateError('');
createPanel.style.display = 'none';
}
function confirmCreate() {
if (!createInput || !createMode) return;
var name = createInput.value.trim();
var validationError = validateCreateName(name);
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 () {});
}
}).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) {
entry = entry || selectedEntry();
if (!entry) return;
cancelRename();
renameTarget = entry;
renameModal = showFileFormModal('rename', {
title: tr('ui.renameTitle', { name: entry.name }, 'Rename ' + entry.name),
placeholder: tr('ui.fileName', null, 'File or folder name'),
value: entry.name,
confirmLabel: tr('ui.rename', null, 'Rename')
});
renameInput.value = entry.name;
setRenameError('');
renamePanel.style.display = 'flex';
renameInput.focus();
renameInput.select();
}
function cancelRename() {
renameTarget = null;
if (renameModal && typeof renameModal.remove === 'function') renameModal.remove();
else if (renameModal && renameModal.parentNode) renameModal.parentNode.removeChild(renameModal);
renameModal = null;
renameInput = null;
renameError = null;
setRenameError('');
renamePanel.style.display = 'none';
}
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() {
if (!renameTarget || !renameInput) return;
if (!renameTarget) return;
var newName = renameInput.value.trim();
if (!newName) {
setRenameError(tr('ui.nameRequired', null, 'Enter a name.'));
return;
}
if (newName === renameTarget.name) {
if (!newName || newName === renameTarget.name) {
cancelRename();
return;
}
if (/[\\/:*?"<>|\x00-\x1f]/.test(newName)) {
setRenameError(tr('ui.invalidCharacters', null, 'The name contains invalid characters.'));
setRenameError('Invalid characters in name');
return;
}
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;
}
var from = renameTarget.relativePath;
@ -1011,20 +910,20 @@
var to = targetParent ? targetParent + '/' + newName : newName;
api.files.metadata(to).then(function () {
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;
}
setRenameError(tr('ui.nameConflict', null, 'An item with that name already exists.'));
setRenameError('A file with that name already exists');
}, function () {
api.files.move(from, to, { overwrite: false }).then(function () {
cancelRename();
loadEntries();
}).catch(function (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;
}
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) {
var count = selectedCount();
if (entry) {
confirmModal(tr('ui.trashConfirm', { name: entry.name }, 'Move "' + entry.name + '" to trash?'), {
danger: true,
confirmText: tr('ui.trash', null, 'Move to trash'),
cancelText: tr('ui.cancel', null, 'Cancel')
}).then(function (ok) {
confirmModal('Move "' + entry.name + '" to trash?', { danger: true }).then(function (ok) {
if (!ok) return;
api.files.trash(entry.relativePath).then(function () {
loadEntries();
}).catch(function (err) {
window.alert(reportError('ui.trashError', 'Could not move this item to trash. Please try again.', err));
});
}).catch(function (err) { window.alert((err && err.message) ? err.message : String(err)); });
});
} else if (count > 1) {
confirmModal(tr('ui.trashManyConfirm', { count: count }, 'Move ' + count + ' items to trash?'), {
danger: true,
confirmText: tr('ui.trash', null, 'Move to trash'),
cancelText: tr('ui.cancel', null, 'Cancel')
}).then(function (ok) {
confirmModal('Move ' + count + ' items to trash?', { danger: true }).then(function (ok) {
if (!ok) return;
var paths = Object.keys(selectedPaths);
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(); });
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 --- */
var ctxMenu = el('div', { className: 'files-ctx-menu', style: { display: 'none' } });
document.body.appendChild(ctxMenu);
@ -1094,23 +991,14 @@
function openExternalEntry(entry, mode) {
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 action = mode === 'explorer' ? filesApi && filesApi.showInFolder : filesApi && filesApi.openExternal;
if (typeof action !== 'function') {
showExternalFallback(entry, mode, fallbackLabels);
showExternalFallback(entry, mode, 'files external-open API is unavailable.');
return;
}
action(entry.relativePath).catch(function (err) {
console.warn('[verstak.files] external open error:', err);
showExternalFallback(entry, mode, fallbackLabels);
showExternalFallback(entry, mode, err && err.message ? err.message : err);
});
}
@ -1156,27 +1044,27 @@
renderList();
}
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(tr('ui.openExternal', null, 'Open externally'), '', 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(isFolder ? 'Open Folder' : 'Open', '', function () { openEntry(entry); }, 'open', 'open'));
ctxMenu.appendChild(ctxItem('Open External', '', function () { openExternalEntry(entry, 'external'); }, 'open-external', 'external'));
ctxMenu.appendChild(ctxItem('Show in Explorer', '', function () { openExternalEntry(entry, 'explorer'); }, 'show-in-explorer', 'explorer'));
appendContributionMenuItems(entry);
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') {
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(ctxItem(tr('ui.cut', null, 'Cut'), '', function () { cutSelection(); }, 'cut', 'cut'));
ctxMenu.appendChild(ctxItem(tr('ui.copy', null, 'Copy'), '', function () { copySelection(); }, 'copy', 'copy'));
ctxMenu.appendChild(ctxItem('Cut', '', function () { cutSelection(); }, 'cut', 'cut'));
ctxMenu.appendChild(ctxItem('Copy', '', function () { copySelection(); }, 'copy', 'copy'));
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 {
ctxMenu.appendChild(ctxItem(tr('ui.newFolder', null, '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(tr('ui.newText', null, 'New text file'), '', function () { startCreate('text'); }, 'new-text', 'textAdd'));
ctxMenu.appendChild(ctxItem('New Folder', '', function () { startCreate('folder'); }, 'new-folder', 'folderAdd'));
ctxMenu.appendChild(ctxItem('New Markdown', '', function () { startCreate('markdown'); }, 'new-markdown', 'markdownAdd'));
ctxMenu.appendChild(ctxItem('New Text', '', function () { startCreate('text'); }, 'new-text', 'textAdd'));
if (window.__filesClipboard && window.__filesClipboard.items && window.__filesClipboard.items.length) {
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';
@ -1269,7 +1157,7 @@
var clip = window.__filesClipboard;
if (!clip || !clip.items || clip.items.length === 0) return;
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;
}
var destinationDir = scopedPath(currentPath);
@ -1610,6 +1498,10 @@
item[0].setAttribute('aria-label', label);
});
filterInput.setAttribute('placeholder', tr('ui.filter', null, 'Filter current folder'));
createConfirm.textContent = tr('ui.create', null, 'Create');
createCancel.textContent = tr('ui.cancel', null, 'Cancel');
renameConfirm.textContent = tr('ui.rename', null, 'Rename');
renameCancel.textContent = tr('ui.cancel', null, 'Cancel');
renderList();
});
}
@ -1628,8 +1520,6 @@
containerEl.__filesCleanup = function () {
disposed = true;
cancelCreate();
cancelRename();
if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
if (typeof fileChangedUnsubscribe === 'function') fileChangedUnsubscribe();
document.removeEventListener('click', onDocClick);

View File

@ -1,6 +1,6 @@
{
"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",
"ui.back": "Back",
"ui.forward": "Forward",
@ -10,11 +10,7 @@
"ui.newMarkdown": "New markdown file",
"ui.newText": "New text file",
"ui.open": "Open",
"ui.openFolder": "Open folder",
"ui.openExternal": "Open externally",
"ui.showInFolder": "Show in folder",
"ui.rename": "Rename",
"ui.duplicate": "Duplicate",
"ui.trash": "Move to trash",
"ui.cut": "Cut",
"ui.copy": "Copy",
@ -26,52 +22,11 @@
"ui.column.modified": "Modified",
"ui.column.size": "Size",
"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.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.noMatches": "No matches",
"ui.clearFilter": "Clear filter",
"ui.loading": "Loading...",
"ui.loadFailed": "Could not load files. Please try again.",
"ui.createError": "Could not create this item. Please try again.",
"ui.renameError": "Could not rename this item. Please try again.",
"ui.trashError": "Could not move this item to trash. Please try again."
"ui.loadFailed": "Failed to load files"
}

View File

@ -1,6 +1,6 @@
{
"manifest.name": "Файлы",
"manifest.description": "Файловый менеджер Дела с созданием, переименованием, корзиной, фильтрацией и сортировкой.",
"manifest.description": "Файловый менеджер рабочего пространства с созданием, переименованием, корзиной, фильтрацией и сортировкой.",
"contributions.workspaceItems.verstak.files.workspace.title": "Файлы",
"ui.back": "Назад",
"ui.forward": "Вперёд",
@ -10,11 +10,7 @@
"ui.newMarkdown": "Новый Markdown-файл",
"ui.newText": "Новый текстовый файл",
"ui.open": "Открыть",
"ui.openFolder": "Открыть папку",
"ui.openExternal": "Открыть во внешнем приложении",
"ui.showInFolder": "Показать в папке",
"ui.rename": "Переименовать",
"ui.duplicate": "Создать копию",
"ui.trash": "Переместить в корзину",
"ui.cut": "Вырезать",
"ui.copy": "Копировать",
@ -26,52 +22,11 @@
"ui.column.modified": "Изменён",
"ui.column.size": "Размер",
"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.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.noMatches": "Совпадений нет",
"ui.clearFilter": "Сбросить фильтр",
"ui.loading": "Загрузка...",
"ui.loadFailed": "Не удалось загрузить файлы. Повторите попытку.",
"ui.createError": "Не удалось создать этот элемент. Повторите попытку.",
"ui.renameError": "Не удалось переименовать этот элемент. Повторите попытку.",
"ui.trashError": "Не удалось переместить элемент в корзину. Повторите попытку."
"ui.loadFailed": "Не удалось загрузить файлы"
}

View File

@ -4,7 +4,7 @@
"name": "Files",
"version": "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" } },
"source": "official",
"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-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.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-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)}',
@ -108,7 +107,7 @@
function scopeFromProps(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 {
mode: 'workspace',
key: WORKLOG_PREFIX + encodeKey(workspaceRoot),
@ -276,7 +275,6 @@
var scope = scopeFromProps(props || {});
var entries = [];
var workspaceOptions = [];
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
@ -285,14 +283,6 @@
var statusClass = '';
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 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' });
@ -314,23 +304,12 @@
containerEl.appendChild(listEl);
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();
var target = cleanWorkspace(workspaceRoot || scope.workspaceRoot);
if (!target) return Promise.resolve();
return api.settings.write(WORKLOG_PREFIX + encodeKey(target), storageEntries(values || entries)).catch(function (err) {
reportError('ui.saveError', 'Could not save journal. Please try again.', err);
});
}
function loadWorkspaceOptions() {
if (!api || !api.files || typeof api.files.list !== 'function') return Promise.resolve();
return api.files.list('').then(function (items) {
workspaceOptions = (Array.isArray(items) ? items : []).filter(function (item) {
return text(item && item.type).toLowerCase() === 'folder';
}).map(function (item) { return cleanWorkspace(item.relativePath || item.name); }).filter(function (value) {
return value && value.indexOf('/') === -1;
});
return api.settings.write(scope.key, storageEntries(entries)).catch(function (err) {
statusText = tr('ui.saveError', { error: err && err.message ? err.message : String(err) }, 'Could not save journal: ' + (err && err.message ? err.message : String(err)));
statusClass = 'error';
});
}
@ -346,7 +325,8 @@
statusText = tr('ui.aggregating', null, 'Aggregating worklogs');
statusClass = '';
}).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) {
@ -354,7 +334,8 @@
statusText = tr('ui.ready', null, 'Ready');
statusClass = '';
}).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');
}
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) {
if (scope.mode !== 'workspace') return;
var editing = !!existingEntry;
var reviewingCandidate = !editing && !!candidate;
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 billableInput = el('input', { type: 'checkbox', 'data-journal-input': 'billable' });
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 input = el('input', { type: 'checkbox', value: activity.activityId, checked: 'checked', 'data-journal-candidate-activity': activity.activityId });
input.checked = true;
@ -420,7 +369,6 @@
summary: summaryInput.value,
minutes: minutesInput.value,
billable: billableInput.checked === true,
workspaceRootPath: workspaceInput ? workspaceInput.value : scope.workspaceRoot,
sourceCandidateId: reviewingCandidate ? candidate.candidateId : (existingEntry ? existingEntry.sourceCandidateId : ''),
sessionId: reviewingCandidate ? candidate.sessionId : '',
handledThrough: reviewingCandidate ? candidate.handledThrough : '',
@ -432,22 +380,22 @@
}
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('div', { textContent: tr('ui.candidate.deal', { deal: candidate.workspaceRootPath }, 'Deal: ' + 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: tr('ui.candidate.duration', { minutes: candidate.estimatedMinutes }, 'Estimated duration: ' + candidate.estimatedMinutes + ' min') }),
el('div', { textContent: tr('ui.candidate.activities', { count: candidate.activityCount }, 'Activities: ' + candidate.activityCount) })
el('strong', { textContent: 'Possible journal entry' }),
el('div', { textContent: 'Workspace: ' + candidate.workspaceRootPath }),
el('div', { textContent: 'Time: ' + candidateTime(candidate.startedAt) + ' - ' + candidateTime(candidate.endedAt) }),
el('div', { textContent: 'Estimated duration: ' + candidate.estimatedMinutes + ' min' }),
el('div', { textContent: 'Activities: ' + candidate.activityCount })
]) : null;
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) {
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]);
}))) : null;
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('div', { textContent: tr('ui.candidate.deal', { deal: completedTodo.workspaceRootPath }, 'Deal: ' + completedTodo.workspaceRootPath) }),
completedTodo.completedAt ? el('div', { textContent: tr('ui.todo.completed', { time: candidateTime(completedTodo.completedAt) }, 'Completed: ' + candidateTime(completedTodo.completedAt)) }) : null
el('strong', { textContent: 'Completed todo' }),
el('div', { textContent: 'Workspace: ' + completedTodo.workspaceRootPath }),
completedTodo.completedAt ? el('div', { textContent: 'Completed: ' + candidateTime(completedTodo.completedAt) }) : null
]) : null;
modalHost.innerHTML = '';
@ -463,7 +411,6 @@
el('div', { className: 'journal-modal-grid' }, [
el('label', { className: 'journal-field' }, [tr('ui.date', null, 'Date'), dateInput]),
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.body', null, 'Body'), summaryInput]),
el('label', { className: 'journal-billable' }, [billableInput, tr('ui.billable', null, 'Billable')])
@ -479,6 +426,7 @@
}
function addOrUpdateEntry(existingEntry, formValue) {
if (scope.mode !== 'workspace') return;
var title = text(formValue && formValue.title).trim();
if (!title) {
statusText = tr('ui.titleRequired', null, 'Title is required');
@ -486,27 +434,25 @@
render();
return;
}
var workspaceRoot = cleanWorkspace(formValue && formValue.workspaceRootPath || scope.workspaceRoot);
if (!workspaceRoot) return;
var sourceCandidateId = text(formValue && formValue.sourceCandidateId || (existingEntry && existingEntry.sourceCandidateId)).trim();
var sessionID = text(formValue && formValue.sessionId).trim();
var handledThrough = text(formValue && formValue.handledThrough).trim();
var sourceTodoId = text(formValue && formValue.sourceTodoId || (existingEntry && existingEntry.sourceTodoId)).trim();
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';
render();
return;
}
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';
render();
return;
}
var entry = normalizeEntry({
entryId: existingEntry ? existingEntry.entryId : entryId(workspaceRoot, formValue.date || today(), title),
workspaceRootPath: workspaceRoot,
entryId: existingEntry ? existingEntry.entryId : entryId(scope.workspaceRoot, formValue.date || today(), title),
workspaceRootPath: scope.workspaceRoot,
date: formValue.date || today(),
title: title,
summary: formValue.summary,
@ -524,11 +470,10 @@
entries = [entry].concat(entries);
}
entries = sortEntries(entries);
var targetEntries = entries.filter(function (item) { return item.workspaceRootPath === workspaceRoot; });
closeEntryModal();
statusText = existingEntry ? tr('ui.updated', null, 'Entry updated') : tr('ui.added', null, 'Entry added');
statusText = existingEntry ? 'Entry updated' : 'Entry added';
statusClass = '';
persist(workspaceRoot, targetEntries).then(function () {
persist().then(function () {
if (!sessionID || !handledThrough || !api || !api.events || typeof api.events.publish !== 'function') return undefined;
return api.events.publish('activity.session.handled', {
sessionId: sessionID,
@ -561,9 +506,9 @@
el('div', { className: 'journal-main' }, [
el('div', { className: 'journal-entry-title', textContent: entry.title }),
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' }, [
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); } })
@ -580,12 +525,12 @@
);
statusEl.textContent = statusText;
statusEl.className = 'journal-status' + (statusClass ? ' ' + statusClass : '');
addBtn.disabled = false;
addBtn.disabled = scope.mode !== 'workspace';
renderList();
}
render();
Promise.all([loadStored(), loadWorkspaceOptions()]).then(function () {
loadStored().then(function () {
render();
var candidate = candidateFromRequest(props && props.toolRequest, scope.workspaceRoot);
var completedTodo = completedTodoFromRequest(props && props.toolRequest, scope.workspaceRoot);

View File

@ -1,6 +1,6 @@
{
"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.sidebarItems.verstak.journal.sidebar.title": "Journal",
"contributions.workspaceItems.verstak.journal.workspace.title": "Journal",
@ -8,9 +8,9 @@
"ui.title": "Journal",
"ui.workspaceTitle": "Journal · {workspace}",
"ui.add": "Add",
"ui.saveError": "Could not save journal. Please try again.",
"ui.saveError": "Could not save journal: {error}",
"ui.aggregating": "Aggregating worklogs",
"ui.loadError": "Could not load journal. Please try again.",
"ui.loadError": "Could not load journal: {error}",
"ui.ready": "Ready",
"ui.workItem": "Work item",
"ui.body": "Body",
@ -31,33 +31,5 @@
"ui.edit": "Edit",
"ui.delete": "Delete",
"ui.entryCount.one": "{count} entry",
"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"
"ui.entryCount.many": "{count} entries"
}

View File

@ -1,6 +1,6 @@
{
"manifest.name": "Журнал",
"manifest.description": "Журнал Дела с ручными записями и необязательными связями с активностью.",
"manifest.description": "Журнал рабочего пространства с ручными записями и необязательными связями с активностью.",
"contributions.views.verstak.journal.view.title": "Журнал",
"contributions.sidebarItems.verstak.journal.sidebar.title": "Журнал",
"contributions.workspaceItems.verstak.journal.workspace.title": "Журнал",
@ -8,9 +8,9 @@
"ui.title": "Журнал",
"ui.workspaceTitle": "Журнал · {workspace}",
"ui.add": "Добавить",
"ui.saveError": "Не удалось сохранить журнал. Повторите попытку.",
"ui.saveError": "Не удалось сохранить журнал: {error}",
"ui.aggregating": "Сбор записей о работе",
"ui.loadError": "Не удалось загрузить журнал. Повторите попытку.",
"ui.loadError": "Не удалось загрузить журнал: {error}",
"ui.ready": "Готово",
"ui.workItem": "Выполненная работа",
"ui.body": "Описание",
@ -31,33 +31,5 @@
"ui.edit": "Изменить",
"ui.delete": "Удалить",
"ui.entryCount.one": "{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": "связанная задача"
"ui.entryCount.many": "Записей: {count}"
}

View File

@ -4,7 +4,7 @@
"name": "Journal",
"version": "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" } },
"source": "official",
"icon": "book-open",
@ -15,7 +15,6 @@
],
"permissions": [
"events.publish",
"files.read",
"storage.namespace",
"ui.register"
],

View File

@ -25,7 +25,7 @@
'.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{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-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}',
@ -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-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-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-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-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-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-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}',
@ -173,7 +171,7 @@
var workspaceNode = props && props.workspaceNode;
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 selectedPath = '';
@ -289,12 +287,23 @@
var listContainer = el('div', { className: 'notes-list', 'data-notes-list': '' });
containerEl.appendChild(listContainer);
var createModal = null;
var createInput = null;
var createError = null;
var renameModal = null;
var renameInput = null;
var renameError = null;
var createPanel = el('div', { className: 'notes-panel', style: { display: 'none' } });
var createInput = el('input', { className: 'notes-input', 'data-notes-create-input': '', placeholder: tr('ui.noteTitle', null, 'Note title') });
var createConfirm = el('button', { className: 'notes-btn', textContent: tr('ui.create', null, 'Create') });
var createCancel = el('button', { className: 'notes-btn', textContent: tr('ui.cancel', null, 'Cancel') });
createPanel.appendChild(createInput);
createPanel.appendChild(createConfirm);
createPanel.appendChild(createCancel);
containerEl.appendChild(createPanel);
var renamePanel = el('div', { className: 'notes-panel', style: { display: 'none' } });
var renameInput = el('input', { className: 'notes-input', 'data-notes-rename-input': '', placeholder: 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 ─────────────────────────────────────
@ -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() {
listContainer.innerHTML = '';
listContainer.appendChild(el('div', { className: 'notes-empty' }, [tr('ui.loading', null, 'Loading...')]));
@ -333,7 +335,7 @@
renderList();
}).catch(function (err) {
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 () {
if (!disposed) setStatus(action.label + ' complete', 'success');
}).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); });
}
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 ────────────────────────────────────────────
function showCreate() {
hideCreate();
createModal = showNoteFormModal('create');
createInput.value = '';
createPanel.style.display = 'flex';
createInput.focus();
}
function hideCreate() {
if (createModal) createModal.remove();
createModal = null;
createInput = null;
createError = null;
}
function setCreateError(message) {
if (!createError || !createInput) return;
createError.textContent = message || '';
createError.style.display = message ? 'block' : 'none';
createInput.setAttribute('aria-invalid', message ? 'true' : 'false');
createPanel.style.display = 'none';
}
function confirmCreate() {
if (!createInput) return;
var title = createInput.value.trim();
if (!title) {
setCreateError(tr('ui.titleRequired', null, 'Enter a note title.'));
createInput.focus();
return;
}
if (!title) return;
setStatus(tr('ui.creating', null, 'Creating note...'), 'loading');
var parent = notesParent();
createNote(parent, title).then(function (data) {
if (disposed) return;
data = data || {};
if (data.conflict) {
setCreateError(conflictMessage(title, data.path));
createInput.focus();
showConflictModal(title, data.path, createInput);
return;
}
hideCreate();
@ -636,55 +533,42 @@
}).catch(function () {});
}
}).catch(function (err) {
setCreateError(userFacingError('ui.createError', 'Could not create the note. Please try again.', err));
setStatus('Error: ' + (err.message || err), 'error');
});
}
// ─── Rename ─────────────────────────────────────────────
function beginRename(note) {
hideRename();
renameTarget = note;
renameModal = showNoteFormModal('rename', note);
renameInput.value = note.title || fileName(note.path);
renamePanel.style.display = 'flex';
renameInput.focus();
renameInput.select();
}
function hideRename() {
renameTarget = null;
if (renameModal) renameModal.remove();
renameModal = null;
renameInput = null;
renameError = null;
}
function setRenameError(message) {
if (!renameError || !renameInput) return;
renameError.textContent = message || '';
renameError.style.display = message ? 'block' : 'none';
renameInput.setAttribute('aria-invalid', message ? 'true' : 'false');
renamePanel.style.display = 'none';
}
function confirmRename() {
if (!renameTarget || !renameInput) return;
if (!renameTarget) return;
var newTitle = renameInput.value.trim();
if (!newTitle) {
setRenameError(tr('ui.titleRequired', null, 'Enter a note title.'));
renameInput.focus();
return;
}
if (!newTitle) return;
setStatus(tr('ui.renaming', null, 'Renaming...'), 'loading');
renameNote(renameTarget.path, newTitle).then(function (data) {
if (disposed) return;
data = data || {};
if (data.conflict) {
setRenameError(conflictMessage(newTitle, data.path));
renameInput.focus();
showConflictModal(newTitle, data.path, renameInput);
return;
}
hideRename();
setStatus(tr('ui.renamed', null, 'Note renamed'), 'success');
loadNotes();
}).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');
loadNotes();
}).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) {
return new Promise(function (resolve) {
var overlay = el('div', { className: 'notes-modal-overlay' });
@ -739,6 +642,18 @@
sortMode = sortSelect.value || 'title-asc';
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 ───────────────────────────────────────────────
@ -751,6 +666,12 @@
createBtn.innerHTML = iconSvg('add') + ' ' + tr('ui.newNote', null, 'New Note');
filterInput.setAttribute('placeholder', tr('ui.filter', null, 'Filter notes'));
titleBar.textContent = tr('ui.title', { workspace: workspaceName }, 'Notes in ' + workspaceName);
createInput.setAttribute('placeholder', tr('ui.noteTitle', null, 'Note title'));
renameInput.setAttribute('placeholder', tr('ui.newTitle', null, 'New title'));
createConfirm.textContent = tr('ui.create', null, 'Create');
createCancel.textContent = tr('ui.cancel', null, 'Cancel');
renameConfirm.textContent = tr('ui.rename', null, 'Rename');
renameCancel.textContent = tr('ui.cancel', null, 'Cancel');
renderList();
});
}
@ -769,8 +690,6 @@
containerEl.__notesCleanup = function () {
disposed = true;
hideCreate();
hideRename();
if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
if (typeof fileChangedUnsubscribe === 'function') fileChangedUnsubscribe();
};

View File

@ -1,14 +1,11 @@
{
"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",
"ui.newNote": "New Note",
"ui.filter": "Filter notes",
"ui.title": "Notes in {workspace}",
"ui.noteTitle": "Note title",
"ui.createTitle": "Create note",
"ui.renameTitle": "Rename note",
"ui.titleRequired": "Enter a note title.",
"ui.create": "Create",
"ui.cancel": "Cancel",
"ui.newTitle": "New title",
@ -31,10 +28,5 @@
"ui.existingFile": " Existing file: {path}.",
"ui.chooseDifferent": " Please choose a different title.",
"ui.trashTitle": "Move Note 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."
"ui.trashConfirm": "Move \"{title}\" to trash?"
}

View File

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

View File

@ -4,7 +4,7 @@
"name": "Notes",
"version": "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" } },
"source": "official",
"icon": "edit",

View File

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

View File

@ -1,6 +1,6 @@
{
"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.commands.verstak.search.searchVaultText.title": "Search Vault Text",
"contributions.searchProviders.verstak.search.vault-text.label": "Vault Text Search",
@ -10,8 +10,6 @@
"ui.searching": "Searching...",
"ui.search": "Search",
"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.open": "Open",
"ui.count": "{count} result(s)"

View File

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

View File

@ -4,7 +4,7 @@
"name": "Search",
"version": "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" } },
"source": "official",
"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-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-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-count{color:var(--vt-color-text-muted,#7f8aa3);font-size:.76rem}',
'.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-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-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-search{font-size:.76rem}',
'.secrets-select{appearance:none;background-color:#0d1117;background-image:linear-gradient(45deg,transparent 50%,#8b949e 50%),linear-gradient(135deg,#8b949e 50%,transparent 50%);background-position:calc(100% - 16px) 50%,calc(100% - 11px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:2rem}.secrets-select option{background:#0d1117;color:var(--vt-color-text-primary,#f4f7fb)}',
'.secrets-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-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-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-status{font-size:.78rem;color:var(--vt-color-text-muted,#7f8aa3);min-height:1rem}',
'.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 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-modal-overlay{position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.6)}',
'.secrets-modal{width:24rem;max-width:90vw;display:grid;gap:.8rem;padding:1rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-lg,8px);background:var(--vt-color-surface,#15152c);box-shadow:var(--vt-elevation-menu,0 14px 32px rgba(0,0,0,.42))}',
'.secrets-modal-title{margin:0;font-size:.96rem}',
'.secrets-modal-message{margin:0;color:var(--vt-color-text-secondary,#b7c0d4);font-size:.84rem;line-height:1.45}',
'.secrets-modal-actions{display:flex;justify-content:flex-end;gap:.5rem}',
'@media(max-width:780px){.secrets-root{grid-template-columns:1fr}.secrets-panel{border-right:0;border-bottom:1px solid #252b36;max-height:45vh}.secrets-row,.secrets-filters{grid-template-columns:1fr}}'
'@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}}'
].join('\n');
function el(tag, attrs, children) {
@ -102,30 +95,29 @@
|| (node && (node.rootPath || node.name || node.id)));
}
function scopeLabel(record, translate) {
function scopeLabel(record) {
var scope = record && record.scope || {};
if (scope.kind === ScopeWorkspace) return cleanWorkspace(scope.workspaceRootPath) || translate('ui.deal', null, 'Deal');
return translate('ui.global', null, 'Global');
if (scope.kind === ScopeWorkspace) return cleanWorkspace(scope.workspaceRootPath) || 'Workspace';
return 'Global';
}
function selectedIDFromProps(props) {
var resource = props && props.resource || {};
var request = props && props.request || {};
var path = text(resource.path || request.path || props && props.secretId);
var path = text(resource.path || props && props.secretId);
if (path.indexOf('verstak-secret://') === 0) return decodeURIComponent(path.slice('verstak-secret://'.length));
return decodeURIComponent(path.replace(/^\/+/, ''));
}
function groupRecords(records, translate) {
function groupRecords(records) {
var groups = {};
records.forEach(function (record) {
var label = scopeLabel(record, translate);
var label = scopeLabel(record);
groups[label] = groups[label] || [];
groups[label].push(record);
});
return Object.keys(groups).sort(function (a, b) {
if (a === translate('ui.global', null, 'Global')) return -1;
if (b === translate('ui.global', null, 'Global')) return 1;
if (a === 'Global') return -1;
if (b === 'Global') return 1;
return a.localeCompare(b);
}).map(function (label) {
groups[label].sort(function (a, b) {
@ -152,13 +144,8 @@
var workspaceRoot = workspaceFromProps(props || {});
var selectedID = selectedIDFromProps(props || {});
var records = [];
var workspaceOptions = [];
var scopeFilter = 'all';
var searchQuery = '';
var selectedRecord = null;
var selectedValue = '';
var isValueVisible = false;
var deleteModal = null;
var initialized = false;
var unlocked = false;
var statusText = '';
@ -174,92 +161,6 @@
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() {
var passwordInput = el('input', {
className: 'secrets-input',
@ -289,7 +190,7 @@
return loadRecords();
}).catch(function (err) {
unlockBtn.disabled = false;
setUserFacingError('unlock', err);
setStatus((err && err.message) ? err.message : String(err), true);
});
}
}, [tr('ui.unlock', null, 'Unlock')]);
@ -325,48 +226,19 @@
}
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 = [
el('div', { className: 'secrets-toolbar' }, [
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('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')]));
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-list' }, group.records.map(function (record) {
var active = selectedRecord && selectedRecord.id === record.id;
@ -387,19 +259,16 @@
function renderSelected() {
if (!selectedRecord) return el('div', { className: 'secrets-card' }, [
el('h2', {}, [tr('ui.select', null, 'Select a secret')]),
el('div', { className: statusError ? 'secrets-status error' : 'secrets-status' }, [statusText])
el('h2', {}, [tr('ui.select', null, 'Select a secret')])
]);
var valueIsAvailable = !!selectedValue;
var valueDisplay = valueIsAvailable && isValueVisible ? selectedValue : '••••••••••••';
return el('div', { className: 'secrets-card' }, [
el('h2', {}, [selectedRecord.title || selectedRecord.id]),
el('table', { className: 'secrets-table' }, [
el('tbody', {}, [
fieldRow(tr('ui.group', null, 'Group'), scopeLabel(selectedRecord, tr)),
fieldRow(tr('ui.group', null, 'Group'), scopeLabel(selectedRecord)),
fieldRow('ID', selectedRecord.id),
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 || '')
])
]),
@ -410,24 +279,6 @@
'data-secret-copy-link': selectedRecord.id,
onClick: function () { copySecretLink(selectedRecord.id); }
}, [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', {
className: 'secrets-btn',
type: 'button',
@ -465,26 +316,9 @@
value.value = isEdit ? selectedValue : '';
var scope = el('select', { className: 'secrets-select' }, [
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);
updateWorkspaceVisibility();
return el('div', { className: 'secrets-card' }, [
el('h2', {}, [isEdit ? tr('ui.editSecret', null, 'Edit secret') : tr('ui.newSecret', null, 'New secret')]),
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' }, [tr('ui.username', null, 'Username')]), username]),
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-actions' }, [
el('button', {
@ -501,24 +334,19 @@
'data-secret-save': '',
onClick: function () {
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({
id: nextID,
title: text(title.value).trim() || nextID,
username: text(username.value).trim(),
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) {
selectedID = record.id;
selectedRecord = record;
selectedValue = '';
return loadRecords();
}).catch(function (err) {
setUserFacingError('save', err);
setStatus((err && err.message) ? err.message : String(err), true);
});
}
}, [tr('ui.save', null, 'Save')]),
@ -553,20 +381,11 @@
if (wanted) {
var found = records.find(function (record) { return record.id === wanted; });
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;
selectedValue = '';
mode = 'selected';
render();
}).catch(function (err) {
setUserFacingError('load', err);
});
}
@ -575,7 +394,6 @@
selectedID = id;
selectedRecord = records.find(function (record) { return record.id === id; }) || null;
selectedValue = '';
isValueVisible = false;
render();
if (!id) return Promise.resolve();
return api.secrets.read(id).then(function (record) {
@ -585,7 +403,7 @@
render();
}).catch(function (err) {
if (disposed) return;
setUserFacingError('read', err);
setStatus((err && err.message) ? err.message : String(err), true);
});
}
@ -605,68 +423,14 @@
}
function deleteSecret(id) {
if (!id) return;
removeDeleteModal();
var title = selectedRecord && selectedRecord.id === id ? selectedRecord.title || selectedRecord.id : id;
var overlay = el('div', {
className: 'secrets-modal-overlay',
'data-secret-delete-modal': '',
role: 'presentation'
});
var cancel = el('button', {
className: 'secrets-btn',
type: 'button',
'data-secret-delete-cancel': '',
onClick: removeDeleteModal
}, [tr('ui.cancel', null, 'Cancel')]);
var confirm = el('button', {
className: 'secrets-btn danger',
type: 'button',
'data-secret-delete-confirm': '',
onClick: function () {
removeDeleteModal();
confirmDeleteSecret(id);
}
}, [tr('ui.delete', null, 'Delete')]);
overlay.appendChild(el('div', {
className: 'secrets-modal',
role: 'dialog',
'aria-modal': 'true',
'aria-label': tr('ui.deleteSecretTitle', null, 'Delete secret')
}, [
el('h2', { className: 'secrets-modal-title' }, [tr('ui.deleteSecretTitle', null, 'Delete secret')]),
el('p', { className: 'secrets-modal-message' }, [tr('ui.deleteConfirm', { title: title }, 'Delete "' + title + '"?')]),
el('div', { className: 'secrets-modal-actions' }, [cancel, confirm])
]));
document.body.appendChild(overlay);
deleteModal = overlay;
if (cancel.focus) cancel.focus();
}
function removeDeleteModal() {
if (deleteModal && typeof deleteModal.remove === 'function') deleteModal.remove();
else if (deleteModal && deleteModal.parentNode) deleteModal.parentNode.removeChild(deleteModal);
deleteModal = null;
}
function confirmDeleteSecret(id) {
if (!id || !window.confirm(tr('ui.deleteConfirm', null, 'Delete this secret?'))) return;
api.secrets.delete(id).then(function () {
selectedID = '';
selectedRecord = null;
selectedValue = '';
isValueVisible = false;
return loadRecords();
}).catch(function (err) {
setUserFacingError('delete', err);
});
}
function copySecretValue(value) {
if (!value) return;
writeClipboard(api, value).then(function () {
setStatus(tr('ui.valueCopied', null, 'Secret value copied'), false);
}).catch(function (err) {
setUserFacingError('copyValue', err);
setStatus((err && err.message) ? err.message : String(err), true);
});
}
@ -676,17 +440,17 @@
setStatus(tr('ui.linkCopied', null, 'Secret link copied'), false);
});
}).catch(function (err) {
setUserFacingError('copyLink', err);
setStatus((err && err.message) ? err.message : String(err), true);
});
}
api.secrets.status().then(function (status) {
initialized = !!(status && status.initialized);
unlocked = !!(status && status.unlocked);
if (unlocked) return loadWorkspaceOptions().then(loadRecords);
if (unlocked) return loadRecords();
render();
}).catch(function (err) {
statusText = userFacingError('status', err);
statusText = (err && err.message) ? err.message : String(err);
statusError = true;
renderLocked();
});
@ -697,7 +461,6 @@
containerEl.__secretsCleanup = function () {
disposed = true;
removeDeleteModal();
if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
};
},

View File

@ -1,17 +1,12 @@
{
"manifest.name": "Secrets",
"manifest.description": "Encrypted global and Deal-scoped secret manager.",
"contributions.views.verstak.secrets.view.title": "Secrets",
"contributions.sidebarItems.verstak.secrets.sidebar.title": "Secrets",
"manifest.description": "Encrypted global and workspace-scoped secret manager.",
"contributions.openProviders.verstak.secrets.secret.title": "Secrets",
"contributions.settingsPanels.verstak.secrets.settings.title": "Secrets",
"contributions.workspaceItems.verstak.secrets.workspace.title": "Secrets",
"ui.masterPassword": "Master password",
"ui.repeatPassword": "Repeat master password",
"ui.passwordMismatch": "Master passwords do not match",
"ui.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.createMaster": "Create master password",
"ui.password": "Password",
@ -21,42 +16,23 @@
"ui.new": "New",
"ui.empty": "No secrets",
"ui.select": "Select a secret",
"ui.requestedUnavailable": "The requested secret is unavailable.",
"ui.group": "Group",
"ui.username": "Username",
"ui.updated": "Updated",
"ui.copyLink": "Copy secret link",
"ui.edit": "Edit",
"ui.delete": "Delete",
"ui.copyValue": "Copy value",
"ui.showValue": "Show value",
"ui.hideValue": "Hide value",
"ui.fieldTitle": "Title",
"ui.optionalUsername": "optional username",
"ui.secretValue": "Secret value",
"ui.global": "Global",
"ui.workspace": "Deal",
"ui.workspace": "Workspace",
"ui.editSecret": "Edit secret",
"ui.newSecret": "New secret",
"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.save": "Save",
"ui.cancel": "Cancel",
"ui.deleteSecretTitle": "Delete secret",
"ui.deleteConfirm": "Delete \"{title}\"? This cannot be undone.",
"ui.linkCopied": "Secret link copied",
"ui.valueCopied": "Secret value copied"
"ui.deleteConfirm": "Delete this secret?",
"ui.linkCopied": "Secret link copied"
}

View File

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

View File

@ -4,7 +4,7 @@
"name": "Secrets",
"version": "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" } },
"source": "official",
"icon": "key-round",
@ -14,7 +14,6 @@
"secrets.write-ui"
],
"permissions": [
"files.read",
"secrets.read",
"secrets.write",
"ui.register"
@ -23,23 +22,6 @@
"entry": "frontend/src/index.js"
},
"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": [
{
"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_FOCUS_STYLE = INPUT_STYLE + 'outline:none;border-color:#4ecca3;'
function reportError(key, fallback, error) {
console.warn('[verstak.sync] operation failed:', error)
return tr(key, null, fallback)
function sanitizeError(msg) {
if (!msg) return tr('ui.unknownError', null, 'Unknown error')
let s = String(msg).replace(/<[^>]+>/g, '')
if (s.length > 200) s = s.substring(0, 200) + '...'
return s
}
function syncAPI() {
@ -49,21 +51,15 @@
syncInterval = saved.syncInterval || 5
}
}
} catch (error) {
console.warn('[verstak.sync] settings load failed:', error)
}
} catch (_) {}
try {
settings = await syncAPI().status()
if (settings) {
if (settings.serverUrl) serverUrl = settings.serverUrl
if (settings.syncInterval != null) syncInterval = settings.syncInterval
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()
@ -84,7 +80,7 @@
resultMsg = tr('ui.settingsSaved', null, 'Settings saved.')
resultKind = ''
} catch (e) {
errorMsg = reportError('ui.saveFailed', 'Could not save sync settings. Please try again.', e)
errorMsg = sanitizeError(e.message || e)
}
loading = false
}
@ -101,7 +97,7 @@
connectionResult = tr('ui.connectionSuccessful', null, 'Connection successful.')
} catch (e) {
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
}
@ -119,7 +115,7 @@
password = ''
await load()
} catch (e) {
errorMsg = reportError('ui.connectFailed', 'Could not connect this device. Please try again.', e)
errorMsg = sanitizeError(e.message || e)
}
loading = false
}
@ -133,8 +129,23 @@
return parts.join(' · ')
}
function formatSyncConflict() {
return tr('ui.syncConflictItem', null, 'A synchronization conflict needs attention.')
function conflictField(conflict, keys) {
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() {
@ -152,7 +163,7 @@
resultKind = warning ? 'warning' : ''
await load()
} catch (e) {
errorMsg = reportError('ui.syncFailed', 'Could not synchronize. Please try again.', e)
errorMsg = sanitizeError(e.message || e)
}
loading = false
}
@ -183,7 +194,7 @@
settings = null
await load()
} catch (e) {
errorMsg = reportError('ui.disconnectFailed', 'Could not disconnect from the server. Please try again.', e)
errorMsg = sanitizeError(e.message || e)
}
loading = false
}
@ -198,7 +209,7 @@
resultKind = ''
await load()
} catch (e) {
errorMsg = reportError('ui.resetKeyFailed', 'Could not reset the sync key. Please try again.', e)
errorMsg = sanitizeError(e.message || e)
}
loading = false
}
@ -232,7 +243,7 @@
{/if}
{#if settings && settings.lastError && !errorMsg}
<div style="padding:0.5rem 0.75rem;margin-bottom:0.75rem;background:rgba(255,107,107,0.1);border:1px solid rgba(255,107,107,0.3);border-radius:6px;color:#ff6b6b;font-size:0.85rem;">
{tr('ui.lastSyncError', null, 'The last synchronization did not finish. Try again.')}
{tr('ui.lastSyncError', { error: sanitizeError(settings.lastError) }, 'Last sync error: {error}')}
</div>
{/if}

View File

@ -5,17 +5,12 @@
"ui.title": "Sync",
"ui.description": "Synchronize your vault across devices.",
"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.settingsSaved": "Settings saved.",
"ui.serverRequired": "Server URL is required.",
"ui.connectionSuccessful": "Connection successful.",
"ui.connectionFailed": "Could not connect. Check the server address and credentials.",
"ui.saveFailed": "Could not save sync settings. Please try again.",
"ui.connectFailed": "Could not connect this device. Please try again.",
"ui.syncFailed": "Could not synchronize. Please try again.",
"ui.disconnectFailed": "Could not disconnect from the server. Please try again.",
"ui.resetKeyFailed": "Could not reset the sync key. Please try again.",
"ui.connectionFailed": "Connection failed: {error}",
"ui.connectedSuccessfully": "Connected successfully.",
"ui.conflictsCount": "{count} conflict(s)",
"ui.errorsCount": "{count} error(s)",
@ -23,8 +18,7 @@
"ui.disconnected": "Disconnected from server.",
"ui.keyReset": "Sync key reset. Connect again to pair this device.",
"ui.syncConflicts": "Sync conflicts",
"ui.lastSyncError": "The last synchronization did not finish. Try again.",
"ui.syncConflictItem": "A synchronization conflict needs attention.",
"ui.lastSyncError": "Last sync error: {error}",
"ui.server": "Server",
"ui.serverUrl": "Server URL",
"ui.username": "Username",

View File

@ -5,17 +5,12 @@
"ui.title": "Синхронизация",
"ui.description": "Синхронизируйте хранилище между устройствами.",
"ui.unknownError": "Неизвестная ошибка",
"ui.apiUnavailable": "Синхронизация сейчас недоступна.",
"ui.apiUnavailable": "API синхронизации плагина недоступен",
"ui.intervalError": "Интервал синхронизации должен быть от 1 до 1440 минут.",
"ui.settingsSaved": "Настройки сохранены.",
"ui.serverRequired": "Укажите URL сервера.",
"ui.connectionSuccessful": "Подключение успешно.",
"ui.connectionFailed": "Не удалось подключиться. Проверьте адрес сервера и учётные данные.",
"ui.saveFailed": "Не удалось сохранить настройки синхронизации. Повторите попытку.",
"ui.connectFailed": "Не удалось подключить это устройство. Повторите попытку.",
"ui.syncFailed": "Не удалось синхронизировать данные. Повторите попытку.",
"ui.disconnectFailed": "Не удалось отключиться от сервера. Повторите попытку.",
"ui.resetKeyFailed": "Не удалось сбросить ключ синхронизации. Повторите попытку.",
"ui.connectionFailed": "Не удалось подключиться: {error}",
"ui.connectedSuccessfully": "Подключение установлено.",
"ui.conflictsCount": "Конфликтов: {count}",
"ui.errorsCount": "Ошибок: {count}",
@ -23,8 +18,7 @@
"ui.disconnected": "Сервер отключён.",
"ui.keyReset": "Ключ синхронизации сброшен. Подключитесь снова, чтобы связать устройство.",
"ui.syncConflicts": "Конфликты синхронизации",
"ui.lastSyncError": "Последняя синхронизация не завершилась. Повторите попытку.",
"ui.syncConflictItem": "Конфликт синхронизации требует внимания.",
"ui.lastSyncError": "Ошибка последней синхронизации: {error}",
"ui.server": "Сервер",
"ui.serverUrl": "URL сервера",
"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-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-input,.todo-select{box-sizing:border-box;min-height:1.9rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-surface,#15152c);color:var(--vt-color-text-primary,#f4f7fb);color-scheme:dark;font:inherit;font-size:.78rem;padding:.32rem .45rem}',
'.todo-input.search{width:min(15rem,100%)}.todo-input.textarea{min-height:6.5rem;resize:vertical;line-height:1.4}.todo-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,.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-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-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)}',
@ -78,7 +78,7 @@
var workspaceRoot = workspaceFromProps(props || {});
return workspaceRoot
? { mode: 'workspace', workspaceRoot: workspaceRoot, label: workspaceRoot }
: { mode: 'global', workspaceRoot: '', label: 'All Deals' };
: { mode: 'global', workspaceRoot: '', label: 'All workspaces' };
}
function now() {
@ -96,28 +96,7 @@
}
function cleanDate(value) {
value = text(value).trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
var match = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(value);
var day;
var month;
var year;
if (match) {
month = Number(match[1]);
day = Number(match[2]);
year = Number(match[3]);
} else {
match = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(value);
if (!match) return '';
day = Number(match[1]);
month = Number(match[2]);
year = Number(match[3]);
}
var date = new Date(Date.UTC(year, month - 1, day));
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return '';
return String(year).padStart(4, '0') + '-' + String(month).padStart(2, '0') + '-' + String(day).padStart(2, '0');
return /^\d{4}-\d{2}-\d{2}$/.test(text(value).trim()) ? text(value).trim() : '';
}
function cleanDateTime(value) {
@ -127,27 +106,6 @@
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) {
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) {
value = value || {};
var status = cleanStatus(value.status);
var reminderAt = cleanDateTime(value.reminderAt || value.reminderDateTime);
var reminder = splitReminderDateTime(reminderAt);
var createdAt = text(value.createdAt).trim() || now();
var completedAt = status === 'done' ? (text(value.completedAt).trim() || createdAt) : '';
return {
@ -168,8 +124,7 @@
status: status,
priority: cleanPriority(value.priority),
dueAt: cleanDate(value.dueAt || value.dueDate),
reminderDate: cleanDate(value.reminderDate) || reminder.date,
reminderAt: reminderAt,
reminderAt: cleanDateTime(value.reminderAt || value.reminderDateTime),
createdAt: createdAt,
updatedAt: text(value.updatedAt).trim() || createdAt,
completedAt: completedAt,
@ -198,7 +153,6 @@
status: todo.status,
priority: todo.priority,
dueAt: todo.dueAt,
reminderDate: todo.reminderDate,
reminderAt: todo.reminderAt,
createdAt: todo.createdAt,
updatedAt: todo.updatedAt,
@ -360,7 +314,7 @@
function renderWorkspaceFilterOptions() {
if (scope.mode !== 'global') return;
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')));
workspaceRoots().forEach(function (workspace) {
workspaceFilterEl.appendChild(option(workspace, workspace));
@ -386,53 +340,21 @@
});
}
function notificationRequests() {
return sortTodos(todos).filter(function (todo) {
return todo.status === 'open' && todo.reminderAt;
}).map(function (todo) {
var dueAt = new Date(todo.reminderAt);
if (isNaN(dueAt.getTime())) return null;
var title = todo.title || tr('ui.untitled', null, 'Untitled todo');
return {
id: todo.id,
dueAt: dueAt.toISOString(),
title: tr('ui.notificationTitle', null, 'Todo reminder'),
body: tr('ui.notificationBody', { title: title }, title)
};
}).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() {
if (!api || !api.notifications || typeof api.notifications.replace !== 'function') return Promise.resolve();
return api.notifications.replace(notificationRequests()).catch(function (err) {
reportError('ui.notificationError', 'Could not schedule reminders. Please try again.', err);
});
}
function persist() {
if (!api || !api.settings || typeof api.settings.write !== 'function') return syncNotifications();
return api.settings.write(GLOBAL_KEY, storageTodos(sortTodos(todos))).then(function () {
return syncNotifications();
}).catch(function (err) {
reportError('ui.saveError', 'Could not save tasks. Please try again.', err);
if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
return api.settings.write(GLOBAL_KEY, storageTodos(sortTodos(todos))).catch(function (err) {
statusText = tr('ui.saveError', { error: err && err.message ? err.message : String(err) }, 'Could not save todos: ' + (err && err.message ? err.message : String(err)));
statusClass = 'error';
});
}
function loadStored() {
if (!api || !api.settings || typeof api.settings.read !== 'function') return syncNotifications();
if (!api || !api.settings || typeof api.settings.read !== 'function') return Promise.resolve();
return api.settings.read().then(function (settings) {
todos = sortTodos(normalizeTodos((settings || {})[GLOBAL_KEY]));
return syncNotifications();
}).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 +386,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'))]);
priorityInput.value = editing ? existingTodo.priority : 'normal';
var dueInput = el('input', { className: 'todo-input', type: 'date', value: editing ? existingTodo.dueAt : '', 'data-todo-input': 'dueAt' });
var reminder = splitReminderDateTime(editing ? existingTodo.reminderAt : '');
var reminderDateInput = el('input', { className: 'todo-input', type: 'date', value: editing ? existingTodo.reminderDate || reminder.date : '', 'data-todo-input': 'reminderDate' });
var reminderTimeInput = el('input', { className: 'todo-input todo-time-input', type: 'text', inputmode: 'numeric', maxlength: '5', placeholder: tr('ui.reminderTimePlaceholder', null, '14:30'), value: reminder.time, 'data-todo-input': 'reminderTime', 'aria-label': tr('ui.field.reminderTime', null, 'Reminder time') });
var reminderInput = el('input', { className: 'todo-input', type: 'datetime-local', value: editing ? existingTodo.reminderAt : '', 'data-todo-input': 'reminderAt' });
var workspaceInput = null;
var workspace = editing ? existingTodo.workspaceRootPath : scope.workspaceRoot;
if (scope.mode === 'global') {
@ -487,20 +407,6 @@
return;
}
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 next = normalizeTodo({
id: editing ? existingTodo.id : todoId(workspaceRoot, title),
@ -511,8 +417,7 @@
status: editing ? existingTodo.status : 'open',
priority: priorityInput.value,
dueAt: dueInput.value,
reminderDate: reminderDate,
reminderAt: joinReminderDateTime(reminderDate, reminderTime),
reminderAt: reminderInput.value,
createdAt: editing ? existingTodo.createdAt : timestamp,
updatedAt: timestamp,
completedAt: editing ? existingTodo.completedAt : '',
@ -537,11 +442,10 @@
el('label', { className: 'todo-field wide' }, [tr('ui.field.description', null, 'Description'), descriptionInput]),
el('label', { className: 'todo-field' }, [tr('ui.field.priority', null, 'Priority'), priorityInput]),
el('label', { className: 'todo-field' }, [tr('ui.field.due', null, 'Due date'), dueInput]),
el('label', { className: 'todo-field' }, [tr('ui.field.reminderDate', null, 'Reminder date'), reminderDateInput]),
el('label', { className: 'todo-field' }, [tr('ui.field.reminderTime', null, 'Reminder time'), reminderTimeInput])
el('label', { className: 'todo-field' }, [tr('ui.field.reminder', null, 'Reminder'), reminderInput])
];
if (workspaceInput) fields.push(el('label', { className: 'todo-field' }, [tr('ui.field.workspace', null, 'Deal'), workspaceInput]));
else fields.push(el('div', { className: 'todo-field', textContent: tr('ui.workspaceValue', { workspace: scope.workspaceRoot }, 'Deal: ' + scope.workspaceRoot) }));
if (workspaceInput) fields.push(el('label', { className: 'todo-field' }, [tr('ui.field.workspace', null, 'Workspace'), workspaceInput]));
else fields.push(el('div', { className: 'todo-field', textContent: tr('ui.workspaceValue', { workspace: scope.workspaceRoot }, 'Workspace: ' + scope.workspaceRoot) }));
modalHost.innerHTML = '';
if (typeof modalHost.removeAttribute === 'function') modalHost.removeAttribute('hidden');
@ -619,7 +523,6 @@
meta.push(el('span', { className: 'todo-badge', textContent: tr('ui.status.' + todo.status, null, todo.status) }));
if (todo.dueAt) meta.push(el('span', { className: 'todo-badge ' + due, textContent: tr('ui.dueValue', { prefix: due === 'overdue' ? tr('ui.overduePrefix', null, 'Overdue · ') : (due === 'due-soon' ? tr('ui.dueSoonPrefix', null, 'Due soon · ') : ''), date: formatDate(todo.dueAt) }, (due === 'overdue' ? 'Overdue · ' : (due === 'due-soon' ? 'Due soon · ' : '')) + 'Due ' + formatDate(todo.dueAt)) }));
if (todo.reminderAt) meta.push(el('span', { className: 'todo-badge ' + (reminderDue ? 'reminder-due' : ''), textContent: tr(reminderDue ? 'ui.reminderDueValue' : 'ui.reminderValue', { date: formatDate(todo.reminderAt) }, (reminderDue ? 'Reminder due ' : 'Reminder ') + formatDate(todo.reminderAt)) }));
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);
}
@ -636,7 +539,7 @@
visible.forEach(function (todo) {
var actionButtons = [];
if (scope.mode === 'global' && todo.workspaceRootPath) {
actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'open-workspace', textContent: 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') {
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'); } }));
@ -686,7 +589,6 @@
searchInput.setAttribute('placeholder', tr('ui.search', null, 'Search todos'));
addBtn.textContent = tr('ui.add', null, 'Add Todo');
render();
syncNotifications().then(render);
});
}
};

View File

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

View File

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

View File

@ -4,7 +4,7 @@
"name": "Todos",
"version": "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" } },
"source": "official",
"icon": "list-todo",
@ -12,14 +12,10 @@
"todo.list",
"todo.workspace"
],
"requires": [
"verstak/core/notifications/v1"
],
"permissions": [
"files.read",
"storage.namespace",
"ui.register",
"notifications.schedule"
"ui.register"
],
"frontend": {
"entry": "frontend/src/index.js"

View File

@ -16,7 +16,7 @@
'.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-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:hover:not(:disabled){border-color:var(--vt-color-accent,#4ecca3);color:var(--vt-color-text-primary,#f4f7fb)}',
'.trash-btn:disabled{opacity:.48;cursor:default}',
@ -217,7 +217,7 @@
value: state.workspace,
'data-trash-filter-workspace': '',
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) {
workspaceSelect.appendChild(el('option', { value: workspace }, [workspace]));
});
@ -270,7 +270,7 @@
var list = el('div', { className: 'trash-list', 'data-trash-list': '' });
list.appendChild(el('div', { className: 'trash-header' }, [
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.deleted', null, 'Deleted')]),
el('span', {}, [tr('ui.typeSize', null, 'Type / size')]),

View File

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

View File

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

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"
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 ""
# Guard official plugins against bypassing the v2 plugin API for note features.
echo "[frontend API boundary]"

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 (!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('Capture converted')) throw new Error('conversion label was not rendered');
if (container.textContent.includes('browser.capture.selection') || container.textContent.includes('browser.capture.converted') || container.textContent.includes('verstak.browser-inbox')) {
throw new Error('Activity must not expose technical event or plugin identifiers in its UI');
}
if (!container.textContent.includes('browser.capture.selection')) throw new Error('event type was not rendered');
if (!container.textContent.includes('browser.capture.converted')) throw new Error('conversion event type 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');
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.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('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');
@ -364,13 +361,6 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
if (!clearButton) throw new Error('clear activity button not found');
clearButton.click();
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('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');
rawClear.click();
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');
component.unmount && component.unmount(rawView.container);

View File

@ -6,11 +6,6 @@ const vm = require('vm');
const root = path.resolve(__dirname, '..');
const sourcePath = path.join(root, 'plugins', 'browser-inbox', 'frontend', 'src', 'index.js');
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 {
constructor(tagName) {
@ -118,15 +113,7 @@ function makeDocument() {
function loadComponents(document) {
const registry = {};
const sandbox = {
console: {
...console,
warn(...args) {
technicalErrors.push(args.map((value) => String(value)).join(' '));
},
error(...args) {
technicalErrors.push(args.map((value) => String(value)).join(' '));
},
},
console,
Date,
document,
window: {
@ -149,7 +136,7 @@ function loadComponent(document) {
return component;
}
function makeApi(initialSettings = {}, locale = 'en') {
function makeApi(initialSettings = {}) {
const settings = { ...initialSettings };
const handlers = {};
const unsubscribed = [];
@ -166,13 +153,6 @@ function makeApi(initialSettings = {}, locale = 'en') {
receiverToken: 'initial-browser-token',
};
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() {
const keys = ['captures:global', 'captures', ...Object.keys(settings).filter((key) => key.startsWith('captures:workspace:'))];
const seen = new Set();
@ -225,11 +205,6 @@ function makeApi(initialSettings = {}, locale = 'en') {
fileByteWrites,
openedURLs,
publishedEvents,
i18n: {
getLocale: () => locale,
t: translate,
onDidChangeLocale: () => () => {},
},
failNextWrite(message) {
nextWriteError = new Error(message || 'write failed');
},
@ -323,18 +298,6 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
}
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 settingsView = await mountSettingsWithApi(makeApi());
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')) {
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');
}
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')) {
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')) {
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');
}
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')) {
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');
}
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);
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');
})().catch((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 component = loadComponent(document);
const opened = [];
@ -146,15 +146,6 @@ async function mountEditor(secretProviderEnabled, translations) {
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');
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');
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');
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');
@ -197,12 +182,6 @@ async function mountEditor(secretProviderEnabled, translations) {
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');
})().catch((err) => {
console.error(err);

View File

@ -195,11 +195,9 @@ function loadFilesComponent(document) {
return { component, clipboard: sandbox.navigator.clipboard };
}
function makeApi(options = {}) {
function makeApi() {
const externalCalls = [];
const contributionCalls = [];
const created = [];
const moved = [];
const eventHandlers = {};
let restored = false;
let externalVisible = false;
@ -214,8 +212,6 @@ function makeApi(options = {}) {
return {
externalCalls,
contributionCalls,
created,
moved,
emitFileChanged(payload) {
(eventHandlers['file.changed'] || []).forEach((handler) => handler({
name: 'file.changed',
@ -233,15 +229,6 @@ function makeApi(options = {}) {
size: 12,
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) {
entries.push({
name: 'deleted.md',
@ -266,15 +253,9 @@ function makeApi(options = {}) {
},
metadata: async () => { throw new Error('not-found'); },
readText: async () => '# Readme\n',
writeText: async (relativePath, content, options) => {
created.push({ type: 'file', relativePath, content, options });
},
createFolder: async (relativePath) => {
created.push({ type: 'folder', relativePath });
},
move: async (fromRelativePath, toRelativePath) => {
moved.push({ fromRelativePath, toRelativePath });
},
writeText: async () => undefined,
createFolder: async () => undefined,
move: async () => undefined,
trash: async () => undefined,
listTrash: async () => trashEntries.slice(),
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() {
externalVisible = true;
},
@ -356,62 +328,6 @@ async function flush() {
const row = walk(container, (node) => node.getAttribute && node.getAttribute('data-file-path') === 'Docs/readme.md');
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);
if (!list) throw new Error('files list not rendered');
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}`);
}
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');
})().catch((err) => {
console.error(err);

View File

@ -6,14 +6,12 @@ const vm = require('vm');
const root = path.resolve(__dirname, '..');
const manifestPath = path.join(root, 'plugins', 'journal', 'plugin.json');
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(sourcePath)) throw new Error('journal frontend entry missing');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const source = fs.readFileSync(sourcePath, 'utf8');
const russianLocale = JSON.parse(fs.readFileSync(russianLocalePath, 'utf8'));
class FakeNode {
constructor(tagName) {
@ -133,7 +131,7 @@ function loadComponent(document) {
return component;
}
function makeApi(initialSettings = {}, locale = null) {
function makeApi(initialSettings = {}) {
const settings = { ...initialSettings };
const publishedEvents = [];
return {
@ -150,17 +148,6 @@ function makeApi(initialSettings = {}, locale = null) {
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) {
return settings[key] || [];
},
@ -252,11 +239,8 @@ function byData(container, attr, value) {
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('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('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', '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');
@ -283,15 +267,6 @@ function byData(container, attr, value) {
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();
await flush();
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')) {
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(candidateView.container);
component.unmount && component.unmount(russianCandidateView.container);
component.unmount && component.unmount(todoView.container);
component.unmount && component.unmount(duplicateTodoView.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');
if (!createButton) throw new Error('create button not found');
createButton.click();
const createModal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-notes-create-modal') !== undefined);
if (!createModal) throw new Error('create modal not found');
const input = walk(createModal, (node) => node.getAttribute && node.getAttribute('data-notes-create-input') !== undefined);
const input = walk(container, (node) => node.getAttribute && node.getAttribute('data-notes-create-input') !== undefined);
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');
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();
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');
if (!renameButton) throw new Error('rename note button not found');
renameButton.click();
const renameModal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-notes-rename-modal') !== undefined);
if (!renameModal) throw new Error('rename modal not found');
const renameInput = walk(renameModal, (node) => node.getAttribute && node.getAttribute('data-notes-rename-input') !== undefined);
const renameInput = walk(container, (node) => node.getAttribute && node.getAttribute('data-notes-rename-input') !== undefined);
if (!renameInput) throw new Error('rename input not found');
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();
const renameError = walk(renameModal, (node) => node.getAttribute && node.getAttribute('data-notes-rename-error') !== undefined);
if (!renameError || !renameError.textContent.includes('Project/Notes/Second_Note.md')) {
throw new Error(`rename conflict should stay in the form modal, got ${renameError && renameError.textContent}`);
const conflictModal = walk(document.body, (node) => node.className === 'notes-modal-msg');
if (!conflictModal || !conflictModal.textContent.includes('Project/Notes/Second_Note.md')) {
throw new Error(`rename conflict modal should include existing path, got ${conflictModal && conflictModal.textContent}`);
}
if (walk(document.body, (node) => node.className === 'notes-modal-msg')) {
throw new Error('rename conflict opened a second modal instead of reporting in the form');
}
renameInput.dispatchEvent('keydown', { key: 'Escape' });
const conflictOk = walk(document.body, (node) => node.tagName === 'BUTTON' && node.textContent === 'OK');
if (!conflictOk) throw new Error('rename conflict OK button not found');
conflictOk.click();
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');
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 source = fs.readFileSync(sourcePath, 'utf8');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const technicalErrors = [];
class FakeNode {
constructor(tagName) {
@ -101,15 +100,7 @@ function makeDocument() {
function loadComponent(document) {
const registry = {};
vm.runInNewContext(source, {
console: {
...console,
warn(...args) {
technicalErrors.push(args.map((value) => String(value)).join(' '));
},
error(...args) {
technicalErrors.push(args.map((value) => String(value)).join(' '));
},
},
console,
document,
window: {
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/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('A search provider is unavailable.')) throw new Error('provider failure should be reported without failing search');
if (container.textContent.includes('provider unavailable')) throw new Error('provider failure leaked a raw backend error');
if (!container.textContent.includes('provider unavailable')) throw new Error('provider failure should be reported without failing search');
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 (!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 (!technicalErrors.some((entry) => entry.includes('provider unavailable'))) throw new Error('provider failure was not retained in the console log');
input = queryInput();
input.value = 'image';

View File

@ -28,16 +28,6 @@ class FakeNode {
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) {
this.attributes[name] = String(value);
}
@ -106,18 +96,10 @@ function makeDocument() {
};
}
function loadComponent(document, errorLog) {
function loadComponent(document) {
const registry = {};
vm.runInNewContext(source, {
console: {
...console,
warn(...args) {
errorLog.push(args.map((value) => String(value)).join(' '));
},
error(...args) {
errorLog.push(args.map((value) => String(value)).join(' '));
},
},
console,
document,
window: {
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.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('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.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.settingsPanels || []).some((item) => item.component === 'SecretsView')) throw new Error('secrets settings panel missing');
const document = makeDocument();
const errorLog = [];
const component = loadComponent(document, errorLog);
const component = loadComponent(document);
const records = [
{ 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' },
];
let initialized = false;
let unlocked = false;
let unlockError = '';
const readCalls = [];
const copied = [];
const deleted = [];
@ -172,7 +149,6 @@ async function flush() {
secrets: {
status: async () => ({ initialized, unlocked }),
unlock: async (password) => {
if (unlockError) throw new Error(unlockError);
if (password !== 'master-password') throw new Error('bad password');
initialized = true;
unlocked = true;
@ -201,12 +177,6 @@ async function flush() {
clipboard: {
writeText: async (text) => copied.push(text),
},
files: {
list: async () => [
{ type: 'folder', relativePath: 'ClientA' },
{ type: 'folder', relativePath: 'ClientB' },
],
},
};
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 unlockButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-unlock') === '');
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';
confirmInput.value = 'master-password';
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('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('secret-value')) throw new Error('secret value must stay hidden until the user explicitly reveals it');
const copyValueButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-copy-value') === 'client-a.db');
if (!copyValueButton) throw new Error('copy value button missing');
copyValueButton.click();
await flush();
if (!copied.includes('secret-value')) throw new Error('secret value was not copied');
const showValueButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-toggle-value') === 'client-a.db');
if (!showValueButton) throw new Error('show value button missing');
showValueButton.click();
await flush();
if (!container.textContent.includes('secret-value')) throw new Error('secret value was not revealed after explicit request');
if (!container.textContent.includes('secret-value')) throw new Error('secret value was not shown in the field table');
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');
@ -281,69 +226,8 @@ async function flush() {
if (!deleteButton) throw new Error('delete button missing');
deleteButton.click();
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');
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');
})().catch((err) => {
console.error(err);

View File

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

View File

@ -146,12 +146,10 @@ function loadComponent(document, emittedEvents) {
function makeApi(initialSettings = {}, initialLocale = 'en') {
const settings = { ...initialSettings };
const notificationCalls = [];
let locale = initialLocale;
const localeListeners = [];
return {
settings,
notificationCalls,
settingsApi: {
read: async (key) => (key ? settings[key] : { ...settings }),
write: async (key, value) => {
@ -165,11 +163,6 @@ function makeApi(initialSettings = {}, initialLocale = 'en') {
{ name: 'Project', relativePath: 'Project', type: 'folder' },
],
},
notifications: {
replace: async (items) => {
notificationCalls.push((Array.isArray(items) ? items : []).map((item) => ({ ...item })));
},
},
setLocale(nextLocale) {
locale = nextLocale;
localeListeners.slice().forEach((listener) => listener(locale));
@ -178,7 +171,6 @@ function makeApi(initialSettings = {}, initialLocale = 'en') {
return {
settings: this.settingsApi,
files: this.files,
notifications: this.notifications,
i18n: {
getLocale: () => locale,
t: (key, params, fallback) => {
@ -216,8 +208,6 @@ async function mountWithApi(apiState, props, emittedEvents = [], document = make
if (manifest.id !== 'verstak.todo') throw new Error('todo manifest id mismatch');
if (!manifest.permissions.includes('storage.namespace')) throw new Error('todo manifest must request storage.namespace');
if (!manifest.permissions.includes('files.read')) throw new Error('todo manifest must request files.read');
if (!manifest.permissions.includes('notifications.schedule')) throw new Error('todo manifest must request notifications.schedule');
if (!manifest.requires.includes('verstak/core/notifications/v1')) throw new Error('todo manifest must require native notifications');
if (!(manifest.contributes.views || []).length) throw new Error('todo manifest must contribute a global view');
if (!(manifest.contributes.workspaceItems || []).length) throw new Error('todo manifest must contribute a workspace item');
@ -231,13 +221,8 @@ async function mountWithApi(apiState, props, emittedEvents = [], document = make
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', 'priority').value = 'high';
byData(container, 'data-todo-input', 'dueAt').value = '01/02/2000';
const reminderDate = byData(container, 'data-todo-input', 'reminderDate');
const reminderTime = byData(container, 'data-todo-input', 'reminderTime');
if (!reminderDate || reminderDate.getAttribute('type') !== 'date') throw new Error('Todo reminder date input was not rendered');
if (!reminderTime || reminderTime.getAttribute('type') !== 'text') throw new Error('Todo reminder time must be a keyboard-editable text input');
reminderDate.value = '01/02/2000';
reminderTime.value = '09:30';
byData(container, 'data-todo-input', 'dueAt').value = '2000-01-01';
byData(container, 'data-todo-input', 'reminderAt').value = '2000-01-01T09:00';
byData(container, 'data-todo-action', 'save').click();
await flush();
@ -246,56 +231,27 @@ async function mountWithApi(apiState, props, emittedEvents = [], document = make
const createdTodo = storedAfterCreate[0];
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.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');
const scheduledAfterCreate = apiState.notificationCalls.at(-1) || [];
if (scheduledAfterCreate.length !== 1
|| scheduledAfterCreate[0].id !== createdTodo.id
|| scheduledAfterCreate[0].dueAt !== new Date(createdTodo.reminderAt).toISOString()
|| scheduledAfterCreate[0].title !== 'Todo reminder'
|| scheduledAfterCreate[0].body !== 'Prepare project review') {
throw new Error('open Todo reminder was not scheduled as a native notification');
}
apiState.setLocale('ru');
if (!container.textContent.includes('Задачи · Project') || !container.textContent.includes('Просрочено')) {
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');
apiState.setLocale('en');
byData(container, 'data-todo-action', 'edit').click();
byData(container, 'data-todo-input', 'title').value = 'Prepare project review updated';
byData(container, 'data-todo-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();
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].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();
await flush();
if (apiState.settings['todos:global'][0].status !== 'done' || !apiState.settings['todos:global'][0].completedAt) {
throw new Error('mark done did not persist completed state');
}
if ((apiState.notificationCalls.at(-1) || []).length !== 0) {
throw new Error('completed Todo reminder was not removed from native notification schedules');
}
if (!byData(container, 'data-todo-action', 'reopen')) throw new Error('done Todo did not expose reopen action');
if (!byData(container, 'data-todo-action', 'create-journal-entry')) throw new Error('done workspace Todo did not expose Journal conversion');
@ -329,9 +285,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')) {
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');
workspaceFilter.value = 'ClientA';
workspaceFilter.dispatchEvent('change');

View File

@ -5,10 +5,8 @@ const vm = require('vm');
const root = path.resolve(__dirname, '..');
const sourcePath = path.join(root, 'plugins', 'trash', 'frontend', 'src', 'index.js');
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 manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const russianLocale = JSON.parse(fs.readFileSync(russianLocalePath, 'utf8'));
class FakeClassList {
constructor(node) {
@ -161,7 +159,7 @@ function loadTrashComponent(document) {
return component;
}
function makeApi(locale = null) {
function makeApi() {
let entries = [
{
trashId: 'old-project-file',
@ -196,11 +194,6 @@ function makeApi(locale = null) {
return {
restoreCalls,
deleteCalls,
i18n: locale ? {
t(key, params, fallback) {
return String(locale[key] || fallback || key).replace(/\{(\w+)\}/g, (_match, name) => String((params || {})[name] ?? ''));
},
} : null,
files: {
listTrash: async () => entries.slice(),
restoreTrash: async (trashId, options) => {
@ -244,10 +237,6 @@ function findByData(rootNode, name, value) {
component.mount(container, {}, api);
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');
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}`);
@ -309,14 +298,6 @@ function findByData(rootNode, name, value) {
}
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');
})().catch((err) => {
console.error(err);