Compare commits

..

6 Commits

11 changed files with 668 additions and 111 deletions

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
dist/ dist/
release/
node_modules/ node_modules/
# compiled backend binaries (built from source) # compiled backend binaries (built from source)
plugins/*/backend/platform-test plugins/*/backend/platform-test

17
LICENSE Normal file
View File

@ -0,0 +1,17 @@
Verstak Official Plugins
Copyright (C) 2026 Verstak contributors
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU Affero General Public License as published by the Free
Software Foundation, either version 3 of the License, or (at your option) any
later version.
This program is distributed in the hope that it will be useful, but WITHOUT
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
details.
The complete license text is available at:
https://www.gnu.org/licenses/agpl-3.0.html
SPDX-License-Identifier: AGPL-3.0-or-later

View File

@ -1,5 +1,53 @@
# verstak-official-plugins # Verstak Official Plugins
Official Verstak plugins monorepo — files, trash, notes, markdown-editor, preview, activity, journal, todos, browser-inbox, search, secrets, templates + shared packages. The first-party plugin set for Verstak Desktop: Files, Trash, Notes, editor and
preview providers, Browser Inbox, Activity, Journal, Todo, Search, Secrets and
Templates. Plugin bundles are built into independent directories that the
desktop host loads from its `plugins/` folder.
`Files` shows live workspace files and folders. Global `Trash` contains deleted items from every workspace; restoring never overwrites an existing item, and permanent deletion is confirmed before it runs. > **Alpha software.** Build this repository together with compatible
> `verstak-desktop` and `verstak-sdk` checkouts.
## Build
Requirements: Node.js with npm, Go and Python 3. Keep this repository next to
`verstak-sdk` so manifest validation can use the SDK schema.
```bash
npm --version
go version
./scripts/check.sh
./scripts/build.sh
```
The build creates `dist/<plugin-id>/` packages. To install them into a sibling
desktop checkout:
```bash
cd ../verstak-desktop
./scripts/install-dev-plugins.sh
```
## Alpha behaviour
- Browser Inbox archives processed captures and can restore them; permanent
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 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.
## Release package
```bash
./scripts/release.sh v0.1.0-alpha.1
```
The command rebuilds all packages and writes
`release/verstak-official-plugins-<version>.tar.gz` plus `SHA256SUMS`.
## License
Copyright © 2026 Verstak contributors. Licensed under
[GNU AGPLv3 or later](LICENSE).

View File

