@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 +8 -1
- package/package.json +1 -1
- package/public/plugins/bulk-select.js +185 -18
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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,
|
|
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,
|
|
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
|
-
{
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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, "&")
|
|
126
|
+
.replace(/</g, "<")
|
|
127
|
+
.replace(/>/g, ">")
|
|
128
|
+
.replace(/"/g, """);
|
|
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, "&")
|
|
160
|
+
.replace(/</g, "<")
|
|
161
|
+
.replace(/>/g, ">")
|
|
162
|
+
.replace(/"/g, """);
|
|
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
|
}
|