@homescriptone/opencode-viewer-extender 0.0.0-stage → 1.2.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 +21 -0
- package/README.md +166 -2
- package/bin/cli.js +2 -0
- package/bin/cli.ts +73 -0
- package/package.json +49 -4
- package/public/index.html +23 -0
- package/public/plugin-api.js +260 -0
- package/public/plugins/bulk-select.js +95 -0
- package/public/plugins/example.js +75 -0
- package/public/script.js +1877 -0
- package/public/styles.css +1174 -0
- package/server/database.ts +482 -0
- package/server/launch.ts +545 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 homescriptone Solutions Ltd
|
|
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,167 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-viewer-extender
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@homescriptone/opencode-viewer-extender)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
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.
|
|
7
|
+
|
|
8
|
+
Reads and writes `~/.local/share/opencode/opencode.db`. Everything stays on your machine.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
bunx @homescriptone/opencode-viewer-extender
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
> On npm: [`@homescriptone/opencode-viewer-extender`](https://www.npmjs.com/package/@homescriptone/opencode-viewer-extender) · Desktop builds (`.dmg`, Linux, Windows) are published on the [releases page](https://github.com/homescriptone/opencode-viewer-extended/releases).
|
|
15
|
+
|
|
16
|
+
## Features
|
|
17
|
+
|
|
18
|
+
- **Three-panel UI** — projects, sessions, and the full message thread
|
|
19
|
+
- **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
|
|
20
|
+
- **Subagents** — interleaved in the timeline; click to drill in, back to return
|
|
21
|
+
- **Search** — cross-project session search scoped to all / user / model messages
|
|
22
|
+
- **Export** — a session as Markdown or JSON, or **select many** projects/sessions and export a single `.zip`
|
|
23
|
+
- **Edit mode** — opt-in create/update/delete (projects, sessions, messages, parts)
|
|
24
|
+
- **Plugins** — drop a `.js` file in `public/plugins/` to add renderers, export formats, and bulk actions; toggle them on/off from the UI
|
|
25
|
+
|
|
26
|
+
## Requirements
|
|
27
|
+
|
|
28
|
+
- [Bun](https://bun.sh) runtime (`curl -fsSL https://bun.sh/install | bash`), or a desktop build
|
|
29
|
+
- [OpenCode](https://opencode.ai) installed and used at least once
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# Run it (auto-opens the browser)
|
|
35
|
+
bunx @homescriptone/opencode-viewer-extender
|
|
36
|
+
|
|
37
|
+
# Custom port
|
|
38
|
+
bunx @homescriptone/opencode-viewer-extender -p 8080
|
|
39
|
+
|
|
40
|
+
# Custom database path
|
|
41
|
+
bunx @homescriptone/opencode-viewer-extender --db /path/to/opencode.db
|
|
42
|
+
|
|
43
|
+
# Enable edit mode (create/update/delete)
|
|
44
|
+
bunx @homescriptone/opencode-viewer-extender --write
|
|
45
|
+
|
|
46
|
+
# Don't open the browser
|
|
47
|
+
bunx @homescriptone/opencode-viewer-extender --no-open
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Default port is `3456` when launched via the CLI. Set `OCV_PORT`, `OCV_DB_PATH`, or `OCV_WRITE=1` to override.
|
|
51
|
+
|
|
52
|
+
## Edit mode
|
|
53
|
+
|
|
54
|
+
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.
|
|
55
|
+
|
|
56
|
+
> **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.
|
|
57
|
+
|
|
58
|
+
## Export
|
|
59
|
+
|
|
60
|
+
- **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`.
|
|
61
|
+
- **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`.
|
|
62
|
+
|
|
63
|
+
## Plugins
|
|
64
|
+
|
|
65
|
+
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`.
|
|
66
|
+
|
|
67
|
+
See [`public/plugins/example.js`](public/plugins/example.js) for a complete example.
|
|
68
|
+
|
|
69
|
+
### Authoring
|
|
70
|
+
|
|
71
|
+
```js
|
|
72
|
+
OCV.define({
|
|
73
|
+
id: "my-plugin",
|
|
74
|
+
name: "My plugin",
|
|
75
|
+
description: "What it does",
|
|
76
|
+
|
|
77
|
+
setup(api) {
|
|
78
|
+
// Render a custom part type -> HTML string (return falsy to fall back)
|
|
79
|
+
api.registerPartRenderer("snapshot", (part, helpers) => `...`);
|
|
80
|
+
|
|
81
|
+
// Add an export format to the Export menu (built in the browser)
|
|
82
|
+
api.registerExportFormat({
|
|
83
|
+
id: "txt", label: "Plain text (.txt)", extension: "txt", mime: "text/plain",
|
|
84
|
+
build: ({ session, messages }) => "..."
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Add a button in the session header
|
|
88
|
+
api.registerSessionAction({ id: "copy", label: "Copy ID", handler: ({ session }) => {} });
|
|
89
|
+
|
|
90
|
+
// Add a bulk action shown when items are selected
|
|
91
|
+
api.registerBulkAction({
|
|
92
|
+
id: "wipe", label: "Delete selected", danger: true, write: true, // write:true => only in Edit mode
|
|
93
|
+
handler: async ({ projectIds, sessionIds, host }) => { /* ... */ }
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Every `register*` call returns an unregister function, and registrations are removed automatically when the plugin is disabled.
|
|
100
|
+
|
|
101
|
+
### Host bridge
|
|
102
|
+
|
|
103
|
+
`api.host` (also `OCV.host`) exposes data and actions:
|
|
104
|
+
|
|
105
|
+
```js
|
|
106
|
+
host.getProjects() // [{ id, name, worktree }]
|
|
107
|
+
host.getSessions() // current session list
|
|
108
|
+
host.getSelection() // { projectIds, sessionIds }
|
|
109
|
+
host.clearSelection()
|
|
110
|
+
host.api.getProjectSessions(projectId) // all sessions for a project
|
|
111
|
+
host.api.getSessionDetail(sessionId) // { session, messages }
|
|
112
|
+
host.api.deleteProject(id)
|
|
113
|
+
host.api.deleteSession(id)
|
|
114
|
+
host.download(name, content, mime) // save a Blob/string
|
|
115
|
+
host.createZip([{ name, data }]) // -> Blob (store method, no deps)
|
|
116
|
+
host.refresh() // reload projects/sessions
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## How it works
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
bin/cli.ts → CLI args, env setup, opens the browser
|
|
123
|
+
server/launch.ts → Bun.serve HTTP server, routing, static files, export
|
|
124
|
+
server/database.ts → SQLite connection, prepared statements, write layer
|
|
125
|
+
public/index.html → Page shell (loads plugin-api.js + script.js)
|
|
126
|
+
public/script.js → Single-page app (vanilla JS, no framework)
|
|
127
|
+
public/plugin-api.js → window.OCV plugin registry (+ zip helper)
|
|
128
|
+
public/plugins/*.js → Drop-in plugins
|
|
129
|
+
public/styles.css → Styles
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
1. Opens `opencode.db` (read-only unless `--write`).
|
|
133
|
+
2. Starts a Bun server on `localhost:3456`.
|
|
134
|
+
3. Serves the static SPA and a small JSON REST API.
|
|
135
|
+
4. All data stays local — nothing leaves your machine.
|
|
136
|
+
|
|
137
|
+
### REST API
|
|
138
|
+
|
|
139
|
+
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`
|
|
140
|
+
|
|
141
|
+
Writes (require `--write`): `POST/PATCH/PUT/DELETE` on `/api/projects`, `/api/sessions`, `/api/sessions/:id/messages`, `/api/messages/:id/parts`.
|
|
142
|
+
|
|
143
|
+
## Development
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
bun run dev # watch mode (server/launch.ts)
|
|
147
|
+
bun run start # production
|
|
148
|
+
bun run typecheck
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Releasing
|
|
152
|
+
|
|
153
|
+
Publishing to npm is automated: pushing to `main`/`master` runs [`.github/workflows/publish.yml`](.github/workflows/publish.yml), which type-checks and publishes the current `package.json` version (skipping if that version already exists). Add an `NPM_TOKEN` repository secret (an npm automation token) for it to publish. Bumping the version is all you need to ship a release.
|
|
154
|
+
|
|
155
|
+
## Desktop app
|
|
156
|
+
|
|
157
|
+
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.
|
|
158
|
+
|
|
159
|
+
## Credits
|
|
160
|
+
|
|
161
|
+
Maintained by [homescriptone Solutions Ltd](https://github.com/homescriptone). Built on
|
|
162
|
+
[opencode-viewer](https://github.com/leeweisern/opencode-viewer) by its original author
|
|
163
|
+
[leeweisern](https://github.com/leeweisern) — thank you.
|
|
164
|
+
|
|
165
|
+
## License
|
|
166
|
+
|
|
167
|
+
MIT © 2026 homescriptone Solutions Ltd. See [LICENSE](LICENSE).
|
package/bin/cli.js
ADDED
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": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.2.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 Solutions Ltd",
|
|
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
|
+
})();
|