@ -8,12 +8,14 @@
var PLUGIN_ID = 'verstak.activity'; var PLUGIN_ID = 'verstak.activity';
var MAX_EVENTS = 250; var MAX_EVENTS = 250;
var RAW_DATA_NAME = 'activity-events';
var MAX_CANDIDATES = 12; var MAX_CANDIDATES = 12;
var LEGACY_KEY = 'events'; var LEGACY_KEY = 'events';
var GLOBAL_KEY = 'events:global'; var GLOBAL_KEY = 'events:global';
var WORKSPACE_PREFIX = 'events:workspace:'; var WORKSPACE_PREFIX = 'events:workspace:';
var CANDIDATE_PREFIX = 'work-session-candidates:workspace:'; var CANDIDATE_PREFIX = 'work-session-candidates:workspace:';
var DISMISSAL_PREFIX = 'work-session-dismissals:workspace:'; var DISMISSAL_PREFIX = 'work-session-dismissals:workspace:';
var SESSION_REGISTRY_KEY = 'activity-session-registry-v2';
var WORKLOG_COMMAND_ID = 'verstak.activity.suggestWorklog'; var WORKLOG_COMMAND_ID = 'verstak.activity.suggestWorklog';
var MIN_SESSION_DURATION_MINUTES = 10; var MIN_SESSION_DURATION_MINUTES = 10;
var MIN_SESSION_ACTIVITY_COUNT = 2; var MIN_SESSION_ACTIVITY_COUNT = 2;
@ -30,7 +32,9 @@
'browser.capture.selection', 'browser.capture.selection',
'browser.capture.link', 'browser.capture.link',
'browser.capture.file', 'browser.capture.file',
'browser.capture.converted' 'browser.capture.converted',
'browser.activity.batch',
'activity.session.handled'
]; ];
var EVENT_LABELS = { var EVENT_LABELS = {
'workspace.selected': 'Workspace selected', 'workspace.selected': 'Workspace selected',
@ -44,7 +48,8 @@
'browser.capture.selection': 'Selection captured', 'browser.capture.selection': 'Selection captured',
'browser.capture.link': 'Link captured', 'browser.capture.link': 'Link captured',
'browser.capture.file': 'File captured', 'browser.capture.file': 'File captured',
'browser.capture.converted': 'Capture converted' 'browser.capture.converted': 'Capture converted',
'browser.activity.domain': 'Browser domain activity'
}; };
var LOW_VALUE_EVENT_TYPES = { var LOW_VALUE_EVENT_TYPES = {
'workspace.selected': true, 'workspace.selected': true,
@ -146,14 +151,16 @@
function scopeFromProps(props) { function scopeFromProps(props) {
var workspaceRoot = workspaceFromProps(props); var workspaceRoot = workspaceFromProps(props);
var workspaceId = text(props && (props.workspaceId || (props.workspaceNode && props.workspaceNode.workspaceId))).trim();
if (!workspaceRoot) { if (!workspaceRoot) {
return { mode: 'global', key: GLOBAL_KEY, label: 'All workspaces', workspaceRoot: '' }; return { mode: 'global', key: GLOBAL_KEY, label: 'All workspaces', workspaceRoot: '', workspaceId: '' };
} }
return { return {
mode: 'workspace', mode: 'workspace',
key: WORKSPACE_PREFIX + encodeKey(workspaceRoot), key: WORKSPACE_PREFIX + encodeKey(workspaceRoot),
label: workspaceRoot, label: workspaceRoot,
workspaceRoot: workspaceRoot workspaceRoot: workspaceRoot,
workspaceId: workspaceId
}; };
} }
@ -175,6 +182,9 @@
receivedAt: text(item.receivedAt), receivedAt: text(item.receivedAt),
sourcePluginId: text(item.sourcePluginId || item.pluginId), sourcePluginId: text(item.sourcePluginId || item.pluginId),
workspaceRootPath: cleanWorkspace(item.workspaceRootPath || workspaceFromPayload(item.payload || {})), workspaceRootPath: cleanWorkspace(item.workspaceRootPath || workspaceFromPayload(item.payload || {})),
workspaceId: text(item.workspaceId || (item.sessionScope && item.sessionScope.workspaceId) || (item.payload && item.payload.workspaceId)),
sessionScope: item.sessionScope && typeof item.sessionScope === 'object' ? item.sessionScope : {},
durationSeconds: Math.max(0, Number(item.durationSeconds || (item.payload && item.payload.durationSeconds) || 0)),
_storageKey: storageKey || '', _storageKey: storageKey || '',
payload: item.payload && typeof item.payload === 'object' ? item.payload : {} payload: item.payload && typeof item.payload === 'object' ? item.payload : {}
}; };
@ -192,6 +202,9 @@
receivedAt: item.receivedAt, receivedAt: item.receivedAt,
sourcePluginId: item.sourcePluginId, sourcePluginId: item.sourcePluginId,
workspaceRootPath: item.workspaceRootPath, workspaceRootPath: item.workspaceRootPath,
workspaceId: item.workspaceId,
sessionScope: item.sessionScope || {},
durationSeconds: item.durationSeconds || 0,
payload: item.payload || {} payload: item.payload || {}
}; };
}); });
@ -225,13 +238,22 @@
return cleanWorkspace(activity && (activity.workspaceRootPath || workspaceFromPayload(activity.payload || {}))); return cleanWorkspace(activity && (activity.workspaceRootPath || workspaceFromPayload(activity.payload || {})));
} }
function candidateScope(activity) {
var workspaceId = text(activity && (activity.workspaceId || (activity.sessionScope && activity.sessionScope.workspaceId) || (activity.payload && activity.payload.workspaceId))).trim();
if (workspaceId) return 'workspace:' + workspaceId;
var workspaceRoot = candidateWorkspace(activity);
if (workspaceRoot) return 'legacy-workspace:' + workspaceRoot;
return 'unassigned';
}
function toISOTime(time) { function toISOTime(time) {
var date = new Date(time); var date = new Date(time);
return isNaN(date.getTime()) ? '' : date.toISOString(); return isNaN(date.getTime()) ? '' : date.toISOString();
} }
function candidateId(workspaceRootPath, firstActivity, lastActivity) { function newSessionId() {
return 'work-session:' + encodeKey(workspaceRootPath) + ':' + encodeKey(firstActivity.activityId) + ':' + encodeKey(lastActivity.activityId); if (typeof crypto !== 'undefined' && crypto && typeof crypto.randomUUID === 'function') return crypto.randomUUID();
return 'session-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
} }
function candidateActivity(activity) { function candidateActivity(activity) {
@ -240,57 +262,149 @@
type: text(activity.type), type: text(activity.type),
occurredAt: toISOTime(eventTimeMs(activity)), occurredAt: toISOTime(eventTimeMs(activity)),
sourcePluginId: text(activity.sourcePluginId), sourcePluginId: text(activity.sourcePluginId),
workspaceRootPath: candidateWorkspace(activity) workspaceRootPath: candidateWorkspace(activity),
workspaceId: text(activity.workspaceId)
}; };
} }
function normalizeSessionRegistry(value) {
value = value && typeof value === 'object' ? value : {};
return {
sessions: value.sessions && typeof value.sessions === 'object' ? value.sessions : {},
eventSessionIds: value.eventSessionIds && typeof value.eventSessionIds === 'object' ? value.eventSessionIds : {}
};
}
function pruneSessionRegistry(registry, activityList) {
var present = {};
(activityList || []).forEach(function (activity) { present[text(activity && activity.activityId)] = true; });
Object.keys(registry.eventSessionIds).forEach(function (activityId) {
if (!present[activityId]) delete registry.eventSessionIds[activityId];
});
var referenced = {};
Object.keys(registry.eventSessionIds).forEach(function (activityId) {
referenced[registry.eventSessionIds[activityId]] = true;
});
Object.keys(registry.sessions).forEach(function (sessionId) {
if (!referenced[sessionId]) delete registry.sessions[sessionId];
});
return registry;
}
function sessionDurationMinutes(session) {
var events = session.activities.slice().sort(function (a, b) { return eventTimeMs(a) - eventTimeMs(b); });
var durationMs = 0;
for (var index = 0; index < events.length; index += 1) {
var explicit = Math.max(0, Number(events[index].durationSeconds || 0)) * 1000;
durationMs += explicit;
if (index === 0 || explicit > 0 || Number(events[index - 1].durationSeconds || 0) > 0) continue;
var gap = eventTimeMs(events[index]) - eventTimeMs(events[index - 1]);
if (gap > 0 && gap <= MAX_IDLE_GAP_MINUTES * 60 * 1000) durationMs += Math.min(gap, 10 * 60 * 1000);
}
return Math.min(MAX_SESSION_DURATION_MINUTES, Math.floor(durationMs / 60000));
}
function findCompatibleSession(sessions, scope, time) {
var nearest = null;
sessions.forEach(function (session) {
if (session.scope !== scope) return;
var before = session.firstTime - time;
var after = time - session.lastTime;
var distance = time < session.firstTime ? before : after;
if (distance < 0 || distance > MAX_IDLE_GAP_MINUTES * 60 * 1000) return;
if (Math.max(session.lastTime, time) - Math.min(session.firstTime, time) > MAX_SESSION_DURATION_MINUTES * 60 * 1000) return;
if (!nearest || distance < nearest.distance) nearest = { session: session, distance: distance };
});
return nearest && nearest.session;
}
function logicalSessions(activityList, registry) {
var sessionsById = {};
var sessions = [];
var lastScope = '';
var ordered = sortEvents(activityList || []).filter(function (activity) {
return isMeaningfulActivity(activity) && eventTimeMs(activity);
}).slice().sort(function (a, b) { return eventTimeMs(a) - eventTimeMs(b); });
ordered.forEach(function (activity) {
var scope = candidateScope(activity);
var activityId = text(activity.activityId);
var sessionId = text(registry.eventSessionIds[activityId]);
var session = sessionId && sessionsById[sessionId];
if (!session && sessionId && registry.sessions[sessionId]) {
var persisted = registry.sessions[sessionId];
session = {
sessionId: sessionId,
scope: text(persisted.scope || scope),
workspaceRootPath: cleanWorkspace(persisted.workspaceRootPath || candidateWorkspace(activity)),
workspaceId: text(persisted.workspaceId || activity.workspaceId),
anchor: text(persisted.anchor),
firstTime: eventTimeMs(activity),
lastTime: eventTimeMs(activity),
activities: []
};
sessionsById[sessionId] = session;
sessions.push(session);
}
if (!session && (!lastScope || lastScope === scope)) {
session = findCompatibleSession(sessions, scope, eventTimeMs(activity));
}
if (!session) {
sessionId = newSessionId();
session = {
sessionId: sessionId,
scope: scope,
workspaceRootPath: candidateWorkspace(activity),
workspaceId: text(activity.workspaceId),
anchor: new Date().toISOString(),
firstTime: eventTimeMs(activity),
lastTime: eventTimeMs(activity),
activities: []
};
sessionsById[sessionId] = session;
sessions.push(session);
registry.sessions[sessionId] = {
scope: session.scope,
workspaceRootPath: session.workspaceRootPath,
workspaceId: session.workspaceId,
anchor: session.anchor
};
}
registry.eventSessionIds[activityId] = session.sessionId;
session.activities.push(activity);
session.firstTime = Math.min(session.firstTime, eventTimeMs(activity));
session.lastTime = Math.max(session.lastTime, eventTimeMs(activity));
if (candidateWorkspace(activity)) session.workspaceRootPath = candidateWorkspace(activity);
if (activity.workspaceId) session.workspaceId = text(activity.workspaceId);
lastScope = scope;
});
return sessions;
}
function buildCandidate(session) { function buildCandidate(session) {
var first = session.activities[0]; var activities = session.activities.slice().sort(function (a, b) { return eventTimeMs(a) - eventTimeMs(b); });
var last = session.activities[session.activities.length - 1]; var first = activities[0];
var duration = Math.round((eventTimeMs(last) - eventTimeMs(first)) / 60000); var last = activities[activities.length - 1];
if (session.activities.length < MIN_SESSION_ACTIVITY_COUNT || duration < MIN_SESSION_DURATION_MINUTES) return null; var duration = sessionDurationMinutes(session);
if (session.scope === 'unassigned' || !session.workspaceRootPath || activities.length < MIN_SESSION_ACTIVITY_COUNT || duration < MIN_SESSION_DURATION_MINUTES) return null;
return { return {
candidateId: candidateId(session.workspaceRootPath, first, last), candidateId: 'work-session:' + encodeKey(session.sessionId) + ':' + encodeKey(last.activityId),
sessionId: session.sessionId,
handledThrough: toISOTime(eventTimeMs(last)),
workspaceRootPath: session.workspaceRootPath, workspaceRootPath: session.workspaceRootPath,
workspaceId: session.workspaceId,
startedAt: toISOTime(eventTimeMs(first)), startedAt: toISOTime(eventTimeMs(first)),
endedAt: toISOTime(eventTimeMs(last)), endedAt: toISOTime(eventTimeMs(last)),
estimatedMinutes: duration, estimatedMinutes: duration,
activityCount: session.activities.length, activityCount: activities.length,
activityIds: session.activities.map(function (activity) { return activity.activityId; }).filter(Boolean), activityIds: activities.map(function (activity) { return activity.activityId; }).filter(Boolean),
activities: session.activities.map(candidateActivity) activities: activities.map(candidateActivity)
}; };
} }
function buildWorkSessionCandidates(activityList, workspaceFilter) { function buildWorkSessionCandidates(activityList, workspaceFilter, registry) {
var filter = cleanWorkspace(workspaceFilter); var filter = cleanWorkspace(workspaceFilter);
var ordered = sortEvents(activityList || []).filter(function (activity) { registry = normalizeSessionRegistry(registry);
return isMeaningfulActivity(activity) && candidateWorkspace(activity) && eventTimeMs(activity); return logicalSessions(activityList, registry).map(buildCandidate).filter(function (candidate) {
}).slice().sort(function (a, b) {
return eventTimeMs(a) - eventTimeMs(b);
});
var sessions = [];
var current = null;
ordered.forEach(function (activity) {
var workspace = candidateWorkspace(activity);
var time = eventTimeMs(activity);
if (!current) {
current = { workspaceRootPath: workspace, activities: [activity] };
return;
}
var firstTime = eventTimeMs(current.activities[0]);
var lastTime = eventTimeMs(current.activities[current.activities.length - 1]);
var switchedWorkspace = current.workspaceRootPath !== workspace;
var idleGap = time - lastTime > MAX_IDLE_GAP_MINUTES * 60 * 1000;
var exceededMaximum = time - firstTime > MAX_SESSION_DURATION_MINUTES * 60 * 1000;
if (switchedWorkspace || idleGap || exceededMaximum) {
sessions.push(current);
current = { workspaceRootPath: workspace, activities: [activity] };
return;
}
current.activities.push(activity);
});
if (current) sessions.push(current);
return sessions.map(buildCandidate).filter(function (candidate) {
return candidate && (!filter || candidate.workspaceRootPath === filter); return candidate && (!filter || candidate.workspaceRootPath === filter);
}).sort(function (a, b) { }).sort(function (a, b) {
return b.endedAt.localeCompare(a.endedAt) || a.workspaceRootPath.localeCompare(b.workspaceRootPath); return b.endedAt.localeCompare(a.endedAt) || a.workspaceRootPath.localeCompare(b.workspaceRootPath);
@ -347,6 +461,15 @@
return sortEvents(scopedEvents.concat(globalEvents, legacyEvents)); return sortEvents(scopedEvents.concat(globalEvents, legacyEvents));
} }
function eventsFromRecords(records, workspaceRoot) {
var normalized = normalizeStoredEvents(records, RAW_DATA_NAME);
var workspace = cleanWorkspace(workspaceRoot);
if (!workspace) return sortEvents(normalized);
return sortEvents(normalized.filter(function (item) {
return item.workspaceRootPath === workspace;
}));
}
function candidateStorageKey(workspaceRoot) { function candidateStorageKey(workspaceRoot) {
return CANDIDATE_PREFIX + encodeKey(workspaceRoot); return CANDIDATE_PREFIX + encodeKey(workspaceRoot);
} }
@ -367,26 +490,45 @@
function dismissedCandidatesFromSettings(settings) { function dismissedCandidatesFromSettings(settings) {
var dismissed = {}; var dismissed = {};
Object.keys(settings || {}).forEach(function (key) { Object.keys(settings || {}).forEach(function (key) {
if (key.indexOf(DISMISSAL_PREFIX) !== 0 || !Array.isArray(settings[key])) return; if (key.indexOf(DISMISSAL_PREFIX) !== 0) return;
var workspace = decodeStoredWorkspace(key, DISMISSAL_PREFIX); var workspace = decodeStoredWorkspace(key, DISMISSAL_PREFIX);
if (!workspace) return; if (!workspace) return;
dismissed[workspace] = {}; dismissed[workspace] = {};
settings[key].forEach(function (candidateId) { if (Array.isArray(settings[key])) {
candidateId = text(candidateId).trim(); settings[key].forEach(function (candidateId) {
if (candidateId) dismissed[workspace][candidateId] = true; candidateId = text(candidateId).trim();
}); if (candidateId) dismissed[workspace][candidateId] = true;
});
return;
}
if (settings[key] && typeof settings[key] === 'object') dismissed[workspace] = settings[key];
}); });
return dismissed; return dismissed;
} }
function isCandidateDismissed(candidate, dismissedByWorkspace) { function candidateAfterWatermark(candidate, watermark) {
return !!(candidate && dismissedByWorkspace && dismissedByWorkspace[candidate.workspaceRootPath] && dismissedByWorkspace[candidate.workspaceRootPath][candidate.candidateId]); if (!watermark || watermark === true) return watermark === true ? null : candidate;
var handledTime = Date.parse(watermark.handledThrough || '');
if (!Number.isFinite(handledTime)) return candidate;
var additions = candidate.activities.filter(function (activity) { return eventTimeMs(activity) > handledTime; });
if (eventTimeMs({ occurredAt: candidate.endedAt }) <= handledTime || additions.length < MIN_SESSION_ACTIVITY_COUNT || eventTimeMs(additions[additions.length - 1]) - handledTime < MIN_SESSION_DURATION_MINUTES * 60 * 1000) return null;
var duration = sessionDurationMinutes({ activities: additions });
if (duration < MIN_SESSION_DURATION_MINUTES) return null;
return Object.assign({}, candidate, {
startedAt: toISOTime(eventTimeMs(additions[0])),
estimatedMinutes: duration,
activityCount: additions.length,
activityIds: additions.map(function (activity) { return activity.activityId; }),
activities: additions.map(candidateActivity)
});
} }
function visibleCandidates(activityList, workspaceFilter, dismissedByWorkspace) { function visibleCandidates(activityList, workspaceFilter, sessionRegistry, dismissedByWorkspace, handledSessions) {
return buildWorkSessionCandidates(activityList, workspaceFilter).filter(function (candidate) { return buildWorkSessionCandidates(activityList, workspaceFilter, sessionRegistry).map(function (candidate) {
return !isCandidateDismissed(candidate, dismissedByWorkspace); var dismissed = dismissedByWorkspace && dismissedByWorkspace[candidate.workspaceRootPath];
}); var watermark = handledSessions && handledSessions[candidate.sessionId];
return candidateAfterWatermark(candidate, watermark || (dismissed && (dismissed[candidate.sessionId] || dismissed[candidate.candidateId])));
}).filter(Boolean);
} }
function formatDate(value) { function formatDate(value) {
@ -409,6 +551,8 @@
var candidateSourceEvents = []; var candidateSourceEvents = [];
var candidates = []; var candidates = [];
var dismissedByWorkspace = {}; var dismissedByWorkspace = {};
var handledSessions = {};
var sessionRegistry = normalizeSessionRegistry({});
function tr(key, params, fallback) { function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback); if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key; return fallback || key;
@ -436,7 +580,7 @@
return candidateWorkspace(activity) !== scope.workspaceRoot; return candidateWorkspace(activity) !== scope.workspaceRoot;
}); });
updateCandidates(); updateCandidates();
persist().then(render); clearWorkspaceRaw(scope.workspaceRoot).then(persist).then(render);
} }
}); });
toolbar.appendChild(titleEl); toolbar.appendChild(titleEl);
@ -455,7 +599,7 @@
containerEl.appendChild(listEl); containerEl.appendChild(listEl);
function candidatesForWorkspace(workspaceRoot) { function candidatesForWorkspace(workspaceRoot) {
return visibleCandidates(candidateSourceEvents, workspaceRoot, dismissedByWorkspace); return visibleCandidates(candidateSourceEvents, workspaceRoot, sessionRegistry, dismissedByWorkspace, handledSessions);
} }
function updateCandidates() { function updateCandidates() {
@ -484,7 +628,12 @@
function persistDismissals(workspaceRoot) { function persistDismissals(workspaceRoot) {
if (!workspaceRoot || !api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve(); if (!workspaceRoot || !api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
var dismissed = dismissedByWorkspace[workspaceRoot] || {}; var dismissed = dismissedByWorkspace[workspaceRoot] || {};
return api.settings.write(dismissalStorageKey(workspaceRoot), Object.keys(dismissed)); return api.settings.write(dismissalStorageKey(workspaceRoot), dismissed);
}
function persistSessionRegistry() {
if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
return api.settings.write(SESSION_REGISTRY_KEY, sessionRegistry);
} }
function persist() { function persist() {
@ -492,7 +641,7 @@
var toStore = scope.mode === 'global' var toStore = scope.mode === 'global'
? events.filter(function (item) { return !item._storageKey || item._storageKey === GLOBAL_KEY; }) ? events.filter(function (item) { return !item._storageKey || item._storageKey === GLOBAL_KEY; })
: events; : events;
return api.settings.write(scope.key, storageEvents(toStore)).then(persistCandidateCaches).catch(function (err) { return api.settings.write(scope.key, storageEvents(toStore)).then(persistSessionRegistry).then(persistCandidateCaches).catch(function (err) {
statusText = tr('ui.saveError', { error: err && err.message ? err.message : String(err) }, 'Could not save activity: ' + (err && err.message ? err.message : String(err))); statusText = tr('ui.saveError', { error: err && err.message ? err.message : String(err) }, 'Could not save activity: ' + (err && err.message ? err.message : String(err)));
statusClass = 'error'; statusClass = 'error';
}); });
@ -503,7 +652,12 @@
events = []; events = [];
return Promise.resolve(); return Promise.resolve();
} }
return api.settings.read().then(function (settings) { var clearRaw = api.storage && api.storage.data && typeof api.storage.data.writeNDJSON === 'function'
? api.storage.data.writeNDJSON(RAW_DATA_NAME, [])
: Promise.resolve();
return clearRaw.then(function () {
return api.settings.read();
}).then(function (settings) {
var keys = globalEventKeys(settings || {}).concat(Object.keys(settings || {}).filter(function (key) { var keys = globalEventKeys(settings || {}).concat(Object.keys(settings || {}).filter(function (key) {
return key.indexOf(CANDIDATE_PREFIX) === 0 || key.indexOf(DISMISSAL_PREFIX) === 0; return key.indexOf(CANDIDATE_PREFIX) === 0 || key.indexOf(DISMISSAL_PREFIX) === 0;
})); }));
@ -511,6 +665,8 @@
candidateSourceEvents = []; candidateSourceEvents = [];
candidates = []; candidates = [];
dismissedByWorkspace = {}; dismissedByWorkspace = {};
handledSessions = {};
sessionRegistry = normalizeSessionRegistry({});
return Promise.all(keys.filter(function (key, index, all) { return Promise.all(keys.filter(function (key, index, all) {
return all.indexOf(key) === index; return all.indexOf(key) === index;
}).map(function (key) { }).map(function (key) {
@ -525,6 +681,19 @@
}); });
} }
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();
}
return api.storage.data.readNDJSON(RAW_DATA_NAME).then(function (records) {
var workspace = cleanWorkspace(workspaceRoot);
var kept = (Array.isArray(records) ? records : []).filter(function (record) {
return cleanWorkspace(record && (record.workspaceRootPath || workspaceFromPayload(record.payload || {}))) !== workspace;
});
return api.storage.data.writeNDJSON(RAW_DATA_NAME, kept);
});
}
function renderList() { function renderList() {
listEl.innerHTML = ''; listEl.innerHTML = '';
if (events.length === 0) { if (events.length === 0) {
@ -572,7 +741,11 @@
function dismissCandidate(candidate) { function dismissCandidate(candidate) {
if (!candidate || !candidate.workspaceRootPath || !candidate.candidateId) return; if (!candidate || !candidate.workspaceRootPath || !candidate.candidateId) return;
dismissedByWorkspace[candidate.workspaceRootPath] = dismissedByWorkspace[candidate.workspaceRootPath] || {}; dismissedByWorkspace[candidate.workspaceRootPath] = dismissedByWorkspace[candidate.workspaceRootPath] || {};
dismissedByWorkspace[candidate.workspaceRootPath][candidate.candidateId] = true; dismissedByWorkspace[candidate.workspaceRootPath][candidate.sessionId || candidate.candidateId] = {
handledThrough: candidate.handledThrough || candidate.endedAt,
handledAt: new Date().toISOString(),
status: 'dismissed'
};
updateCandidates(); updateCandidates();
Promise.all([ Promise.all([
persistDismissals(candidate.workspaceRootPath), persistDismissals(candidate.workspaceRootPath),
@ -634,13 +807,25 @@
function loadStored() { function loadStored() {
if (!api || !api.settings || typeof api.settings.read !== 'function') return Promise.resolve(); if (!api || !api.settings || typeof api.settings.read !== 'function') return Promise.resolve();
return api.settings.read().then(function (settings) { var readRaw = api.storage && api.storage.data && typeof api.storage.data.readNDJSON === 'function'
? api.storage.data.readNDJSON(RAW_DATA_NAME)
: Promise.resolve([]);
return Promise.all([api.settings.read(), readRaw]).then(function (results) {
var settings = results[0] || {};
var rawRecords = Array.isArray(results[1]) ? results[1] : [];
settings = settings || {}; settings = settings || {};
candidateSourceEvents = eventsFromSettings(settings, ''); sessionRegistry = normalizeSessionRegistry(settings[SESSION_REGISTRY_KEY]);
events = eventsFromSettings(settings, scope.mode === 'workspace' ? scope.workspaceRoot : ''); handledSessions = settings["activity-session-handling-v2"] && typeof settings["activity-session-handling-v2"] === 'object'
? settings["activity-session-handling-v2"]
: {};
candidateSourceEvents = rawRecords.length ? eventsFromRecords(rawRecords, '') : eventsFromSettings(settings, '');
sessionRegistry = pruneSessionRegistry(sessionRegistry, candidateSourceEvents);
events = rawRecords.length
? eventsFromRecords(rawRecords, scope.mode === 'workspace' ? scope.workspaceRoot : '')
: eventsFromSettings(settings, scope.mode === 'workspace' ? scope.workspaceRoot : '');
dismissedByWorkspace = dismissedCandidatesFromSettings(settings); dismissedByWorkspace = dismissedCandidatesFromSettings(settings);
updateCandidates(); updateCandidates();
return persistCandidateCaches(); return persistSessionRegistry().then(persistCandidateCaches);
}).catch(function (err) { }).catch(function (err) {
statusText = 'Could not load activity: ' + (err && err.message ? err.message : String(err)); statusText = 'Could not load activity: ' + (err && err.message ? err.message : String(err));
statusClass = 'error'; statusClass = 'error';
@ -650,11 +835,16 @@
function listWorkSessionCandidates(args) { function listWorkSessionCandidates(args) {
var workspace = cleanWorkspace(args && args.workspaceRootPath); var workspace = cleanWorkspace(args && args.workspaceRootPath);
if (!api || !api.settings || typeof api.settings.read !== 'function') { if (!api || !api.settings || typeof api.settings.read !== 'function') {
return Promise.resolve({ candidates: visibleCandidates(candidateSourceEvents, workspace || (scope.mode === 'workspace' ? scope.workspaceRoot : ''), dismissedByWorkspace) }); return Promise.resolve({ candidates: visibleCandidates(candidateSourceEvents, workspace || (scope.mode === 'workspace' ? scope.workspaceRoot : ''), sessionRegistry, dismissedByWorkspace, handledSessions) });
} }
return api.settings.read().then(function (settings) { var readRaw = api.storage && api.storage.data && typeof api.storage.data.readNDJSON === 'function'
settings = settings || {}; ? api.storage.data.readNDJSON(RAW_DATA_NAME)
return { candidates: visibleCandidates(eventsFromSettings(settings, ''), workspace, dismissedCandidatesFromSettings(settings)) }; : Promise.resolve([]);
return Promise.all([api.settings.read(), readRaw]).then(function (results) {
var settings = results[0] || {};
var rawRecords = Array.isArray(results[1]) ? results[1] : [];
var source = rawRecords.length ? eventsFromRecords(rawRecords, '') : eventsFromSettings(settings, '');
return { candidates: visibleCandidates(source, workspace, sessionRegistry, dismissedCandidatesFromSettings(settings), settings["activity-session-handling-v2"] || {}) };
}).catch(function () { }).catch(function () {
return { candidates: [] }; return { candidates: [] };
}); });

View File

@ -183,6 +183,12 @@
return safeNoteFilename(title).replace(/\.md$/, '.url'); return safeNoteFilename(title).replace(/\.md$/, '.url');
} }
function numberedLinkFilename(title, number) {
var filename = safeLinkFilename(title);
if (number <= 1) return filename;
return filename.replace(/\.url$/, ' (' + number + ').url');
}
function safeFileFilename(name) { function safeFileFilename(name) {
var base = text(name).trim() var base = text(name).trim()
.replace(/[\\/:*?"<>|\r\n\t]+/g, '_') .replace(/[\\/:*?"<>|\r\n\t]+/g, '_')
@ -238,8 +244,10 @@
fileDataBase64: text(payload.fileDataBase64).trim(), fileDataBase64: text(payload.fileDataBase64).trim(),
source: text(payload.source).trim(), source: text(payload.source).trim(),
browserName: text(payload.browserName).trim(), browserName: text(payload.browserName).trim(),
// The receiver provides the active workspace. Untagged captures remain unassigned. workspaceRootPath: workspaceFromPayload(payload),
workspaceRootPath: workspaceFromPayload(payload) workspaceId: text(payload.workspaceId).trim(),
workspaceState: text(payload.workspaceState || 'unassigned').trim(),
workspaceTrashId: text(payload.workspaceTrashId).trim()
}; };
} }
@ -258,7 +266,6 @@
return value.filter(function (item) { return value.filter(function (item) {
return item && typeof item === 'object' && item.captureId; return item && typeof item === 'object' && item.captureId;
}).map(function (item) { }).map(function (item) {
// Workspace root paths are the current stable identifiers; core has no immutable workspace ID yet.
var workspaceRootPath = cleanWorkspace(item.workspaceRootPath) || workspaceFromStorageKey(storageKey); var workspaceRootPath = cleanWorkspace(item.workspaceRootPath) || workspaceFromStorageKey(storageKey);
return { return {
captureId: text(item.captureId), captureId: text(item.captureId),
@ -278,6 +285,10 @@
browserName: text(item.browserName), browserName: text(item.browserName),
workspaceRootPath: workspaceRootPath, workspaceRootPath: workspaceRootPath,
workspaceName: cleanWorkspace(item.workspaceName || workspaceRootPath), workspaceName: cleanWorkspace(item.workspaceName || workspaceRootPath),
workspaceId: text(item.workspaceId),
workspaceState: text(item.workspaceState || (workspaceRootPath ? 'active' : 'unassigned')),
workspaceTrashId: text(item.workspaceTrashId),
globalState: text(item.globalState || 'inbox') === 'archived' ? 'archived' : 'inbox',
processed: item.processed === true, processed: item.processed === true,
_storageKey: storageKey || '' _storageKey: storageKey || ''
}; };
@ -304,6 +315,10 @@
browserName: item.browserName, browserName: item.browserName,
workspaceRootPath: item.workspaceRootPath, workspaceRootPath: item.workspaceRootPath,
workspaceName: item.workspaceName || item.workspaceRootPath || '', workspaceName: item.workspaceName || item.workspaceRootPath || '',
workspaceId: item.workspaceId || '',
workspaceState: item.workspaceState || (item.workspaceRootPath ? 'active' : 'unassigned'),
workspaceTrashId: item.workspaceTrashId || '',
globalState: item.globalState === 'archived' ? 'archived' : 'inbox',
processed: item.processed === true processed: item.processed === true
}; };
}); });
@ -381,7 +396,8 @@
el('option', { value: 'all', textContent: tr('ui.allCaptures', null, 'All captures') }), el('option', { value: 'all', textContent: tr('ui.allCaptures', null, 'All captures') }),
el('option', { value: 'unassigned', textContent: tr('ui.unassigned', null, 'Unassigned') }), el('option', { value: 'unassigned', textContent: tr('ui.unassigned', null, 'Unassigned') }),
el('option', { value: 'unprocessed', textContent: tr('ui.unprocessed', null, 'Unprocessed') }), el('option', { value: 'unprocessed', textContent: tr('ui.unprocessed', null, 'Unprocessed') }),
el('option', { value: 'processed', textContent: tr('ui.processed', null, 'Processed') }) el('option', { value: 'processed', textContent: tr('ui.processed', null, 'Processed') }),
el('option', { value: 'archived', textContent: tr('ui.archive', null, 'Archive') })
]); ]);
var workspaceFilterEl = el('select', { var workspaceFilterEl = el('select', {
className: 'browser-inbox-select', className: 'browser-inbox-select',
@ -468,6 +484,8 @@
var workspaceRoot = cleanWorkspace(capture && capture.workspaceRootPath); var workspaceRoot = cleanWorkspace(capture && capture.workspaceRootPath);
if (scope.mode === 'workspace' && workspaceRoot !== scope.workspaceRoot) return false; if (scope.mode === 'workspace' && workspaceRoot !== scope.workspaceRoot) return false;
if (scope.mode === 'global' && workspaceFilter && workspaceRoot !== workspaceFilter) return false; if (scope.mode === 'global' && workspaceFilter && workspaceRoot !== workspaceFilter) return false;
if (statusFilter === 'archived' && capture.globalState !== 'archived') return false;
if (statusFilter !== 'archived' && capture.globalState === 'archived') return false;
if (statusFilter === 'unassigned' && workspaceRoot) return false; if (statusFilter === 'unassigned' && workspaceRoot) return false;
if (statusFilter === 'unprocessed' && capture.processed === true) return false; if (statusFilter === 'unprocessed' && capture.processed === true) return false;
if (statusFilter === 'processed' && capture.processed !== true) return false; if (statusFilter === 'processed' && capture.processed !== true) return false;
@ -505,13 +523,15 @@
}); });
} }
function removeCaptures(captureIds, successText) { function archiveCaptures(captureIds, successText) {
var ids = {}; var ids = {};
captureIds.forEach(function (captureId) { captureIds.forEach(function (captureId) {
ids[captureId] = true; ids[captureId] = true;
}); });
return publishMutation('delete', { captureIds: captureIds }, function () { return publishMutation('archive', { captureIds: captureIds }, function () {
return !captures.some(function (capture) { return ids[capture.captureId]; }); return captures.every(function (capture) {
return !ids[capture.captureId] || capture.globalState === 'archived';
});
}).then(function (saved) { }).then(function (saved) {
if (!saved) return; if (!saved) return;
if (ids[selectedId]) selectedId = ''; if (ids[selectedId]) selectedId = '';
@ -525,7 +545,7 @@
var ids = scope.mode === 'global' var ids = scope.mode === 'global'
? captures.map(function (capture) { return capture.captureId; }) ? captures.map(function (capture) { return capture.captureId; })
: captures.filter(function (capture) { return capture.workspaceRootPath === scope.workspaceRoot; }).map(function (capture) { return capture.captureId; }); : captures.filter(function (capture) { return capture.workspaceRootPath === scope.workspaceRoot; }).map(function (capture) { return capture.captureId; });
return removeCaptures(ids, scope.mode === 'global' ? 'Inbox cleared' : 'Workspace captures cleared'); return archiveCaptures(ids, scope.mode === 'global' ? 'Inbox archived' : 'Workspace captures archived');
} }
function selectedCapture() { function selectedCapture() {
@ -578,8 +598,33 @@
}); });
} }
function removeCapture(captureId) { function archiveCapture(captureId) {
return removeCaptures([captureId], 'Capture deleted'); return archiveCaptures([captureId], 'Capture archived');
}
function restoreCapture(captureId) {
return publishMutation('restore', { captureId: captureId }, function () {
return captures.some(function (capture) {
return capture.captureId === captureId && capture.globalState === 'inbox';
});
}).then(function (saved) {
if (!saved) return;
statusText = 'Capture restored to Inbox';
statusClass = '';
render();
});
}
function permanentlyDeleteCapture(captureId) {
return publishMutation('delete', { captureId: captureId, permanent: true }, function () {
return !captures.some(function (capture) { return capture.captureId === captureId; });
}).then(function (saved) {
if (!saved) return;
if (selectedId === captureId) selectedId = '';
statusText = 'Capture permanently deleted';
statusClass = '';
render();
});
} }
function setProcessed(captureId, processed) { function setProcessed(captureId, processed) {
@ -630,7 +675,7 @@
}).then(function () { }).then(function () {
statusText = 'Created note: ' + notePath; statusText = 'Created note: ' + notePath;
statusClass = ''; statusClass = '';
return removeCapture(capture.captureId); return archiveCapture(capture.captureId);
}).catch(function (err) { }).catch(function (err) {
statusText = 'Could not create note: ' + (err && err.message ? err.message : String(err)); statusText = 'Could not create note: ' + (err && err.message ? err.message : String(err));
statusClass = 'error'; statusClass = 'error';
@ -647,14 +692,24 @@
return Promise.resolve(); return Promise.resolve();
} }
var title = noteTitle(capture); var title = noteTitle(capture);
var linkPath = capture.workspaceRootPath + '/Links/' + safeLinkFilename(title);
statusText = 'Creating link...'; statusText = 'Creating link...';
statusClass = ''; statusClass = '';
render(); render();
return api.files.writeText(linkPath, captureToUrlShortcut(capture), { function writeLink(number) {
createIfMissing: true, var linkPath = capture.workspaceRootPath + '/Links/' + numberedLinkFilename(title, number);
overwrite: false return api.files.writeText(linkPath, captureToUrlShortcut(capture), {
}).then(function () { createIfMissing: true,
overwrite: false
}).then(function () {
return linkPath;
}).catch(function (error) {
if (number < 99 && /^conflict:/.test(text(error && error.message ? error.message : error))) {
return writeLink(number + 1);
}
throw error;
});
}
return writeLink(1).then(function (linkPath) {
if (api.events && typeof api.events.publish === 'function') { if (api.events && typeof api.events.publish === 'function') {
return api.events.publish('browser.capture.converted', { return api.events.publish('browser.capture.converted', {
captureId: capture.captureId, captureId: capture.captureId,
@ -664,13 +719,15 @@
title: title, title: title,
url: capture.url || '', url: capture.url || '',
sourcePluginId: PLUGIN_ID sourcePluginId: PLUGIN_ID
}).then(function () {
return linkPath;
}); });
} }
return undefined; return linkPath;
}).then(function () { }).then(function () {
statusText = 'Created link: ' + linkPath; statusText = 'Created link';
statusClass = ''; statusClass = '';
return removeCapture(capture.captureId); return archiveCapture(capture.captureId);
}).catch(function (err) { }).catch(function (err) {
statusText = 'Could not create link: ' + (err && err.message ? err.message : String(err)); statusText = 'Could not create link: ' + (err && err.message ? err.message : String(err));
statusClass = 'error'; statusClass = 'error';
@ -678,6 +735,15 @@
}); });
} }
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) {
statusText = 'Could not open link: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
render();
});
}
function createFileFromCapture(capture) { function createFileFromCapture(capture) {
if (!capture || !capture.workspaceRootPath || capture.kind !== 'file' || !capture.fileName || (!capture.fileText && !capture.fileDataBase64)) return Promise.resolve(); if (!capture || !capture.workspaceRootPath || capture.kind !== 'file' || !capture.fileName || (!capture.fileText && !capture.fileDataBase64)) return Promise.resolve();
if (!api || !api.files || (capture.fileDataBase64 ? typeof api.files.writeBytes !== 'function' : typeof api.files.writeText !== 'function')) { if (!api || !api.files || (capture.fileDataBase64 ? typeof api.files.writeBytes !== 'function' : typeof api.files.writeText !== 'function')) {
@ -717,7 +783,7 @@
}).then(function () { }).then(function () {
statusText = 'Created file: ' + filePath; statusText = 'Created file: ' + filePath;
statusClass = ''; statusClass = '';
return removeCapture(capture.captureId); return archiveCapture(capture.captureId);
}).catch(function (err) { }).catch(function (err) {
statusText = 'Could not create file: ' + (err && err.message ? err.message : String(err)); statusText = 'Could not create file: ' + (err && err.message ? err.message : String(err));
statusClass = 'error'; statusClass = 'error';
@ -759,7 +825,8 @@
el('span', { el('span', {
className: 'browser-inbox-badge' + (capture.processed ? ' processed' : ''), className: 'browser-inbox-badge' + (capture.processed ? ' processed' : ''),
textContent: capture.processed ? 'Processed' : 'Unprocessed' textContent: capture.processed ? 'Processed' : 'Unprocessed'
}) }),
capture.globalState === 'archived' ? el('span', { className: 'browser-inbox-badge', textContent: 'Archived' }) : null
])); ]));
if (capture.text) { if (capture.text) {
row.appendChild(el('div', { className: 'browser-inbox-row-text', textContent: capture.text })); row.appendChild(el('div', { className: 'browser-inbox-row-text', textContent: capture.text }));
@ -839,6 +906,16 @@
setProcessed(capture.captureId, !capture.processed); setProcessed(capture.captureId, !capture.processed);
} }
})); }));
if (capture.url) {
actionButtons.push(el('button', {
className: 'browser-inbox-btn',
'data-browser-inbox-action': 'open-link',
textContent: 'Open link',
onClick: function () {
openCaptureURL(capture);
}
}));
}
if (capture.workspaceRootPath) { if (capture.workspaceRootPath) {
actionButtons.push(el('button', { actionButtons.push(el('button', {
className: 'browser-inbox-btn', className: 'browser-inbox-btn',
@ -869,12 +946,31 @@
})); }));
} }
} }
if (capture.globalState === 'archived') {
actionButtons.push(el('button', {
className: 'browser-inbox-btn',
'data-browser-inbox-action': 'restore',
textContent: 'Restore to Inbox',
onClick: function () {
restoreCapture(capture.captureId);
}
}));
} else {
actionButtons.push(el('button', {
className: 'browser-inbox-btn',
'data-browser-inbox-action': 'archive',
textContent: 'Archive',
onClick: function () {
archiveCapture(capture.captureId);
}
}));
}
actionButtons.push(el('button', { actionButtons.push(el('button', {
className: 'browser-inbox-btn danger', className: 'browser-inbox-btn danger',
'data-browser-inbox-action': 'remove', 'data-browser-inbox-action': 'delete-permanently',
textContent: tr('ui.delete', null, 'Delete'), textContent: 'Delete permanently',
onClick: function () { onClick: function () {
removeCapture(capture.captureId); permanentlyDeleteCapture(capture.captureId);
} }
})); }));
detailEl.appendChild(el('div', { className: 'browser-inbox-detail-actions' }, actionButtons)); detailEl.appendChild(el('div', { className: 'browser-inbox-detail-actions' }, actionButtons));

