Compare commits

..

No commits in common. "e93584c37c1992db2ba5f6d9fd9a4ba4cf0631a7" and "69a1938ff3e6a2b272e334a3d111620fe986eff4" have entirely different histories.

7 changed files with 108 additions and 570 deletions

View File

@ -8,14 +8,12 @@
var PLUGIN_ID = 'verstak.activity';
var MAX_EVENTS = 250;
var RAW_DATA_NAME = 'activity-events';
var MAX_CANDIDATES = 12;
var LEGACY_KEY = 'events';
var GLOBAL_KEY = 'events:global';
var WORKSPACE_PREFIX = 'events:workspace:';
var CANDIDATE_PREFIX = 'work-session-candidates:workspace:';
var DISMISSAL_PREFIX = 'work-session-dismissals:workspace:';
var SESSION_REGISTRY_KEY = 'activity-session-registry-v2';
var WORKLOG_COMMAND_ID = 'verstak.activity.suggestWorklog';
var MIN_SESSION_DURATION_MINUTES = 10;
var MIN_SESSION_ACTIVITY_COUNT = 2;
@ -32,9 +30,7 @@
'browser.capture.selection',
'browser.capture.link',
'browser.capture.file',
'browser.capture.converted',
'browser.activity.batch',
'activity.session.handled'
'browser.capture.converted'
];
var EVENT_LABELS = {
'workspace.selected': 'Workspace selected',
@ -48,8 +44,7 @@
'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'
'browser.capture.converted': 'Capture converted'
};
var LOW_VALUE_EVENT_TYPES = {
'workspace.selected': true,
@ -151,16 +146,14 @@
function scopeFromProps(props) {
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 workspaces', workspaceRoot: '', workspaceId: '' };
return { mode: 'global', key: GLOBAL_KEY, label: 'All workspaces', workspaceRoot: '' };
}
return {
mode: 'workspace',
key: WORKSPACE_PREFIX + encodeKey(workspaceRoot),
label: workspaceRoot,
workspaceRoot: workspaceRoot,
workspaceId: workspaceId
workspaceRoot: workspaceRoot
};
}
@ -182,9 +175,6 @@
receivedAt: text(item.receivedAt),
sourcePluginId: text(item.sourcePluginId || item.pluginId),
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 || '',
payload: item.payload && typeof item.payload === 'object' ? item.payload : {}
};
@ -202,9 +192,6 @@
receivedAt: item.receivedAt,
sourcePluginId: item.sourcePluginId,
workspaceRootPath: item.workspaceRootPath,
workspaceId: item.workspaceId,
sessionScope: item.sessionScope || {},
durationSeconds: item.durationSeconds || 0,
payload: item.payload || {}
};
});
@ -238,22 +225,13 @@
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) {
var date = new Date(time);
return isNaN(date.getTime()) ? '' : date.toISOString();
}
function newSessionId() {
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 candidateId(workspaceRootPath, firstActivity, lastActivity) {
return 'work-session:' + encodeKey(workspaceRootPath) + ':' + encodeKey(firstActivity.activityId) + ':' + encodeKey(lastActivity.activityId);
}
function candidateActivity(activity) {
@ -262,149 +240,57 @@
type: text(activity.type),
occurredAt: toISOTime(eventTimeMs(activity)),
sourcePluginId: text(activity.sourcePluginId),
workspaceRootPath: candidateWorkspace(activity),
workspaceId: text(activity.workspaceId)
workspaceRootPath: candidateWorkspace(activity)
};
}
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) {
var activities = session.activities.slice().sort(function (a, b) { return eventTimeMs(a) - eventTimeMs(b); });
var first = activities[0];
var last = activities[activities.length - 1];
var duration = sessionDurationMinutes(session);
if (session.scope === 'unassigned' || !session.workspaceRootPath || activities.length < MIN_SESSION_ACTIVITY_COUNT || duration < MIN_SESSION_DURATION_MINUTES) return null;
var first = session.activities[0];
var last = session.activities[session.activities.length - 1];
var duration = Math.round((eventTimeMs(last) - eventTimeMs(first)) / 60000);
if (session.activities.length < MIN_SESSION_ACTIVITY_COUNT || duration < MIN_SESSION_DURATION_MINUTES) return null;
return {
candidateId: 'work-session:' + encodeKey(session.sessionId) + ':' + encodeKey(last.activityId),
sessionId: session.sessionId,
handledThrough: toISOTime(eventTimeMs(last)),
candidateId: candidateId(session.workspaceRootPath, first, last),
workspaceRootPath: session.workspaceRootPath,
workspaceId: session.workspaceId,
startedAt: toISOTime(eventTimeMs(first)),
endedAt: toISOTime(eventTimeMs(last)),
estimatedMinutes: duration,
activityCount: activities.length,
activityIds: activities.map(function (activity) { return activity.activityId; }).filter(Boolean),
activities: activities.map(candidateActivity)
activityCount: session.activities.length,
activityIds: session.activities.map(function (activity) { return activity.activityId; }).filter(Boolean),
activities: session.activities.map(candidateActivity)
};
}
function buildWorkSessionCandidates(activityList, workspaceFilter, registry) {
function buildWorkSessionCandidates(activityList, workspaceFilter) {
var filter = cleanWorkspace(workspaceFilter);
registry = normalizeSessionRegistry(registry);
return logicalSessions(activityList, registry).map(buildCandidate).filter(function (candidate) {
var ordered = sortEvents(activityList || []).filter(function (activity) {
return isMeaningfulActivity(activity) && candidateWorkspace(activity) && eventTimeMs(activity);
}).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);
}).sort(function (a, b) {
return b.endedAt.localeCompare(a.endedAt) || a.workspaceRootPath.localeCompare(b.workspaceRootPath);
@ -461,15 +347,6 @@
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) {
return CANDIDATE_PREFIX + encodeKey(workspaceRoot);
}
@ -490,45 +367,26 @@
function dismissedCandidatesFromSettings(settings) {
var dismissed = {};
Object.keys(settings || {}).forEach(function (key) {
if (key.indexOf(DISMISSAL_PREFIX) !== 0) return;
if (key.indexOf(DISMISSAL_PREFIX) !== 0 || !Array.isArray(settings[key])) return;
var workspace = decodeStoredWorkspace(key, DISMISSAL_PREFIX);
if (!workspace) return;
dismissed[workspace] = {};
if (Array.isArray(settings[key])) {
settings[key].forEach(function (candidateId) {
candidateId = text(candidateId).trim();
if (candidateId) dismissed[workspace][candidateId] = true;
});
return;
}
if (settings[key] && typeof settings[key] === 'object') dismissed[workspace] = settings[key];
});
return dismissed;
}
function candidateAfterWatermark(candidate, watermark) {
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 isCandidateDismissed(candidate, dismissedByWorkspace) {
return !!(candidate && dismissedByWorkspace && dismissedByWorkspace[candidate.workspaceRootPath] && dismissedByWorkspace[candidate.workspaceRootPath][candidate.candidateId]);
}
function visibleCandidates(activityList, workspaceFilter, sessionRegistry, dismissedByWorkspace, handledSessions) {
return buildWorkSessionCandidates(activityList, workspaceFilter, sessionRegistry).map(function (candidate) {
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 visibleCandidates(activityList, workspaceFilter, dismissedByWorkspace) {
return buildWorkSessionCandidates(activityList, workspaceFilter).filter(function (candidate) {
return !isCandidateDismissed(candidate, dismissedByWorkspace);
});
}
function formatDate(value) {
@ -551,8 +409,6 @@
var candidateSourceEvents = [];
var candidates = [];
var dismissedByWorkspace = {};
var handledSessions = {};
var sessionRegistry = normalizeSessionRegistry({});
function tr(key, params, fallback) {
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
return fallback || key;
@ -580,7 +436,7 @@
return candidateWorkspace(activity) !== scope.workspaceRoot;
});
updateCandidates();
clearWorkspaceRaw(scope.workspaceRoot).then(persist).then(render);
persist().then(render);
}
});
toolbar.appendChild(titleEl);
@ -599,7 +455,7 @@
containerEl.appendChild(listEl);
function candidatesForWorkspace(workspaceRoot) {
return visibleCandidates(candidateSourceEvents, workspaceRoot, sessionRegistry, dismissedByWorkspace, handledSessions);
return visibleCandidates(candidateSourceEvents, workspaceRoot, dismissedByWorkspace);
}
function updateCandidates() {
@ -628,12 +484,7 @@
function persistDismissals(workspaceRoot) {
if (!workspaceRoot || !api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
var dismissed = dismissedByWorkspace[workspaceRoot] || {};
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);
return api.settings.write(dismissalStorageKey(workspaceRoot), Object.keys(dismissed));
}
function persist() {
@ -641,7 +492,7 @@
var toStore = scope.mode === 'global'
? 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) {
return api.settings.write(scope.key, storageEvents(toStore)).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)));
statusClass = 'error';
});
@ -652,12 +503,7 @@
events = [];
return Promise.resolve();
}
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) {
return api.settings.read().then(function (settings) {
var keys = globalEventKeys(settings || {}).concat(Object.keys(settings || {}).filter(function (key) {
return key.indexOf(CANDIDATE_PREFIX) === 0 || key.indexOf(DISMISSAL_PREFIX) === 0;
}));
@ -665,8 +511,6 @@
candidateSourceEvents = [];
candidates = [];
dismissedByWorkspace = {};
handledSessions = {};
sessionRegistry = normalizeSessionRegistry({});
return Promise.all(keys.filter(function (key, index, all) {
return all.indexOf(key) === index;
}).map(function (key) {
@ -681,19 +525,6 @@
});
}
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() {
listEl.innerHTML = '';
if (events.length === 0) {
@ -741,11 +572,7 @@
function dismissCandidate(candidate) {
if (!candidate || !candidate.workspaceRootPath || !candidate.candidateId) return;
dismissedByWorkspace[candidate.workspaceRootPath] = dismissedByWorkspace[candidate.workspaceRootPath] || {};
dismissedByWorkspace[candidate.workspaceRootPath][candidate.sessionId || candidate.candidateId] = {
handledThrough: candidate.handledThrough || candidate.endedAt,
handledAt: new Date().toISOString(),
status: 'dismissed'
};
dismissedByWorkspace[candidate.workspaceRootPath][candidate.candidateId] = true;
updateCandidates();
Promise.all([
persistDismissals(candidate.workspaceRootPath),
@ -807,25 +634,13 @@
function loadStored() {
if (!api || !api.settings || typeof api.settings.read !== 'function') return Promise.resolve();
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] : [];
return api.settings.read().then(function (settings) {
settings = settings || {};
sessionRegistry = normalizeSessionRegistry(settings[SESSION_REGISTRY_KEY]);
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 : '');
candidateSourceEvents = eventsFromSettings(settings, '');
events = eventsFromSettings(settings, scope.mode === 'workspace' ? scope.workspaceRoot : '');
dismissedByWorkspace = dismissedCandidatesFromSettings(settings);
updateCandidates();
return persistSessionRegistry().then(persistCandidateCaches);
return persistCandidateCaches();
}).catch(function (err) {
statusText = 'Could not load activity: ' + (err && err.message ? err.message : String(err));
statusClass = 'error';
@ -835,16 +650,11 @@
function listWorkSessionCandidates(args) {
var workspace = cleanWorkspace(args && args.workspaceRootPath);
if (!api || !api.settings || typeof api.settings.read !== 'function') {
return Promise.resolve({ candidates: visibleCandidates(candidateSourceEvents, workspace || (scope.mode === 'workspace' ? scope.workspaceRoot : ''), sessionRegistry, dismissedByWorkspace, handledSessions) });
return Promise.resolve({ candidates: visibleCandidates(candidateSourceEvents, workspace || (scope.mode === 'workspace' ? scope.workspaceRoot : ''), dismissedByWorkspace) });
}
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] : [];
var source = rawRecords.length ? eventsFromRecords(rawRecords, '') : eventsFromSettings(settings, '');
return { candidates: visibleCandidates(source, workspace, sessionRegistry, dismissedCandidatesFromSettings(settings), settings["activity-session-handling-v2"] || {}) };
return api.settings.read().then(function (settings) {
settings = settings || {};
return { candidates: visibleCandidates(eventsFromSettings(settings, ''), workspace, dismissedCandidatesFromSettings(settings)) };
}).catch(function () {
return { candidates: [] };
});

View File

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

View File

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

View File

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

View File

@ -116,9 +116,8 @@ function loadComponent(document) {
return component;
}
function makeApi(initialSettings = {}, initialData = {}) {
function makeApi(initialSettings = {}) {
const settings = { ...initialSettings };
const data = { ...initialData };
const handlers = {};
const commandHandlers = new Map();
const unsubscribed = [];
@ -133,14 +132,6 @@ function makeApi(initialSettings = {}, initialData = {}) {
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: {
subscribe: async (name, handler) => {
handlers[name] = handler;
@ -159,14 +150,11 @@ function makeApi(initialSettings = {}, initialData = {}) {
storedEvents(key = 'events') {
return settings[key] || [];
},
storedData(name) {
return Array.isArray(data[name]) ? data[name] : [];
},
};
}
async function flush() {
for (let i = 0; i < 20; i += 1) await Promise.resolve();
for (let i = 0; i < 10; i += 1) await Promise.resolve();
}
async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, workspaceRootPath: 'Project' }, document = makeDocument()) {
@ -287,7 +275,7 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
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('Workspace: Project')) throw new Error('candidate workspace was not rendered');
if (!candidateNode.textContent.includes('Estimated duration: 20 min')) throw new Error('candidate duration was not rendered');
if (!candidateNode.textContent.includes('Estimated duration: 30 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');
if (!walk(candidateNode, (node) => node.getAttribute && node.getAttribute('data-work-session-action') === 'dismiss')) throw new Error('candidate dismiss action was not rendered');
@ -299,7 +287,7 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
if (!candidate.candidateId) throw new Error('candidate id is missing');
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.estimatedMinutes !== 20) throw new Error(`expected 20 candidate minutes, got ${candidate.estimatedMinutes}`);
if (candidate.estimatedMinutes !== 30) throw new Error(`expected 30 candidate minutes, got ${candidate.estimatedMinutes}`);
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 (!Array.isArray(candidate.activities) || candidate.activities.length !== 3) throw new Error('candidate activity list is missing');
@ -365,7 +353,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');
component.unmount && component.unmount(container);
if (api.unsubscribed.length !== 39) throw new Error(`expected 39 unsubscribers, got ${api.unsubscribed.length}`);
if (api.unsubscribed.length !== 33) throw new Error(`expected 33 unsubscribers, got ${api.unsubscribed.length}`);
const persistedApi = makeApi({
'events:workspace:Project': [{
@ -461,79 +449,6 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
}
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');
})().catch((err) => {
console.error(err);

View File

@ -142,7 +142,6 @@ function makeApi(initialSettings = {}) {
const unsubscribed = [];
const fileWrites = [];
const fileByteWrites = [];
const openedURLs = [];
const publishedEvents = [];
const workspaceEntries = [
{ name: 'ClientA', relativePath: 'ClientA', type: 'folder' },
@ -183,8 +182,6 @@ function makeApi(initialSettings = {}) {
const next = captures.flatMap((capture) => {
if (!ids.has(capture.captureId)) return [capture];
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 === 'processed') return [{ ...capture, processed: payload.processed === true }];
return [capture];
@ -203,7 +200,6 @@ function makeApi(initialSettings = {}) {
unsubscribed,
fileWrites,
fileByteWrites,
openedURLs,
publishedEvents,
failNextWrite(message) {
nextWriteError = new Error(message || 'write failed');
@ -249,9 +245,6 @@ function makeApi(initialSettings = {}) {
}
fileByteWrites.push({ relativePath, dataBase64, options });
},
openURL: async (url) => {
openedURLs.push(url);
},
},
browserReceiver: {
pairing: async () => ({ ...receiverPairing }),
@ -431,8 +424,8 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
if (!clearButton) throw new Error('clear button not found');
clearButton.click();
await flush();
if (!api.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'capture-1' && capture.globalState === 'archived')) {
throw new Error('workspace clear action did not archive the Project capture');
if (api.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'capture-1')) {
throw new Error('workspace clear action did not remove the Project capture');
}
if (!api.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'capture-2')) {
throw new Error('workspace clear action removed a capture from another workspace');
@ -639,8 +632,7 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
if (!assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned' && capture.processed === false)) {
throw new Error('mark unprocessed did not persist state');
}
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');
const deleteButton = walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'remove');
deleteButton.click();
await flush();
if (assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned')) {
@ -648,37 +640,6 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
}
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({
'captures:workspace:Project': [{
captureId: 'convert-selection',
@ -708,8 +669,8 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
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('Selected text from the page')) throw new Error('note content missing selected text');
if (!conversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-selection' && capture.globalState === 'archived')) {
throw new Error('converted capture was not archived');
if (conversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-selection')) {
throw new Error('converted capture was not removed from queue');
}
const convertedEvent = conversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted');
if (!convertedEvent) throw new Error('browser.capture.converted event was not published');
@ -761,11 +722,6 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
const linkConversionView = await mountWithApi(linkConversionApi);
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');
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();
await flush();
if (linkConversionApi.fileWrites.length !== 1) throw new Error(`expected one link write, got ${linkConversionApi.fileWrites.length}`);
@ -778,8 +734,8 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
}
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 (!linkConversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-link' && capture.globalState === 'archived')) {
throw new Error('converted link capture was not archived');
if (linkConversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-link')) {
throw new Error('converted link capture was not removed from queue');
}
const convertedLinkEvent = linkConversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted');
if (!convertedLinkEvent) throw new Error('browser.capture.converted link event was not published');
@ -787,25 +743,6 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
if (convertedLinkEvent.payload.linkPath !== 'Project/Links/Example_Article.url') throw new Error('converted link event linkPath mismatch');
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({
'captures:workspace:Project': [{
captureId: 'convert-link-conflict',
@ -865,8 +802,8 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
if (fileWrite.options.createIfMissing !== true || fileWrite.options.overwrite !== false) {
throw new Error(`file write options mismatch: ${JSON.stringify(fileWrite.options)}`);
}
if (!fileConversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-file' && capture.globalState === 'archived')) {
throw new Error('converted file capture was not archived');
if (fileConversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-file')) {
throw new Error('converted file capture was not removed from queue');
}
const convertedFileEvent = fileConversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted');
if (!convertedFileEvent) throw new Error('browser.capture.converted file event was not published');

View File

@ -133,9 +133,7 @@ function loadComponent(document) {
function makeApi(initialSettings = {}) {
const settings = { ...initialSettings };
const publishedEvents = [];
return {
publishedEvents,
settings: {
read: async (key) => (key ? settings[key] : { ...settings }),
write: async (key, value) => {
@ -143,11 +141,6 @@ function makeApi(initialSettings = {}) {
return { ...settings };
},
},
events: {
publish: async (name, payload) => {
publishedEvents.push({ name, payload });
},
},
storedEntries(key) {
return settings[key] || [];
},
@ -179,7 +172,6 @@ function byData(container, attr, value) {
}
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('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.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');
@ -220,8 +212,6 @@ function byData(container, attr, value) {
const candidate = {
candidateId: 'work-session:Project:capture-1:note-1',
sessionId: 'session-journal-1',
handledThrough: '2026-06-27T11:03:00.000Z',
workspaceRootPath: 'Project',
startedAt: '2026-06-27T10:12:00.000Z',
endedAt: '2026-06-27T11:03:00.000Z',
@ -259,10 +249,6 @@ function byData(container, attr, value) {
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');
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')) {
throw new Error('journal rows must not navigate to Activity by default');
}