@lotics/cli 0.32.0 → 0.33.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 +27 -0
- package/dist/cli.js +673 -0
- package/dist/{src/dev → dev}/wrapper_page.js +33 -5
- package/dist/docx.d.ts +2 -0
- package/dist/docx.js +341 -0
- package/dist/docx.test.js +145 -0
- package/dist/file_command_io.d.ts +12 -0
- package/dist/file_command_io.js +34 -0
- package/dist/src/cli.js +46757 -500
- package/dist/{src/starter_template.d.ts → starter_template.d.ts} +1 -1
- package/dist/{src/starter_template.js → starter_template.js} +1 -1
- package/dist/starter_template.test.d.ts +1 -0
- package/dist/xlsx.d.ts +2 -0
- package/dist/xlsx.js +489 -0
- package/dist/xlsx.test.d.ts +1 -0
- package/dist/xlsx.test.js +131 -0
- package/package.json +6 -2
- /package/dist/{src/app_commands.d.ts → app_commands.d.ts} +0 -0
- /package/dist/{src/app_commands.js → app_commands.js} +0 -0
- /package/dist/{src/app_commands.test.d.ts → app_commands.test.d.ts} +0 -0
- /package/dist/{src/app_commands.test.js → app_commands.test.js} +0 -0
- /package/dist/{src/args.d.ts → args.d.ts} +0 -0
- /package/dist/{src/args.js → args.js} +0 -0
- /package/dist/{src/args.test.d.ts → args.test.d.ts} +0 -0
- /package/dist/{src/args.test.js → args.test.js} +0 -0
- /package/dist/{src/cli.d.ts → cli.d.ts} +0 -0
- /package/dist/{src/client.d.ts → client.d.ts} +0 -0
- /package/dist/{src/client.js → client.js} +0 -0
- /package/dist/{src/config.d.ts → config.d.ts} +0 -0
- /package/dist/{src/config.js → config.js} +0 -0
- /package/dist/{src/config.test.d.ts → config.test.d.ts} +0 -0
- /package/dist/{src/config.test.js → config.test.js} +0 -0
- /package/dist/{src/dev → dev}/rpc_handler.d.ts +0 -0
- /package/dist/{src/dev → dev}/rpc_handler.js +0 -0
- /package/dist/{src/dev → dev}/server.d.ts +0 -0
- /package/dist/{src/dev → dev}/server.js +0 -0
- /package/dist/{src/dev → dev}/wrapper_page.d.ts +0 -0
- /package/dist/{src/starter_template.test.d.ts → docx.test.d.ts} +0 -0
- /package/dist/{src/generate_app_queries_dts.d.ts → generate_app_queries_dts.d.ts} +0 -0
- /package/dist/{src/generate_app_queries_dts.js → generate_app_queries_dts.js} +0 -0
- /package/dist/{src/generate_app_workflows_dts.d.ts → generate_app_workflows_dts.d.ts} +0 -0
- /package/dist/{src/generate_app_workflows_dts.js → generate_app_workflows_dts.js} +0 -0
- /package/dist/{src/starter_template.test.js → starter_template.test.js} +0 -0
- /package/dist/{src/version.d.ts → version.d.ts} +0 -0
- /package/dist/{src/version.js → version.js} +0 -0
|
@@ -89,6 +89,38 @@ export function buildWrapperPage(args) {
|
|
|
89
89
|
// The iframe SDK sends one "upload" op carrying a File. A File can't
|
|
90
90
|
// cross the JSON /_rpc hop, so the upload runs here in the browser:
|
|
91
91
|
// mint a presigned URL, PUT the bytes to storage, then finalize.
|
|
92
|
+
//
|
|
93
|
+
// Retry policy parity with the production SDK
|
|
94
|
+
// (packages/app-sdk/src/upload/transport.ts): 3 PUT attempts with
|
|
95
|
+
// 1s/2s/4s backoff on 5xx + network errors. 4xx is terminal. Image
|
|
96
|
+
// compression is intentionally NOT inlined here — dev networks are
|
|
97
|
+
// stable enough, and end-user mobile cases are handled by the SDK
|
|
98
|
+
// and bridged host directly.
|
|
99
|
+
async function putWithRetry(url, file) {
|
|
100
|
+
const MAX_ATTEMPTS = 3;
|
|
101
|
+
let lastError;
|
|
102
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
103
|
+
try {
|
|
104
|
+
const res = await fetch(url, {
|
|
105
|
+
method: "PUT",
|
|
106
|
+
body: file,
|
|
107
|
+
headers: { "Content-Type": file.type },
|
|
108
|
+
});
|
|
109
|
+
if (res.ok) return res;
|
|
110
|
+
// 4xx is terminal — retrying a malformed/expired URL is pointless.
|
|
111
|
+
if (res.status >= 400 && res.status < 500) return res;
|
|
112
|
+
lastError = new Error("Storage PUT returned " + res.status);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
lastError = err;
|
|
115
|
+
}
|
|
116
|
+
if (attempt < MAX_ATTEMPTS) {
|
|
117
|
+
const delayMs = 1000 * Math.pow(2, attempt - 1);
|
|
118
|
+
await new Promise(function (r) { setTimeout(r, delayMs); });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
throw lastError || new Error("Storage PUT failed");
|
|
122
|
+
}
|
|
123
|
+
|
|
92
124
|
async function handleUpload(payload) {
|
|
93
125
|
const file = payload && payload.file;
|
|
94
126
|
if (!(file instanceof File)) {
|
|
@@ -99,11 +131,7 @@ export function buildWrapperPage(args) {
|
|
|
99
131
|
mime_type: file.type,
|
|
100
132
|
file_size: file.size,
|
|
101
133
|
});
|
|
102
|
-
const putRes = await
|
|
103
|
-
method: "PUT",
|
|
104
|
-
body: file,
|
|
105
|
-
headers: { "Content-Type": file.type },
|
|
106
|
-
});
|
|
134
|
+
const putRes = await putWithRetry(init.upload_url, file);
|
|
107
135
|
if (!putRes.ok) {
|
|
108
136
|
throw new Error("Storage upload failed (" + putRes.status + ")");
|
|
109
137
|
}
|
package/dist/docx.d.ts
ADDED
package/dist/docx.js
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { fail, writeFileAtomic } from "./file_command_io.js";
|
|
3
|
+
import { parseDocx, serializeDocx, } from "@lotics/docx";
|
|
4
|
+
const DEFAULT_DOC_ATTRS = {
|
|
5
|
+
"@_xmlns:w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
|
|
6
|
+
};
|
|
7
|
+
async function loadFile(filePath) {
|
|
8
|
+
if (!fs.existsSync(filePath))
|
|
9
|
+
fail(`File not found: ${filePath}`);
|
|
10
|
+
const bytes = fs.readFileSync(filePath);
|
|
11
|
+
return parseDocx(new Uint8Array(bytes));
|
|
12
|
+
}
|
|
13
|
+
async function writeDoc(filePath, doc) {
|
|
14
|
+
const bytes = await serializeDocx(doc);
|
|
15
|
+
writeFileAtomic(filePath, bytes);
|
|
16
|
+
}
|
|
17
|
+
// =============================================================================
|
|
18
|
+
// Block construction (typed, immutable)
|
|
19
|
+
// =============================================================================
|
|
20
|
+
function buildParagraph(text, style) {
|
|
21
|
+
const properties = style ? [buildPStyle(style)] : null;
|
|
22
|
+
const content = [buildRun(text)];
|
|
23
|
+
return { kind: "paragraph", properties, content };
|
|
24
|
+
}
|
|
25
|
+
function buildPStyle(styleId) {
|
|
26
|
+
return { "w:pStyle": [], ":@": { "@_w:val": styleId } };
|
|
27
|
+
}
|
|
28
|
+
function buildRun(text) {
|
|
29
|
+
// Split on \n so newlines render as Word line breaks (<w:br/>) instead of
|
|
30
|
+
// collapsing into a single line — multi-line agent input is common.
|
|
31
|
+
const segments = text.split("\n");
|
|
32
|
+
const content = [];
|
|
33
|
+
for (let i = 0; i < segments.length; i++) {
|
|
34
|
+
const value = segments[i];
|
|
35
|
+
if (value !== "") {
|
|
36
|
+
content.push({
|
|
37
|
+
kind: "text",
|
|
38
|
+
value,
|
|
39
|
+
preserveSpace: value.startsWith(" ") || value.endsWith(" "),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
if (i < segments.length - 1) {
|
|
43
|
+
content.push({ kind: "opaque_run_child", xml: { "w:br": [] } });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (content.length === 0)
|
|
47
|
+
content.push({ kind: "text", value: "", preserveSpace: false });
|
|
48
|
+
return { kind: "run", properties: null, content };
|
|
49
|
+
}
|
|
50
|
+
function replaceChildren(doc, children) {
|
|
51
|
+
return {
|
|
52
|
+
parts: doc.parts,
|
|
53
|
+
documentAttrs: doc.documentAttrs,
|
|
54
|
+
body: { children },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// =============================================================================
|
|
58
|
+
// Read: project body to a simplified shape
|
|
59
|
+
// =============================================================================
|
|
60
|
+
function readToJson(doc, includeOpaque) {
|
|
61
|
+
const blocks = doc.body.children.map((block, index) => projectBlock(block, index, includeOpaque));
|
|
62
|
+
return { documentAttrs: doc.documentAttrs, blocks };
|
|
63
|
+
}
|
|
64
|
+
function projectBlock(block, index, includeOpaque) {
|
|
65
|
+
switch (block.kind) {
|
|
66
|
+
case "paragraph":
|
|
67
|
+
return {
|
|
68
|
+
kind: "paragraph",
|
|
69
|
+
index,
|
|
70
|
+
text: extractText(block),
|
|
71
|
+
style: extractStyleId(block.properties),
|
|
72
|
+
};
|
|
73
|
+
case "body_sect_pr":
|
|
74
|
+
return includeOpaque
|
|
75
|
+
? { kind: "body_sect_pr", index, xml: block.xml }
|
|
76
|
+
: { kind: "body_sect_pr", index };
|
|
77
|
+
case "opaque_block":
|
|
78
|
+
return includeOpaque
|
|
79
|
+
? { kind: "opaque_block", index, tag: firstTagName(block.xml), xml: block.xml }
|
|
80
|
+
: { kind: "opaque_block", index, tag: firstTagName(block.xml) };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function extractText(para) {
|
|
84
|
+
let out = "";
|
|
85
|
+
for (const inline of para.content) {
|
|
86
|
+
if (inline.kind !== "run")
|
|
87
|
+
continue;
|
|
88
|
+
for (const child of inline.content) {
|
|
89
|
+
if (child.kind === "text")
|
|
90
|
+
out += child.value;
|
|
91
|
+
else if (child.kind === "opaque_run_child" && firstTagName(child.xml) === "w:br")
|
|
92
|
+
out += "\n";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
function extractStyleId(properties) {
|
|
98
|
+
if (!properties)
|
|
99
|
+
return undefined;
|
|
100
|
+
for (const prop of properties) {
|
|
101
|
+
if (firstTagName(prop) !== "w:pStyle")
|
|
102
|
+
continue;
|
|
103
|
+
const attrs = prop[":@"];
|
|
104
|
+
if (attrs?.["@_w:val"])
|
|
105
|
+
return attrs["@_w:val"];
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
function firstTagName(el) {
|
|
110
|
+
for (const key of Object.keys(el)) {
|
|
111
|
+
if (key !== ":@" && key !== "#text")
|
|
112
|
+
return key;
|
|
113
|
+
}
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
// =============================================================================
|
|
117
|
+
// Write: build DocxDocument from a SimpleDoc
|
|
118
|
+
// =============================================================================
|
|
119
|
+
function buildDoc(input) {
|
|
120
|
+
if (!input || !Array.isArray(input.blocks))
|
|
121
|
+
fail("docx write JSON must include a `blocks` array");
|
|
122
|
+
const children = [];
|
|
123
|
+
for (let i = 0; i < input.blocks.length; i++) {
|
|
124
|
+
const b = input.blocks[i];
|
|
125
|
+
if (!b || typeof b !== "object")
|
|
126
|
+
fail(`blocks[${i}] must be an object`);
|
|
127
|
+
if (b.kind === "paragraph") {
|
|
128
|
+
if (typeof b.text !== "string")
|
|
129
|
+
fail(`blocks[${i}].text must be a string`);
|
|
130
|
+
if (b.style !== undefined && typeof b.style !== "string")
|
|
131
|
+
fail(`blocks[${i}].style must be a string`);
|
|
132
|
+
children.push(buildParagraph(b.text, b.style));
|
|
133
|
+
}
|
|
134
|
+
else if (b.kind === "opaque_block") {
|
|
135
|
+
fail(`blocks[${i}]: opaque_block cannot be created from JSON; read+modify an existing .docx instead`);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
const kind = b.kind;
|
|
139
|
+
fail(`blocks[${i}]: unknown kind ${JSON.stringify(kind)} (expected "paragraph")`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const documentAttrs = { ...DEFAULT_DOC_ATTRS, ...(input.documentAttrs ?? {}) };
|
|
143
|
+
return { parts: new Map(), documentAttrs, body: { children } };
|
|
144
|
+
}
|
|
145
|
+
// =============================================================================
|
|
146
|
+
// Subcommand handlers
|
|
147
|
+
// =============================================================================
|
|
148
|
+
async function docxRead(filePath, rest) {
|
|
149
|
+
if (!filePath)
|
|
150
|
+
fail("Usage: lotics docx read <file> [--include-opaque]");
|
|
151
|
+
const includeOpaque = rest.includes("--include-opaque");
|
|
152
|
+
const doc = await loadFile(filePath);
|
|
153
|
+
console.log(JSON.stringify(readToJson(doc, includeOpaque), null, 2));
|
|
154
|
+
}
|
|
155
|
+
async function docxWrite(filePath, json) {
|
|
156
|
+
if (!filePath || !json)
|
|
157
|
+
fail("Usage: lotics docx write <file> '<json>'");
|
|
158
|
+
let parsed;
|
|
159
|
+
try {
|
|
160
|
+
parsed = JSON.parse(json);
|
|
161
|
+
}
|
|
162
|
+
catch (e) {
|
|
163
|
+
fail(`Invalid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
164
|
+
}
|
|
165
|
+
const doc = buildDoc(parsed);
|
|
166
|
+
await writeDoc(filePath, doc);
|
|
167
|
+
}
|
|
168
|
+
function getStyleFlag(rest) {
|
|
169
|
+
for (const arg of rest) {
|
|
170
|
+
if (arg.startsWith("--style="))
|
|
171
|
+
return arg.slice("--style=".length);
|
|
172
|
+
}
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
function getAtFlag(rest) {
|
|
176
|
+
for (const arg of rest) {
|
|
177
|
+
if (arg.startsWith("--at=")) {
|
|
178
|
+
const n = parseInt(arg.slice("--at=".length), 10);
|
|
179
|
+
return Number.isFinite(n) ? n : undefined;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
function firstPositional(rest) {
|
|
185
|
+
return rest.find((a) => !a.startsWith("--"));
|
|
186
|
+
}
|
|
187
|
+
async function docxAppendParagraph(filePath, rest) {
|
|
188
|
+
const text = firstPositional(rest);
|
|
189
|
+
if (!filePath || text === undefined)
|
|
190
|
+
fail("Usage: lotics docx append-paragraph <file> '<text>' [--style=NAME]");
|
|
191
|
+
const style = getStyleFlag(rest);
|
|
192
|
+
const doc = await loadFile(filePath);
|
|
193
|
+
const para = buildParagraph(text, style);
|
|
194
|
+
const newChildren = [...doc.body.children, para];
|
|
195
|
+
await writeDoc(filePath, replaceChildren(doc, newChildren));
|
|
196
|
+
}
|
|
197
|
+
async function docxInsertParagraph(filePath, rest) {
|
|
198
|
+
const text = firstPositional(rest);
|
|
199
|
+
const at = getAtFlag(rest);
|
|
200
|
+
if (!filePath || text === undefined || at === undefined) {
|
|
201
|
+
fail("Usage: lotics docx insert-paragraph <file> '<text>' --at=<index> [--style=NAME]");
|
|
202
|
+
}
|
|
203
|
+
const style = getStyleFlag(rest);
|
|
204
|
+
const doc = await loadFile(filePath);
|
|
205
|
+
if (at < 0 || at > doc.body.children.length) {
|
|
206
|
+
fail(`--at=${at} out of range (0..${doc.body.children.length})`);
|
|
207
|
+
}
|
|
208
|
+
const para = buildParagraph(text, style);
|
|
209
|
+
const newChildren = [...doc.body.children];
|
|
210
|
+
newChildren.splice(at, 0, para);
|
|
211
|
+
await writeDoc(filePath, replaceChildren(doc, newChildren));
|
|
212
|
+
}
|
|
213
|
+
async function docxDeleteBlock(filePath, rest) {
|
|
214
|
+
const at = getAtFlag(rest);
|
|
215
|
+
if (!filePath || at === undefined)
|
|
216
|
+
fail("Usage: lotics docx delete-block <file> --at=<index>");
|
|
217
|
+
const doc = await loadFile(filePath);
|
|
218
|
+
if (at < 0 || at >= doc.body.children.length) {
|
|
219
|
+
fail(`--at=${at} out of range (0..${doc.body.children.length - 1})`);
|
|
220
|
+
}
|
|
221
|
+
const newChildren = doc.body.children.filter((_, i) => i !== at);
|
|
222
|
+
await writeDoc(filePath, replaceChildren(doc, newChildren));
|
|
223
|
+
}
|
|
224
|
+
async function docxReplaceText(filePath, rest) {
|
|
225
|
+
const [search, replace] = rest;
|
|
226
|
+
if (!filePath || !search || replace === undefined) {
|
|
227
|
+
fail("Usage: lotics docx replace-text <file> '<search>' '<replace>'");
|
|
228
|
+
}
|
|
229
|
+
const doc = await loadFile(filePath);
|
|
230
|
+
const newChildren = doc.body.children.map((b) => (b.kind === "paragraph" ? replaceInParagraph(b, search, replace) : b));
|
|
231
|
+
await writeDoc(filePath, replaceChildren(doc, newChildren));
|
|
232
|
+
}
|
|
233
|
+
function replaceInParagraph(p, search, replace) {
|
|
234
|
+
const content = p.content.map((inline) => {
|
|
235
|
+
if (inline.kind !== "run")
|
|
236
|
+
return inline;
|
|
237
|
+
const newChildren = inline.content.map((c) => c.kind === "text"
|
|
238
|
+
? { kind: "text", value: c.value.split(search).join(replace), preserveSpace: c.preserveSpace }
|
|
239
|
+
: c);
|
|
240
|
+
return { kind: "run", properties: inline.properties, content: newChildren };
|
|
241
|
+
});
|
|
242
|
+
return { kind: "paragraph", properties: p.properties, content };
|
|
243
|
+
}
|
|
244
|
+
async function docxBatch(filePath, json) {
|
|
245
|
+
if (!filePath || !json)
|
|
246
|
+
fail("Usage: lotics docx batch <file> '<json-array-of-ops>'");
|
|
247
|
+
let ops;
|
|
248
|
+
try {
|
|
249
|
+
ops = JSON.parse(json);
|
|
250
|
+
}
|
|
251
|
+
catch (e) {
|
|
252
|
+
fail(`Invalid batch JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
253
|
+
}
|
|
254
|
+
if (!Array.isArray(ops))
|
|
255
|
+
fail("Batch must be a JSON array of ops");
|
|
256
|
+
for (let i = 0; i < ops.length; i++) {
|
|
257
|
+
const op = ops[i];
|
|
258
|
+
if (!op || typeof op !== "object" || typeof op.op !== "string") {
|
|
259
|
+
fail(`[op ${i}] each op must be an object with an "op" string field`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
let doc = await loadFile(filePath);
|
|
263
|
+
for (let i = 0; i < ops.length; i++) {
|
|
264
|
+
doc = applyBatchOp(doc, ops[i], i);
|
|
265
|
+
}
|
|
266
|
+
await writeDoc(filePath, doc);
|
|
267
|
+
}
|
|
268
|
+
function applyBatchOp(doc, op, index) {
|
|
269
|
+
switch (op.op) {
|
|
270
|
+
case "append-paragraph": {
|
|
271
|
+
const para = buildParagraph(op.text, op.style);
|
|
272
|
+
return replaceChildren(doc, [...doc.body.children, para]);
|
|
273
|
+
}
|
|
274
|
+
case "insert-paragraph": {
|
|
275
|
+
if (op.at < 0 || op.at > doc.body.children.length) {
|
|
276
|
+
fail(`[op ${index}] at=${op.at} out of range`);
|
|
277
|
+
}
|
|
278
|
+
const next = [...doc.body.children];
|
|
279
|
+
next.splice(op.at, 0, buildParagraph(op.text, op.style));
|
|
280
|
+
return replaceChildren(doc, next);
|
|
281
|
+
}
|
|
282
|
+
case "delete-block": {
|
|
283
|
+
if (op.at < 0 || op.at >= doc.body.children.length) {
|
|
284
|
+
fail(`[op ${index}] at=${op.at} out of range`);
|
|
285
|
+
}
|
|
286
|
+
return replaceChildren(doc, doc.body.children.filter((_, i) => i !== op.at));
|
|
287
|
+
}
|
|
288
|
+
case "replace-text": {
|
|
289
|
+
const next = doc.body.children.map((b) => (b.kind === "paragraph" ? replaceInParagraph(b, op.search, op.replace) : b));
|
|
290
|
+
return replaceChildren(doc, next);
|
|
291
|
+
}
|
|
292
|
+
default: {
|
|
293
|
+
const exhaustive = op;
|
|
294
|
+
void exhaustive;
|
|
295
|
+
fail(`[op ${index}] Unknown op: ${JSON.stringify(op)}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// =============================================================================
|
|
300
|
+
// Dispatch + help
|
|
301
|
+
// =============================================================================
|
|
302
|
+
export function printDocxHelp() {
|
|
303
|
+
console.error(`Lotics docx commands — manipulate .docx files in place.
|
|
304
|
+
Uses Lotics' own OOXML engine; round-trips faithfully with the Lotics editor and templates.
|
|
305
|
+
|
|
306
|
+
lotics docx read <file> [--include-opaque] Dump file as JSON (blocks with text/style)
|
|
307
|
+
lotics docx write <file> '<json>' Create .docx from {"blocks":[{"kind":"paragraph","text":...}]}
|
|
308
|
+
lotics docx append-paragraph <file> '<text>' [--style=NAME]
|
|
309
|
+
Append a paragraph (styles: Title, Heading1..3, Quote, Code)
|
|
310
|
+
lotics docx insert-paragraph <file> '<text>' --at=<i> [--style=NAME]
|
|
311
|
+
Insert a paragraph at index <i> (0-based)
|
|
312
|
+
lotics docx delete-block <file> --at=<i> Remove the block at index <i>
|
|
313
|
+
lotics docx replace-text <file> '<search>' '<replace>' Replace text in all paragraph runs
|
|
314
|
+
lotics docx batch <file> '<json-array-of-ops>' Apply many ops in one parse/serialize cycle
|
|
315
|
+
|
|
316
|
+
Edit ops mutate the file atomically (temp file + rename). Opaque blocks (tables, custom XML) are preserved verbatim.`);
|
|
317
|
+
}
|
|
318
|
+
export async function runDocxCommand(subcommand, toolArgs, restArgs) {
|
|
319
|
+
switch (subcommand) {
|
|
320
|
+
case "read":
|
|
321
|
+
return docxRead(toolArgs, restArgs);
|
|
322
|
+
case "write":
|
|
323
|
+
return docxWrite(toolArgs, restArgs[0]);
|
|
324
|
+
case "append-paragraph":
|
|
325
|
+
return docxAppendParagraph(toolArgs, restArgs);
|
|
326
|
+
case "insert-paragraph":
|
|
327
|
+
return docxInsertParagraph(toolArgs, restArgs);
|
|
328
|
+
case "delete-block":
|
|
329
|
+
return docxDeleteBlock(toolArgs, restArgs);
|
|
330
|
+
case "replace-text":
|
|
331
|
+
return docxReplaceText(toolArgs, restArgs);
|
|
332
|
+
case "batch":
|
|
333
|
+
return docxBatch(toolArgs, restArgs[0]);
|
|
334
|
+
default: {
|
|
335
|
+
if (subcommand)
|
|
336
|
+
console.error(`Unknown docx subcommand: ${subcommand}\n`);
|
|
337
|
+
printDocxHelp();
|
|
338
|
+
process.exit(subcommand ? 1 : 0);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { parseDocx } from "@lotics/docx";
|
|
6
|
+
import { runDocxCommand } from "./docx.js";
|
|
7
|
+
import { CliError } from "./file_command_io.js";
|
|
8
|
+
let tmpDir;
|
|
9
|
+
let file;
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "docx-test-"));
|
|
12
|
+
file = path.join(tmpDir, "test.docx");
|
|
13
|
+
vi.spyOn(console, "error").mockImplementation(() => { });
|
|
14
|
+
});
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
vi.restoreAllMocks();
|
|
17
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
async function writeFresh(json) {
|
|
20
|
+
await runDocxCommand("write", file, [json]);
|
|
21
|
+
}
|
|
22
|
+
async function readBlocks() {
|
|
23
|
+
const stdout = vi.spyOn(console, "log").mockImplementation(() => { });
|
|
24
|
+
try {
|
|
25
|
+
await runDocxCommand("read", file, []);
|
|
26
|
+
const printed = stdout.mock.calls[0]?.[0];
|
|
27
|
+
const out = JSON.parse(printed);
|
|
28
|
+
return out.blocks
|
|
29
|
+
.filter((b) => b.kind === "paragraph")
|
|
30
|
+
.map((b) => ({ text: b.text ?? "", style: b.style }));
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
stdout.mockRestore();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
describe("docx write + read", () => {
|
|
37
|
+
it("round-trips paragraphs with styles", async () => {
|
|
38
|
+
await writeFresh(JSON.stringify({
|
|
39
|
+
blocks: [
|
|
40
|
+
{ kind: "paragraph", text: "Title", style: "Heading1" },
|
|
41
|
+
{ kind: "paragraph", text: "Body" },
|
|
42
|
+
],
|
|
43
|
+
}));
|
|
44
|
+
expect(await readBlocks()).toEqual([
|
|
45
|
+
{ text: "Title", style: "Heading1" },
|
|
46
|
+
{ text: "Body", style: undefined },
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
it("renders multi-line text as <w:br/> line breaks and round-trips through read", async () => {
|
|
50
|
+
await writeFresh(JSON.stringify({ blocks: [{ kind: "paragraph", text: "L1\nL2\nL3" }] }));
|
|
51
|
+
expect(await readBlocks()).toEqual([{ text: "L1\nL2\nL3", style: undefined }]);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
describe("docx single-op mutations", () => {
|
|
55
|
+
beforeEach(async () => {
|
|
56
|
+
await writeFresh(JSON.stringify({ blocks: [{ kind: "paragraph", text: "first" }] }));
|
|
57
|
+
});
|
|
58
|
+
it("append-paragraph adds at end", async () => {
|
|
59
|
+
await runDocxCommand("append-paragraph", file, ["second"]);
|
|
60
|
+
expect((await readBlocks()).map((b) => b.text)).toEqual(["first", "second"]);
|
|
61
|
+
});
|
|
62
|
+
it("append-paragraph carries a style flag", async () => {
|
|
63
|
+
await runDocxCommand("append-paragraph", file, ["heading", "--style=Heading2"]);
|
|
64
|
+
expect((await readBlocks())[1]).toEqual({ text: "heading", style: "Heading2" });
|
|
65
|
+
});
|
|
66
|
+
it("append-paragraph does NOT consume --style as positional text", async () => {
|
|
67
|
+
// Regression: an earlier draft picked rest[0] without filtering flags,
|
|
68
|
+
// so `--style=X` became the paragraph text.
|
|
69
|
+
await expect(runDocxCommand("append-paragraph", file, ["--style=Heading1"])).rejects.toBeInstanceOf(CliError);
|
|
70
|
+
});
|
|
71
|
+
it("insert-paragraph splices at index", async () => {
|
|
72
|
+
await runDocxCommand("insert-paragraph", file, ["front", "--at=0"]);
|
|
73
|
+
expect((await readBlocks()).map((b) => b.text)).toEqual(["front", "first"]);
|
|
74
|
+
});
|
|
75
|
+
it("delete-block removes by index", async () => {
|
|
76
|
+
await runDocxCommand("append-paragraph", file, ["second"]);
|
|
77
|
+
await runDocxCommand("delete-block", file, ["--at=0"]);
|
|
78
|
+
expect((await readBlocks()).map((b) => b.text)).toEqual(["second"]);
|
|
79
|
+
});
|
|
80
|
+
it("replace-text rewrites all paragraph runs", async () => {
|
|
81
|
+
await runDocxCommand("append-paragraph", file, ["the {{name}}"]);
|
|
82
|
+
await runDocxCommand("replace-text", file, ["{{name}}", "Acme"]);
|
|
83
|
+
expect((await readBlocks()).map((b) => b.text)).toEqual(["first", "the Acme"]);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
describe("docx batch", () => {
|
|
87
|
+
it("applies many ops in one parse/serialize cycle", async () => {
|
|
88
|
+
await writeFresh(JSON.stringify({ blocks: [{ kind: "paragraph", text: "a" }] }));
|
|
89
|
+
const ops = [
|
|
90
|
+
{ op: "append-paragraph", text: "b" },
|
|
91
|
+
{ op: "insert-paragraph", text: "front", at: 0 },
|
|
92
|
+
{ op: "replace-text", search: "b", replace: "B" },
|
|
93
|
+
];
|
|
94
|
+
await runDocxCommand("batch", file, [JSON.stringify(ops)]);
|
|
95
|
+
expect((await readBlocks()).map((b) => b.text)).toEqual(["front", "a", "B"]);
|
|
96
|
+
});
|
|
97
|
+
it("rejects batch op without an 'op' string", async () => {
|
|
98
|
+
await writeFresh(JSON.stringify({ blocks: [{ kind: "paragraph", text: "x" }] }));
|
|
99
|
+
await expect(runDocxCommand("batch", file, [JSON.stringify([{ text: "y" }])])).rejects.toBeInstanceOf(CliError);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
describe("docx round-trip preserves opaque blocks", () => {
|
|
103
|
+
it("a table (parsed as opaque_block) survives replace-text + write back", async () => {
|
|
104
|
+
// Build a docx by hand with a paragraph + a synthetic table cell.
|
|
105
|
+
// Use the live parse/serialize path to confirm opaque content survives.
|
|
106
|
+
await writeFresh(JSON.stringify({ blocks: [{ kind: "paragraph", text: "before" }] }));
|
|
107
|
+
// We can't construct an opaque_block via write JSON, so just verify
|
|
108
|
+
// that the round-trip of a doc containing only paragraphs is stable
|
|
109
|
+
// and that error path for opaque_block is hit on write.
|
|
110
|
+
await expect(runDocxCommand("write", file, [JSON.stringify({
|
|
111
|
+
blocks: [{ kind: "opaque_block" }],
|
|
112
|
+
})])).rejects.toBeInstanceOf(CliError);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
describe("docx error paths", () => {
|
|
116
|
+
it("fails on missing file", async () => {
|
|
117
|
+
await expect(runDocxCommand("append-paragraph", path.join(tmpDir, "no.docx"), ["x"])).rejects.toBeInstanceOf(CliError);
|
|
118
|
+
});
|
|
119
|
+
it("fails on invalid write JSON", async () => {
|
|
120
|
+
await expect(runDocxCommand("write", file, ["{not json"])).rejects.toBeInstanceOf(CliError);
|
|
121
|
+
});
|
|
122
|
+
it("fails on missing blocks array", async () => {
|
|
123
|
+
await expect(runDocxCommand("write", file, [JSON.stringify({})])).rejects.toBeInstanceOf(CliError);
|
|
124
|
+
});
|
|
125
|
+
it("fails on out-of-range --at on insert-paragraph", async () => {
|
|
126
|
+
await writeFresh(JSON.stringify({ blocks: [{ kind: "paragraph", text: "only" }] }));
|
|
127
|
+
await expect(runDocxCommand("insert-paragraph", file, ["x", "--at=99"])).rejects.toBeInstanceOf(CliError);
|
|
128
|
+
});
|
|
129
|
+
it("fails on out-of-range --at on delete-block", async () => {
|
|
130
|
+
await writeFresh(JSON.stringify({ blocks: [{ kind: "paragraph", text: "only" }] }));
|
|
131
|
+
await expect(runDocxCommand("delete-block", file, ["--at=5"])).rejects.toBeInstanceOf(CliError);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
// Verify parseDocx is callable from this test (sanity that the barrel import
|
|
135
|
+
// works under tsgo + vitest) — write a doc, parse with the library directly,
|
|
136
|
+
// confirm at least one paragraph block.
|
|
137
|
+
describe("docx barrel sanity", () => {
|
|
138
|
+
it("parseDocx is reachable from @lotics/docx", async () => {
|
|
139
|
+
await writeFresh(JSON.stringify({ blocks: [{ kind: "paragraph", text: "hi" }] }));
|
|
140
|
+
const bytes = fs.readFileSync(file);
|
|
141
|
+
const doc = await parseDocx(new Uint8Array(bytes));
|
|
142
|
+
const paragraphs = doc.body.children.filter((b) => b.kind === "paragraph");
|
|
143
|
+
expect(paragraphs.length).toBeGreaterThanOrEqual(1);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Throw a tagged user-facing error from a CLI subcommand.
|
|
3
|
+
*
|
|
4
|
+
* The top-level CLI catch (`main().catch` in cli.ts) prints `message` to
|
|
5
|
+
* stderr and exits 1, so callers get the same UX as `process.exit(1)` but
|
|
6
|
+
* tests can catch the throw without killing the test runner.
|
|
7
|
+
*/
|
|
8
|
+
export declare class CliError extends Error {
|
|
9
|
+
constructor(message: string);
|
|
10
|
+
}
|
|
11
|
+
export declare function fail(message: string): never;
|
|
12
|
+
export declare function writeFileAtomic(filePath: string, bytes: Uint8Array): void;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Throw a tagged user-facing error from a CLI subcommand.
|
|
5
|
+
*
|
|
6
|
+
* The top-level CLI catch (`main().catch` in cli.ts) prints `message` to
|
|
7
|
+
* stderr and exits 1, so callers get the same UX as `process.exit(1)` but
|
|
8
|
+
* tests can catch the throw without killing the test runner.
|
|
9
|
+
*/
|
|
10
|
+
export class CliError extends Error {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "CliError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function fail(message) {
|
|
17
|
+
throw new CliError(message);
|
|
18
|
+
}
|
|
19
|
+
export function writeFileAtomic(filePath, bytes) {
|
|
20
|
+
const dir = path.dirname(path.resolve(filePath));
|
|
21
|
+
const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
|
|
22
|
+
fs.writeFileSync(tmp, bytes);
|
|
23
|
+
try {
|
|
24
|
+
fs.renameSync(tmp, filePath);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
try {
|
|
28
|
+
fs.unlinkSync(tmp);
|
|
29
|
+
}
|
|
30
|
+
catch { /* best-effort cleanup */ }
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
console.error(`Wrote ${filePath}`);
|
|
34
|
+
}
|