@homescriptone/opencode-viewer-extender 1.2.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/README.md CHANGED
@@ -58,7 +58,14 @@ The database is opened **read-only** by default. Writes only become available wh
58
58
  ## Export
59
59
 
60
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`.
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
+ ```
62
69
 
63
70
  ## Plugins
64
71
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homescriptone/opencode-viewer-extender",
3
- "version": "1.2.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"
@@ -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
  }