Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7687f410c4 | ||
|
|
7e34a73835 | ||
|
|
47c1db9979 | ||
|
|
22c842d5e3 | ||
|
|
1a15d57033 | ||
|
|
483b6ba46e | ||
|
|
226464f082 | ||
|
|
e93584c37c | ||
|
|
50c4fc5e68 | ||
|
|
b45a56b09d | ||
|
|
0db9f896ce |
@@ -1,4 +1,6 @@
|
||||
dist/
|
||||
dist-windows/
|
||||
release/
|
||||
node_modules/
|
||||
# compiled backend binaries (built from source)
|
||||
plugins/*/backend/platform-test
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
Verstak Official Plugins
|
||||
Copyright (C) 2026 Verstak contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify it under
|
||||
the terms of the GNU Affero General Public License as published by the Free
|
||||
Software Foundation, either version 3 of the License, or (at your option) any
|
||||
later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
details.
|
||||
|
||||
The complete license text is available at:
|
||||
https://www.gnu.org/licenses/agpl-3.0.html
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
@@ -1,5 +1,74 @@
|
||||
# verstak-official-plugins
|
||||
# Verstak Official Plugins
|
||||
|
||||
Official Verstak plugins monorepo — files, trash, notes, markdown-editor, preview, activity, journal, todos, browser-inbox, search, secrets, templates + shared packages.
|
||||
The first-party plugin set for Verstak Desktop: Files, Trash, Notes, editor and
|
||||
preview providers, Browser Inbox, Activity, Journal, Todo, Search, Secrets and
|
||||
Templates. Plugin bundles are built into independent directories that the
|
||||
desktop host loads from its `plugins/` folder.
|
||||
|
||||
`Files` shows live workspace files and folders. Global `Trash` contains deleted items from every workspace; restoring never overwrites an existing item, and permanent deletion is confirmed before it runs.
|
||||
> **Alpha software.** Build this repository together with compatible
|
||||
> `verstak-desktop` and `verstak-sdk` checkouts.
|
||||
|
||||
## Build
|
||||
|
||||
Requirements: Node.js with npm, Go and Python 3. Keep this repository next to
|
||||
`verstak-sdk` so manifest validation can use the SDK schema.
|
||||
|
||||
```bash
|
||||
npm --version
|
||||
go version
|
||||
./scripts/check.sh
|
||||
./scripts/build.sh
|
||||
```
|
||||
|
||||
The build creates `dist/<plugin-id>/` packages. To install them into a sibling
|
||||
desktop checkout:
|
||||
|
||||
```bash
|
||||
cd ../verstak-desktop
|
||||
./scripts/install-dev-plugins.sh
|
||||
```
|
||||
|
||||
## Portable plugin archives
|
||||
|
||||
Build target-specific archives on Linux:
|
||||
|
||||
```bash
|
||||
sudo apt install gcc-mingw-w64-x86-64
|
||||
./scripts/package-portable.sh v0.1.0-alpha.1
|
||||
```
|
||||
|
||||
The command creates `release/verstak-official-plugins-linux-amd64-<version>.tar.gz`
|
||||
and `release/verstak-official-plugins-windows-amd64-<version>.zip`, plus
|
||||
`SHA256SUMS`. Each archive expands directly into the desktop application's
|
||||
`plugins/` directory. Frontends and manifests are shared; native Go sidecars
|
||||
are built for the target OS. This is a local package operation and does not
|
||||
create a GitHub Release.
|
||||
|
||||
## Alpha behaviour
|
||||
|
||||
- Browser Inbox archives processed captures and can restore them; permanent
|
||||
deletion is a separate action.
|
||||
- Saving a browser link creates a collision-safe `.url` file. Opening a link is
|
||||
handled by Verstak, so it does not depend on a Linux `.url` file association.
|
||||
- Activity sessions can be scoped to a durable workspace identity or to an
|
||||
explicit unassigned scope. Journal creation is always a user action.
|
||||
- Todo is optional: Overview uses it only when the Todo capability is present.
|
||||
|
||||
## Publish a GitHub Release
|
||||
|
||||
```bash
|
||||
./scripts/publish-github-release.sh v0.1.0-alpha.1
|
||||
```
|
||||
|
||||
The publisher requires an authenticated [`gh`](https://cli.github.com/) CLI
|
||||
and a clean, up-to-date `main`. It runs the local release command, creates and
|
||||
pushes an annotated tag if necessary, then creates or updates the GitHub
|
||||
Release with the Linux archive, Windows archive and `SHA256SUMS`. Alpha, beta
|
||||
and release-candidate tags are published as prereleases; stable tags are
|
||||
marked latest. Re-running it for a tag that points at the current commit
|
||||
replaces the release assets.
|
||||
|
||||
## License
|
||||
|
||||
Copyright © 2026 Verstak contributors. Licensed under
|
||||
[GNU AGPLv3 or later](LICENSE).
|
||||
|
||||
@@ -8,12 +8,14 @@
|
||||
|
||||
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;
|
||||
@@ -30,7 +32,9 @@
|
||||
'browser.capture.selection',
|
||||
'browser.capture.link',
|
||||
'browser.capture.file',
|
||||
'browser.capture.converted'
|
||||
'browser.capture.converted',
|
||||
'browser.activity.batch',
|
||||
'activity.session.handled'
|
||||
];
|
||||
var EVENT_LABELS = {
|
||||
'workspace.selected': 'Workspace selected',
|
||||
@@ -44,7 +48,8 @@
|
||||
'browser.capture.selection': 'Selection captured',
|
||||
'browser.capture.link': 'Link 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 = {
|
||||
'workspace.selected': true,
|
||||
@@ -146,14 +151,16 @@
|
||||
|
||||
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: '' };
|
||||
return { mode: 'global', key: GLOBAL_KEY, label: 'All workspaces', workspaceRoot: '', workspaceId: '' };
|
||||
}
|
||||
return {
|
||||
mode: 'workspace',
|
||||
key: WORKSPACE_PREFIX + encodeKey(workspaceRoot),
|
||||
label: workspaceRoot,
|
||||
workspaceRoot: workspaceRoot
|
||||
workspaceRoot: workspaceRoot,
|
||||
workspaceId: workspaceId
|
||||
};
|
||||
}
|
||||
|
||||
@@ -175,6 +182,9 @@
|
||||
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 : {}
|
||||
};
|
||||
@@ -192,6 +202,9 @@
|
||||
receivedAt: item.receivedAt,
|
||||
sourcePluginId: item.sourcePluginId,
|
||||
workspaceRootPath: item.workspaceRootPath,
|
||||
workspaceId: item.workspaceId,
|
||||
sessionScope: item.sessionScope || {},
|
||||
durationSeconds: item.durationSeconds || 0,
|
||||
payload: item.payload || {}
|
||||
};
|
||||
});
|
||||
@@ -225,13 +238,22 @@
|
||||
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 candidateId(workspaceRootPath, firstActivity, lastActivity) {
|
||||
return 'work-session:' + encodeKey(workspaceRootPath) + ':' + encodeKey(firstActivity.activityId) + ':' + encodeKey(lastActivity.activityId);
|
||||
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 candidateActivity(activity) {
|
||||
@@ -240,57 +262,149 @@
|
||||
type: text(activity.type),
|
||||
occurredAt: toISOTime(eventTimeMs(activity)),
|
||||
sourcePluginId: text(activity.sourcePluginId),
|
||||
workspaceRootPath: candidateWorkspace(activity)
|
||||
workspaceRootPath: candidateWorkspace(activity),
|
||||
workspaceId: text(activity.workspaceId)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSessionRegistry(value) {
|
||||
value = value && typeof value === 'object' ? value : {};
|
||||
return {
|
||||
sessions: value.sessions && typeof value.sessions === 'object' ? value.sessions : {},
|
||||
eventSessionIds: value.eventSessionIds && typeof value.eventSessionIds === 'object' ? value.eventSessionIds : {}
|
||||
};
|
||||
}
|
||||
|
||||
function pruneSessionRegistry(registry, activityList) {
|
||||
var present = {};
|
||||
(activityList || []).forEach(function (activity) { present[text(activity && activity.activityId)] = true; });
|
||||
Object.keys(registry.eventSessionIds).forEach(function (activityId) {
|
||||
if (!present[activityId]) delete registry.eventSessionIds[activityId];
|
||||
});
|
||||
var referenced = {};
|
||||
Object.keys(registry.eventSessionIds).forEach(function (activityId) {
|
||||
referenced[registry.eventSessionIds[activityId]] = true;
|
||||
});
|
||||
Object.keys(registry.sessions).forEach(function (sessionId) {
|
||||
if (!referenced[sessionId]) delete registry.sessions[sessionId];
|
||||
});
|
||||
return registry;
|
||||
}
|
||||
|
||||
function sessionDurationMinutes(session) {
|
||||
var events = session.activities.slice().sort(function (a, b) { return eventTimeMs(a) - eventTimeMs(b); });
|
||||
var durationMs = 0;
|
||||
for (var index = 0; index < events.length; index += 1) {
|
||||
var explicit = Math.max(0, Number(events[index].durationSeconds || 0)) * 1000;
|
||||
durationMs += explicit;
|
||||
if (index === 0 || explicit > 0 || Number(events[index - 1].durationSeconds || 0) > 0) continue;
|
||||
var gap = eventTimeMs(events[index]) - eventTimeMs(events[index - 1]);
|
||||
if (gap > 0 && gap <= MAX_IDLE_GAP_MINUTES * 60 * 1000) durationMs += Math.min(gap, 10 * 60 * 1000);
|
||||
}
|
||||
return Math.min(MAX_SESSION_DURATION_MINUTES, Math.floor(durationMs / 60000));
|
||||
}
|
||||
|
||||
function findCompatibleSession(sessions, scope, time) {
|
||||
var nearest = null;
|
||||
sessions.forEach(function (session) {
|
||||
if (session.scope !== scope) return;
|
||||
var before = session.firstTime - time;
|
||||
var after = time - session.lastTime;
|
||||
var distance = time < session.firstTime ? before : after;
|
||||
if (distance < 0 || distance > MAX_IDLE_GAP_MINUTES * 60 * 1000) return;
|
||||
if (Math.max(session.lastTime, time) - Math.min(session.firstTime, time) > MAX_SESSION_DURATION_MINUTES * 60 * 1000) return;
|
||||
if (!nearest || distance < nearest.distance) nearest = { session: session, distance: distance };
|
||||
});
|
||||
return nearest && nearest.session;
|
||||
}
|
||||
|
||||
function logicalSessions(activityList, registry) {
|
||||
var sessionsById = {};
|
||||
var sessions = [];
|
||||
var lastScope = '';
|
||||
var ordered = sortEvents(activityList || []).filter(function (activity) {
|
||||
return isMeaningfulActivity(activity) && eventTimeMs(activity);
|
||||
}).slice().sort(function (a, b) { return eventTimeMs(a) - eventTimeMs(b); });
|
||||
ordered.forEach(function (activity) {
|
||||
var scope = candidateScope(activity);
|
||||
var activityId = text(activity.activityId);
|
||||
var sessionId = text(registry.eventSessionIds[activityId]);
|
||||
var session = sessionId && sessionsById[sessionId];
|
||||
if (!session && sessionId && registry.sessions[sessionId]) {
|
||||
var persisted = registry.sessions[sessionId];
|
||||
session = {
|
||||
sessionId: sessionId,
|
||||
scope: text(persisted.scope || scope),
|
||||
workspaceRootPath: cleanWorkspace(persisted.workspaceRootPath || candidateWorkspace(activity)),
|
||||
workspaceId: text(persisted.workspaceId || activity.workspaceId),
|
||||
anchor: text(persisted.anchor),
|
||||
firstTime: eventTimeMs(activity),
|
||||
lastTime: eventTimeMs(activity),
|
||||
activities: []
|
||||
};
|
||||
sessionsById[sessionId] = session;
|
||||
sessions.push(session);
|
||||
}
|
||||
if (!session && (!lastScope || lastScope === scope)) {
|
||||
session = findCompatibleSession(sessions, scope, eventTimeMs(activity));
|
||||
}
|
||||
if (!session) {
|
||||
sessionId = newSessionId();
|
||||
session = {
|
||||
sessionId: sessionId,
|
||||
scope: scope,
|
||||
workspaceRootPath: candidateWorkspace(activity),
|
||||
workspaceId: text(activity.workspaceId),
|
||||
anchor: new Date().toISOString(),
|
||||
firstTime: eventTimeMs(activity),
|
||||
lastTime: eventTimeMs(activity),
|
||||
activities: []
|
||||
};
|
||||
sessionsById[sessionId] = session;
|
||||
sessions.push(session);
|
||||
registry.sessions[sessionId] = {
|
||||
scope: session.scope,
|
||||
workspaceRootPath: session.workspaceRootPath,
|
||||
workspaceId: session.workspaceId,
|
||||
anchor: session.anchor
|
||||
};
|
||||
}
|
||||
registry.eventSessionIds[activityId] = session.sessionId;
|
||||
session.activities.push(activity);
|
||||
session.firstTime = Math.min(session.firstTime, eventTimeMs(activity));
|
||||
session.lastTime = Math.max(session.lastTime, eventTimeMs(activity));
|
||||
if (candidateWorkspace(activity)) session.workspaceRootPath = candidateWorkspace(activity);
|
||||
if (activity.workspaceId) session.workspaceId = text(activity.workspaceId);
|
||||
lastScope = scope;
|
||||
});
|
||||
return sessions;
|
||||
}
|
||||
|
||||
function buildCandidate(session) {
|
||||
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;
|
||||
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;
|
||||
return {
|
||||
candidateId: candidateId(session.workspaceRootPath, first, last),
|
||||
candidateId: 'work-session:' + encodeKey(session.sessionId) + ':' + encodeKey(last.activityId),
|
||||
sessionId: session.sessionId,
|
||||
handledThrough: toISOTime(eventTimeMs(last)),
|
||||
workspaceRootPath: session.workspaceRootPath,
|
||||
workspaceId: session.workspaceId,
|
||||
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)
|
||||
activityCount: activities.length,
|
||||
activityIds: activities.map(function (activity) { return activity.activityId; }).filter(Boolean),
|
||||
activities: activities.map(candidateActivity)
|
||||
};
|
||||
}
|
||||
|
||||
function buildWorkSessionCandidates(activityList, workspaceFilter) {
|
||||
function buildWorkSessionCandidates(activityList, workspaceFilter, registry) {
|
||||
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) {
|
||||
registry = normalizeSessionRegistry(registry);
|
||||
return logicalSessions(activityList, registry).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);
|
||||
@@ -347,6 +461,15 @@
|
||||
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);
|
||||
}
|
||||
@@ -367,26 +490,45 @@
|
||||
function dismissedCandidatesFromSettings(settings) {
|
||||
var dismissed = {};
|
||||
Object.keys(settings || {}).forEach(function (key) {
|
||||
if (key.indexOf(DISMISSAL_PREFIX) !== 0 || !Array.isArray(settings[key])) return;
|
||||
if (key.indexOf(DISMISSAL_PREFIX) !== 0) return;
|
||||
var workspace = decodeStoredWorkspace(key, DISMISSAL_PREFIX);
|
||||
if (!workspace) return;
|
||||
dismissed[workspace] = {};
|
||||
settings[key].forEach(function (candidateId) {
|
||||
candidateId = text(candidateId).trim();
|
||||
if (candidateId) dismissed[workspace][candidateId] = true;
|
||||
});
|
||||
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 isCandidateDismissed(candidate, dismissedByWorkspace) {
|
||||
return !!(candidate && dismissedByWorkspace && dismissedByWorkspace[candidate.workspaceRootPath] && dismissedByWorkspace[candidate.workspaceRootPath][candidate.candidateId]);
|
||||
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 visibleCandidates(activityList, workspaceFilter, dismissedByWorkspace) {
|
||||
return buildWorkSessionCandidates(activityList, workspaceFilter).filter(function (candidate) {
|
||||
return !isCandidateDismissed(candidate, dismissedByWorkspace);
|
||||
});
|
||||
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 formatDate(value) {
|
||||
@@ -409,6 +551,8 @@
|
||||
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;
|
||||
@@ -436,7 +580,7 @@
|
||||
return candidateWorkspace(activity) !== scope.workspaceRoot;
|
||||
});
|
||||
updateCandidates();
|
||||
persist().then(render);
|
||||
clearWorkspaceRaw(scope.workspaceRoot).then(persist).then(render);
|
||||
}
|
||||
});
|
||||
toolbar.appendChild(titleEl);
|
||||
@@ -455,7 +599,7 @@
|
||||
containerEl.appendChild(listEl);
|
||||
|
||||
function candidatesForWorkspace(workspaceRoot) {
|
||||
return visibleCandidates(candidateSourceEvents, workspaceRoot, dismissedByWorkspace);
|
||||
return visibleCandidates(candidateSourceEvents, workspaceRoot, sessionRegistry, dismissedByWorkspace, handledSessions);
|
||||
}
|
||||
|
||||
function updateCandidates() {
|
||||
@@ -484,7 +628,12 @@
|
||||
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));
|
||||
return api.settings.write(dismissalStorageKey(workspaceRoot), dismissed);
|
||||
}
|
||||
|
||||
function persistSessionRegistry() {
|
||||
if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
|
||||
return api.settings.write(SESSION_REGISTRY_KEY, sessionRegistry);
|
||||
}
|
||||
|
||||
function persist() {
|
||||
@@ -492,7 +641,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(persistCandidateCaches).catch(function (err) {
|
||||
return api.settings.write(scope.key, storageEvents(toStore)).then(persistSessionRegistry).then(persistCandidateCaches).catch(function (err) {
|
||||
statusText = tr('ui.saveError', { error: err && err.message ? err.message : String(err) }, 'Could not save activity: ' + (err && err.message ? err.message : String(err)));
|
||||
statusClass = 'error';
|
||||
});
|
||||
@@ -503,7 +652,12 @@
|
||||
events = [];
|
||||
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) {
|
||||
return key.indexOf(CANDIDATE_PREFIX) === 0 || key.indexOf(DISMISSAL_PREFIX) === 0;
|
||||
}));
|
||||
@@ -511,6 +665,8 @@
|
||||
candidateSourceEvents = [];
|
||||
candidates = [];
|
||||
dismissedByWorkspace = {};
|
||||
handledSessions = {};
|
||||
sessionRegistry = normalizeSessionRegistry({});
|
||||
return Promise.all(keys.filter(function (key, index, all) {
|
||||
return all.indexOf(key) === index;
|
||||
}).map(function (key) {
|
||||
@@ -525,6 +681,19 @@
|
||||
});
|
||||
}
|
||||
|
||||
function clearWorkspaceRaw(workspaceRoot) {
|
||||
if (!api || !api.storage || !api.storage.data || typeof api.storage.data.readNDJSON !== 'function' || typeof api.storage.data.writeNDJSON !== 'function') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return api.storage.data.readNDJSON(RAW_DATA_NAME).then(function (records) {
|
||||
var workspace = cleanWorkspace(workspaceRoot);
|
||||
var kept = (Array.isArray(records) ? records : []).filter(function (record) {
|
||||
return cleanWorkspace(record && (record.workspaceRootPath || workspaceFromPayload(record.payload || {}))) !== workspace;
|
||||
});
|
||||
return api.storage.data.writeNDJSON(RAW_DATA_NAME, kept);
|
||||
});
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
listEl.innerHTML = '';
|
||||
if (events.length === 0) {
|
||||
@@ -572,7 +741,11 @@
|
||||
function dismissCandidate(candidate) {
|
||||
if (!candidate || !candidate.workspaceRootPath || !candidate.candidateId) return;
|
||||
dismissedByWorkspace[candidate.workspaceRootPath] = dismissedByWorkspace[candidate.workspaceRootPath] || {};
|
||||
dismissedByWorkspace[candidate.workspaceRootPath][candidate.candidateId] = true;
|
||||
dismissedByWorkspace[candidate.workspaceRootPath][candidate.sessionId || candidate.candidateId] = {
|
||||
handledThrough: candidate.handledThrough || candidate.endedAt,
|
||||
handledAt: new Date().toISOString(),
|
||||
status: 'dismissed'
|
||||
};
|
||||
updateCandidates();
|
||||
Promise.all([
|
||||
persistDismissals(candidate.workspaceRootPath),
|
||||
@@ -634,13 +807,25 @@
|
||||
|
||||
function loadStored() {
|
||||
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 || {};
|
||||
candidateSourceEvents = eventsFromSettings(settings, '');
|
||||
events = eventsFromSettings(settings, scope.mode === 'workspace' ? scope.workspaceRoot : '');
|
||||
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 : '');
|
||||
dismissedByWorkspace = dismissedCandidatesFromSettings(settings);
|
||||
updateCandidates();
|
||||
return persistCandidateCaches();
|
||||
return persistSessionRegistry().then(persistCandidateCaches);
|
||||
}).catch(function (err) {
|
||||
statusText = 'Could not load activity: ' + (err && err.message ? err.message : String(err));
|
||||
statusClass = 'error';
|
||||
@@ -650,11 +835,16 @@
|
||||
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) });
|
||||
return Promise.resolve({ candidates: visibleCandidates(candidateSourceEvents, workspace || (scope.mode === 'workspace' ? scope.workspaceRoot : ''), sessionRegistry, dismissedByWorkspace, handledSessions) });
|
||||
}
|
||||
return api.settings.read().then(function (settings) {
|
||||
settings = settings || {};
|
||||
return { candidates: visibleCandidates(eventsFromSettings(settings, ''), workspace, dismissedCandidatesFromSettings(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] : [];
|
||||
var source = rawRecords.length ? eventsFromRecords(rawRecords, '') : eventsFromSettings(settings, '');
|
||||
return { candidates: visibleCandidates(source, workspace, sessionRegistry, dismissedCandidatesFromSettings(settings), settings["activity-session-handling-v2"] || {}) };
|
||||
}).catch(function () {
|
||||
return { candidates: [] };
|
||||
});
|
||||
|
||||
@@ -183,6 +183,12 @@
|
||||
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, '_')
|
||||
@@ -238,8 +244,10 @@
|
||||
fileDataBase64: text(payload.fileDataBase64).trim(),
|
||||
source: text(payload.source).trim(),
|
||||
browserName: text(payload.browserName).trim(),
|
||||
// The receiver provides the active workspace. Untagged captures remain unassigned.
|
||||
workspaceRootPath: workspaceFromPayload(payload)
|
||||
workspaceRootPath: workspaceFromPayload(payload),
|
||||
workspaceId: text(payload.workspaceId).trim(),
|
||||
workspaceState: text(payload.workspaceState || 'unassigned').trim(),
|
||||
workspaceTrashId: text(payload.workspaceTrashId).trim()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -258,7 +266,6 @@
|
||||
return value.filter(function (item) {
|
||||
return 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),
|
||||
@@ -278,6 +285,10 @@
|
||||
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 || ''
|
||||
};
|
||||
@@ -304,6 +315,10 @@
|
||||
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
|
||||
};
|
||||
});
|
||||
@@ -381,7 +396,8 @@
|
||||
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: 'processed', textContent: tr('ui.processed', null, 'Processed') }),
|
||||
el('option', { value: 'archived', textContent: tr('ui.archive', null, 'Archive') })
|
||||
]);
|
||||
var workspaceFilterEl = el('select', {
|
||||
className: 'browser-inbox-select',
|
||||
@@ -468,6 +484,8 @@
|
||||
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;
|
||||
@@ -505,13 +523,15 @@
|
||||
});
|
||||
}
|
||||
|
||||
function removeCaptures(captureIds, successText) {
|
||||
function archiveCaptures(captureIds, successText) {
|
||||
var ids = {};
|
||||
captureIds.forEach(function (captureId) {
|
||||
ids[captureId] = true;
|
||||
});
|
||||
return publishMutation('delete', { captureIds: captureIds }, function () {
|
||||
return !captures.some(function (capture) { return ids[capture.captureId]; });
|
||||
return publishMutation('archive', { captureIds: captureIds }, function () {
|
||||
return captures.every(function (capture) {
|
||||
return !ids[capture.captureId] || capture.globalState === 'archived';
|
||||
});
|
||||
}).then(function (saved) {
|
||||
if (!saved) return;
|
||||
if (ids[selectedId]) selectedId = '';
|
||||
@@ -525,7 +545,7 @@
|
||||
var ids = scope.mode === 'global'
|
||||
? captures.map(function (capture) { return capture.captureId; })
|
||||
: captures.filter(function (capture) { return capture.workspaceRootPath === scope.workspaceRoot; }).map(function (capture) { return capture.captureId; });
|
||||
return removeCaptures(ids, scope.mode === 'global' ? 'Inbox cleared' : 'Workspace captures cleared');
|
||||
return archiveCaptures(ids, scope.mode === 'global' ? 'Inbox archived' : 'Workspace captures archived');
|
||||
}
|
||||
|
||||
function selectedCapture() {
|
||||
@@ -578,8 +598,33 @@
|
||||
});
|
||||
}
|
||||
|
||||
function removeCapture(captureId) {
|
||||
return removeCaptures([captureId], 'Capture deleted');
|
||||
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 setProcessed(captureId, processed) {
|
||||
@@ -630,7 +675,7 @@
|
||||
}).then(function () {
|
||||
statusText = 'Created note: ' + notePath;
|
||||
statusClass = '';
|
||||
return removeCapture(capture.captureId);
|
||||
return archiveCapture(capture.captureId);
|
||||
}).catch(function (err) {
|
||||
statusText = 'Could not create note: ' + (err && err.message ? err.message : String(err));
|
||||
statusClass = 'error';
|
||||
@@ -647,14 +692,24 @@
|
||||
return Promise.resolve();
|
||||
}
|
||||
var title = noteTitle(capture);
|
||||
var linkPath = capture.workspaceRootPath + '/Links/' + safeLinkFilename(title);
|
||||
statusText = 'Creating link...';
|
||||
statusClass = '';
|
||||
render();
|
||||
return api.files.writeText(linkPath, captureToUrlShortcut(capture), {
|
||||
createIfMissing: true,
|
||||
overwrite: false
|
||||
}).then(function () {
|
||||
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,
|
||||
@@ -664,13 +719,15 @@
|
||||
title: title,
|
||||
url: capture.url || '',
|
||||
sourcePluginId: PLUGIN_ID
|
||||
}).then(function () {
|
||||
return linkPath;
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
return linkPath;
|
||||
}).then(function () {
|
||||
statusText = 'Created link: ' + linkPath;
|
||||
statusText = 'Created link';
|
||||
statusClass = '';
|
||||
return removeCapture(capture.captureId);
|
||||
return archiveCapture(capture.captureId);
|
||||
}).catch(function (err) {
|
||||
statusText = 'Could not create link: ' + (err && err.message ? err.message : String(err));
|
||||
statusClass = 'error';
|
||||
@@ -678,6 +735,15 @@
|
||||
});
|
||||
}
|
||||
|
||||
function openCaptureURL(capture) {
|
||||
if (!capture || !capture.url || !api || !api.files || typeof api.files.openURL !== 'function') return Promise.resolve();
|
||||
return api.files.openURL(capture.url).catch(function (err) {
|
||||
statusText = 'Could not open link: ' + (err && err.message ? err.message : String(err));
|
||||
statusClass = 'error';
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
function createFileFromCapture(capture) {
|
||||
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')) {
|
||||
@@ -717,7 +783,7 @@
|
||||
}).then(function () {
|
||||
statusText = 'Created file: ' + filePath;
|
||||
statusClass = '';
|
||||
return removeCapture(capture.captureId);
|
||||
return archiveCapture(capture.captureId);
|
||||
}).catch(function (err) {
|
||||
statusText = 'Could not create file: ' + (err && err.message ? err.message : String(err));
|
||||
statusClass = 'error';
|
||||
@@ -759,7 +825,8 @@
|
||||
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 }));
|
||||
@@ -839,6 +906,16 @@
|
||||
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',
|
||||
@@ -869,12 +946,31 @@
|
||||
}));
|
||||
}
|
||||
}
|
||||
if (capture.globalState === 'archived') {
|
||||
actionButtons.push(el('button', {
|
||||
className: 'browser-inbox-btn',
|
||||
'data-browser-inbox-action': 'restore',
|
||||
textContent: 'Restore to Inbox',
|
||||
onClick: function () {
|
||||
restoreCapture(capture.captureId);
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
actionButtons.push(el('button', {
|
||||
className: 'browser-inbox-btn',
|
||||
'data-browser-inbox-action': 'archive',
|
||||
textContent: 'Archive',
|
||||
onClick: function () {
|
||||
archiveCapture(capture.captureId);
|
||||
}
|
||||
}));
|
||||
}
|
||||
actionButtons.push(el('button', {
|
||||
className: 'browser-inbox-btn danger',
|
||||
'data-browser-inbox-action': 'remove',
|
||||
textContent: tr('ui.delete', null, 'Delete'),
|
||||
'data-browser-inbox-action': 'delete-permanently',
|
||||
textContent: 'Delete permanently',
|
||||
onClick: function () {
|
||||
removeCapture(capture.captureId);
|
||||
permanentlyDeleteCapture(capture.captureId);
|
||||
}
|
||||
}));
|
||||
detailEl.appendChild(el('div', { className: 'browser-inbox-detail-actions' }, actionButtons));
|
||||
|
||||
@@ -227,6 +227,8 @@
|
||||
}
|
||||
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),
|
||||
@@ -368,6 +370,8 @@
|
||||
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; })
|
||||
@@ -431,6 +435,8 @@
|
||||
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';
|
||||
@@ -467,7 +473,14 @@
|
||||
closeEntryModal();
|
||||
statusText = existingEntry ? 'Entry updated' : 'Entry added';
|
||||
statusClass = '';
|
||||
persist().then(render);
|
||||
persist().then(function () {
|
||||
if (!sessionID || !handledThrough || !api || !api.events || typeof api.events.publish !== 'function') return undefined;
|
||||
return api.events.publish('activity.session.handled', {
|
||||
sessionId: sessionID,
|
||||
handledThrough: handledThrough,
|
||||
status: 'accepted'
|
||||
});
|
||||
}).then(render);
|
||||
}
|
||||
|
||||
function deleteEntry(entry) {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"report.worklog"
|
||||
],
|
||||
"permissions": [
|
||||
"events.publish",
|
||||
"storage.namespace",
|
||||
"ui.register"
|
||||
],
|
||||
|
||||
@@ -340,18 +340,45 @@
|
||||
});
|
||||
}
|
||||
|
||||
function notificationRequests() {
|
||||
return sortTodos(todos).filter(function (todo) {
|
||||
return todo.status === 'open' && todo.reminderAt;
|
||||
}).map(function (todo) {
|
||||
var dueAt = new Date(todo.reminderAt);
|
||||
if (isNaN(dueAt.getTime())) return null;
|
||||
var title = todo.title || tr('ui.untitled', null, 'Untitled todo');
|
||||
return {
|
||||
id: todo.id,
|
||||
dueAt: dueAt.toISOString(),
|
||||
title: tr('ui.notificationTitle', null, 'Todo reminder'),
|
||||
body: tr('ui.notificationBody', { title: title }, title)
|
||||
};
|
||||
}).filter(function (item) { return item !== null; });
|
||||
}
|
||||
|
||||
function syncNotifications() {
|
||||
if (!api || !api.notifications || typeof api.notifications.replace !== 'function') return Promise.resolve();
|
||||
return api.notifications.replace(notificationRequests()).catch(function (err) {
|
||||
statusText = tr('ui.notificationError', { error: err && err.message ? err.message : String(err) }, 'Could not schedule reminders: ' + (err && err.message ? err.message : String(err)));
|
||||
statusClass = 'error';
|
||||
});
|
||||
}
|
||||
|
||||
function persist() {
|
||||
if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
|
||||
return api.settings.write(GLOBAL_KEY, storageTodos(sortTodos(todos))).catch(function (err) {
|
||||
if (!api || !api.settings || typeof api.settings.write !== 'function') return syncNotifications();
|
||||
return api.settings.write(GLOBAL_KEY, storageTodos(sortTodos(todos))).then(function () {
|
||||
return syncNotifications();
|
||||
}).catch(function (err) {
|
||||
statusText = tr('ui.saveError', { error: err && err.message ? err.message : String(err) }, 'Could not save todos: ' + (err && err.message ? err.message : String(err)));
|
||||
statusClass = 'error';
|
||||
});
|
||||
}
|
||||
|
||||
function loadStored() {
|
||||
if (!api || !api.settings || typeof api.settings.read !== 'function') return Promise.resolve();
|
||||
if (!api || !api.settings || typeof api.settings.read !== 'function') return syncNotifications();
|
||||
return api.settings.read().then(function (settings) {
|
||||
todos = sortTodos(normalizeTodos((settings || {})[GLOBAL_KEY]));
|
||||
return syncNotifications();
|
||||
}).catch(function (err) {
|
||||
statusText = tr('ui.loadError', { error: err && err.message ? err.message : String(err) }, 'Could not load todos: ' + (err && err.message ? err.message : String(err)));
|
||||
statusClass = 'error';
|
||||
@@ -589,6 +616,7 @@
|
||||
searchInput.setAttribute('placeholder', tr('ui.search', null, 'Search todos'));
|
||||
addBtn.textContent = tr('ui.add', null, 'Add Todo');
|
||||
render();
|
||||
syncNotifications().then(render);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
"ui.unassigned": "Unassigned",
|
||||
"ui.saveError": "Could not save todos: {error}",
|
||||
"ui.loadError": "Could not load todos: {error}",
|
||||
"ui.notificationError": "Could not schedule reminders: {error}",
|
||||
"ui.notificationTitle": "Todo reminder",
|
||||
"ui.notificationBody": "{title}",
|
||||
"ui.titlePlaceholder": "Todo title",
|
||||
"ui.descriptionPlaceholder": "Optional description",
|
||||
"ui.priority.low": "Low",
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
"ui.unassigned": "Не назначено",
|
||||
"ui.saveError": "Не удалось сохранить задачи: {error}",
|
||||
"ui.loadError": "Не удалось загрузить задачи: {error}",
|
||||
"ui.notificationError": "Не удалось запланировать напоминания: {error}",
|
||||
"ui.notificationTitle": "Напоминание о задаче",
|
||||
"ui.notificationBody": "{title}",
|
||||
"ui.titlePlaceholder": "Название задачи",
|
||||
"ui.descriptionPlaceholder": "Необязательное описание",
|
||||
"ui.priority.low": "Низкий",
|
||||
|
||||
@@ -12,10 +12,14 @@
|
||||
"todo.list",
|
||||
"todo.workspace"
|
||||
],
|
||||
"requires": [
|
||||
"verstak/core/notifications/v1"
|
||||
],
|
||||
"permissions": [
|
||||
"files.read",
|
||||
"storage.namespace",
|
||||
"ui.register"
|
||||
"ui.register",
|
||||
"notifications.schedule"
|
||||
],
|
||||
"frontend": {
|
||||
"entry": "frontend/src/index.js"
|
||||
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
WINDOWS_DIST="${VERSTAK_WINDOWS_PLUGIN_DIST:-$ROOT/dist-windows}"
|
||||
WINDOWS_CC="${VERSTAK_WINDOWS_CC:-x86_64-w64-mingw32-gcc}"
|
||||
GO_BIN="${GO_BIN:-go}"
|
||||
|
||||
if ! command -v "$WINDOWS_CC" >/dev/null; then
|
||||
echo "Windows cross-compiler not found: $WINDOWS_CC" >&2
|
||||
echo "Install x86_64-w64-mingw32-gcc (for example: sudo apt install gcc-mingw-w64-x86-64)." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v "$GO_BIN" >/dev/null; then
|
||||
echo "go is required to build Windows plugin backends" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$ROOT"
|
||||
echo "=== verstak official plugins Windows amd64 build ==="
|
||||
|
||||
# Reuse the canonical frontend and manifest packager, then replace every Go
|
||||
# sidecar with its Windows executable in a separate dist-windows tree.
|
||||
./scripts/build.sh
|
||||
rm -rf "$WINDOWS_DIST"
|
||||
mkdir -p "$WINDOWS_DIST"
|
||||
cp -R "$ROOT/dist/." "$WINDOWS_DIST/"
|
||||
|
||||
backend_count=0
|
||||
for plugin_dir in "$ROOT"/plugins/*/; do
|
||||
[[ -d "$plugin_dir" ]] || continue
|
||||
plugin_name="$(basename "$plugin_dir")"
|
||||
backend_dir="$plugin_dir/backend"
|
||||
[[ -d "$backend_dir" ]] || continue
|
||||
if [[ ! -f "$backend_dir/go.mod" && ! -f "$backend_dir/main.go" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
package_backend="$WINDOWS_DIST/$plugin_name/backend"
|
||||
rm -rf "$package_backend"
|
||||
mkdir -p "$package_backend"
|
||||
echo " → Windows backend: $plugin_name.exe"
|
||||
(
|
||||
cd "$backend_dir"
|
||||
GOOS=windows GOARCH=amd64 CGO_ENABLED=1 CC="$WINDOWS_CC" \
|
||||
"$GO_BIN" build -o "$package_backend/$plugin_name.exe" .
|
||||
)
|
||||
backend_count=$((backend_count + 1))
|
||||
done
|
||||
|
||||
echo "Windows plugin packages: $WINDOWS_DIST"
|
||||
echo "Windows backends built: $backend_count"
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
VERSION="${1:-}"
|
||||
|
||||
if [[ -z "$VERSION" || ! "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
|
||||
echo "usage: $0 <version>" >&2
|
||||
echo "example: $0 v0.1.0-alpha.1" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! command -v zip >/dev/null; then
|
||||
echo "zip is required to create the Windows plugin archive" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== verstak official portable plugin packages $VERSION ==="
|
||||
"$ROOT/scripts/build.sh"
|
||||
"$ROOT/scripts/build-windows.sh"
|
||||
|
||||
RELEASE_ROOT="$ROOT/release"
|
||||
LINUX_ARCHIVE="$RELEASE_ROOT/verstak-official-plugins-linux-amd64-$VERSION.tar.gz"
|
||||
WINDOWS_ARCHIVE="$RELEASE_ROOT/verstak-official-plugins-windows-amd64-$VERSION.zip"
|
||||
rm -rf "$RELEASE_ROOT"
|
||||
mkdir -p "$RELEASE_ROOT"
|
||||
|
||||
# Each archive expands directly into the desktop application's plugins/
|
||||
# directory. Frontends and manifests are shared; platform-test's native
|
||||
# sidecar is built for the target operating system.
|
||||
tar -C "$ROOT/dist" -czf "$LINUX_ARCHIVE" .
|
||||
(cd "$ROOT/dist-windows" && zip -qr "$WINDOWS_ARCHIVE" .)
|
||||
(cd "$RELEASE_ROOT" && sha256sum "$(basename "$LINUX_ARCHIVE")" "$(basename "$WINDOWS_ARCHIVE")" > SHA256SUMS)
|
||||
|
||||
echo "Linux plugins: $LINUX_ARCHIVE"
|
||||
echo "Windows plugins: $WINDOWS_ARCHIVE"
|
||||
echo "checksums: $RELEASE_ROOT/SHA256SUMS"
|
||||
Executable
+80
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
VERSION="${1:-}"
|
||||
REPOSITORY="mirivlad/verstak-official-plugins"
|
||||
RELEASE_SCRIPT="${VERSTAK_RELEASE_SCRIPT:-$ROOT/scripts/release.sh}"
|
||||
RELEASE_DIR="${VERSTAK_RELEASE_DIR:-$ROOT/release}"
|
||||
GIT_BIN="${GIT_BIN:-git}"
|
||||
GH_BIN="${GH_BIN:-gh}"
|
||||
|
||||
cd "$ROOT"
|
||||
|
||||
if [[ -z "$VERSION" || ! "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
|
||||
echo "usage: $0 <version>" >&2
|
||||
echo "example: $0 v0.1.0-alpha.1" >&2
|
||||
exit 2
|
||||
fi
|
||||
if ! command -v "$GH_BIN" >/dev/null; then
|
||||
echo "gh CLI is required to publish a GitHub Release" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$("$GIT_BIN" branch --show-current)" != "main" ]]; then
|
||||
echo "GitHub releases must be published from main" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$("$GIT_BIN" status --porcelain)" ]]; then
|
||||
echo "working tree must be clean before publishing a release" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"$GH_BIN" auth status
|
||||
"$GIT_BIN" fetch origin main --tags
|
||||
HEAD="$("$GIT_BIN" rev-parse HEAD)"
|
||||
if [[ "$HEAD" != "$("$GIT_BIN" rev-parse origin/main)" ]]; then
|
||||
echo "local main must match origin/main before publishing a release" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"$RELEASE_SCRIPT" "$VERSION"
|
||||
|
||||
ASSETS=(
|
||||
"$RELEASE_DIR/verstak-official-plugins-linux-amd64-$VERSION.tar.gz"
|
||||
"$RELEASE_DIR/verstak-official-plugins-windows-amd64-$VERSION.zip"
|
||||
"$RELEASE_DIR/SHA256SUMS"
|
||||
)
|
||||
for asset in "${ASSETS[@]}"; do
|
||||
if [[ ! -s "$asset" ]]; then
|
||||
echo "required release asset is missing or empty: $asset" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if "$GIT_BIN" rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then
|
||||
if [[ "$("$GIT_BIN" rev-parse "${VERSION}^{commit}")" != "$HEAD" ]]; then
|
||||
echo "existing tag $VERSION does not point at HEAD" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
"$GIT_BIN" tag -a "$VERSION" -m "Release $VERSION"
|
||||
"$GIT_BIN" push origin "refs/tags/$VERSION"
|
||||
fi
|
||||
|
||||
if "$GH_BIN" release view "$VERSION" --repo "$REPOSITORY" >/dev/null 2>&1; then
|
||||
"$GH_BIN" release upload "$VERSION" "${ASSETS[@]}" --repo "$REPOSITORY" --clobber
|
||||
else
|
||||
RELEASE_OPTIONS=(--generate-notes --verify-tag)
|
||||
if [[ "$VERSION" == *-* ]]; then
|
||||
RELEASE_OPTIONS+=(--prerelease)
|
||||
else
|
||||
RELEASE_OPTIONS+=(--latest)
|
||||
fi
|
||||
"$GH_BIN" release create "$VERSION" "${ASSETS[@]}" \
|
||||
--repo "$REPOSITORY" \
|
||||
--title "Verstak Official Plugins $VERSION" \
|
||||
"${RELEASE_OPTIONS[@]}"
|
||||
fi
|
||||
|
||||
echo "GitHub release:"
|
||||
"$GH_BIN" release view "$VERSION" --repo "$REPOSITORY" --json url --jq .url
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
VERSION="${1:-}"
|
||||
|
||||
if [[ -z "$VERSION" || ! "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
|
||||
echo "usage: $0 <version>" >&2
|
||||
echo "example: $0 v0.1.0-alpha.1" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "=== verstak official plugins release $VERSION ==="
|
||||
"$ROOT/scripts/check.sh"
|
||||
"$ROOT/scripts/package-portable.sh" "$VERSION"
|
||||
@@ -116,8 +116,9 @@ function loadComponent(document) {
|
||||
return component;
|
||||
}
|
||||
|
||||
function makeApi(initialSettings = {}) {
|
||||
function makeApi(initialSettings = {}, initialData = {}) {
|
||||
const settings = { ...initialSettings };
|
||||
const data = { ...initialData };
|
||||
const handlers = {};
|
||||
const commandHandlers = new Map();
|
||||
const unsubscribed = [];
|
||||
@@ -132,6 +133,14 @@ function makeApi(initialSettings = {}) {
|
||||
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;
|
||||
@@ -150,11 +159,14 @@ function makeApi(initialSettings = {}) {
|
||||
storedEvents(key = 'events') {
|
||||
return settings[key] || [];
|
||||
},
|
||||
storedData(name) {
|
||||
return Array.isArray(data[name]) ? data[name] : [];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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()) {
|
||||
@@ -275,7 +287,7 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
|
||||
const candidateNode = walk(container, (node) => node.getAttribute && node.getAttribute('data-work-session-candidate'));
|
||||
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: 30 min')) throw new Error('candidate duration was not rendered');
|
||||
if (!candidateNode.textContent.includes('Estimated duration: 20 min')) throw new Error('candidate duration was not rendered');
|
||||
if (!candidateNode.textContent.includes('Activities: 3')) throw new Error('candidate activity count was not rendered');
|
||||
if (!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');
|
||||
@@ -287,7 +299,7 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
|
||||
if (!candidate.candidateId) throw new Error('candidate id is missing');
|
||||
if (candidate.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 !== 30) throw new Error(`expected 30 candidate minutes, got ${candidate.estimatedMinutes}`);
|
||||
if (candidate.estimatedMinutes !== 20) throw new Error(`expected 20 candidate minutes, got ${candidate.estimatedMinutes}`);
|
||||
if (candidate.activityCount !== 3) throw new Error(`expected three candidate activities, got ${candidate.activityCount}`);
|
||||
if (candidate.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');
|
||||
@@ -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');
|
||||
|
||||
component.unmount && component.unmount(container);
|
||||
if (api.unsubscribed.length !== 33) throw new Error(`expected 33 unsubscribers, got ${api.unsubscribed.length}`);
|
||||
if (api.unsubscribed.length !== 39) throw new Error(`expected 39 unsubscribers, got ${api.unsubscribed.length}`);
|
||||
|
||||
const persistedApi = makeApi({
|
||||
'events:workspace:Project': [{
|
||||
@@ -449,6 +461,79 @@ 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);
|
||||
|
||||
@@ -142,6 +142,7 @@ function makeApi(initialSettings = {}) {
|
||||
const unsubscribed = [];
|
||||
const fileWrites = [];
|
||||
const fileByteWrites = [];
|
||||
const openedURLs = [];
|
||||
const publishedEvents = [];
|
||||
const workspaceEntries = [
|
||||
{ name: 'ClientA', relativePath: 'ClientA', type: 'folder' },
|
||||
@@ -182,6 +183,8 @@ 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];
|
||||
@@ -200,6 +203,7 @@ function makeApi(initialSettings = {}) {
|
||||
unsubscribed,
|
||||
fileWrites,
|
||||
fileByteWrites,
|
||||
openedURLs,
|
||||
publishedEvents,
|
||||
failNextWrite(message) {
|
||||
nextWriteError = new Error(message || 'write failed');
|
||||
@@ -245,6 +249,9 @@ function makeApi(initialSettings = {}) {
|
||||
}
|
||||
fileByteWrites.push({ relativePath, dataBase64, options });
|
||||
},
|
||||
openURL: async (url) => {
|
||||
openedURLs.push(url);
|
||||
},
|
||||
},
|
||||
browserReceiver: {
|
||||
pairing: async () => ({ ...receiverPairing }),
|
||||
@@ -424,8 +431,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')) {
|
||||
throw new Error('workspace clear action did not remove the Project capture');
|
||||
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-2')) {
|
||||
throw new Error('workspace clear action removed a capture from another workspace');
|
||||
@@ -632,7 +639,8 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
|
||||
if (!assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned' && capture.processed === false)) {
|
||||
throw new Error('mark unprocessed did not persist state');
|
||||
}
|
||||
const deleteButton = walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'remove');
|
||||
const deleteButton = walk(assignmentView.container, (node) => node.getAttribute && node.getAttribute('data-browser-inbox-action') === 'delete-permanently');
|
||||
if (!deleteButton) throw new Error('permanent delete action was not rendered');
|
||||
deleteButton.click();
|
||||
await flush();
|
||||
if (assignmentApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'assignment-unassigned')) {
|
||||
@@ -640,6 +648,37 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
|
||||
}
|
||||
component.unmount && component.unmount(assignmentView.container);
|
||||
|
||||
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',
|
||||
@@ -669,8 +708,8 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
|
||||
if (!noteWrite.content.includes('# Example Article')) throw new Error('note content missing heading');
|
||||
if (!noteWrite.content.includes('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')) {
|
||||
throw new Error('converted capture was not removed from queue');
|
||||
if (!conversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-selection' && capture.globalState === 'archived')) {
|
||||
throw new Error('converted capture was not archived');
|
||||
}
|
||||
const convertedEvent = conversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted');
|
||||
if (!convertedEvent) throw new Error('browser.capture.converted event was not published');
|
||||
@@ -722,6 +761,11 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
|
||||
const linkConversionView = await mountWithApi(linkConversionApi);
|
||||
const 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}`);
|
||||
@@ -734,8 +778,8 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
|
||||
}
|
||||
if (!linkWrite.content.includes('[InternetShortcut]')) throw new Error('link content missing InternetShortcut header');
|
||||
if (!linkWrite.content.includes('URL=https://example.com/article')) throw new Error('link content missing URL');
|
||||
if (linkConversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-link')) {
|
||||
throw new Error('converted link capture was not removed from queue');
|
||||
if (!linkConversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-link' && capture.globalState === 'archived')) {
|
||||
throw new Error('converted link capture was not archived');
|
||||
}
|
||||
const convertedLinkEvent = linkConversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted');
|
||||
if (!convertedLinkEvent) throw new Error('browser.capture.converted link event was not published');
|
||||
@@ -743,6 +787,25 @@ async function mountSettingsWithApi(api, document = makeDocument()) {
|
||||
if (convertedLinkEvent.payload.linkPath !== 'Project/Links/Example_Article.url') throw new Error('converted link event linkPath mismatch');
|
||||
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',
|
||||
@@ -802,8 +865,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')) {
|
||||
throw new Error('converted file capture was not removed from queue');
|
||||
if (!fileConversionApi.getStoredCaptures(globalKey).some((capture) => capture.captureId === 'convert-file' && capture.globalState === 'archived')) {
|
||||
throw new Error('converted file capture was not archived');
|
||||
}
|
||||
const convertedFileEvent = fileConversionApi.publishedEvents.find((event) => event.name === 'browser.capture.converted');
|
||||
if (!convertedFileEvent) throw new Error('browser.capture.converted file event was not published');
|
||||
|
||||
@@ -133,7 +133,9 @@ 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) => {
|
||||
@@ -141,6 +143,11 @@ function makeApi(initialSettings = {}) {
|
||||
return { ...settings };
|
||||
},
|
||||
},
|
||||
events: {
|
||||
publish: async (name, payload) => {
|
||||
publishedEvents.push({ name, payload });
|
||||
},
|
||||
},
|
||||
storedEntries(key) {
|
||||
return settings[key] || [];
|
||||
},
|
||||
@@ -172,6 +179,7 @@ function byData(container, attr, value) {
|
||||
}
|
||||
if ((manifest.optionalRequires || []).includes('activity.reconstruction')) throw new Error('Journal must remain available without Activity');
|
||||
if (!manifest.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');
|
||||
@@ -212,6 +220,8 @@ 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',
|
||||
@@ -249,6 +259,10 @@ 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');
|
||||
}
|
||||
|
||||
@@ -146,10 +146,12 @@ function loadComponent(document, emittedEvents) {
|
||||
|
||||
function makeApi(initialSettings = {}, initialLocale = 'en') {
|
||||
const settings = { ...initialSettings };
|
||||
const notificationCalls = [];
|
||||
let locale = initialLocale;
|
||||
const localeListeners = [];
|
||||
return {
|
||||
settings,
|
||||
notificationCalls,
|
||||
settingsApi: {
|
||||
read: async (key) => (key ? settings[key] : { ...settings }),
|
||||
write: async (key, value) => {
|
||||
@@ -163,6 +165,11 @@ function makeApi(initialSettings = {}, initialLocale = 'en') {
|
||||
{ name: 'Project', relativePath: 'Project', type: 'folder' },
|
||||
],
|
||||
},
|
||||
notifications: {
|
||||
replace: async (items) => {
|
||||
notificationCalls.push((Array.isArray(items) ? items : []).map((item) => ({ ...item })));
|
||||
},
|
||||
},
|
||||
setLocale(nextLocale) {
|
||||
locale = nextLocale;
|
||||
localeListeners.slice().forEach((listener) => listener(locale));
|
||||
@@ -171,6 +178,7 @@ function makeApi(initialSettings = {}, initialLocale = 'en') {
|
||||
return {
|
||||
settings: this.settingsApi,
|
||||
files: this.files,
|
||||
notifications: this.notifications,
|
||||
i18n: {
|
||||
getLocale: () => locale,
|
||||
t: (key, params, fallback) => {
|
||||
@@ -208,6 +216,8 @@ async function mountWithApi(apiState, props, emittedEvents = [], document = make
|
||||
if (manifest.id !== 'verstak.todo') throw new Error('todo manifest id mismatch');
|
||||
if (!manifest.permissions.includes('storage.namespace')) throw new Error('todo manifest must request storage.namespace');
|
||||
if (!manifest.permissions.includes('files.read')) throw new Error('todo manifest must request files.read');
|
||||
if (!manifest.permissions.includes('notifications.schedule')) throw new Error('todo manifest must request notifications.schedule');
|
||||
if (!manifest.requires.includes('verstak/core/notifications/v1')) throw new Error('todo manifest must require native notifications');
|
||||
if (!(manifest.contributes.views || []).length) throw new Error('todo manifest must contribute a global view');
|
||||
if (!(manifest.contributes.workspaceItems || []).length) throw new Error('todo manifest must contribute a workspace item');
|
||||
|
||||
@@ -233,6 +243,14 @@ async function mountWithApi(apiState, props, emittedEvents = [], document = make
|
||||
if (createdTodo.status !== 'open' || createdTodo.priority !== 'high') throw new Error('Todo status or priority was not stored');
|
||||
if (createdTodo.dueAt !== '2000-01-01' || createdTodo.reminderAt !== '2000-01-01T09:00') throw new Error('Todo due/reminder metadata was not stored');
|
||||
if (!container.textContent.includes('Overdue') || !container.textContent.includes('Reminder due')) throw new Error('due/reminder indicators were not rendered');
|
||||
const scheduledAfterCreate = apiState.notificationCalls.at(-1) || [];
|
||||
if (scheduledAfterCreate.length !== 1
|
||||
|| scheduledAfterCreate[0].id !== createdTodo.id
|
||||
|| scheduledAfterCreate[0].dueAt !== new Date(createdTodo.reminderAt).toISOString()
|
||||
|| scheduledAfterCreate[0].title !== 'Todo reminder'
|
||||
|| scheduledAfterCreate[0].body !== 'Prepare project review') {
|
||||
throw new Error('open Todo reminder was not scheduled as a native notification');
|
||||
}
|
||||
|
||||
apiState.setLocale('ru');
|
||||
if (!container.textContent.includes('Задачи · Project') || !container.textContent.includes('Просрочено')) {
|
||||
@@ -252,6 +270,9 @@ async function mountWithApi(apiState, props, emittedEvents = [], document = make
|
||||
if (apiState.settings['todos:global'][0].status !== 'done' || !apiState.settings['todos:global'][0].completedAt) {
|
||||
throw new Error('mark done did not persist completed state');
|
||||
}
|
||||
if ((apiState.notificationCalls.at(-1) || []).length !== 0) {
|
||||
throw new Error('completed Todo reminder was not removed from native notification schedules');
|
||||
}
|
||||
if (!byData(container, 'data-todo-action', 'reopen')) throw new Error('done Todo did not expose reopen action');
|
||||
if (!byData(container, 'data-todo-action', 'create-journal-entry')) throw new Error('done workspace Todo did not expose Journal conversion');
|
||||
|
||||
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
BUILDER="$ROOT/scripts/build-windows.sh"
|
||||
|
||||
if [[ ! -x "$BUILDER" ]]; then
|
||||
echo "Windows plugin builder is missing or not executable: $BUILDER" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
bash -n "$BUILDER"
|
||||
grep -Fq 'GOOS=windows' "$BUILDER"
|
||||
grep -Fq 'GOARCH=amd64' "$BUILDER"
|
||||
grep -Fq 'dist-windows' "$BUILDER"
|
||||
grep -Fq '.exe' "$BUILDER"
|
||||
grep -Fq 'x86_64-w64-mingw32-gcc' "$BUILDER"
|
||||
|
||||
echo "Windows plugin build script contract passed"
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
PACKAGER="$ROOT/scripts/package-portable.sh"
|
||||
|
||||
if [[ ! -x "$PACKAGER" ]]; then
|
||||
echo "portable plugin packager is missing or not executable: $PACKAGER" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
bash -n "$PACKAGER"
|
||||
grep -Fq 'scripts/build.sh' "$PACKAGER"
|
||||
grep -Fq 'scripts/build-windows.sh' "$PACKAGER"
|
||||
grep -Fq 'verstak-official-plugins-linux-amd64-' "$PACKAGER"
|
||||
grep -Fq 'verstak-official-plugins-windows-amd64-' "$PACKAGER"
|
||||
grep -Fq 'tar -C' "$PACKAGER"
|
||||
grep -Fq 'zip -qr' "$PACKAGER"
|
||||
grep -Fq 'SHA256SUMS' "$PACKAGER"
|
||||
grep -Fq 'package-portable.sh' "$ROOT/scripts/release.sh"
|
||||
|
||||
echo "portable plugin package script contract passed"
|
||||
Executable
+98
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
PUBLISHER="$ROOT/scripts/publish-github-release.sh"
|
||||
VERSION="v0.0.0-test"
|
||||
REPOSITORY="mirivlad/verstak-official-plugins"
|
||||
ASSET_NAMES=(
|
||||
"verstak-official-plugins-linux-amd64-v0.0.0-test.tar.gz"
|
||||
"verstak-official-plugins-windows-amd64-v0.0.0-test.zip"
|
||||
)
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
if [[ ! -x "$PUBLISHER" ]]; then
|
||||
echo "publisher is missing or not executable: $PUBLISHER" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$WORK/bin" "$WORK/release" "$WORK/state"
|
||||
LOG="$WORK/log"
|
||||
export LOG
|
||||
|
||||
cat > "$WORK/release.sh" <<'SCRIPT'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf 'release:%s\n' "$1" >> "$LOG"
|
||||
printf 'linux\n' > "$VERSTAK_RELEASE_DIR/verstak-official-plugins-linux-amd64-$1.tar.gz"
|
||||
printf 'windows\n' > "$VERSTAK_RELEASE_DIR/verstak-official-plugins-windows-amd64-$1.zip"
|
||||
printf 'checksum\n' > "$VERSTAK_RELEASE_DIR/SHA256SUMS"
|
||||
SCRIPT
|
||||
chmod +x "$WORK/release.sh"
|
||||
|
||||
cat > "$WORK/bin/git" <<'SCRIPT'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$PWD" != "${EXPECTED_ROOT:?}" ]]; then
|
||||
echo "publisher did not enter repository root: $PWD" >&2
|
||||
exit 1
|
||||
fi
|
||||
case "${1:-}" in
|
||||
status) exit 0 ;;
|
||||
branch) echo main ;;
|
||||
fetch) printf 'fetch\n' >> "$LOG" ;;
|
||||
rev-parse)
|
||||
if [[ "${2:-}" == "-q" ]]; then
|
||||
if [[ -f "$TEST_STATE/tag" ]]; then echo test-commit; exit 0; fi
|
||||
exit 1
|
||||
fi
|
||||
echo test-commit
|
||||
;;
|
||||
tag) touch "$TEST_STATE/tag"; printf 'tag:%s\n' "${3:-}" >> "$LOG" ;;
|
||||
push) printf 'push:%s:%s\n' "${2:-}" "${3:-}" >> "$LOG" ;;
|
||||
*) echo "unexpected git invocation: $*" >&2; exit 1 ;;
|
||||
esac
|
||||
SCRIPT
|
||||
chmod +x "$WORK/bin/git"
|
||||
|
||||
cat > "$WORK/bin/gh" <<'SCRIPT'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
case "${1:-}:${2:-}" in
|
||||
auth:status) printf 'auth\n' >> "$LOG" ;;
|
||||
release:view)
|
||||
if [[ -f "$TEST_STATE/release" ]]; then echo https://github.example/release; else exit 1; fi
|
||||
;;
|
||||
release:create) touch "$TEST_STATE/release"; printf 'create:%s\n' "$*" >> "$LOG" ;;
|
||||
release:upload) printf 'upload:%s\n' "$*" >> "$LOG" ;;
|
||||
*) echo "unexpected gh invocation: $*" >&2; exit 1 ;;
|
||||
esac
|
||||
SCRIPT
|
||||
chmod +x "$WORK/bin/gh"
|
||||
|
||||
run_publisher() {
|
||||
VERSTAK_RELEASE_SCRIPT="$WORK/release.sh" \
|
||||
VERSTAK_RELEASE_DIR="$WORK/release" \
|
||||
GIT_BIN="$WORK/bin/git" \
|
||||
GH_BIN="$WORK/bin/gh" \
|
||||
EXPECTED_ROOT="$ROOT" \
|
||||
TEST_STATE="$WORK/state" \
|
||||
"$PUBLISHER" "$VERSION"
|
||||
}
|
||||
|
||||
run_publisher
|
||||
grep -Fqx "release:$VERSION" "$LOG"
|
||||
grep -Fqx "tag:$VERSION" "$LOG"
|
||||
grep -Fqx "push:origin:refs/tags/$VERSION" "$LOG"
|
||||
grep -F "release create $VERSION" "$LOG" >/dev/null
|
||||
for asset in "${ASSET_NAMES[@]}"; do
|
||||
grep -F "$asset" "$LOG" >/dev/null
|
||||
done
|
||||
grep -F "SHA256SUMS" "$LOG" >/dev/null
|
||||
grep -F -- "--prerelease" "$LOG" >/dev/null
|
||||
|
||||
run_publisher
|
||||
grep -F "release upload $VERSION" "$LOG" >/dev/null
|
||||
|
||||
echo "GitHub release publisher test passed"
|
||||
Reference in New Issue
Block a user