feat: read activity from append-only storage
This commit is contained in:
parent
69a1938ff3
commit
0db9f896ce
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
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';
|
||||||
|
|
@ -30,7 +31,8 @@
|
||||||
'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'
|
||||||
];
|
];
|
||||||
var EVENT_LABELS = {
|
var EVENT_LABELS = {
|
||||||
'workspace.selected': 'Workspace selected',
|
'workspace.selected': 'Workspace selected',
|
||||||
|
|
@ -44,7 +46,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,
|
||||||
|
|
@ -347,6 +350,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);
|
||||||
}
|
}
|
||||||
|
|
@ -436,7 +448,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);
|
||||||
|
|
@ -503,7 +515,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;
|
||||||
}));
|
}));
|
||||||
|
|
@ -525,6 +542,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) {
|
||||||
|
|
@ -634,10 +664,17 @@
|
||||||
|
|
||||||
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, '');
|
candidateSourceEvents = rawRecords.length ? eventsFromRecords(rawRecords, '') : eventsFromSettings(settings, '');
|
||||||
events = eventsFromSettings(settings, scope.mode === 'workspace' ? scope.workspaceRoot : '');
|
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 persistCandidateCaches();
|
||||||
|
|
@ -652,9 +689,14 @@
|
||||||
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 : ''), dismissedByWorkspace) });
|
||||||
}
|
}
|
||||||
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, dismissedCandidatesFromSettings(settings)) };
|
||||||
}).catch(function () {
|
}).catch(function () {
|
||||||
return { candidates: [] };
|
return { candidates: [] };
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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()) {
|
||||||
|
|
@ -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 !== 36) throw new Error(`expected 36 unsubscribers, got ${api.unsubscribed.length}`);
|
||||||
|
|
||||||
const persistedApi = makeApi({
|
const persistedApi = makeApi({
|
||||||
'events:workspace:Project': [{
|
'events:workspace:Project': [{
|
||||||
|
|
@ -449,6 +461,28 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
|
||||||
}
|
}
|
||||||
component.unmount && component.unmount(sessionView.container);
|
component.unmount && component.unmount(sessionView.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);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue