@homescriptone/opencode-viewer-extender 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 homescriptone
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,154 @@
1
- # Temporary Holding Version
1
+ # opencode-viewer-extender
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Local GUI to **browse, edit, and export** your [OpenCode](https://opencode.ai) sessions, messages, and tool calls from the SQLite database — with a small **plugin API** you can extend.
4
+
5
+ Reads and writes `~/.local/share/opencode/opencode.db`. Everything stays on your machine.
6
+
7
+ ```bash
8
+ bunx @homescriptone/opencode-viewer-extender
9
+ ```
10
+
11
+ > Desktop builds (`.dmg`, Linux, Windows) are published on the [releases page](https://github.com/homescriptone/opencode-viewer-extended/releases).
12
+
13
+ ## Features
14
+
15
+ - **Three-panel UI** — projects, sessions, and the full message thread
16
+ - **Every part type rendered** — text (markdown-lite), reasoning, tool calls with input/output, patches, file attachments, images (click-to-expand), agent pills, step-finish costs, compaction markers
17
+ - **Subagents** — interleaved in the timeline; click to drill in, back to return
18
+ - **Search** — cross-project session search scoped to all / user / model messages
19
+ - **Export** — a session as Markdown or JSON, or **select many** projects/sessions and export a single `.zip`
20
+ - **Edit mode** — opt-in create/update/delete (projects, sessions, messages, parts)
21
+ - **Plugins** — drop a `.js` file in `public/plugins/` to add renderers, export formats, and bulk actions; toggle them on/off from the UI
22
+
23
+ ## Requirements
24
+
25
+ - [Bun](https://bun.sh) runtime (`curl -fsSL https://bun.sh/install | bash`), or a desktop build
26
+ - [OpenCode](https://opencode.ai) installed and used at least once
27
+
28
+ ## Usage
29
+
30
+ ```bash
31
+ # Run it (auto-opens the browser)
32
+ bunx @homescriptone/opencode-viewer-extender
33
+
34
+ # Custom port
35
+ bunx @homescriptone/opencode-viewer-extender -p 8080
36
+
37
+ # Custom database path
38
+ bunx @homescriptone/opencode-viewer-extender --db /path/to/opencode.db
39
+
40
+ # Enable edit mode (create/update/delete)
41
+ bunx @homescriptone/opencode-viewer-extender --write
42
+
43
+ # Don't open the browser
44
+ bunx @homescriptone/opencode-viewer-extender --no-open
45
+ ```
46
+
47
+ Default port is `3456` when launched via the CLI. Set `OCV_PORT`, `OCV_DB_PATH`, or `OCV_WRITE=1` to override.
48
+
49
+ ## Edit mode
50
+
51
+ The database is opened **read-only** by default. Writes only become available when you start with `--write` (or `OCV_WRITE=1`); the server then exposes the write API and the UI shows an **Edit** toggle.
52
+
53
+ > **Warning:** edits and deletes hit your real OpenCode database, and deletes **cascade** (deleting a project removes its sessions, messages, and parts). There is no undo. Back up `opencode.db` before making changes.
54
+
55
+ ## Export
56
+
57
+ - **Single session** — the **Export ▾** menu in the message header offers Markdown (`.md`) and JSON (`.json`), served by `GET /api/sessions/:id/export?format=md|json`.
58
+ - **Bulk** — turn on **Select**, pick multiple projects/sessions, and choose **Export selected (.zip)**. The archive contains a `project.json` per project, each session as `sessions/<id>.json`, and a `manifest.json`.
59
+
60
+ ## Plugins
61
+
62
+ Plugins are plain ES scripts discovered at startup from `public/plugins/` (listed by `GET /api/plugins`) and loaded before the UI renders. Enable/disable them from the **Plugins** button; disabled plugins are remembered in `localStorage`.
63
+
64
+ See [`public/plugins/example.js`](public/plugins/example.js) for a complete example.
65
+
66
+ ### Authoring
67
+
68
+ ```js
69
+ OCV.define({
70
+ id: "my-plugin",
71
+ name: "My plugin",
72
+ description: "What it does",
73
+
74
+ setup(api) {
75
+ // Render a custom part type -> HTML string (return falsy to fall back)
76
+ api.registerPartRenderer("snapshot", (part, helpers) => `...`);
77
+
78
+ // Add an export format to the Export menu (built in the browser)
79
+ api.registerExportFormat({
80
+ id: "txt", label: "Plain text (.txt)", extension: "txt", mime: "text/plain",
81
+ build: ({ session, messages }) => "..."
82
+ });
83
+
84
+ // Add a button in the session header
85
+ api.registerSessionAction({ id: "copy", label: "Copy ID", handler: ({ session }) => {} });
86
+
87
+ // Add a bulk action shown when items are selected
88
+ api.registerBulkAction({
89
+ id: "wipe", label: "Delete selected", danger: true, write: true, // write:true => only in Edit mode
90
+ handler: async ({ projectIds, sessionIds, host }) => { /* ... */ }
91
+ });
92
+ }
93
+ });
94
+ ```
95
+
96
+ Every `register*` call returns an unregister function, and registrations are removed automatically when the plugin is disabled.
97
+
98
+ ### Host bridge
99
+
100
+ `api.host` (also `OCV.host`) exposes data and actions:
101
+
102
+ ```js
103
+ host.getProjects() // [{ id, name, worktree }]
104
+ host.getSessions() // current session list
105
+ host.getSelection() // { projectIds, sessionIds }
106
+ host.clearSelection()
107
+ host.api.getProjectSessions(projectId) // all sessions for a project
108
+ host.api.getSessionDetail(sessionId) // { session, messages }
109
+ host.api.deleteProject(id)
110
+ host.api.deleteSession(id)
111
+ host.download(name, content, mime) // save a Blob/string
112
+ host.createZip([{ name, data }]) // -> Blob (store method, no deps)
113
+ host.refresh() // reload projects/sessions
114
+ ```
115
+
116
+ ## How it works
117
+
118
+ ```
119
+ bin/cli.ts → CLI args, env setup, opens the browser
120
+ server/launch.ts → Bun.serve HTTP server, routing, static files, export
121
+ server/database.ts → SQLite connection, prepared statements, write layer
122
+ public/index.html → Page shell (loads plugin-api.js + script.js)
123
+ public/script.js → Single-page app (vanilla JS, no framework)
124
+ public/plugin-api.js → window.OCV plugin registry (+ zip helper)
125
+ public/plugins/*.js → Drop-in plugins
126
+ public/styles.css → Styles
127
+ ```
128
+
129
+ 1. Opens `opencode.db` (read-only unless `--write`).
130
+ 2. Starts a Bun server on `localhost:3456`.
131
+ 3. Serves the static SPA and a small JSON REST API.
132
+ 4. All data stays local — nothing leaves your machine.
133
+
134
+ ### REST API
135
+
136
+ Reads: `GET /api/projects` · `GET /api/projects/:id/sessions` · `GET /api/sessions/search` · `GET /api/sessions/:id` · `GET /api/sessions/:id/subagents` · `GET /api/sessions/:id/export` · `GET /api/parts/:id/image` · `GET /api/plugins` · `GET /api/config`
137
+
138
+ Writes (require `--write`): `POST/PATCH/PUT/DELETE` on `/api/projects`, `/api/sessions`, `/api/sessions/:id/messages`, `/api/messages/:id/parts`.
139
+
140
+ ## Development
141
+
142
+ ```bash
143
+ bun run dev # watch mode (server/launch.ts)
144
+ bun run start # production
145
+ bun run typecheck
146
+ ```
147
+
148
+ ## Desktop app
149
+
150
+ A Tauri wrapper lives in [`src-tauri/`](src-tauri/) and bundles the Bun server as a sidecar; installers are produced by CI and attached to releases.
151
+
152
+ ## License
153
+
154
+ [MIT](LICENSE)
package/bin/cli.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ import("./cli.ts");
package/bin/cli.ts ADDED
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { existsSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { parseArgs } from "node:util";
7
+
8
+ const { values } = parseArgs({
9
+ options: {
10
+ port: { type: "string", short: "p", default: "3456" },
11
+ "no-open": { type: "boolean", default: false },
12
+ write: { type: "boolean", default: false },
13
+ help: { type: "boolean", short: "h", default: false },
14
+ db: { type: "string" },
15
+ },
16
+ strict: false,
17
+ });
18
+
19
+ if (values.help) {
20
+ console.log(`
21
+ opencode-viewer — Browse your OpenCode sessions locally
22
+
23
+ Usage:
24
+ bunx opencode-viewer [options]
25
+
26
+ Options:
27
+ -p, --port <port> Port to listen on (default: 3456)
28
+ --db <path> Custom path to opencode.db
29
+ --write Enable edit mode (create/update/delete). Off by default.
30
+ --no-open Don't auto-open browser
31
+ -h, --help Show this help
32
+
33
+ Requires Bun runtime (uses bun:sqlite).
34
+ `);
35
+ process.exit(0);
36
+ }
37
+
38
+ const port = Number(values.port) || 3456;
39
+ const dbPath =
40
+ (values.db as string) ||
41
+ join(homedir(), ".local", "share", "opencode", "opencode.db");
42
+
43
+ if (!existsSync(dbPath)) {
44
+ console.error(`\n ✗ OpenCode database not found at: ${dbPath}\n`);
45
+ console.error(
46
+ " Make sure OpenCode is installed and has been run at least once.",
47
+ );
48
+ console.error(" Or specify a custom path: bunx opencode-viewer --db /path/to/opencode.db\n");
49
+ process.exit(1);
50
+ }
51
+
52
+ // Set env vars for the server to pick up
53
+ process.env.OCV_PORT = String(port);
54
+ process.env.OCV_DB_PATH = dbPath;
55
+ if (values.write) process.env.OCV_WRITE = "1";
56
+
57
+ // Import and start server
58
+ const serverPath = join(import.meta.dir, "..", "server", "launch.ts");
59
+ await import(serverPath);
60
+
61
+ // Auto-open browser
62
+ if (!values["no-open"]) {
63
+ const url = `http://localhost:${port}`;
64
+ const { platform } = process;
65
+ const cmd =
66
+ platform === "darwin"
67
+ ? ["open", url]
68
+ : platform === "win32"
69
+ ? ["cmd", "/c", "start", url]
70
+ : ["xdg-open", url];
71
+
72
+ Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore" });
73
+ }
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "@homescriptone/opencode-viewer-extender",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.1.0",
4
+ "description": "Local GUI to browse, edit, and export your OpenCode sessions, messages, and tool calls from the SQLite database, with a plugin API",
5
+ "bin": {
6
+ "opencode-viewer": "bin/cli.js"
7
+ },
8
+ "files": [
9
+ "bin/",
10
+ "public/",
11
+ "server/"
12
+ ],
13
+ "scripts": {
14
+ "dev": "bun run --watch server/launch.ts",
15
+ "start": "bun run server/launch.ts",
16
+ "typecheck": "bun check",
17
+ "prepublishOnly": "bun check",
18
+ "deploy": "npm version patch --no-git-tag-version && git add -A && git commit -m \"v$(node -p require('./package.json').version)\" && git push && npm publish --access public"
19
+ },
20
+ "keywords": [
21
+ "opencode",
22
+ "opencode-viewer",
23
+ "viewer",
24
+ "sqlite",
25
+ "sessions",
26
+ "ai",
27
+ "cli",
28
+ "bun",
29
+ "plugins"
30
+ ],
31
+ "author": "homescriptone",
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/homescriptone/opencode-viewer-extended.git"
36
+ },
37
+ "homepage": "https://homescriptone.github.io/opencode-viewer-extended/",
38
+ "bugs": {
39
+ "url": "https://github.com/homescriptone/opencode-viewer-extended/issues"
40
+ },
41
+ "engines": {
42
+ "bun": ">=1.0.0"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "devDependencies": {
48
+ "@types/bun": "^1.4.3",
49
+ "@types/node": "^26.6.5"
50
+ }
51
+ }
@@ -0,0 +1,23 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>OpenCode Session Viewer</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
10
+ <link rel="stylesheet" href="styles.css">
11
+ </head>
12
+ <body>
13
+ <div id="app">
14
+ <aside id="projects-panel" class="panel"></aside>
15
+ <section id="sessions-panel" class="panel"></section>
16
+ <main id="messages-panel" class="panel"></main>
17
+ </div>
18
+ <div id="bulk-bar" class="bulk-bar" hidden></div>
19
+ <div id="modal-root"></div>
20
+ <script src="plugin-api.js"></script>
21
+ <script src="script.js"></script>
22
+ </body>
23
+ </html>
@@ -0,0 +1,260 @@
1
+ (function () {
2
+ "use strict";
3
+
4
+ const STORAGE_KEY = "ocv.plugins.disabled";
5
+
6
+ const plugins = new Map();
7
+ const partRenderers = new Map();
8
+ const exportFormats = [];
9
+ const sessionActions = [];
10
+ const bulkActions = [];
11
+
12
+ let hostImpl = {};
13
+ let readyResolve = null;
14
+ const readyPromise = new Promise((resolve) => {
15
+ readyResolve = resolve;
16
+ });
17
+
18
+ function readDisabled() {
19
+ try {
20
+ const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
21
+ return new Set(Array.isArray(parsed) ? parsed : []);
22
+ } catch {
23
+ return new Set();
24
+ }
25
+ }
26
+
27
+ const disabled = readDisabled();
28
+
29
+ function persistDisabled() {
30
+ try {
31
+ localStorage.setItem(STORAGE_KEY, JSON.stringify([...disabled]));
32
+ } catch {
33
+ /* storage unavailable */
34
+ }
35
+ }
36
+
37
+ function makeApi(id) {
38
+ const cleanups = [];
39
+
40
+ const track = (arr, item) => {
41
+ const remove = () => {
42
+ const index = arr.indexOf(item);
43
+ if (index >= 0) arr.splice(index, 1);
44
+ };
45
+ cleanups.push(remove);
46
+ return remove;
47
+ };
48
+
49
+ return {
50
+ id,
51
+ get host() {
52
+ return hostImpl;
53
+ },
54
+ registerPartRenderer(type, renderer) {
55
+ const key = String(type ?? "").toLowerCase();
56
+ partRenderers.set(key, renderer);
57
+ const remove = () => {
58
+ if (partRenderers.get(key) === renderer) partRenderers.delete(key);
59
+ };
60
+ cleanups.push(remove);
61
+ return remove;
62
+ },
63
+ registerExportFormat(format) {
64
+ if (!format || typeof format.build !== "function") return () => {};
65
+ exportFormats.push(format);
66
+ return track(exportFormats, format);
67
+ },
68
+ registerSessionAction(action) {
69
+ if (!action || typeof action.handler !== "function") return () => {};
70
+ sessionActions.push(action);
71
+ return track(sessionActions, action);
72
+ },
73
+ registerBulkAction(action) {
74
+ if (!action || typeof action.handler !== "function") return () => {};
75
+ bulkActions.push(action);
76
+ return track(bulkActions, action);
77
+ },
78
+ _cleanups: cleanups,
79
+ };
80
+ }
81
+
82
+ function define(def) {
83
+ if (!def || typeof def.id !== "string" || typeof def.setup !== "function") {
84
+ console.warn("[OCV] define() ignored: needs { id, name?, setup }", def);
85
+ return;
86
+ }
87
+ plugins.set(def.id, { def, enabled: false, cleanups: [], api: null });
88
+ }
89
+
90
+ function enable(id) {
91
+ const entry = plugins.get(id);
92
+ if (!entry || entry.enabled) return;
93
+ const api = makeApi(id);
94
+ entry.api = api;
95
+ entry.enabled = true;
96
+ try {
97
+ const extra = entry.def.setup(api);
98
+ if (Array.isArray(extra)) api._cleanups.push(...extra);
99
+ } catch (error) {
100
+ console.error(`[OCV] plugin "${id}" setup failed:`, error);
101
+ }
102
+ entry.cleanups = api._cleanups;
103
+ }
104
+
105
+ function disable(id) {
106
+ const entry = plugins.get(id);
107
+ if (!entry || !entry.enabled) return;
108
+ for (const cleanup of entry.cleanups) {
109
+ try {
110
+ cleanup();
111
+ } catch (error) {
112
+ console.error(`[OCV] plugin "${id}" cleanup failed:`, error);
113
+ }
114
+ }
115
+ entry.cleanups = [];
116
+ try {
117
+ entry.def.teardown?.(entry.api);
118
+ } catch (error) {
119
+ console.error(`[OCV] plugin "${id}" teardown failed:`, error);
120
+ }
121
+ entry.enabled = false;
122
+ entry.api = null;
123
+ }
124
+
125
+ function setEnabled(id, value) {
126
+ if (value) {
127
+ disabled.delete(id);
128
+ enable(id);
129
+ } else {
130
+ disabled.add(id);
131
+ disable(id);
132
+ }
133
+ persistDisabled();
134
+ hostImpl.onPluginsChanged?.();
135
+ }
136
+
137
+ function listPlugins() {
138
+ return [...plugins.values()].map((entry) => ({
139
+ id: entry.def.id,
140
+ name: entry.def.name || entry.def.id,
141
+ description: entry.def.description || "",
142
+ enabled: entry.enabled,
143
+ }));
144
+ }
145
+
146
+ function activateAll() {
147
+ for (const [id] of plugins) {
148
+ if (!disabled.has(id)) enable(id);
149
+ }
150
+ }
151
+
152
+ // Minimal ZIP writer (store method, no compression) so plugins can bundle
153
+ // multiple JSON files into one download without any dependency.
154
+ const crcTable = (() => {
155
+ const table = new Uint32Array(256);
156
+ for (let n = 0; n < 256; n++) {
157
+ let c = n;
158
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
159
+ table[n] = c >>> 0;
160
+ }
161
+ return table;
162
+ })();
163
+
164
+ function crc32(bytes) {
165
+ let c = 0xffffffff;
166
+ for (let i = 0; i < bytes.length; i++) c = crcTable[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
167
+ return (c ^ 0xffffffff) >>> 0;
168
+ }
169
+
170
+ function createZip(entries) {
171
+ const encoder = new TextEncoder();
172
+ const chunks = [];
173
+ const central = [];
174
+ let offset = 0;
175
+
176
+ for (const entry of entries) {
177
+ const nameBytes = encoder.encode(String(entry.name));
178
+ const dataBytes = typeof entry.data === "string" ? encoder.encode(entry.data) : entry.data;
179
+ const crc = crc32(dataBytes);
180
+
181
+ const local = new Uint8Array(30 + nameBytes.length);
182
+ const lv = new DataView(local.buffer);
183
+ lv.setUint32(0, 0x04034b50, true);
184
+ lv.setUint16(4, 20, true);
185
+ lv.setUint16(6, 0x0800, true);
186
+ lv.setUint16(8, 0, true);
187
+ lv.setUint16(10, 0, true);
188
+ lv.setUint16(12, 0, true);
189
+ lv.setUint32(14, crc, true);
190
+ lv.setUint32(18, dataBytes.length, true);
191
+ lv.setUint32(22, dataBytes.length, true);
192
+ lv.setUint16(26, nameBytes.length, true);
193
+ lv.setUint16(28, 0, true);
194
+ local.set(nameBytes, 30);
195
+ chunks.push(local, dataBytes);
196
+
197
+ const record = new Uint8Array(46 + nameBytes.length);
198
+ const cv = new DataView(record.buffer);
199
+ cv.setUint32(0, 0x02014b50, true);
200
+ cv.setUint16(4, 20, true);
201
+ cv.setUint16(6, 20, true);
202
+ cv.setUint16(8, 0x0800, true);
203
+ cv.setUint16(10, 0, true);
204
+ cv.setUint16(12, 0, true);
205
+ cv.setUint16(14, 0, true);
206
+ cv.setUint32(16, crc, true);
207
+ cv.setUint32(20, dataBytes.length, true);
208
+ cv.setUint32(24, dataBytes.length, true);
209
+ cv.setUint16(28, nameBytes.length, true);
210
+ cv.setUint16(30, 0, true);
211
+ cv.setUint16(32, 0, true);
212
+ cv.setUint16(34, 0, true);
213
+ cv.setUint16(36, 0, true);
214
+ cv.setUint32(38, 0, true);
215
+ cv.setUint32(42, offset, true);
216
+ record.set(nameBytes, 46);
217
+ central.push(record);
218
+
219
+ offset += local.length + dataBytes.length;
220
+ }
221
+
222
+ let centralSize = 0;
223
+ for (const record of central) centralSize += record.length;
224
+
225
+ const end = new Uint8Array(22);
226
+ const ev = new DataView(end.buffer);
227
+ ev.setUint32(0, 0x06054b50, true);
228
+ ev.setUint16(4, 0, true);
229
+ ev.setUint16(6, 0, true);
230
+ ev.setUint16(8, central.length, true);
231
+ ev.setUint16(10, central.length, true);
232
+ ev.setUint32(12, centralSize, true);
233
+ ev.setUint32(16, offset, true);
234
+ ev.setUint16(20, 0, true);
235
+
236
+ return new Blob([...chunks, ...central, end], { type: "application/zip" });
237
+ }
238
+
239
+ window.OCV = {
240
+ version: "2",
241
+ define,
242
+ setEnabled,
243
+ isEnabled: (id) => Boolean(plugins.get(id)?.enabled),
244
+ list: listPlugins,
245
+ ready: () => readyPromise,
246
+ get host() {
247
+ return hostImpl;
248
+ },
249
+ getPartRenderer: (type) => partRenderers.get(String(type ?? "").toLowerCase()) || null,
250
+ listExportFormats: () => exportFormats.slice(),
251
+ listSessionActions: () => sessionActions.slice(),
252
+ listBulkActions: () => bulkActions.slice(),
253
+ helpers: { createZip },
254
+ _registerHost(impl) {
255
+ hostImpl = impl;
256
+ },
257
+ _activateAll: activateAll,
258
+ _markReady: () => readyResolve(),
259
+ };
260
+ })();
@@ -0,0 +1,95 @@
1
+ // Bulk select plugin: select multiple projects and sessions, then export them
2
+ // as a single .zip of JSON files or delete them (project deletes cascade).
3
+
4
+ OCV.define({
5
+ id: "bulk-select",
6
+ name: "Bulk select · export & delete",
7
+ description: "Select multiple projects and sessions to export as a .zip or delete.",
8
+
9
+ setup(api) {
10
+ api.registerBulkAction({
11
+ id: "bulk-export-zip",
12
+ label: "Export selected (.zip)",
13
+ async handler({ projectIds, sessionIds, host }) {
14
+ const entries = [];
15
+ const seenSessions = new Set();
16
+ const projectsById = new Map(host.getProjects().map((project) => [project.id, project]));
17
+
18
+ const addSession = async (sessionId, folder) => {
19
+ if (seenSessions.has(sessionId)) return;
20
+ seenSessions.add(sessionId);
21
+ const detail = await host.api.getSessionDetail(sessionId);
22
+ entries.push({ name: `${folder}/${sessionId}.json`, data: JSON.stringify(detail, null, 2) });
23
+ };
24
+
25
+ for (const projectId of projectIds) {
26
+ const project = projectsById.get(projectId);
27
+ const folderName = sanitize(`${project?.name || projectId}-${projectId}`);
28
+ entries.push({
29
+ name: `projects/${folderName}/project.json`,
30
+ data: JSON.stringify(project ?? { id: projectId }, null, 2)
31
+ });
32
+ const sessions = await host.api.getProjectSessions(projectId);
33
+ for (const session of sessions) {
34
+ await addSession(session.id, `projects/${folderName}/sessions`);
35
+ }
36
+ }
37
+
38
+ for (const sessionId of sessionIds) {
39
+ await addSession(sessionId, "sessions");
40
+ }
41
+
42
+ entries.push({
43
+ name: "manifest.json",
44
+ data: JSON.stringify(
45
+ { exportedAt: new Date().toISOString(), projectIds, sessionIds, files: entries.length + 1 },
46
+ null,
47
+ 2
48
+ )
49
+ });
50
+
51
+ const blob = host.createZip(entries);
52
+ host.download(`ocv-export-${Date.now()}.zip`, blob, "application/zip");
53
+ host.notify(`Exported ${entries.length} files.`);
54
+ }
55
+ });
56
+
57
+ api.registerBulkAction({
58
+ id: "bulk-delete",
59
+ label: "Delete selected",
60
+ danger: true,
61
+ write: true,
62
+ async handler({ projectIds, sessionIds, host }) {
63
+ const parts = [];
64
+ if (projectIds.length) parts.push(`${projectIds.length} project(s) (and all their sessions)`);
65
+ if (sessionIds.length) parts.push(`${sessionIds.length} session(s)`);
66
+ if (!parts.length) return;
67
+
68
+ const ok = window.confirm(`Delete ${parts.join(" and ")}? This cannot be undone.`);
69
+ if (!ok) return;
70
+
71
+ let deleted = 0;
72
+ for (const projectId of projectIds) {
73
+ await host.api.deleteProject(projectId);
74
+ deleted += 1;
75
+ }
76
+ for (const sessionId of sessionIds) {
77
+ await host.api.deleteSession(sessionId);
78
+ deleted += 1;
79
+ }
80
+
81
+ host.clearSelection();
82
+ host.refresh();
83
+ host.notify(`Deleted ${deleted} item(s).`);
84
+ }
85
+ });
86
+ }
87
+ });
88
+
89
+ function sanitize(value) {
90
+ return String(value ?? "item")
91
+ .toLowerCase()
92
+ .replace(/[^a-z0-9]+/g, "-")
93
+ .replace(/^-+|-+$/g, "")
94
+ .slice(0, 60) || "item";
95
+ }