@homescriptone/opencode-viewer-extender 1.1.0 → 1.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 homescriptone
3
+ Copyright (c) 2026 homescriptone Solutions Ltd
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # opencode-viewer-extender
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/@homescriptone/opencode-viewer-extender.svg)](https://www.npmjs.com/package/@homescriptone/opencode-viewer-extender)
4
+ [![license](https://img.shields.io/npm/l/@homescriptone/opencode-viewer-extender.svg)](LICENSE)
5
+
3
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.
4
7
 
5
8
  Reads and writes `~/.local/share/opencode/opencode.db`. Everything stays on your machine.
@@ -8,7 +11,7 @@ Reads and writes `~/.local/share/opencode/opencode.db`. Everything stays on your
8
11
  bunx @homescriptone/opencode-viewer-extender
9
12
  ```
10
13
 
11
- > Desktop builds (`.dmg`, Linux, Windows) are published on the [releases page](https://github.com/homescriptone/opencode-viewer-extended/releases).
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).
12
15
 
13
16
  ## Features
14
17
 
@@ -55,7 +58,14 @@ The database is opened **read-only** by default. Writes only become available wh
55
58
  ## Export
56
59
 
57
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`.
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`.
61
+ - **Bulk** — turn on **Select**, pick multiple projects/sessions, and choose **Export selected (.zip)**. The archive is grouped into folders, and every item is written in three formats (`.json`, `.xml`, `.html`):
62
+
63
+ ```
64
+ manifest.json
65
+ projects/<project>/project.{json,xml,html}
66
+ projects/<project>/sessions/<session>/session.{json,xml,html}
67
+ sessions/<session>/session.{json,xml,html} # directly-selected sessions
68
+ ```
59
69
 
60
70
  ## Plugins
61
71
 
@@ -145,10 +155,20 @@ bun run start # production
145
155
  bun run typecheck
146
156
  ```
147
157
 
158
+ ## Releasing
159
+
160
+ 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.
161
+
148
162
  ## Desktop app
149
163
 
150
164
  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
165
 
166
+ ## Credits
167
+
168
+ Maintained by [homescriptone Solutions Ltd](https://github.com/homescriptone). Built on
169
+ [opencode-viewer](https://github.com/leeweisern/opencode-viewer) by its original author
170
+ [leeweisern](https://github.com/leeweisern) — thank you.
171
+
152
172
  ## License
153
173
 
154
- [MIT](LICENSE)
174
+ MIT © 2026 homescriptone Solutions Ltd. See [LICENSE](LICENSE).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homescriptone/opencode-viewer-extender",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
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
5
  "bin": {
6
6
  "opencode-viewer": "bin/cli.js"
@@ -28,7 +28,7 @@
28
28
  "bun",
29
29
  "plugins"
30
30
  ],
31
- "author": "homescriptone",
31
+ "author": "Homescriptone Solutions Ltd",
32
32
  "license": "MIT",
33
33
  "repository": {
34
34
  "type": "git",
@@ -1,10 +1,21 @@
1
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).
2
+ // as a single .zip or delete them (project deletes cascade).
3
+ //
4
+ // The archive is grouped into folders and each item is written in three
5
+ // formats: JSON (.json), XML (.xml) and a standalone HTML page (.html).
6
+ //
7
+ // manifest.json
8
+ // projects/<project>/
9
+ // project.json project.xml project.html
10
+ // sessions/<session>/
11
+ // session.json session.xml session.html
12
+ // sessions/<session>/ (directly-selected sessions)
13
+ // session.json session.xml session.html
3
14
 
4
15
  OCV.define({
5
16
  id: "bulk-select",
6
17
  name: "Bulk select · export & delete",
7
- description: "Select multiple projects and sessions to export as a .zip or delete.",
18
+ description: "Select multiple projects and sessions to export as a .zip (json/xml/html) or delete.",
8
19
 
9
20
  setup(api) {
10
21
  api.registerBulkAction({
@@ -15,34 +26,47 @@ OCV.define({
15
26
  const seenSessions = new Set();
16
27
  const projectsById = new Map(host.getProjects().map((project) => [project.id, project]));
17
28
 
29
+ // Write one folder of json/xml/html for a { session, messages } payload.
18
30
  const addSession = async (sessionId, folder) => {
19
31
  if (seenSessions.has(sessionId)) return;
20
32
  seenSessions.add(sessionId);
33
+
21
34
  const detail = await host.api.getSessionDetail(sessionId);
22
- entries.push({ name: `${folder}/${sessionId}.json`, data: JSON.stringify(detail, null, 2) });
35
+ const title = detail?.session?.title || sessionId;
36
+ entries.push({ name: `${folder}/session.json`, data: JSON.stringify(detail, null, 2) });
37
+ entries.push({ name: `${folder}/session.xml`, data: toXmlDocument("export", detail) });
38
+ entries.push({ name: `${folder}/session.html`, data: toHtmlDocument(title, detail) });
23
39
  };
24
40
 
25
41
  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
- });
42
+ const project = projectsById.get(projectId) || { id: projectId };
43
+ const folderName = sanitize(`${project.name || projectId}-${projectId}`);
44
+ const base = `projects/${folderName}`;
45
+
46
+ entries.push({ name: `${base}/project.json`, data: JSON.stringify(project, null, 2) });
47
+ entries.push({ name: `${base}/project.xml`, data: toXmlDocument("project", project) });
48
+ entries.push({ name: `${base}/project.html`, data: toProjectHtml(project) });
49
+
32
50
  const sessions = await host.api.getProjectSessions(projectId);
33
51
  for (const session of sessions) {
34
- await addSession(session.id, `projects/${folderName}/sessions`);
52
+ await addSession(session.id, `${base}/sessions/${sanitize(session.id)}`);
35
53
  }
36
54
  }
37
55
 
38
56
  for (const sessionId of sessionIds) {
39
- await addSession(sessionId, "sessions");
57
+ await addSession(sessionId, `sessions/${sanitize(sessionId)}`);
40
58
  }
41
59
 
42
60
  entries.push({
43
61
  name: "manifest.json",
44
62
  data: JSON.stringify(
45
- { exportedAt: new Date().toISOString(), projectIds, sessionIds, files: entries.length + 1 },
63
+ {
64
+ exportedAt: new Date().toISOString(),
65
+ formats: ["json", "xml", "html"],
66
+ projectIds,
67
+ sessionIds,
68
+ files: entries.length + 1
69
+ },
46
70
  null,
47
71
  2
48
72
  )
@@ -50,7 +74,7 @@ OCV.define({
50
74
 
51
75
  const blob = host.createZip(entries);
52
76
  host.download(`ocv-export-${Date.now()}.zip`, blob, "application/zip");
53
- host.notify(`Exported ${entries.length} files.`);
77
+ host.notify(`Exported ${entries.length} files (json/xml/html).`);
54
78
  }
55
79
  });
56
80
 
@@ -87,9 +111,152 @@ OCV.define({
87
111
  });
88
112
 
89
113
  function sanitize(value) {
90
- return String(value ?? "item")
91
- .toLowerCase()
92
- .replace(/[^a-z0-9]+/g, "-")
93
- .replace(/^-+|-+$/g, "")
94
- .slice(0, 60) || "item";
114
+ return (
115
+ String(value ?? "item")
116
+ .toLowerCase()
117
+ .replace(/[^a-z0-9]+/g, "-")
118
+ .replace(/^-+|-+$/g, "")
119
+ .slice(0, 60) || "item"
120
+ );
121
+ }
122
+
123
+ function xmlEscape(value) {
124
+ return String(value)
125
+ .replace(/&/g, "&amp;")
126
+ .replace(/</g, "&lt;")
127
+ .replace(/>/g, "&gt;")
128
+ .replace(/"/g, "&quot;");
129
+ }
130
+
131
+ function xmlTag(name) {
132
+ const clean = String(name).replace(/[^a-zA-Z0-9_.-]/g, "_");
133
+ return /^[a-zA-Z_]/.test(clean) ? clean : `_${clean}`;
134
+ }
135
+
136
+ function jsonToXml(name, value, indent) {
137
+ const pad = `${indent} `;
138
+ if (value === null || value === undefined) return `${indent}<${name}/>`;
139
+ if (Array.isArray(value)) {
140
+ if (!value.length) return `${indent}<${name}/>`;
141
+ const inner = value.map((item) => jsonToXml("item", item, pad)).join("\n");
142
+ return `${indent}<${name}>\n${inner}\n${indent}</${name}>`;
143
+ }
144
+ if (typeof value === "object") {
145
+ const keys = Object.keys(value);
146
+ if (!keys.length) return `${indent}<${name}/>`;
147
+ const inner = keys.map((key) => jsonToXml(xmlTag(key), value[key], pad)).join("\n");
148
+ return `${indent}<${name}>\n${inner}\n${indent}</${name}>`;
149
+ }
150
+ return `${indent}<${name}>${xmlEscape(value)}</${name}>`;
151
+ }
152
+
153
+ function toXmlDocument(rootName, value) {
154
+ return `<?xml version="1.0" encoding="UTF-8"?>\n${jsonToXml(rootName, value, "")}\n`;
155
+ }
156
+
157
+ function htmlEscape(value) {
158
+ return String(value ?? "")
159
+ .replace(/&/g, "&amp;")
160
+ .replace(/</g, "&lt;")
161
+ .replace(/>/g, "&gt;")
162
+ .replace(/"/g, "&quot;");
163
+ }
164
+
165
+ function stringify(value) {
166
+ if (value === null || value === undefined) return "";
167
+ if (typeof value === "string") return value;
168
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
169
+ try {
170
+ return JSON.stringify(value, null, 2);
171
+ } catch {
172
+ return String(value);
173
+ }
174
+ }
175
+
176
+ const PAGE_CSS = `
177
+ :root { color-scheme: light; }
178
+ body { font-family: system-ui, -apple-system, sans-serif; margin: 0 auto; max-width: 900px; padding: 32px 20px; color: #1c1b18; background: #fbfaf7; line-height: 1.55; }
179
+ h1 { font-size: 1.6rem; margin: 0 0 4px; }
180
+ h2 { font-size: 1.1rem; margin: 28px 0 8px; padding-top: 12px; border-top: 1px solid #e8e6df; }
181
+ h3 { font-size: 0.95rem; margin: 16px 0 4px; }
182
+ .meta { color: #6f6d66; font-size: 0.85rem; margin: 0 0 8px; }
183
+ .part { margin: 10px 0; }
184
+ pre { background: #f5f4f0; border: 1px solid #e8e6df; border-radius: 8px; padding: 12px; overflow-x: auto; font-size: 0.85rem; white-space: pre-wrap; word-break: break-word; }
185
+ details { border: 1px solid #e8e6df; border-radius: 8px; padding: 8px 12px; }
186
+ summary { cursor: pointer; font-weight: 600; }
187
+ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
188
+ .role-user h2 { color: oklch(0.38 0.08 185); }
189
+ `;
190
+
191
+ function toProjectHtml(project) {
192
+ const title = project?.name || project?.id || "Project";
193
+ return `<!doctype html>
194
+ <html lang="en"><head><meta charset="utf-8"/>
195
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
196
+ <title>${htmlEscape(title)}</title><style>${PAGE_CSS}</style></head>
197
+ <body>
198
+ <h1>${htmlEscape(title)}</h1>
199
+ <p class="meta">${htmlEscape(project?.worktree || "")}</p>
200
+ <pre>${htmlEscape(JSON.stringify(project, null, 2))}</pre>
201
+ </body></html>
202
+ `;
203
+ }
204
+
205
+ function renderPartHtml(part) {
206
+ const type = String(part?.type ?? "text");
207
+ if (type === "text" || type === "reasoning") {
208
+ const label = type === "reasoning" ? `<h3>Reasoning</h3>` : "";
209
+ return `${label}<div class="part"><pre>${htmlEscape(stringify(part.text).trim())}</pre></div>`;
210
+ }
211
+ if (type === "tool") {
212
+ const output = part.state && part.state.output !== undefined ? stringify(part.state.output) : "";
213
+ return `<div class="part"><details><summary>Tool: ${htmlEscape(String(part.tool ?? "unknown"))}</summary>
214
+ <pre>${htmlEscape(stringify(part.state && part.state.input) || "{}")}</pre>
215
+ ${output ? `<pre>${htmlEscape(output)}</pre>` : ""}
216
+ </details></div>`;
217
+ }
218
+ if (type === "patch") {
219
+ return `<div class="part"><h3>Patch <code>${htmlEscape(String(part.hash ?? "").slice(0, 7))}</code></h3></div>`;
220
+ }
221
+ if (type === "file") {
222
+ return `<div class="part"><h3>File: ${htmlEscape(String(part.filename ?? "attachment"))}</h3></div>`;
223
+ }
224
+ return "";
225
+ }
226
+
227
+ function toHtmlDocument(title, detail) {
228
+ const session = detail?.session ?? {};
229
+ const messages = Array.isArray(detail?.messages) ? detail.messages : [];
230
+ const updated = Number(session.time_updated);
231
+ const meta = [
232
+ `ID: ${session.id ?? ""}`,
233
+ session.directory ? `Directory: ${session.directory}` : "",
234
+ Number.isFinite(updated) && updated > 0 ? `Updated: ${new Date(updated).toISOString()}` : ""
235
+ ]
236
+ .filter(Boolean)
237
+ .map(htmlEscape)
238
+ .join(" · ");
239
+
240
+ const body = messages
241
+ .map((message) => {
242
+ if (message._type === "subagent") {
243
+ return `<h2>Subagent: ${htmlEscape(message.title || message.id)}</h2>`;
244
+ }
245
+ const role = String(message.role ?? "unknown");
246
+ const parts = (message.parts ?? []).map(renderPartHtml).join("\n");
247
+ const model = message.modelID ? `<div class="meta">model: ${htmlEscape(message.modelID)}</div>` : "";
248
+ return `<div class="role-${htmlEscape(role)}"><h2>${htmlEscape(role)}</h2>${model}${parts}</div>`;
249
+ })
250
+ .join("\n");
251
+
252
+ return `<!doctype html>
253
+ <html lang="en"><head><meta charset="utf-8"/>
254
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
255
+ <title>${htmlEscape(title)}</title><style>${PAGE_CSS}</style></head>
256
+ <body>
257
+ <h1>${htmlEscape(title)}</h1>
258
+ <p class="meta">${meta}</p>
259
+ ${body}
260
+ </body></html>
261
+ `;
95
262
  }