778 lines
35 KiB
JavaScript
778 lines
35 KiB
JavaScript
/* ===========================================================
|
|
Activity Plugin - Verstak v2 Frontend Bundle
|
|
Contract: window.VerstakPluginRegister(id, { components })
|
|
=========================================================== */
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
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 WORKLOG_COMMAND_ID = 'verstak.activity.suggestWorklog';
|
|
var MIN_SESSION_DURATION_MINUTES = 10;
|
|
var MIN_SESSION_ACTIVITY_COUNT = 2;
|
|
var MAX_IDLE_GAP_MINUTES = 20;
|
|
var MAX_SESSION_DURATION_MINUTES = 120;
|
|
var ACTIVITY_EVENTS = [
|
|
'file.opened',
|
|
'file.changed',
|
|
'note.saved',
|
|
'action.started',
|
|
'browser.capture.received',
|
|
'case.selected',
|
|
'browser.capture.page',
|
|
'browser.capture.selection',
|
|
'browser.capture.link',
|
|
'browser.capture.file',
|
|
'browser.capture.converted',
|
|
'browser.activity.batch'
|
|
];
|
|
var EVENT_LABELS = {
|
|
'workspace.selected': 'Workspace selected',
|
|
'case.selected': 'Workspace selected',
|
|
'file.opened': 'File opened',
|
|
'file.changed': 'File changed',
|
|
'note.saved': 'Note edited',
|
|
'action.started': 'Work session detected',
|
|
'browser.capture.received': 'Browser capture received',
|
|
'browser.capture.page': 'Page captured',
|
|
'browser.capture.selection': 'Selection captured',
|
|
'browser.capture.link': 'Link captured',
|
|
'browser.capture.file': 'File captured',
|
|
'browser.capture.converted': 'Capture converted',
|
|
'browser.activity.domain': 'Browser domain activity'
|
|
};
|
|
var LOW_VALUE_EVENT_TYPES = {
|
|
'workspace.selected': true,
|
|
'case.selected': true,
|
|
'file.selected': true,
|
|
'file.opened': true,
|
|
'note.opened': true
|
|
};
|
|
|
|
function injectStyles() {
|
|
if (document.getElementById('activity-style-injected')) return;
|
|
var style = document.createElement('style');
|
|
style.id = 'activity-style-injected';
|
|
style.textContent = STYLES;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
var STYLES = [
|
|
'.activity-root{display:flex;flex-direction:column;height:100%;min-height:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;color:var(--vt-color-text-primary,#f4f7fb);background:var(--vt-color-background,#101020)}',
|
|
'.activity-toolbar{display:flex;align-items:center;gap:.5rem;min-height:2.75rem;padding:.5rem .75rem;border-bottom:1px solid var(--vt-color-border,#202b46);background:var(--vt-color-surface-muted,#111629);flex-shrink:0;flex-wrap:wrap}',
|
|
'.activity-title{font-size:.84rem;font-weight:600;color:var(--vt-color-text-primary,#f4f7fb)}',
|
|
'.activity-count{font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3)}',
|
|
'.activity-spacer{flex:1}',
|
|
'.activity-btn{font-size:.78rem;padding:.32rem .65rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-md,6px);background:var(--vt-color-surface-hover,#1b2440);color:var(--vt-color-text-secondary,#b7c0d4);cursor:pointer}',
|
|
'.activity-btn:hover{background:var(--vt-color-surface-hover,#1b2440);border-color:var(--vt-color-accent,#4ecca3);color:var(--vt-color-text-primary,#f4f7fb)}',
|
|
'.activity-btn:disabled{opacity:.45;cursor:default}',
|
|
'.activity-btn.danger{border-color:rgba(233,69,96,.42);color:#ff9a9a}',
|
|
'.activity-status{font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3);white-space:nowrap}',
|
|
'.activity-status.error{display:inline-flex;border:1px solid rgba(233,69,96,.45);border-radius:var(--vt-radius-sm,4px);background:var(--vt-color-danger-muted,rgba(233,69,96,.14));color:#ffc6ce;padding:.18rem .4rem}',
|
|
'.activity-candidates{border-bottom:1px solid rgba(32,43,70,.72);background:var(--vt-color-surface-muted,#111629);padding:.65rem .75rem;display:grid;gap:.5rem}',
|
|
'.activity-candidates-title{font-size:.76rem;font-weight:600;color:var(--vt-color-text-muted,#7f8aa3);text-transform:uppercase;letter-spacing:.04em}',
|
|
'.activity-candidate{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.65rem;align-items:start;padding:.65rem .75rem;border:1px solid rgba(78,204,163,.34);border-radius:var(--vt-radius-lg,8px);background:var(--vt-color-surface,#15152c)}',
|
|
'.activity-candidate-title{font-size:.84rem;color:var(--vt-color-text-primary,#f4f7fb);font-weight:600}',
|
|
'.activity-candidate-facts{margin-top:.28rem;display:grid;gap:.16rem;font-size:.76rem;color:var(--vt-color-text-secondary,#b7c0d4)}',
|
|
'.activity-candidate-activities{margin-top:.38rem;font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3)}.activity-candidate-activities summary{cursor:pointer}.activity-candidate-activity{margin-top:.2rem;overflow-wrap:anywhere}',
|
|
'.activity-candidate-actions{display:flex;gap:.35rem;align-items:center;flex-wrap:wrap;justify-content:flex-end}',
|
|
'.activity-candidate-duration{font-size:.76rem;color:var(--vt-color-accent,#4ecca3);white-space:nowrap}',
|
|
'.activity-list{flex:1;min-height:0;overflow:auto;background:var(--vt-color-background,#101020)}',
|
|
'.activity-empty{height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.35rem;color:var(--vt-color-text-muted,#7f8aa3);font-size:.86rem;padding:2rem;text-align:center}',
|
|
'.activity-empty-title{color:var(--vt-color-text-secondary,#b7c0d4);font-weight:650}',
|
|
'.activity-row{display:grid;grid-template-columns:9.5rem minmax(0,1fr);gap:.75rem;padding:.72rem .85rem;border-bottom:1px solid rgba(32,43,70,.72)}',
|
|
'.activity-row:hover{background:var(--vt-color-surface-hover,#1b2440)}',
|
|
'.activity-time{font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3);white-space:nowrap}',
|
|
'.activity-main{min-width:0}',
|
|
'.activity-row-head{display:flex;align-items:center;gap:.45rem;min-width:0}',
|
|
'.activity-type{font-size:.68rem;color:var(--vt-color-accent,#4ecca3);text-transform:uppercase;letter-spacing:.04em;flex-shrink:0}',
|
|
'.activity-title-text{font-size:.86rem;color:var(--vt-color-text-primary,#f4f7fb);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
'.activity-summary{margin-top:.25rem;font-size:.78rem;line-height:1.4;color:var(--vt-color-text-secondary,#b7c0d4);white-space:pre-wrap;overflow-wrap:anywhere}',
|
|
'.activity-source{margin-top:.25rem;font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3)}',
|
|
'.activity-details{margin-top:.25rem;font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3)}.activity-details summary{cursor:pointer}',
|
|
'@media(max-width:760px){.activity-row,.activity-candidate{grid-template-columns:1fr;gap:.25rem}.activity-candidate-actions{justify-content:flex-start}.activity-toolbar{align-items:stretch}.activity-status{width:100%}}'
|
|
].join('\n');
|
|
|
|
function el(tag, attrs, children) {
|
|
var elem = document.createElement(tag);
|
|
if (attrs) {
|
|
Object.keys(attrs).forEach(function (k) {
|
|
if (attrs[k] == null) return;
|
|
if (k === 'className') elem.className = attrs[k];
|
|
else if (k.slice(0, 2) === 'on') elem.addEventListener(k.slice(2).toLowerCase(), attrs[k]);
|
|
else if (k === 'textContent') elem.textContent = attrs[k];
|
|
else elem.setAttribute(k, attrs[k]);
|
|
});
|
|
}
|
|
if (children) {
|
|
(Array.isArray(children) ? children : [children]).forEach(function (child) {
|
|
if (child == null) return;
|
|
elem.appendChild(typeof child === 'string' ? document.createTextNode(child) : child);
|
|
});
|
|
}
|
|
return elem;
|
|
}
|
|
|
|
function text(value) {
|
|
return String(value == null ? '' : value);
|
|
}
|
|
|
|
function encodeKey(value) {
|
|
return encodeURIComponent(text(value).trim());
|
|
}
|
|
|
|
function cleanWorkspace(value) {
|
|
return text(value).trim().replace(/^\/+|\/+$/g, '');
|
|
}
|
|
|
|
function workspaceFromProps(props) {
|
|
var node = props && props.workspaceNode;
|
|
return cleanWorkspace((props && (props.workspaceRootPath || props.workspaceName || props.workspaceNodeId))
|
|
|| (node && (node.rootPath || node.name || node.id)));
|
|
}
|
|
|
|
function workspaceFromPayload(payload) {
|
|
var explicit = cleanWorkspace(payload && (payload.workspaceRootPath || payload.workspaceName || payload.workspaceNodeId));
|
|
if (explicit) return explicit;
|
|
var path = cleanWorkspace(payload && payload.path);
|
|
if (!path || path.indexOf('/') === -1) return '';
|
|
return cleanWorkspace(path.split('/')[0]);
|
|
}
|
|
|
|
function scopeFromProps(props) {
|
|
var workspaceRoot = workspaceFromProps(props);
|
|
if (!workspaceRoot) {
|
|
return { mode: 'global', key: GLOBAL_KEY, label: 'All workspaces', workspaceRoot: '' };
|
|
}
|
|
return {
|
|
mode: 'workspace',
|
|
key: WORKSPACE_PREFIX + encodeKey(workspaceRoot),
|
|
label: workspaceRoot,
|
|
workspaceRoot: workspaceRoot
|
|
};
|
|
}
|
|
|
|
function eventPayload(event) {
|
|
return event && event.payload && typeof event.payload === 'object' ? event.payload : {};
|
|
}
|
|
|
|
function normalizeStoredEvents(value, storageKey) {
|
|
if (!Array.isArray(value)) return [];
|
|
return value.filter(function (item) {
|
|
return item && typeof item === 'object' && item.activityId;
|
|
}).map(function (item) {
|
|
return {
|
|
activityId: text(item.activityId),
|
|
type: text(item.type || item.name || 'activity.event'),
|
|
title: text(item.title || item.type || 'Activity event'),
|
|
summary: text(item.summary),
|
|
occurredAt: text(item.occurredAt || item.timestamp || item.receivedAt),
|
|
receivedAt: text(item.receivedAt),
|
|
sourcePluginId: text(item.sourcePluginId || item.pluginId),
|
|
workspaceRootPath: cleanWorkspace(item.workspaceRootPath || workspaceFromPayload(item.payload || {})),
|
|
_storageKey: storageKey || '',
|
|
payload: item.payload && typeof item.payload === 'object' ? item.payload : {}
|
|
};
|
|
}).slice(0, MAX_EVENTS);
|
|
}
|
|
|
|
function storageEvents(activityList) {
|
|
return activityList.map(function (item) {
|
|
return {
|
|
activityId: item.activityId,
|
|
type: item.type,
|
|
title: item.title,
|
|
summary: item.summary,
|
|
occurredAt: item.occurredAt,
|
|
receivedAt: item.receivedAt,
|
|
sourcePluginId: item.sourcePluginId,
|
|
workspaceRootPath: item.workspaceRootPath,
|
|
payload: item.payload || {}
|
|
};
|
|
});
|
|
}
|
|
|
|
function sortEvents(activityList) {
|
|
var seen = {};
|
|
return activityList.filter(function (item) {
|
|
var key = item && item.activityId;
|
|
if (!key) return false;
|
|
if (seen[key]) return false;
|
|
seen[key] = true;
|
|
return true;
|
|
}).slice().sort(function (a, b) {
|
|
return text(b.occurredAt || b.receivedAt).localeCompare(text(a.occurredAt || a.receivedAt));
|
|
}).slice(0, MAX_EVENTS);
|
|
}
|
|
|
|
function isMeaningfulActivity(activity) {
|
|
var type = text(activity && activity.type).toLowerCase();
|
|
return !LOW_VALUE_EVENT_TYPES[type];
|
|
}
|
|
|
|
function eventTimeMs(activity) {
|
|
var value = activity && (activity.occurredAt || activity.receivedAt);
|
|
var date = value ? new Date(value) : null;
|
|
return date && !isNaN(date.getTime()) ? date.getTime() : 0;
|
|
}
|
|
|
|
function candidateWorkspace(activity) {
|
|
return cleanWorkspace(activity && (activity.workspaceRootPath || workspaceFromPayload(activity.payload || {})));
|
|
}
|
|
|
|
function toISOTime(time) {
|
|
var date = new Date(time);
|
|
return isNaN(date.getTime()) ? '' : date.toISOString();
|
|
}
|
|
|
|
function candidateId(workspaceRootPath, firstActivity, lastActivity) {
|
|
return 'work-session:' + encodeKey(workspaceRootPath) + ':' + encodeKey(firstActivity.activityId) + ':' + encodeKey(lastActivity.activityId);
|
|
}
|
|
|
|
function candidateActivity(activity) {
|
|
return {
|
|
activityId: text(activity.activityId),
|
|
type: text(activity.type),
|
|
occurredAt: toISOTime(eventTimeMs(activity)),
|
|
sourcePluginId: text(activity.sourcePluginId),
|
|
workspaceRootPath: candidateWorkspace(activity)
|
|
};
|
|
}
|
|
|
|
function buildCandidate(session) {
|
|
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: candidateId(session.workspaceRootPath, first, last),
|
|
workspaceRootPath: session.workspaceRootPath,
|
|
startedAt: toISOTime(eventTimeMs(first)),
|
|
endedAt: toISOTime(eventTimeMs(last)),
|
|
estimatedMinutes: duration,
|
|
activityCount: session.activities.length,
|
|
activityIds: session.activities.map(function (activity) { return activity.activityId; }).filter(Boolean),
|
|
activities: session.activities.map(candidateActivity)
|
|
};
|
|
}
|
|
|
|
function buildWorkSessionCandidates(activityList, workspaceFilter) {
|
|
var filter = cleanWorkspace(workspaceFilter);
|
|
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);
|
|
}).slice(0, MAX_CANDIDATES);
|
|
}
|
|
|
|
function humanEventType(type) {
|
|
return EVENT_LABELS[text(type).toLowerCase()] || '';
|
|
}
|
|
|
|
function humanEventTitle(activity) {
|
|
var explicit = text(activity && activity.title).trim();
|
|
var type = text(activity && activity.type).trim();
|
|
if (explicit && explicit.toLowerCase() !== type.toLowerCase()) return explicit;
|
|
return humanEventType(type) || text(activity && (activity.summary || activity.activityId)).trim() || 'Activity event';
|
|
}
|
|
|
|
function eventKind(activity) {
|
|
var type = text(activity && activity.type).toLowerCase();
|
|
if (type.indexOf('browser.capture') === 0) return 'Capture';
|
|
if (type.indexOf('file.') === 0) return 'File';
|
|
if (type.indexOf('note.') === 0) return 'Note';
|
|
if (type.indexOf('workspace') !== -1 || type.indexOf('case.') === 0) return 'Workspace';
|
|
if (type.indexOf('action.') === 0) return 'Work';
|
|
return 'Activity';
|
|
}
|
|
|
|
function globalEventKeys(settings) {
|
|
var keys = [LEGACY_KEY, GLOBAL_KEY];
|
|
Object.keys(settings || {}).forEach(function (key) {
|
|
if (key.indexOf(WORKSPACE_PREFIX) === 0 && keys.indexOf(key) === -1) keys.push(key);
|
|
});
|
|
return keys;
|
|
}
|
|
|
|
function eventsFromSettings(settings, workspaceRoot) {
|
|
settings = settings || {};
|
|
var workspace = cleanWorkspace(workspaceRoot);
|
|
if (!workspace) {
|
|
var all = [];
|
|
globalEventKeys(settings).forEach(function (key) {
|
|
all = all.concat(normalizeStoredEvents(settings[key], key));
|
|
});
|
|
return sortEvents(all);
|
|
}
|
|
var workspaceKey = WORKSPACE_PREFIX + encodeKey(workspace);
|
|
var scopedEvents = normalizeStoredEvents(settings[workspaceKey], workspaceKey);
|
|
var globalEvents = normalizeStoredEvents(settings[GLOBAL_KEY], GLOBAL_KEY).filter(function (item) {
|
|
return item.workspaceRootPath === workspace;
|
|
});
|
|
var legacyEvents = normalizeStoredEvents(settings[LEGACY_KEY], LEGACY_KEY).filter(function (item) {
|
|
return item.workspaceRootPath === workspace;
|
|
});
|
|
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);
|
|
}
|
|
|
|
function dismissalStorageKey(workspaceRoot) {
|
|
return DISMISSAL_PREFIX + encodeKey(workspaceRoot);
|
|
}
|
|
|
|
function decodeStoredWorkspace(key, prefix) {
|
|
if (key.indexOf(prefix) !== 0) return '';
|
|
try {
|
|
return cleanWorkspace(decodeURIComponent(key.slice(prefix.length)));
|
|
} catch (err) {
|
|
return cleanWorkspace(key.slice(prefix.length));
|
|
}
|
|
}
|
|
|
|
function dismissedCandidatesFromSettings(settings) {
|
|
var dismissed = {};
|
|
Object.keys(settings || {}).forEach(function (key) {
|
|
if (key.indexOf(DISMISSAL_PREFIX) !== 0 || !Array.isArray(settings[key])) return;
|
|
var workspace = decodeStoredWorkspace(key, DISMISSAL_PREFIX);
|
|
if (!workspace) return;
|
|
dismissed[workspace] = {};
|
|
settings[key].forEach(function (candidateId) {
|
|
candidateId = text(candidateId).trim();
|
|
if (candidateId) dismissed[workspace][candidateId] = true;
|
|
});
|
|
});
|
|
return dismissed;
|
|
}
|
|
|
|
function isCandidateDismissed(candidate, dismissedByWorkspace) {
|
|
return !!(candidate && dismissedByWorkspace && dismissedByWorkspace[candidate.workspaceRootPath] && dismissedByWorkspace[candidate.workspaceRootPath][candidate.candidateId]);
|
|
}
|
|
|
|
function visibleCandidates(activityList, workspaceFilter, dismissedByWorkspace) {
|
|
return buildWorkSessionCandidates(activityList, workspaceFilter).filter(function (candidate) {
|
|
return !isCandidateDismissed(candidate, dismissedByWorkspace);
|
|
});
|
|
}
|
|
|
|
function formatDate(value) {
|
|
if (!value) return '';
|
|
var date = new Date(value);
|
|
if (isNaN(date.getTime())) return text(value);
|
|
return date.toLocaleString(undefined, { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
|
|
function ActivityView() {}
|
|
|
|
ActivityView.mount = function (containerEl, props, api) {
|
|
injectStyles();
|
|
containerEl.innerHTML = '';
|
|
containerEl.className = 'activity-root';
|
|
containerEl.setAttribute('data-plugin-id', PLUGIN_ID);
|
|
|
|
var scope = scopeFromProps(props || {});
|
|
var events = [];
|
|
var candidateSourceEvents = [];
|
|
var candidates = [];
|
|
var dismissedByWorkspace = {};
|
|
function tr(key, params, fallback) {
|
|
if (api && api.i18n && typeof api.i18n.t === 'function') return api.i18n.t(key, params, fallback);
|
|
return fallback || key;
|
|
}
|
|
var statusText = tr('ui.loading', null, 'Loading activity...');
|
|
var statusClass = '';
|
|
var disposed = false;
|
|
var unsubscribers = [];
|
|
|
|
var toolbar = el('div', { className: 'activity-toolbar' });
|
|
var titleEl = el('span', { className: 'activity-title', textContent: scope.mode === 'global' ? tr('ui.title', null, 'Activity') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Activity · ' + scope.label) });
|
|
var countEl = el('span', { className: 'activity-count' });
|
|
var statusEl = el('span', { className: 'activity-status' });
|
|
var clearBtn = el('button', {
|
|
className: 'activity-btn danger',
|
|
'data-activity-action': 'clear',
|
|
textContent: tr('ui.clear', null, 'Clear'),
|
|
onClick: function () {
|
|
if (scope.mode === 'global') {
|
|
clearGlobal().then(render);
|
|
return;
|
|
}
|
|
events = [];
|
|
candidateSourceEvents = candidateSourceEvents.filter(function (activity) {
|
|
return candidateWorkspace(activity) !== scope.workspaceRoot;
|
|
});
|
|
updateCandidates();
|
|
clearWorkspaceRaw(scope.workspaceRoot).then(persist).then(render);
|
|
}
|
|
});
|
|
toolbar.appendChild(titleEl);
|
|
toolbar.appendChild(countEl);
|
|
toolbar.appendChild(el('span', { className: 'activity-spacer' }));
|
|
toolbar.appendChild(statusEl);
|
|
toolbar.appendChild(clearBtn);
|
|
|
|
var candidatesEl = el('div', {
|
|
className: 'activity-candidates',
|
|
'data-activity-section': 'work-session-candidates'
|
|
});
|
|
var listEl = el('div', { className: 'activity-list' });
|
|
containerEl.appendChild(toolbar);
|
|
containerEl.appendChild(candidatesEl);
|
|
containerEl.appendChild(listEl);
|
|
|
|
function candidatesForWorkspace(workspaceRoot) {
|
|
return visibleCandidates(candidateSourceEvents, workspaceRoot, dismissedByWorkspace);
|
|
}
|
|
|
|
function updateCandidates() {
|
|
candidates = candidatesForWorkspace(scope.mode === 'workspace' ? scope.workspaceRoot : '');
|
|
}
|
|
|
|
function candidateWorkspaces() {
|
|
var workspaces = {};
|
|
candidateSourceEvents.forEach(function (activity) {
|
|
var workspace = candidateWorkspace(activity);
|
|
if (workspace) workspaces[workspace] = true;
|
|
});
|
|
if (scope.mode === 'workspace' && scope.workspaceRoot) workspaces[scope.workspaceRoot] = true;
|
|
return Object.keys(workspaces);
|
|
}
|
|
|
|
function persistCandidateCache(workspaceRoot) {
|
|
if (!workspaceRoot || !api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
|
|
return api.settings.write(candidateStorageKey(workspaceRoot), candidatesForWorkspace(workspaceRoot));
|
|
}
|
|
|
|
function persistCandidateCaches() {
|
|
return Promise.all(candidateWorkspaces().map(persistCandidateCache));
|
|
}
|
|
|
|
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), Object.keys(dismissed));
|
|
}
|
|
|
|
function persist() {
|
|
if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
|
|
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(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';
|
|
});
|
|
}
|
|
|
|
function clearGlobal() {
|
|
if (!api || !api.settings || typeof api.settings.read !== 'function' || typeof api.settings.write !== 'function') {
|
|
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) {
|
|
var keys = globalEventKeys(settings || {}).concat(Object.keys(settings || {}).filter(function (key) {
|
|
return key.indexOf(CANDIDATE_PREFIX) === 0 || key.indexOf(DISMISSAL_PREFIX) === 0;
|
|
}));
|
|
events = [];
|
|
candidateSourceEvents = [];
|
|
candidates = [];
|
|
dismissedByWorkspace = {};
|
|
return Promise.all(keys.filter(function (key, index, all) {
|
|
return all.indexOf(key) === index;
|
|
}).map(function (key) {
|
|
return api.settings.write(key, []);
|
|
}));
|
|
}).then(function () {
|
|
statusText = tr('ui.cleared', null, 'Activity cleared');
|
|
statusClass = '';
|
|
}).catch(function (err) {
|
|
statusText = tr('ui.clearError', { error: err && err.message ? err.message : String(err) }, 'Could not clear activity: ' + (err && err.message ? err.message : String(err)));
|
|
statusClass = 'error';
|
|
});
|
|
}
|
|
|
|
function 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) {
|
|
listEl.appendChild(el('div', { className: 'activity-empty' }, [
|
|
el('div', { className: 'activity-empty-title', textContent: tr('ui.empty', null, 'No activity events yet') }),
|
|
el('div', { textContent: tr('ui.emptyHint', null, 'File changes, browser captures, and conversions will appear here.') })
|
|
]));
|
|
return;
|
|
}
|
|
events.forEach(function (activity) {
|
|
listEl.appendChild(el('div', {
|
|
className: 'activity-row',
|
|
'data-activity-id': activity.activityId
|
|
}, [
|
|
el('div', { className: 'activity-time', textContent: formatDate(activity.occurredAt) || '-' }),
|
|
el('div', { className: 'activity-main' }, [
|
|
el('div', { className: 'activity-row-head' }, [
|
|
el('span', { className: 'activity-type', textContent: eventKind(activity) }),
|
|
el('span', { className: 'activity-title-text', textContent: humanEventTitle(activity) })
|
|
]),
|
|
activity.summary ? el('div', { className: 'activity-summary', textContent: activity.summary }) : null,
|
|
el('details', { className: 'activity-details' }, [
|
|
el('summary', {}, [tr('ui.details', null, 'Details')]),
|
|
el('div', { className: 'activity-source', textContent: tr('ui.eventSource', { event: activity.type, source: activity.sourcePluginId ? ' · ' + activity.sourcePluginId : '' }, 'Event: ' + activity.type + (activity.sourcePluginId ? ' · Source: ' + activity.sourcePluginId : '')) })
|
|
])
|
|
])
|
|
]));
|
|
});
|
|
}
|
|
|
|
function candidateTimeRange(candidate) {
|
|
return formatDate(candidate.startedAt) + ' - ' + formatDate(candidate.endedAt);
|
|
}
|
|
|
|
function reviewCandidate(candidate) {
|
|
if (typeof window === 'undefined' || typeof window.dispatchEvent !== 'function' || typeof CustomEvent !== 'function') return;
|
|
window.dispatchEvent(new CustomEvent('verstak:workspace-open-tool', {
|
|
detail: {
|
|
kind: 'journal',
|
|
toolRequest: { type: 'work-session-candidate', candidate: candidate }
|
|
}
|
|
}));
|
|
}
|
|
|
|
function dismissCandidate(candidate) {
|
|
if (!candidate || !candidate.workspaceRootPath || !candidate.candidateId) return;
|
|
dismissedByWorkspace[candidate.workspaceRootPath] = dismissedByWorkspace[candidate.workspaceRootPath] || {};
|
|
dismissedByWorkspace[candidate.workspaceRootPath][candidate.candidateId] = true;
|
|
updateCandidates();
|
|
Promise.all([
|
|
persistDismissals(candidate.workspaceRootPath),
|
|
persistCandidateCache(candidate.workspaceRootPath)
|
|
]).then(function () {
|
|
statusText = tr('ui.dismissed', null, 'Candidate dismissed');
|
|
statusClass = '';
|
|
}).catch(function (err) {
|
|
statusText = 'Could not dismiss candidate: ' + (err && err.message ? err.message : String(err));
|
|
statusClass = 'error';
|
|
}).then(render);
|
|
}
|
|
|
|
function renderCandidates() {
|
|
candidatesEl.innerHTML = '';
|
|
if (!candidates.length) {
|
|
candidatesEl.setAttribute('hidden', 'hidden');
|
|
return;
|
|
}
|
|
if (typeof candidatesEl.removeAttribute === 'function') candidatesEl.removeAttribute('hidden');
|
|
else delete candidatesEl.attributes.hidden;
|
|
candidatesEl.appendChild(el('div', { className: 'activity-candidates-title', textContent: tr('ui.candidates', null, 'Possible journal entries') }));
|
|
candidates.forEach(function (candidate) {
|
|
candidatesEl.appendChild(el('div', {
|
|
className: 'activity-candidate',
|
|
'data-work-session-candidate': candidate.candidateId
|
|
}, [
|
|
el('div', {}, [
|
|
el('div', { className: 'activity-candidate-title', textContent: tr('ui.candidate', null, 'Possible journal entry') }),
|
|
el('div', { className: 'activity-candidate-facts' }, [
|
|
el('div', { textContent: 'Workspace: ' + candidate.workspaceRootPath }),
|
|
el('div', { textContent: 'Time: ' + candidateTimeRange(candidate) }),
|
|
el('div', { textContent: 'Estimated duration: ' + candidate.estimatedMinutes + ' min' }),
|
|
el('div', { textContent: 'Activities: ' + candidate.activityCount })
|
|
]),
|
|
el('details', { className: 'activity-candidate-activities' }, [
|
|
el('summary', { textContent: 'Included activities (' + candidate.activityCount + ')' })
|
|
].concat(candidate.activities.map(function (activity) {
|
|
return el('div', { className: 'activity-candidate-activity', textContent: activity.occurredAt + ' · ' + activity.type + ' · ' + activity.activityId });
|
|
})))
|
|
]),
|
|
el('div', { className: 'activity-candidate-actions' }, [
|
|
el('div', { className: 'activity-candidate-duration', textContent: candidate.estimatedMinutes + ' min' }),
|
|
el('button', { className: 'activity-btn', type: 'button', 'data-work-session-action': 'review', textContent: tr('ui.review', null, 'Review'), onClick: function () { reviewCandidate(candidate); } }),
|
|
el('button', { className: 'activity-btn', type: 'button', 'data-work-session-action': 'dismiss', textContent: tr('ui.dismiss', null, 'Dismiss'), onClick: function () { dismissCandidate(candidate); } })
|
|
])
|
|
]));
|
|
});
|
|
}
|
|
|
|
function render() {
|
|
countEl.textContent = events.length + ' event' + (events.length === 1 ? '' : 's');
|
|
clearBtn.disabled = events.length === 0;
|
|
statusEl.textContent = statusText;
|
|
statusEl.className = 'activity-status' + (statusClass ? ' ' + statusClass : '');
|
|
renderCandidates();
|
|
renderList();
|
|
}
|
|
|
|
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] : [];
|
|
settings = settings || {};
|
|
candidateSourceEvents = rawRecords.length ? eventsFromRecords(rawRecords, '') : eventsFromSettings(settings, '');
|
|
events = rawRecords.length
|
|
? eventsFromRecords(rawRecords, scope.mode === 'workspace' ? scope.workspaceRoot : '')
|
|
: eventsFromSettings(settings, scope.mode === 'workspace' ? scope.workspaceRoot : '');
|
|
dismissedByWorkspace = dismissedCandidatesFromSettings(settings);
|
|
updateCandidates();
|
|
return persistCandidateCaches();
|
|
}).catch(function (err) {
|
|
statusText = 'Could not load activity: ' + (err && err.message ? err.message : String(err));
|
|
statusClass = 'error';
|
|
});
|
|
}
|
|
|
|
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 : ''), 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, dismissedCandidatesFromSettings(settings)) };
|
|
}).catch(function () {
|
|
return { candidates: [] };
|
|
});
|
|
}
|
|
|
|
function registerCommands() {
|
|
if (!api || !api.commands || typeof api.commands.register !== 'function') return Promise.resolve();
|
|
return api.commands.register(WORKLOG_COMMAND_ID, listWorkSessionCandidates).then(function (unregister) {
|
|
if (typeof unregister === 'function') unsubscribers.push(unregister);
|
|
}).catch(function (err) {
|
|
statusText = 'Activity commands unavailable: ' + (err && err.message ? err.message : String(err));
|
|
statusClass = 'error';
|
|
});
|
|
}
|
|
|
|
function subscribeEvents() {
|
|
if (!api || !api.events || typeof api.events.subscribe !== 'function') return Promise.resolve();
|
|
return Promise.all(ACTIVITY_EVENTS.map(function (eventName) {
|
|
return api.events.subscribe(eventName, function (event) {
|
|
var eventWorkspace = workspaceFromPayload(eventPayload(event));
|
|
if (scope.mode === 'workspace' && eventWorkspace && eventWorkspace !== scope.workspaceRoot) return Promise.resolve();
|
|
return loadStored().then(render);
|
|
}).then(function (unsubscribe) {
|
|
if (typeof unsubscribe === 'function') unsubscribers.push(unsubscribe);
|
|
});
|
|
})).then(function () {
|
|
statusText = scope.mode === 'global' ? 'Listening for all activity' : 'Listening for workspace activity';
|
|
statusClass = '';
|
|
}).catch(function (err) {
|
|
statusText = 'Activity subscriptions unavailable: ' + (err && err.message ? err.message : String(err));
|
|
statusClass = 'error';
|
|
});
|
|
}
|
|
|
|
render();
|
|
loadStored().then(function () {
|
|
if (disposed) return;
|
|
render();
|
|
return registerCommands();
|
|
}).then(function () {
|
|
if (disposed) return;
|
|
return subscribeEvents();
|
|
}).then(function () {
|
|
if (!disposed) render();
|
|
});
|
|
if (api && api.i18n && typeof api.i18n.onDidChangeLocale === 'function') {
|
|
api.i18n.onDidChangeLocale(function () {
|
|
titleEl.textContent = scope.mode === 'global' ? tr('ui.title', null, 'Activity') : tr('ui.workspaceTitle', { workspace: scope.label }, 'Activity · ' + scope.label);
|
|
clearBtn.textContent = tr('ui.clear', null, 'Clear');
|
|
render();
|
|
});
|
|
}
|
|
|
|
containerEl.__activityUnmount = function () {
|
|
disposed = true;
|
|
while (unsubscribers.length > 0) {
|
|
try {
|
|
unsubscribers.pop()();
|
|
} catch (err) {
|
|
console.error('[Activity] unsubscribe error:', err);
|
|
}
|
|
}
|
|
};
|
|
};
|
|
|
|
ActivityView.unmount = function (containerEl) {
|
|
if (containerEl && typeof containerEl.__activityUnmount === 'function') {
|
|
containerEl.__activityUnmount();
|
|
delete containerEl.__activityUnmount;
|
|
}
|
|
};
|
|
|
|
window.VerstakPluginRegister(PLUGIN_ID, {
|
|
components: {
|
|
ActivityView: ActivityView
|
|
}
|
|
});
|
|
})();
|