View File

@ -227,6 +227,8 @@
} }
return { return {
candidateId: text(value.candidateId), candidateId: text(value.candidateId),
sessionId: text(value.sessionId),
handledThrough: text(value.handledThrough || value.endedAt),
workspaceRootPath: workspace, workspaceRootPath: workspace,
startedAt: text(value.startedAt), startedAt: text(value.startedAt),
endedAt: text(value.endedAt), endedAt: text(value.endedAt),
@ -368,6 +370,8 @@
minutes: minutesInput.value, minutes: minutesInput.value,
billable: billableInput.checked === true, billable: billableInput.checked === true,
sourceCandidateId: reviewingCandidate ? candidate.candidateId : (existingEntry ? existingEntry.sourceCandidateId : ''), sourceCandidateId: reviewingCandidate ? candidate.candidateId : (existingEntry ? existingEntry.sourceCandidateId : ''),
sessionId: reviewingCandidate ? candidate.sessionId : '',
handledThrough: reviewingCandidate ? candidate.handledThrough : '',
sourceTodoId: reviewingTodo ? completedTodo.id : (existingEntry ? existingEntry.sourceTodoId : ''), sourceTodoId: reviewingTodo ? completedTodo.id : (existingEntry ? existingEntry.sourceTodoId : ''),
activityIds: reviewingCandidate activityIds: reviewingCandidate
? activityInputs.filter(function (item) { return item.input.checked === true; }).map(function (item) { return item.activity.activityId; }) ? activityInputs.filter(function (item) { return item.input.checked === true; }).map(function (item) { return item.activity.activityId; })
@ -431,6 +435,8 @@
return; return;
} }
var sourceCandidateId = text(formValue && formValue.sourceCandidateId || (existingEntry && existingEntry.sourceCandidateId)).trim(); var sourceCandidateId = text(formValue && formValue.sourceCandidateId || (existingEntry && existingEntry.sourceCandidateId)).trim();
var sessionID = text(formValue && formValue.sessionId).trim();
var handledThrough = text(formValue && formValue.handledThrough).trim();
var sourceTodoId = text(formValue && formValue.sourceTodoId || (existingEntry && existingEntry.sourceTodoId)).trim(); var sourceTodoId = text(formValue && formValue.sourceTodoId || (existingEntry && existingEntry.sourceTodoId)).trim();
if (!existingEntry && sourceCandidateId && entries.some(function (entry) { return entry.sourceCandidateId === sourceCandidateId; })) { if (!existingEntry && sourceCandidateId && entries.some(function (entry) { return entry.sourceCandidateId === sourceCandidateId; })) {
statusText = 'A journal entry already references this candidate'; statusText = 'A journal entry already references this candidate';
@ -467,7 +473,14 @@
closeEntryModal(); closeEntryModal();
statusText = existingEntry ? 'Entry updated' : 'Entry added'; statusText = existingEntry ? 'Entry updated' : 'Entry added';
statusClass = ''; statusClass = '';
persist().then(render); 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,
handledThrough: handledThrough,
status: 'accepted'
});
}).then(render);
} }
function deleteEntry(entry) { function deleteEntry(entry) {

View File

@ -14,6 +14,7 @@
"report.worklog" "report.worklog"
], ],
"permissions": [ "permissions": [
"events.publish",
"storage.namespace", "storage.namespace",
"ui.register" "ui.register"
], ],

29
scripts/release.sh Executable file
View File

@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VERSION="${1:-}"
if [[ -z "$VERSION" || ! "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
echo "usage: $0 <version>" >&2
echo "example: $0 v0.1.0-alpha.1" >&2
exit 2
fi
echo "=== verstak official plugins release $VERSION ==="
"$ROOT/scripts/check.sh"
"$ROOT/scripts/build.sh"
RELEASE_ROOT="$ROOT/release"
STAGING="$RELEASE_ROOT/verstak-official-plugins-$VERSION"
ARCHIVE="$RELEASE_ROOT/verstak-official-plugins-$VERSION.tar.gz"
rm -rf "$STAGING" "$ARCHIVE"
mkdir -p "$STAGING"
cp "$ROOT/README.md" "$ROOT/LICENSE" "$STAGING/"
cp -R "$ROOT/dist" "$STAGING/dist"
tar -C "$RELEASE_ROOT" -czf "$ARCHIVE" "$(basename "$STAGING")"
(cd "$RELEASE_ROOT" && sha256sum "$(basename "$ARCHIVE")" > SHA256SUMS)
echo "release archive: $ARCHIVE"
echo "checksums: $RELEASE_ROOT/SHA256SUMS"

View File

@ -116,8 +116,9 @@ function loadComponent(document) {
return component; return component;
} }
function makeApi(initialSettings = {}) { function makeApi(initialSettings = {}, initialData = {}) {
const settings = { ...initialSettings }; const settings = { ...initialSettings };
const data = { ...initialData };
const handlers = {}; const handlers = {};
const commandHandlers = new Map(); const commandHandlers = new Map();
const unsubscribed = []; const unsubscribed = [];
@ -132,6 +133,14 @@ function makeApi(initialSettings = {}) {
return { ...settings }; return { ...settings };
}, },
}, },
storage: {
data: {
readNDJSON: async (name) => Array.isArray(data[name]) ? data[name].slice() : [],
writeNDJSON: async (name, records) => {
data[name] = Array.isArray(records) ? records.slice() : [];
},
},
},
events: { events: {
subscribe: async (name, handler) => { subscribe: async (name, handler) => {
handlers[name] = handler; handlers[name] = handler;
@ -150,11 +159,14 @@ function makeApi(initialSettings = {}) {
storedEvents(key = 'events') { storedEvents(key = 'events') {
return settings[key] || []; return settings[key] || [];
}, },
storedData(name) {
return Array.isArray(data[name]) ? data[name] : [];
},
}; };
} }
async function flush() { async function flush() {
for (let i = 0; i < 10; i += 1) await Promise.resolve(); for (let i = 0; i < 20; i += 1) await Promise.resolve();
} }
async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, workspaceRootPath: 'Project' }, document = makeDocument()) { async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, workspaceRootPath: 'Project' }, document = makeDocument()) {
@ -275,7 +287,7 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
const candidateNode = walk(container, (node) => node.getAttribute && node.getAttribute('data-work-session-candidate')); const candidateNode = walk(container, (node) => node.getAttribute && node.getAttribute('data-work-session-candidate'));
if (!candidateNode) throw new Error('work session candidate data attribute was not rendered'); if (!candidateNode) throw new Error('work session candidate data attribute was not rendered');
if (!candidateNode.textContent.includes('Workspace: Project')) throw new Error('candidate workspace was not rendered'); if (!candidateNode.textContent.includes('Workspace: Project')) throw new Error('candidate workspace was not rendered');
if (!candidateNode.textContent.includes('Estimated duration: 30 min')) throw new Error('candidate duration was not rendered'); if (!candidateNode.textContent.includes('Estimated duration: 20 min')) throw new Error('candidate duration was not rendered');
if (!candidateNode.textContent.includes('Activities: 3')) throw new Error('candidate activity count was not rendered'); if (!candidateNode.textContent.includes('Activities: 3')) throw new Error('candidate activity count was not rendered');
if (!walk(candidateNode, (node) => node.getAttribute && node.getAttribute('data-work-session-action') === 'review')) throw new Error('candidate review action was not rendered'); if (!walk(candidateNode, (node) => node.getAttribute && node.getAttribute('data-work-session-action') === 'review')) throw new Error('candidate review action was not rendered');
if (!walk(candidateNode, (node) => node.getAttribute && node.getAttribute('data-work-session-action') === 'dismiss')) throw new Error('candidate dismiss action was not rendered'); if (!walk(candidateNode, (node) => node.getAttribute && node.getAttribute('data-work-session-action') === 'dismiss')) throw new Error('candidate dismiss action was not rendered');
@ -287,7 +299,7 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
if (!candidate.candidateId) throw new Error('candidate id is missing'); if (!candidate.candidateId) throw new Error('candidate id is missing');
if (candidate.workspaceRootPath !== 'Project') throw new Error('candidate workspace mismatch'); if (candidate.workspaceRootPath !== 'Project') throw new Error('candidate workspace mismatch');
if (candidate.startedAt !== '2026-06-27T00:00:00.000Z' || candidate.endedAt !== '2026-06-27T00:30:00.000Z') throw new Error('candidate range mismatch'); if (candidate.startedAt !== '2026-06-27T00:00:00.000Z' || candidate.endedAt !== '2026-06-27T00:30:00.000Z') throw new Error('candidate range mismatch');
if (candidate.estimatedMinutes !== 30) throw new Error(`expected 30 candidate minutes, got ${candidate.estimatedMinutes}`); if (candidate.estimatedMinutes !== 20) throw new Error(`expected 20 candidate minutes, got ${candidate.estimatedMinutes}`);
if (candidate.activityCount !== 3) throw new Error(`expected three candidate activities, got ${candidate.activityCount}`); if (candidate.activityCount !== 3) throw new Error(`expected three candidate activities, got ${candidate.activityCount}`);
if (candidate.activityIds.join(',') !== 'capture-1,note-1,capture-1:browser.capture.converted') throw new Error('candidate activity ids mismatch'); if (candidate.activityIds.join(',') !== 'capture-1,note-1,capture-1:browser.capture.converted') throw new Error('candidate activity ids mismatch');
if (!Array.isArray(candidate.activities) || candidate.activities.length !== 3) throw new Error('candidate activity list is missing'); if (!Array.isArray(candidate.activities) || candidate.activities.length !== 3) throw new Error('candidate activity list is missing');
@ -353,7 +365,7 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
if (api.storedEvents('work-session-candidates:workspace:Project').length !== 0) throw new Error('clear action did not remove cached candidates'); if (api.storedEvents('work-session-candidates:workspace:Project').length !== 0) throw new Error('clear action did not remove cached candidates');
component.unmount && component.unmount(container); component.unmount && component.unmount(container);
if (api.unsubscribed.length !== 33) throw new Error(`expected 33 unsubscribers, got ${api.unsubscribed.length}`); if (api.unsubscribed.length !== 39) throw new Error(`expected 39 unsubscribers, got ${api.unsubscribed.length}`);
const persistedApi = makeApi({ const persistedApi = makeApi({
'events:workspace:Project': [{ 'events:workspace:Project': [{
@ -449,6 +461,79 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
} }
component.unmount && component.unmount(sessionView.container); component.unmount && component.unmount(sessionView.container);
const lateApi = makeApi({
'events:workspace:Project': [
{ activityId: 'late-a', type: 'note.saved', occurredAt: '2026-07-12T10:00:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-b', type: 'file.changed', occurredAt: '2026-07-12T10:10:00Z', workspaceRootPath: 'Project' },
],
});
const lateView = await mountWithApi(lateApi);
const lateCommand = lateApi.commandHandlers.get(WORKLOG_COMMAND_ID);
const firstLateCandidate = (await lateCommand({ workspaceRootPath: 'Project' })).candidates[0];
if (!firstLateCandidate || !firstLateCandidate.sessionId || firstLateCandidate.estimatedMinutes !== 10) {
throw new Error('file activity duration must use the capped adjacent-event algorithm');
}
await lateApi.settings.write('events:workspace:Project', [
{ activityId: 'late-before', type: 'note.saved', occurredAt: '2026-07-12T09:55:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-a', type: 'note.saved', occurredAt: '2026-07-12T10:00:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-b', type: 'file.changed', occurredAt: '2026-07-12T10:10:00Z', workspaceRootPath: 'Project' },
]);
await lateApi.handlers['note.saved']({ name: 'note.saved', payload: {} });
await flush();
const afterLateCandidate = (await lateCommand({ workspaceRootPath: 'Project' })).candidates[0];
if (!afterLateCandidate || afterLateCandidate.sessionId !== firstLateCandidate.sessionId) {
throw new Error('late events changed an immutable session identity');
}
const dismissLate = walk(lateView.container, (node) => node.getAttribute && node.getAttribute('data-work-session-action') === 'dismiss');
dismissLate.click();
await flush();
await lateApi.settings.write('events:workspace:Project', [
{ activityId: 'late-before', type: 'note.saved', occurredAt: '2026-07-12T09:55:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-a', type: 'note.saved', occurredAt: '2026-07-12T10:00:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-b', type: 'file.changed', occurredAt: '2026-07-12T10:10:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-one', type: 'note.saved', occurredAt: '2026-07-12T10:15:00Z', workspaceRootPath: 'Project' },
]);
await lateApi.handlers['note.saved']({ name: 'note.saved', payload: {} });
await flush();
if ((await lateCommand({ workspaceRootPath: 'Project' })).candidates.length !== 0) {
throw new Error('dismissed session was re-offered without substantial new activity');
}
await lateApi.settings.write('events:workspace:Project', [
{ activityId: 'late-before', type: 'note.saved', occurredAt: '2026-07-12T09:55:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-a', type: 'note.saved', occurredAt: '2026-07-12T10:00:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-b', type: 'file.changed', occurredAt: '2026-07-12T10:10:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-one', type: 'note.saved', occurredAt: '2026-07-12T10:15:00Z', workspaceRootPath: 'Project' },
{ activityId: 'late-two', type: 'file.changed', occurredAt: '2026-07-12T10:25:00Z', workspaceRootPath: 'Project' },
]);
await lateApi.handlers['file.changed']({ name: 'file.changed', payload: {} });
await flush();
if ((await lateCommand({ workspaceRootPath: 'Project' })).candidates.length !== 1) {
throw new Error('dismissed session was not re-offered after substantial new activity');
}
component.unmount && component.unmount(lateView.container);
const rawApi = makeApi({}, {
'activity-events': [{
activityId: 'browser-domain:batch-1:0',
type: 'browser.activity.domain',
title: 'example.com',
summary: '5 min browser activity',
occurredAt: '2026-07-12T10:05:00Z',
sourcePluginId: 'verstak-browser-extension',
sourceBatchId: 'batch-1',
hostname: 'example.com',
durationSeconds: 300,
payload: { hostname: 'example.com', durationSeconds: 300 },
}],
});
const rawView = await mountWithApi(rawApi, {});
if (!rawView.container.textContent.includes('example.com')) throw new Error('append-only browser activity was not rendered');
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('clear activity did not replace append-only data');
component.unmount && component.unmount(rawView.container);
console.log('activity plugin smoke passed'); console.log('activity plugin smoke passed');
})().catch((err) => { })().catch((err) => {
console.error(err); console.error(err);

View File

@ -142,6 +142,7 @@ function makeApi(initialSettings = {}) {
const unsubscribed = []; const unsubscribed = [];
const fileWrites = []; const fileWrites = [];
const fileByteWrites = []; const fileByteWrites = [];
const openedURLs = [];
const publishedEvents = []; const publishedEvents = [];
const workspaceEntries = [ const workspaceEntries = [
{ name: 'ClientA', relativePath: 'ClientA', type: 'folder' }, { name: 'ClientA', relativePath: 'ClientA', type: 'folder' },
@ -182,6 +183,8 @@ function makeApi(initialSettings = {}) {
const next = captures.flatMap((capture) => { const next = captures.flatMap((capture) => {
if (!ids.has(capture.captureId)) return [capture]; if (!ids.has(capture.captureId)) return [capture];
if (payload.action === 'delete') return []; if (payload.action === 'delete') return [];
if (payload.action === 'archive') return [{ ...capture, globalState: 'archived' }];
if (payload.action === 'restore') return [{ ...capture, globalState: 'inbox' }];
if (payload.action === 'assign') return [{ ...capture, workspaceRootPath: payload.workspaceRootPath || '', workspaceName: payload.workspaceRootPath || '' }]; if (payload.action === 'assign') return [{ ...capture, workspaceRootPath: payload.workspaceRootPath || '', workspaceName: payload.workspaceRootPath || '' }];
if (payload.action === 'processed') return [{ ...capture, processed: payload.processed === true }]; if (payload.action === 'processed') return [{ ...capture, processed: payload.processed === true }];
return [capture]; return [capture];
@ -200,6 +203,7 @@ function makeApi(initialSettings = {}) {
unsubscribed, unsubscribed,
fileWrites, fileWrites,
fileByteWrites, fileByteWrites,
openedURLs,
publishedEvents, publishedEvents,
failNextWrite(message) { failNextWrite(message) {
nextWriteError = new Error(message || 'write failed'); nextWriteError = new Error(message || 'write failed');
@ -245,6 +249,9 @@ function makeApi(initialSettings = {}) {
} }
fileByteWrites.push({ relativePath, dataBase64, options }); fileByteWrites.push({ relativePath, dataBase64, options });
}, },
openURL: async (url) => {
openedURLs.push(url);
},
}, },
browserReceiver: { browserReceiver: {
pairing: async () => ({ ...receiverPairing }), pairing: async () => ({ ...receiverPairing }),
@ -424,8 +431,8 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
if (!clearButton) throw new Error('clear button not found'); if (!clearButton) throw new Error('clear button not found');
clearButton.click(); clearButton.click();
await flush(); await flush();
if (api.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'capture-1')) { if (!api.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'capture-1' && capture.globalState === 'archived')) {
throw new Error('workspace clear action did not remove the Project capture'); throw new Error('workspace clear action did not archive the Project capture');
} }
if (!api.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'capture-2')) { if (!api.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'capture-2')) {
throw new Error('workspace clear action removed a capture from another workspace'); throw new Error('workspace clear action removed a capture from another workspace');
@ -632,7 +639,8 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
if (!assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned' && capture.processed === false)) { if (!assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned' && capture.processed === false)) {
throw new Error('mark unprocessed did not persist state'); throw new Error('mark unprocessed did not persist state');
} }
const deleteButton = walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'remove'); const deleteButton = walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'delete-permanently');
if (!deleteButton) throw new Error('permanent delete action was not rendered');
deleteButton.click(); deleteButton.click();
await flush(); await flush();
if (assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned')) { if (assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned')) {
@ -640,6 +648,37 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
} }
component.unmount && component.unmount(assignmentView.container); component.unmount && component.unmount(assignmentView.container);
const archiveApi = makeApi({
'captures:global': [{
captureId: 'archived-capture',
capturedAt: '2026-07-12T10:00:00.000Z',
kind: 'page',
url: 'https://example.com/archive',
title: 'Archived capture',
workspaceRootPath: 'Project',
globalState: 'archived',
}],
});
const archiveView = await mountWithApi(archiveApi, {});
if (walk(archiveView.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'archived-capture')) {
throw new Error('archived capture leaked into the active inbox');
}
const archiveFilter = walk(archiveView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-filter') === 'status');
archiveFilter.value = 'archived';
archiveFilter.dispatchEvent('change');
await flush();
if (!walk(archiveView.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'archived-capture')) {
throw new Error('archive filter did not reveal archived capture');
}
const restoreButton = walk(archiveView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'restore');
if (!restoreButton) throw new Error('restore archived capture action was not rendered');
restoreButton.click();
await flush();
if (!archiveApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'archived-capture' && capture.globalState === 'inbox')) {
throw new Error('restore archived capture did not return it to Inbox');
}
component.unmount && component.unmount(archiveView.container);
const conversionApi = makeApi({ const conversionApi = makeApi({
'captures:workspace:Project': [{ 'captures:workspace:Project': [{
captureId: 'convert-selection', captureId: 'convert-selection',
@ -669,8 +708,8 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
if (!noteWrite.content.includes('# Example Article')) throw new Error('note content missing heading'); if (!noteWrite.content.includes('# Example Article')) throw new Error('note content missing heading');
if (!noteWrite.content.includes('Source: https://example.com/article')) throw new Error('note content missing source URL'); if (!noteWrite.content.includes('Source: https://example.com/article')) throw new Error('note content missing source URL');
if (!noteWrite.content.includes('Selected text from the page')) throw new Error('note content missing selected text'); if (!noteWrite.content.includes('Selected text from the page')) throw new Error('note content missing selected text');
if (conversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-selection')) { if (!conversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-selection' && capture.globalState === 'archived')) {
throw new Error('converted capture was not removed from queue'); throw new Error('converted capture was not archived');
} }
const convertedEvent = conversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted'); const convertedEvent = conversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted');
if (!convertedEvent) throw new Error('browser.capture.converted event was not published'); if (!convertedEvent) throw new Error('browser.capture.converted event was not published');
@ -722,6 +761,11 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
const linkConversionView = await mountWithApi(linkConversionApi); const linkConversionView = await mountWithApi(linkConversionApi);
const createLinkButton = walk(linkConversionView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'create-link'); const createLinkButton = walk(linkConversionView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'create-link');
if (!createLinkButton) throw new Error('create link button was not rendered'); if (!createLinkButton) throw new Error('create link button was not rendered');
const openLinkButton = walk(linkConversionView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'open-link');
if (!openLinkButton) throw new Error('open link action was not rendered');
openLinkButton.click();
await flush();
if (linkConversionApi.openedURLs.join(',') !== 'https://example.com/article') throw new Error('open link did not use browser URL capability');
createLinkButton.click(); createLinkButton.click();
await flush(); await flush();
if (linkConversionApi.fileWrites.length !== 1) throw new Error(`expected one link write, got ${linkConversionApi.fileWrites.length}`); if (linkConversionApi.fileWrites.length !== 1) throw new Error(`expected one link write, got ${linkConversionApi.fileWrites.length}`);
@ -734,8 +778,8 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
} }
if (!linkWrite.content.includes('[InternetShortcut]')) throw new Error('link content missing InternetShortcut header'); if (!linkWrite.content.includes('[InternetShortcut]')) throw new Error('link content missing InternetShortcut header');
if (!linkWrite.content.includes('URL=https://example.com/article')) throw new Error('link content missing URL'); if (!linkWrite.content.includes('URL=https://example.com/article')) throw new Error('link content missing URL');
if (linkConversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-link')) { if (!linkConversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-link' && capture.globalState === 'archived')) {
throw new Error('converted link capture was not removed from queue'); throw new Error('converted link capture was not archived');
} }
const convertedLinkEvent = linkConversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted'); const convertedLinkEvent = linkConversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted');
if (!convertedLinkEvent) throw new Error('browser.capture.converted link event was not published'); if (!convertedLinkEvent) throw new Error('browser.capture.converted link event was not published');
@ -743,6 +787,25 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
if (convertedLinkEvent.payload.linkPath !== 'Project/Links/Example_Article.url') throw new Error('converted link event linkPath mismatch'); if (convertedLinkEvent.payload.linkPath !== 'Project/Links/Example_Article.url') throw new Error('converted link event linkPath mismatch');
component.unmount && component.unmount(linkConversionView.container); component.unmount && component.unmount(linkConversionView.container);
const collisionLinkApi = makeApi({
'captures:workspace:Project': [{
captureId: 'convert-link-collision',
capturedAt: '2026-06-29T01:25:00.000Z',
kind: 'link',
url: 'https://example.com/collision',
title: 'Example Article',
workspaceRootPath: 'Project',
}],
});
collisionLinkApi.failNextWrite('conflict: Project/Links/Example_Article.url');
const collisionLinkView = await mountWithApi(collisionLinkApi);
walk(collisionLinkView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'create-link').click();
await flush();
if (collisionLinkApi.fileWrites.length !== 1 || collisionLinkApi.fileWrites[0].relativePath !== 'Project/Links/Example_Article (2).url') {
throw new Error('link filename collision did not use the numbered suffix');
}
component.unmount && component.unmount(collisionLinkView.container);
const failedLinkApi = makeApi({ const failedLinkApi = makeApi({
'captures:workspace:Project': [{ 'captures:workspace:Project': [{
captureId: 'convert-link-conflict', captureId: 'convert-link-conflict',
@ -802,8 +865,8 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
if (fileWrite.options.createIfMissing !== true || fileWrite.options.overwrite !== false) { if (fileWrite.options.createIfMissing !== true || fileWrite.options.overwrite !== false) {
throw new Error(`file write options mismatch: ${JSON.stringify(fileWrite.options)}`); throw new Error(`file write options mismatch: ${JSON.stringify(fileWrite.options)}`);
} }
if (fileConversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-file')) { if (!fileConversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-file' && capture.globalState === 'archived')) {
throw new Error('converted file capture was not removed from queue'); throw new Error('converted file capture was not archived');
} }
const convertedFileEvent = fileConversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted'); const convertedFileEvent = fileConversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted');
if (!convertedFileEvent) throw new Error('browser.capture.converted file event was not published'); if (!convertedFileEvent) throw new Error('browser.capture.converted file event was not published');

View File

@ -133,7 +133,9 @@ function loadComponent(document) {
function makeApi(initialSettings = {}) { function makeApi(initialSettings = {}) {
const settings = { ...initialSettings }; const settings = { ...initialSettings };
const publishedEvents = [];
return { return {
publishedEvents,
settings: { settings: {
read: async (key) => (key ? settings[key] : { ...settings }), read: async (key) => (key ? settings[key] : { ...settings }),
write: async (key, value) => { write: async (key, value) => {
@ -141,6 +143,11 @@ function makeApi(initialSettings = {}) {
return { ...settings }; return { ...settings };
}, },
}, },
events: {
publish: async (name, payload) => {
publishedEvents.push({ name, payload });
},
},
storedEntries(key) { storedEntries(key) {
return settings[key] || []; return settings[key] || [];
}, },
@ -172,6 +179,7 @@ function byData(container, attr, value) {
} }
if ((manifest.optionalRequires || []).includes('activity.reconstruction')) throw new Error('Journal must remain available without Activity'); if ((manifest.optionalRequires || []).includes('activity.reconstruction')) throw new Error('Journal must remain available without Activity');
if (!manifest.permissions.includes('storage.namespace')) throw new Error('journal manifest must request storage.namespace'); if (!manifest.permissions.includes('storage.namespace')) throw new Error('journal manifest must request storage.namespace');
if (!manifest.permissions.includes('events.publish')) throw new Error('journal manifest must request events.publish');
if (!manifest.permissions.includes('ui.register')) throw new Error('journal manifest must request ui.register'); if (!manifest.permissions.includes('ui.register')) throw new Error('journal manifest must request ui.register');
if (!(manifest.contributes.workspaceItems || []).some((item) => item.component === 'JournalView')) throw new Error('journal workspace item missing'); if (!(manifest.contributes.workspaceItems || []).some((item) => item.component === 'JournalView')) throw new Error('journal workspace item missing');
if (!(manifest.contributes.sidebarItems || []).some((item) => item.view === 'verstak.journal.view')) throw new Error('journal sidebar item missing'); if (!(manifest.contributes.sidebarItems || []).some((item) => item.view === 'verstak.journal.view')) throw new Error('journal sidebar item missing');
@ -212,6 +220,8 @@ function byData(container, attr, value) {
const candidate = { const candidate = {
candidateId: 'work-session:Project:capture-1:note-1', candidateId: 'work-session:Project:capture-1:note-1',
sessionId: 'session-journal-1',
handledThrough: '2026-06-27T11:03:00.000Z',
workspaceRootPath: 'Project', workspaceRootPath: 'Project',
startedAt: '2026-06-27T10:12:00.000Z', startedAt: '2026-06-27T10:12:00.000Z',
endedAt: '2026-06-27T11:03:00.000Z', endedAt: '2026-06-27T11:03:00.000Z',
@ -249,6 +259,10 @@ function byData(container, attr, value) {
throw new Error('candidate review did not keep the user-authored entry fields'); throw new Error('candidate review did not keep the user-authored entry fields');
} }
if (linkedEntry.activityIds.join(',') !== 'capture-1') throw new Error('candidate review did not persist selected activity ids'); if (linkedEntry.activityIds.join(',') !== 'capture-1') throw new Error('candidate review did not persist selected activity ids');
const handledEvent = api.publishedEvents.find((event) => event.name === 'activity.session.handled');
if (!handledEvent || handledEvent.payload.sessionId !== 'session-journal-1' || handledEvent.payload.handledThrough !== '2026-06-27T11:03:00.000Z' || handledEvent.payload.status !== 'accepted') {
throw new Error('accepted Journal candidate did not persist a session watermark');
}
if (walk(candidateView.container, (node) => node.getAttribute && node.getAttribute('data-journal-action') === 'view-activity')) { if (walk(candidateView.container, (node) => node.getAttribute && node.getAttribute('data-journal-action') === 'view-activity')) {
throw new Error('journal rows must not navigate to Activity by default'); throw new Error('journal rows must not navigate to Activity by default');
} }