@spinabot/brigade 1.9.0 → 1.11.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.
Files changed (104) hide show
  1. package/README.md +12 -10
  2. package/dist/agents/agent-loop.d.ts +55 -0
  3. package/dist/agents/agent-loop.d.ts.map +1 -1
  4. package/dist/agents/agent-loop.js +90 -1
  5. package/dist/agents/agent-loop.js.map +1 -1
  6. package/dist/agents/channels/inbound-pipeline.d.ts +22 -0
  7. package/dist/agents/channels/inbound-pipeline.d.ts.map +1 -1
  8. package/dist/agents/channels/inbound-pipeline.js +31 -1
  9. package/dist/agents/channels/inbound-pipeline.js.map +1 -1
  10. package/dist/agents/channels/media-capture.d.ts +69 -6
  11. package/dist/agents/channels/media-capture.d.ts.map +1 -1
  12. package/dist/agents/channels/media-capture.js +125 -8
  13. package/dist/agents/channels/media-capture.js.map +1 -1
  14. package/dist/agents/channels/telegram/media.d.ts.map +1 -1
  15. package/dist/agents/channels/telegram/media.js +16 -4
  16. package/dist/agents/channels/telegram/media.js.map +1 -1
  17. package/dist/agents/channels/whatsapp/media.d.ts +19 -0
  18. package/dist/agents/channels/whatsapp/media.d.ts.map +1 -1
  19. package/dist/agents/channels/whatsapp/media.js +37 -2
  20. package/dist/agents/channels/whatsapp/media.js.map +1 -1
  21. package/dist/agents/media-understanding/anthropic-adapter.d.ts +49 -0
  22. package/dist/agents/media-understanding/anthropic-adapter.d.ts.map +1 -0
  23. package/dist/agents/media-understanding/anthropic-adapter.js +162 -0
  24. package/dist/agents/media-understanding/anthropic-adapter.js.map +1 -0
  25. package/dist/agents/media-understanding/config.d.ts +57 -0
  26. package/dist/agents/media-understanding/config.d.ts.map +1 -0
  27. package/dist/agents/media-understanding/config.js +289 -0
  28. package/dist/agents/media-understanding/config.js.map +1 -0
  29. package/dist/agents/media-understanding/gemini-adapter.d.ts +57 -0
  30. package/dist/agents/media-understanding/gemini-adapter.d.ts.map +1 -0
  31. package/dist/agents/media-understanding/gemini-adapter.js +343 -0
  32. package/dist/agents/media-understanding/gemini-adapter.js.map +1 -0
  33. package/dist/agents/media-understanding/index.d.ts +58 -0
  34. package/dist/agents/media-understanding/index.d.ts.map +1 -0
  35. package/dist/agents/media-understanding/index.js +275 -0
  36. package/dist/agents/media-understanding/index.js.map +1 -0
  37. package/dist/agents/media-understanding/pi-adapter.d.ts +72 -0
  38. package/dist/agents/media-understanding/pi-adapter.d.ts.map +1 -0
  39. package/dist/agents/media-understanding/pi-adapter.js +160 -0
  40. package/dist/agents/media-understanding/pi-adapter.js.map +1 -0
  41. package/dist/agents/media-understanding/types.d.ts +189 -0
  42. package/dist/agents/media-understanding/types.d.ts.map +1 -0
  43. package/dist/agents/media-understanding/types.js +51 -0
  44. package/dist/agents/media-understanding/types.js.map +1 -0
  45. package/dist/agents/session-wiring.d.ts +11 -0
  46. package/dist/agents/session-wiring.d.ts.map +1 -1
  47. package/dist/agents/session-wiring.js +1 -0
  48. package/dist/agents/session-wiring.js.map +1 -1
  49. package/dist/agents/tools/analyze-media-tool.d.ts +263 -0
  50. package/dist/agents/tools/analyze-media-tool.d.ts.map +1 -0
  51. package/dist/agents/tools/analyze-media-tool.js +2321 -0
  52. package/dist/agents/tools/analyze-media-tool.js.map +1 -0
  53. package/dist/agents/tools/doc-shared.d.ts +187 -0
  54. package/dist/agents/tools/doc-shared.d.ts.map +1 -0
  55. package/dist/agents/tools/doc-shared.js +484 -0
  56. package/dist/agents/tools/doc-shared.js.map +1 -0
  57. package/dist/agents/tools/edit-document-tool.d.ts +133 -0
  58. package/dist/agents/tools/edit-document-tool.d.ts.map +1 -0
  59. package/dist/agents/tools/edit-document-tool.js +815 -0
  60. package/dist/agents/tools/edit-document-tool.js.map +1 -0
  61. package/dist/agents/tools/image-downscale.d.ts +93 -0
  62. package/dist/agents/tools/image-downscale.d.ts.map +1 -0
  63. package/dist/agents/tools/image-downscale.js +257 -0
  64. package/dist/agents/tools/image-downscale.js.map +1 -0
  65. package/dist/agents/tools/make-document-tool.d.ts +114 -0
  66. package/dist/agents/tools/make-document-tool.d.ts.map +1 -0
  67. package/dist/agents/tools/make-document-tool.js +542 -0
  68. package/dist/agents/tools/make-document-tool.js.map +1 -0
  69. package/dist/agents/tools/media-cache.d.ts +56 -0
  70. package/dist/agents/tools/media-cache.d.ts.map +1 -0
  71. package/dist/agents/tools/media-cache.js +133 -0
  72. package/dist/agents/tools/media-cache.js.map +1 -0
  73. package/dist/agents/tools/ooxml-images.d.ts +107 -0
  74. package/dist/agents/tools/ooxml-images.d.ts.map +1 -0
  75. package/dist/agents/tools/ooxml-images.js +308 -0
  76. package/dist/agents/tools/ooxml-images.js.map +1 -0
  77. package/dist/agents/tools/registry.d.ts +12 -0
  78. package/dist/agents/tools/registry.d.ts.map +1 -1
  79. package/dist/agents/tools/registry.js +47 -0
  80. package/dist/agents/tools/registry.js.map +1 -1
  81. package/dist/buildstamp.json +1 -1
  82. package/dist/cli/commands/doctor.d.ts.map +1 -1
  83. package/dist/cli/commands/doctor.js +41 -0
  84. package/dist/cli/commands/doctor.js.map +1 -1
  85. package/dist/cli/commands/update.d.ts +48 -11
  86. package/dist/cli/commands/update.d.ts.map +1 -1
  87. package/dist/cli/commands/update.js +133 -46
  88. package/dist/cli/commands/update.js.map +1 -1
  89. package/dist/cli/program/build-program.d.ts.map +1 -1
  90. package/dist/cli/program/build-program.js +11 -5
  91. package/dist/cli/program/build-program.js.map +1 -1
  92. package/dist/core/console-stream.d.ts.map +1 -1
  93. package/dist/core/console-stream.js +7 -5
  94. package/dist/core/console-stream.js.map +1 -1
  95. package/dist/core/server.js +6 -1
  96. package/dist/core/server.js.map +1 -1
  97. package/dist/system-prompt/assembler.d.ts.map +1 -1
  98. package/dist/system-prompt/assembler.js +25 -1
  99. package/dist/system-prompt/assembler.js.map +1 -1
  100. package/dist/system-prompt/guidance.d.ts +30 -0
  101. package/dist/system-prompt/guidance.d.ts.map +1 -1
  102. package/dist/system-prompt/guidance.js +50 -0
  103. package/dist/system-prompt/guidance.js.map +1 -1
  104. package/package.json +9 -1
@@ -0,0 +1,815 @@
1
+ /**
2
+ * `edit_document` — EDIT an existing Word / Excel / PowerPoint / PDF file in
3
+ * place (or to a sibling). The high-fidelity counterpart to `make_document`:
4
+ * where create builds a file from scratch, edit OPENS an existing one and
5
+ * mutates it, preserving the parts it does not touch.
6
+ *
7
+ * ─────────────────────────────────────────────────────────────────────────
8
+ * HIGH-FIDELITY TECHNIQUE (read before changing)
9
+ * ─────────────────────────────────────────────────────────────────────────
10
+ * Two strategies depending on format + action:
11
+ *
12
+ * • docx/pptx `replace_text` → the OOXML UNZIP→edit-XML→REZIP technique
13
+ * (the same one `analyze_media` reads with): `fflate.unzipSync` the file,
14
+ * run a string replacement over the text runs in `word/document.xml`
15
+ * (docx) or every `ppt/slides/slideN.xml` (pptx), then `fflate.zipSync`
16
+ * the entries back. This preserves ALL styling, themes, images, and
17
+ * relationships — only the run text changes. (A full `docx patchDocument`
18
+ * token-replace is available too via the `docx` lib, but the unzip-rezip
19
+ * path keeps arbitrary existing formatting that patchDocument would not
20
+ * round-trip, and works identically for pptx which has no patch API.)
21
+ *
22
+ * • docx `append` → re-open is not needed: we unzip, splice new paragraph
23
+ * XML before `</w:body>`, and rezip — additive, style-preserving.
24
+ *
25
+ * • xlsx `set_cells` / `append_rows` → `exceljs` read→modify→write, which
26
+ * preserves the other sheets + their formatting.
27
+ *
28
+ * • pdf `fill_form` / `merge` / `split` / `stamp` / `add_pages` /
29
+ * `remove_pages` → `@cantoo/pdf-lib` load→mutate→save.
30
+ *
31
+ * SECURITY: the SOURCE path is scoped with the SAME guard as `analyze_media`'s
32
+ * local reads (`acquireSourceBytes`), and the OUTPUT path with the same write
33
+ * guard as `make_document` (`resolveOutputPath`). Reading a source OR writing an
34
+ * output outside the allowed roots is refused. Default output overwrites the
35
+ * source. NOT owner-only — editing a workspace file is safe and the path guards
36
+ * are the boundary.
37
+ *
38
+ * Robust to malformed input: every failure surfaces as a `BrigadeToolInputError`
39
+ * (clean `.message` to the model), never a raw library throw.
40
+ */
41
+ import path from "node:path";
42
+ import { Type } from "typebox";
43
+ import { BrigadeToolInputError, jsonResult } from "./common.js";
44
+ import { acquireSourceBytes, embedUnicodeFont, formatFromExtension, isFormulaCell, resolveOutputPath, sanitizeForFont, toExcelCellValue, writeDocFile, } from "./doc-shared.js";
45
+ /** Hard cap on a single source/merge-input file read for editing. */
46
+ const MAX_SOURCE_BYTES = 48 * 1024 * 1024; // 48 MiB
47
+ /* ─────────────────────────── params ─────────────────────────── */
48
+ /** A formula cell: `{ formula: "SUM(B2:B10)" }` (no leading "="). */
49
+ const FormulaCell = Type.Object({
50
+ formula: Type.String({ description: 'An Excel formula WITHOUT the leading "=" (e.g. "SUM(B2:B10)").' }),
51
+ numFmt: Type.Optional(Type.String({ description: 'Optional number format for this cell (e.g. "#,##0.00").' })),
52
+ });
53
+ const CellEdit = Type.Object({
54
+ ref: Type.Optional(Type.String({ description: 'A1-style cell reference (e.g. "B3"). Use this OR row+col.' })),
55
+ row: Type.Optional(Type.Integer({ minimum: 1, description: "1-based row index (with `col`)." })),
56
+ col: Type.Optional(Type.Integer({ minimum: 1, description: "1-based column index (with `row`)." })),
57
+ value: Type.Union([Type.String(), Type.Number(), FormulaCell], {
58
+ description: 'New cell value: a string, a number, or a formula object { formula: "SUM(A1:A3)" }.',
59
+ }),
60
+ numFmt: Type.Optional(Type.String({ description: 'Optional number format for this cell (e.g. "0.00%").' })),
61
+ });
62
+ const EditDocumentParams = Type.Object({
63
+ source: Type.String({
64
+ description: "Path to the EXISTING document to edit (scoped to allowed roots: workspace / cwd / cache / temp). Format is detected from the extension unless `format` is set.",
65
+ }),
66
+ format: Type.Optional(Type.Union([Type.Literal("docx"), Type.Literal("xlsx"), Type.Literal("pptx"), Type.Literal("pdf")], { description: "Override the format if the extension is wrong/missing." })),
67
+ action: Type.String({
68
+ description: "What to do. docx: append | replace_text | fill_template. xlsx: set_cells | append_rows. pptx: replace_text | fill_template. pdf: fill_form | merge | split | stamp | watermark | add_pages | remove_pages.",
69
+ }),
70
+ outputPath: Type.Optional(Type.String({
71
+ description: "Where to write the result. Omit to OVERWRITE the source. Relative paths resolve against the workspace; must land inside an allowed root. (split ignores this and writes numbered siblings of the source.)",
72
+ })),
73
+ // replace_text (docx/pptx)
74
+ find: Type.Optional(Type.String({ description: "replace_text: the text to find (literal)." })),
75
+ replace: Type.Optional(Type.String({ description: "replace_text: the replacement text." })),
76
+ // fill_template (docx/pptx)
77
+ values: Type.Optional(Type.Record(Type.String(), Type.Union([Type.String(), Type.Number()]), {
78
+ description: 'fill_template: a map of placeholder → value, e.g. { "{{client}}": "Acme", "{{date}}": "2026-01-01" }. Each key is replaced wherever it appears (matches even when Word/PowerPoint split it across runs).',
79
+ })),
80
+ // append (docx)
81
+ paragraphs: Type.Optional(Type.Array(Type.String(), { description: "append (docx): paragraphs to add at the end." })),
82
+ heading: Type.Optional(Type.String({ description: "append (docx): an optional heading before the new paragraphs." })),
83
+ // set_cells / append_rows (xlsx)
84
+ sheet: Type.Optional(Type.String({ description: "xlsx: target sheet name (default: the first sheet)." })),
85
+ cells: Type.Optional(Type.Array(CellEdit, { description: "set_cells: the cells to update." })),
86
+ rows: Type.Optional(Type.Array(Type.Array(Type.Union([Type.String(), Type.Number(), FormulaCell])), {
87
+ description: 'append_rows: rows to append to the sheet; each cell is a string, number, or formula object { formula: "SUM(A1:A3)" }.',
88
+ })),
89
+ // fill_form (pdf)
90
+ fields: Type.Optional(Type.Record(Type.String(), Type.Union([Type.String(), Type.Number(), Type.Boolean()]), {
91
+ description: "fill_form: { fieldName: value } — text fields set text, checkboxes accept true/false.",
92
+ })),
93
+ // merge (pdf)
94
+ pdfs: Type.Optional(Type.Array(Type.String(), { description: "merge: additional PDF paths to append AFTER the source." })),
95
+ // split (pdf)
96
+ pages: Type.Optional(Type.String({
97
+ description: 'split: page ranges to extract, comma-separated (e.g. "1-3,5,8-"). Each range → one output file. remove_pages: pages to delete (same syntax).',
98
+ })),
99
+ // stamp / watermark (pdf)
100
+ text: Type.Optional(Type.String({ description: "stamp/watermark: the text to overlay on every page." })),
101
+ });
102
+ export function makeEditDocumentTool(opts = {}) {
103
+ const rootOpts = {
104
+ ...(opts.workspaceDir ? { workspaceDir: opts.workspaceDir } : {}),
105
+ ...(opts.cwd ? { cwd: opts.cwd } : {}),
106
+ };
107
+ return {
108
+ name: "edit_document",
109
+ label: "Edit Document",
110
+ displaySummary: "editing a document",
111
+ ownerOnly: false,
112
+ description: [
113
+ "Edit an EXISTING Word (docx), Excel (xlsx), PowerPoint (pptx), or PDF file, preserving the parts you don't change.",
114
+ "Pass `source` (the file), `action`, and the action's params. docx: append ({paragraphs,heading?}) | replace_text ({find,replace}). xlsx: set_cells ({sheet?,cells:[{ref|row,col, value}]}) | append_rows ({sheet?,rows}). pptx: replace_text ({find,replace}). pdf: fill_form ({fields}) | merge ({pdfs}) | split ({pages}) | stamp/watermark ({text}) | add_pages ({pdfs}) | remove_pages ({pages}).",
115
+ "Writes back over the source by default (pass `outputPath` for a copy). To create a NEW file from scratch use `make_document`; to send the result use `send_media({path})`.",
116
+ ].join(" "),
117
+ parameters: EditDocumentParams,
118
+ execute: async (_toolCallId, args, signal) => {
119
+ void signal;
120
+ const source = (args.source ?? "").trim();
121
+ if (!source)
122
+ throw new BrigadeToolInputError("source required");
123
+ const format = args.format ?? formatFromExtension(source);
124
+ if (!format) {
125
+ throw new BrigadeToolInputError("could not determine the document format from the source extension — pass `format` (docx/xlsx/pptx/pdf).");
126
+ }
127
+ const action = (args.action ?? "").trim().toLowerCase();
128
+ if (!action)
129
+ throw new BrigadeToolInputError("action required");
130
+ // Read the source (guarded + scoped).
131
+ const sourceBytes = await acquireSourceBytes(source, { ...rootOpts, maxBytes: MAX_SOURCE_BYTES });
132
+ // Resolve the output path (default: overwrite the source). `split` is the
133
+ // one action that writes multiple siblings, handled inside its branch.
134
+ const outRaw = typeof args.outputPath === "string" && args.outputPath.trim() ? args.outputPath.trim() : source;
135
+ const absOut = action === "split" ? source : resolveOutputPath(outRaw, rootOpts);
136
+ let result;
137
+ switch (format) {
138
+ case "docx":
139
+ result = await editDocx(action, sourceBytes, absOut, args, rootOpts);
140
+ break;
141
+ case "pptx":
142
+ result = await editPptx(action, sourceBytes, absOut, args);
143
+ break;
144
+ case "xlsx":
145
+ result = await editXlsx(action, sourceBytes, absOut, args);
146
+ break;
147
+ case "pdf":
148
+ result = await editPdf(action, sourceBytes, source, absOut, args, rootOpts);
149
+ break;
150
+ }
151
+ return result;
152
+ },
153
+ };
154
+ }
155
+ /* ─────────────────────────── docx edits ─────────────────────────── */
156
+ async function editDocx(action, bytes, absOut, args, rootOpts) {
157
+ void rootOpts;
158
+ if (action === "replace_text") {
159
+ const find = args.find ?? "";
160
+ if (!find)
161
+ throw new BrigadeToolInputError("replace_text: `find` is required.");
162
+ const replace = args.replace ?? "";
163
+ const { entries, decode, encode } = await unzipDoc(bytes, "docx");
164
+ const docXml = entries["word/document.xml"];
165
+ if (!docXml)
166
+ throw new BrigadeToolInputError("not a valid .docx (missing word/document.xml).");
167
+ const { xml, count } = replaceInRunText(decode(docXml), find, replace);
168
+ entries["word/document.xml"] = encode(xml);
169
+ const out = await rezipDoc(entries);
170
+ const written = await writeDocFile(absOut, out);
171
+ return ok({ action, format: "docx", path: absOut, bytes: written, replacements: count });
172
+ }
173
+ if (action === "append") {
174
+ const paragraphs = (args.paragraphs ?? []).filter((p) => typeof p === "string");
175
+ const heading = typeof args.heading === "string" ? args.heading.trim() : "";
176
+ if (paragraphs.length === 0 && !heading) {
177
+ throw new BrigadeToolInputError("append: provide `paragraphs` (and/or a `heading`).");
178
+ }
179
+ const { entries, decode, encode } = await unzipDoc(bytes, "docx");
180
+ const docXml = entries["word/document.xml"];
181
+ if (!docXml)
182
+ throw new BrigadeToolInputError("not a valid .docx (missing word/document.xml).");
183
+ let xml = decode(docXml);
184
+ const additions = [];
185
+ if (heading) {
186
+ additions.push(`<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t xml:space="preserve">${escapeXml(heading)}</w:t></w:r></w:p>`);
187
+ }
188
+ for (const para of paragraphs) {
189
+ additions.push(`<w:p><w:r><w:t xml:space="preserve">${escapeXml(String(para ?? ""))}</w:t></w:r></w:p>`);
190
+ }
191
+ const insert = additions.join("");
192
+ // Splice BEFORE the final sectPr (page setup) when present, else before </w:body>.
193
+ const sectPrIdx = xml.lastIndexOf("<w:sectPr");
194
+ const bodyClose = xml.lastIndexOf("</w:body>");
195
+ if (sectPrIdx !== -1 && sectPrIdx < bodyClose) {
196
+ xml = xml.slice(0, sectPrIdx) + insert + xml.slice(sectPrIdx);
197
+ }
198
+ else if (bodyClose !== -1) {
199
+ xml = xml.slice(0, bodyClose) + insert + xml.slice(bodyClose);
200
+ }
201
+ else {
202
+ throw new BrigadeToolInputError("not a valid .docx (no <w:body> to append into).");
203
+ }
204
+ entries["word/document.xml"] = encode(xml);
205
+ const out = await rezipDoc(entries);
206
+ const written = await writeDocFile(absOut, out);
207
+ return ok({ action, format: "docx", path: absOut, bytes: written });
208
+ }
209
+ if (action === "fill_template") {
210
+ const pairs = templatePairs(args.values);
211
+ if (pairs.length === 0) {
212
+ throw new BrigadeToolInputError("fill_template: provide `values` ({ \"{{token}}\": \"value\" }).");
213
+ }
214
+ const { entries, decode, encode } = await unzipDoc(bytes, "docx");
215
+ const docXml = entries["word/document.xml"];
216
+ if (!docXml)
217
+ throw new BrigadeToolInputError("not a valid .docx (missing word/document.xml).");
218
+ const { xml, filled, notFound } = fillTemplate(decode(docXml), pairs);
219
+ entries["word/document.xml"] = encode(xml);
220
+ const out = await rezipDoc(entries);
221
+ const written = await writeDocFile(absOut, out);
222
+ return templateResult("docx", absOut, written, filled, notFound);
223
+ }
224
+ throw new BrigadeToolInputError(`unsupported docx action "${action}". Use: append, replace_text, fill_template.`);
225
+ }
226
+ /* ─────────────────────────── pptx edits ─────────────────────────── */
227
+ async function editPptx(action, bytes, absOut, args) {
228
+ if (action === "replace_text" || action === "edit_slide") {
229
+ const find = args.find ?? "";
230
+ if (!find)
231
+ throw new BrigadeToolInputError("replace_text: `find` is required.");
232
+ const replace = args.replace ?? "";
233
+ const { entries, decode, encode } = await unzipDoc(bytes, "pptx");
234
+ const slideNames = Object.keys(entries).filter((n) => /^ppt\/slides\/slide\d+\.xml$/.test(n));
235
+ if (slideNames.length === 0)
236
+ throw new BrigadeToolInputError("not a valid .pptx (no slides found).");
237
+ let total = 0;
238
+ for (const name of slideNames) {
239
+ const xml = decode(entries[name]);
240
+ const { xml: next, count } = replaceInRunText(xml, find, replace);
241
+ if (count > 0)
242
+ entries[name] = encode(next);
243
+ total += count;
244
+ }
245
+ const out = await rezipDoc(entries);
246
+ const written = await writeDocFile(absOut, out);
247
+ return ok({ action: "replace_text", format: "pptx", path: absOut, bytes: written, replacements: total });
248
+ }
249
+ if (action === "fill_template") {
250
+ const pairs = templatePairs(args.values);
251
+ if (pairs.length === 0) {
252
+ throw new BrigadeToolInputError("fill_template: provide `values` ({ \"{{token}}\": \"value\" }).");
253
+ }
254
+ const { entries, decode, encode } = await unzipDoc(bytes, "pptx");
255
+ const slideNames = Object.keys(entries).filter((n) => /^ppt\/slides\/slide\d+\.xml$/.test(n));
256
+ if (slideNames.length === 0)
257
+ throw new BrigadeToolInputError("not a valid .pptx (no slides found).");
258
+ const filledAll = new Set();
259
+ for (const name of slideNames) {
260
+ const { xml: next, filled } = fillTemplate(decode(entries[name]), pairs);
261
+ if (filled.length > 0)
262
+ entries[name] = encode(next);
263
+ for (const t of filled)
264
+ filledAll.add(t);
265
+ }
266
+ const filled = pairs.map((pr) => pr.find).filter((t) => filledAll.has(t));
267
+ const notFound = pairs.map((pr) => pr.find).filter((t) => !filledAll.has(t));
268
+ const out = await rezipDoc(entries);
269
+ const written = await writeDocFile(absOut, out);
270
+ return templateResult("pptx", absOut, written, filled, notFound);
271
+ }
272
+ throw new BrigadeToolInputError(`unsupported pptx action "${action}". Use: replace_text, fill_template.`);
273
+ }
274
+ /* ─────────────────────────── xlsx edits ─────────────────────────── */
275
+ async function editXlsx(action, bytes, absOut, args) {
276
+ const ExcelJSImport = await import("exceljs");
277
+ const ExcelJS = ExcelJSImport.default ?? ExcelJSImport;
278
+ const wb = new ExcelJS.Workbook();
279
+ try {
280
+ // exceljs's bundled types expect a non-generic Buffer; @types/node's Buffer
281
+ // is generic over ArrayBufferLike. Cast at the boundary — the value is a
282
+ // real Node Buffer at runtime.
283
+ await wb.xlsx.load(bytes);
284
+ }
285
+ catch {
286
+ throw new BrigadeToolInputError("could not read the .xlsx (corrupt or not a real spreadsheet).");
287
+ }
288
+ const sheet = args.sheet && args.sheet.trim() ? wb.getWorksheet(args.sheet.trim()) : wb.worksheets[0];
289
+ if (!sheet) {
290
+ const names = wb.worksheets.map((w) => w.name).join(", ") || "(none)";
291
+ throw new BrigadeToolInputError(`sheet not found. Available sheets: ${names}.`);
292
+ }
293
+ if (action === "set_cells") {
294
+ const cells = args.cells ?? [];
295
+ if (cells.length === 0)
296
+ throw new BrigadeToolInputError("set_cells: `cells` is required.");
297
+ let n = 0;
298
+ for (const c of cells) {
299
+ const value = toExcelCellValue(c.value);
300
+ // A per-cell numFmt can come from the top-level `numFmt` or a formula object.
301
+ const numFmt = typeof c.numFmt === "string" && c.numFmt
302
+ ? c.numFmt
303
+ : isFormulaCell(c.value) && typeof c.value.numFmt === "string"
304
+ ? c.value.numFmt
305
+ : undefined;
306
+ let target;
307
+ if (typeof c.ref === "string" && c.ref.trim()) {
308
+ target = sheet.getCell(c.ref.trim().toUpperCase());
309
+ }
310
+ else if (typeof c.row === "number" && typeof c.col === "number") {
311
+ target = sheet.getCell(c.row, c.col);
312
+ }
313
+ else {
314
+ throw new BrigadeToolInputError("each cell needs either `ref` or both `row` and `col`.");
315
+ }
316
+ target.value = value;
317
+ if (numFmt)
318
+ target.numFmt = numFmt;
319
+ n += 1;
320
+ }
321
+ const out = Buffer.from((await wb.xlsx.writeBuffer()));
322
+ const written = await writeDocFile(absOut, out);
323
+ return ok({ action, format: "xlsx", path: absOut, bytes: written, cellsSet: n });
324
+ }
325
+ if (action === "append_rows") {
326
+ const rows = args.rows ?? [];
327
+ if (rows.length === 0)
328
+ throw new BrigadeToolInputError("append_rows: `rows` is required.");
329
+ for (const row of rows) {
330
+ const added = sheet.addRow([]);
331
+ (row ?? []).forEach((c, colIdx) => {
332
+ added.getCell(colIdx + 1).value = toExcelCellValue(c);
333
+ });
334
+ added.commit();
335
+ }
336
+ const out = Buffer.from((await wb.xlsx.writeBuffer()));
337
+ const written = await writeDocFile(absOut, out);
338
+ return ok({ action, format: "xlsx", path: absOut, bytes: written, rowsAppended: rows.length });
339
+ }
340
+ throw new BrigadeToolInputError(`unsupported xlsx action "${action}". Use: set_cells, append_rows.`);
341
+ }
342
+ /* ─────────────────────────── pdf edits ─────────────────────────── */
343
+ async function editPdf(action, bytes, source, absOut, args, rootOpts) {
344
+ const pdfLib = await import("@cantoo/pdf-lib");
345
+ const { PDFDocument, StandardFonts, rgb, degrees } = pdfLib;
346
+ const load = async (buf) => {
347
+ try {
348
+ return await PDFDocument.load(buf);
349
+ }
350
+ catch {
351
+ throw new BrigadeToolInputError("could not read the PDF (corrupt or password-protected?).");
352
+ }
353
+ };
354
+ if (action === "fill_form") {
355
+ const fields = args.fields ?? {};
356
+ const names = Object.keys(fields);
357
+ if (names.length === 0)
358
+ throw new BrigadeToolInputError("fill_form: `fields` is required.");
359
+ const pdf = await load(bytes);
360
+ const form = pdf.getForm();
361
+ let set = 0;
362
+ const missing = [];
363
+ for (const name of names) {
364
+ const value = fields[name];
365
+ try {
366
+ const field = findField(form, name);
367
+ if (!field) {
368
+ missing.push(name);
369
+ continue;
370
+ }
371
+ const kind = field.constructor?.name ?? "";
372
+ const f = field;
373
+ if (kind.includes("CheckBox") && typeof f.check === "function") {
374
+ if (value === true || value === "true" || value === 1)
375
+ f.check();
376
+ else if (typeof f.uncheck === "function")
377
+ f.uncheck();
378
+ }
379
+ else if (typeof f.setText === "function") {
380
+ f.setText(String(value ?? ""));
381
+ }
382
+ else if (typeof f.select === "function") {
383
+ f.select(String(value ?? ""));
384
+ }
385
+ else {
386
+ missing.push(name);
387
+ continue;
388
+ }
389
+ set += 1;
390
+ }
391
+ catch {
392
+ missing.push(name);
393
+ }
394
+ }
395
+ const out = Buffer.from(await pdf.save());
396
+ const written = await writeDocFile(absOut, out);
397
+ const details = {
398
+ ok: true,
399
+ action,
400
+ format: "pdf",
401
+ path: absOut,
402
+ bytes: written,
403
+ fieldsSet: set,
404
+ pages: pdf.getPageCount(),
405
+ };
406
+ if (missing.length > 0)
407
+ details.warning = `Fields not found / unsettable: ${missing.join(", ")}.`;
408
+ return jsonResult(details);
409
+ }
410
+ if (action === "merge" || action === "add_pages") {
411
+ const extra = (args.pdfs ?? []).filter((p) => typeof p === "string" && p.trim());
412
+ if (extra.length === 0)
413
+ throw new BrigadeToolInputError(`${action}: \`pdfs\` (paths to append) is required.`);
414
+ const merged = await load(bytes);
415
+ for (const p of extra) {
416
+ const buf = await acquireSourceBytes(p.trim(), { ...rootOpts, maxBytes: MAX_SOURCE_BYTES });
417
+ const next = await load(buf);
418
+ const copied = await merged.copyPages(next, next.getPageIndices());
419
+ for (const page of copied)
420
+ merged.addPage(page);
421
+ }
422
+ const out = Buffer.from(await merged.save());
423
+ const written = await writeDocFile(absOut, out);
424
+ return ok({ action, format: "pdf", path: absOut, bytes: written, pages: merged.getPageCount() });
425
+ }
426
+ if (action === "remove_pages") {
427
+ const spec = args.pages ?? "";
428
+ if (!spec.trim())
429
+ throw new BrigadeToolInputError("remove_pages: `pages` is required (e.g. \"2,4-5\").");
430
+ const pdf = await load(bytes);
431
+ const total = pdf.getPageCount();
432
+ const toRemove = new Set(parsePageList(spec, total));
433
+ if (toRemove.size === 0)
434
+ throw new BrigadeToolInputError("remove_pages: no valid pages in that range.");
435
+ if (toRemove.size >= total)
436
+ throw new BrigadeToolInputError("remove_pages: refusing to remove every page.");
437
+ // Remove from the highest index down so earlier indices stay valid.
438
+ for (const num of [...toRemove].sort((a, b) => b - a))
439
+ pdf.removePage(num - 1);
440
+ const out = Buffer.from(await pdf.save());
441
+ const written = await writeDocFile(absOut, out);
442
+ return ok({ action, format: "pdf", path: absOut, bytes: written, pages: pdf.getPageCount() });
443
+ }
444
+ if (action === "split") {
445
+ const spec = args.pages ?? "";
446
+ if (!spec.trim())
447
+ throw new BrigadeToolInputError("split: `pages` is required (e.g. \"1-3,5,8-\").");
448
+ const pdf = await load(bytes);
449
+ const total = pdf.getPageCount();
450
+ const ranges = spec.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
451
+ const dir = path.dirname(source);
452
+ const base = path.basename(source, path.extname(source));
453
+ const outPaths = [];
454
+ let part = 0;
455
+ for (const range of ranges) {
456
+ const nums = parsePageList(range, total);
457
+ if (nums.length === 0)
458
+ continue;
459
+ part += 1;
460
+ const sub = await PDFDocument.create();
461
+ const copied = await sub.copyPages(pdf, nums.map((n) => n - 1));
462
+ for (const page of copied)
463
+ sub.addPage(page);
464
+ const outName = path.join(dir, `${base}-part${part}.pdf`);
465
+ const abs = resolveOutputPath(outName, rootOpts);
466
+ const out = Buffer.from(await sub.save());
467
+ await writeDocFile(abs, out);
468
+ outPaths.push(abs);
469
+ }
470
+ if (outPaths.length === 0)
471
+ throw new BrigadeToolInputError("split: no valid page ranges produced output.");
472
+ const details = { ok: true, action, format: "pdf", paths: outPaths };
473
+ return jsonResult(details);
474
+ }
475
+ if (action === "stamp" || action === "watermark") {
476
+ const text = (args.text ?? "").trim();
477
+ if (!text)
478
+ throw new BrigadeToolInputError(`${action}: \`text\` is required.`);
479
+ const pdf = await load(bytes);
480
+ // Prefer the bundled Unicode font so accented / Greek / Cyrillic stamps
481
+ // render; fall back to WinAnsi HelveticaBold if the asset is unavailable.
482
+ let font;
483
+ let safe;
484
+ try {
485
+ const uni = await embedUnicodeFont(pdf);
486
+ font = uni;
487
+ safe = sanitizeForFont(text, uni);
488
+ }
489
+ catch {
490
+ font = await pdf.embedFont(StandardFonts.HelveticaBold);
491
+ safe = text.replace(/[^\x20-\x7e]/g, "?");
492
+ }
493
+ const drawFont = font;
494
+ const isWatermark = action === "watermark";
495
+ for (const page of pdf.getPages()) {
496
+ const { width, height } = page.getSize();
497
+ if (isWatermark) {
498
+ const size = Math.max(24, Math.min(72, Math.floor(width / 10)));
499
+ page.drawText(safe, {
500
+ x: width * 0.15,
501
+ y: height * 0.45,
502
+ size,
503
+ font: drawFont,
504
+ color: rgb(0.6, 0.6, 0.6),
505
+ rotate: degrees(45),
506
+ opacity: 0.3,
507
+ });
508
+ }
509
+ else {
510
+ const size = 12;
511
+ page.drawText(safe, { x: 40, y: 20, size, font: drawFont, color: rgb(0.3, 0.3, 0.3), opacity: 0.7 });
512
+ }
513
+ }
514
+ const out = Buffer.from(await pdf.save());
515
+ const written = await writeDocFile(absOut, out);
516
+ return ok({ action, format: "pdf", path: absOut, bytes: written, pages: pdf.getPageCount() });
517
+ }
518
+ throw new BrigadeToolInputError(`unsupported pdf action "${action}". Use: fill_form, merge, split, stamp, watermark, add_pages, remove_pages.`);
519
+ }
520
+ /** pdf-lib form field lookup tolerant of versions without getFieldMaybe. */
521
+ function findField(form, name) {
522
+ try {
523
+ return form.getFields().find((f) => f.getName() === name);
524
+ }
525
+ catch {
526
+ return undefined;
527
+ }
528
+ }
529
+ /** Unzip an OOXML file with fflate; clean error on a non-zip / corrupt input. */
530
+ async function unzipDoc(bytes, kind) {
531
+ const { unzipSync, strFromU8, strToU8 } = await import("fflate");
532
+ let entries;
533
+ try {
534
+ entries = unzipSync(new Uint8Array(bytes));
535
+ }
536
+ catch {
537
+ throw new BrigadeToolInputError(`could not read the file as a .${kind} (corrupt, password-protected, or not a real Office document).`);
538
+ }
539
+ return {
540
+ entries,
541
+ decode: (u8) => strFromU8(u8),
542
+ encode: (s) => strToU8(s),
543
+ };
544
+ }
545
+ /** Re-zip OOXML entries back into a single buffer. */
546
+ async function rezipDoc(entries) {
547
+ const { zipSync } = await import("fflate");
548
+ const out = zipSync(entries);
549
+ return Buffer.from(out);
550
+ }
551
+ /** Matches a single run-text element: `<w:t ...>inner</w:t>` / `<a:t>…</a:t>` / `<t>…</t>`. */
552
+ const RUN_TEXT_RE = /(<(?:[a-zA-Z]+:)?t(?:\s[^>]*)?>)([\s\S]*?)(<\/(?:[a-zA-Z]+:)?t>)/g;
553
+ /** Matches a whole paragraph block: `<w:p …>…</w:p>` / `<a:p>…</a:p>` (non-greedy; OOXML never nests them). */
554
+ const PARAGRAPH_RE = /(<(?:[a-zA-Z]+:)?p(?:\s[^>]*)?>)([\s\S]*?)(<\/(?:[a-zA-Z]+:)?p>)/g;
555
+ /**
556
+ * Plan all non-overlapping replacements over a joined paragraph string. At each
557
+ * position the earliest-starting `find` among all pairs wins (ties: the longer
558
+ * `find`); once a span is consumed it is not re-matched. Returns spans sorted by
559
+ * `start` plus a count.
560
+ */
561
+ function planReplacements(joined, pairs) {
562
+ const usable = pairs.filter((p) => p.find.length > 0);
563
+ const spans = [];
564
+ let pos = 0;
565
+ while (pos < joined.length) {
566
+ let best;
567
+ for (const pair of usable) {
568
+ const at = joined.indexOf(pair.find, pos);
569
+ if (at === -1)
570
+ continue;
571
+ if (!best || at < best.idx || (at === best.idx && pair.find.length > best.find.length)) {
572
+ best = { idx: at, find: pair.find, replace: pair.replace };
573
+ }
574
+ }
575
+ if (!best)
576
+ break;
577
+ spans.push({ start: best.idx, end: best.idx + best.find.length, replacement: best.replace });
578
+ pos = best.idx + best.find.length;
579
+ }
580
+ return { spans, count: spans.length };
581
+ }
582
+ /**
583
+ * Rewrite the run-text elements of ONE paragraph so the planned spans apply
584
+ * across run boundaries: the replacement lands in the run that owns each match's
585
+ * START, and the remaining matched characters (which may live in later runs) are
586
+ * removed. Runs untouched by any span keep their exact text.
587
+ */
588
+ function rewriteParagraphRuns(segs, pairs) {
589
+ if (segs.length === 0)
590
+ return { segs, count: 0 };
591
+ const joined = segs.map((s) => s.text).join("");
592
+ const { spans, count } = planReplacements(joined, pairs);
593
+ if (count === 0)
594
+ return { segs, count: 0 };
595
+ // Per-run char ranges over the joined string.
596
+ const ranges = [];
597
+ let cursor = 0;
598
+ for (const seg of segs) {
599
+ ranges.push({ start: cursor, end: cursor + seg.text.length });
600
+ cursor += seg.text.length;
601
+ }
602
+ const ownerOf = (posn) => {
603
+ for (let i = 0; i < ranges.length; i++) {
604
+ const r = ranges[i];
605
+ if (posn >= r.start && posn < r.end)
606
+ return i;
607
+ }
608
+ return Math.max(0, segs.length - 1);
609
+ };
610
+ // Walk the joined string once, attributing each output char to its run.
611
+ const out = segs.map(() => "");
612
+ let i = 0;
613
+ let spanIdx = 0;
614
+ while (i < joined.length) {
615
+ const span = spans[spanIdx];
616
+ if (span && i === span.start) {
617
+ out[ownerOf(span.start)] += span.replacement;
618
+ i = span.end;
619
+ spanIdx += 1;
620
+ continue;
621
+ }
622
+ out[ownerOf(i)] += joined[i];
623
+ i += 1;
624
+ }
625
+ const nextSegs = segs.map((seg, idx) => ({ ...seg, text: out[idx] }));
626
+ return { segs: nextSegs, count };
627
+ }
628
+ /** Re-serialize a paragraph block's run-text elements from rewritten `RunSeg`s. */
629
+ function serializeParagraph(block, newSegs) {
630
+ let k = 0;
631
+ return block.replace(RUN_TEXT_RE, (_m, open, _inner, close) => {
632
+ const seg = newSegs[k];
633
+ k += 1;
634
+ if (!seg)
635
+ return _m;
636
+ // Keep leading/trailing whitespace across a Word round-trip.
637
+ const needsPreserve = /^\s|\s$/.test(seg.text);
638
+ const openTag = needsPreserve && !/xml:space=/.test(open) ? open.replace(/>$/, ' xml:space="preserve">') : open;
639
+ return `${openTag}${escapeXml(seg.text)}${close}`;
640
+ });
641
+ }
642
+ /** Collect the run-text elements of a paragraph block (decoded text). */
643
+ function collectRuns(block) {
644
+ const segs = [];
645
+ let m;
646
+ RUN_TEXT_RE.lastIndex = 0;
647
+ while ((m = RUN_TEXT_RE.exec(block)) !== null) {
648
+ segs.push({
649
+ full: m[0],
650
+ open: m[1],
651
+ inner: m[2],
652
+ close: m[3],
653
+ text: decodeXmlBasic(m[2]),
654
+ });
655
+ }
656
+ return segs;
657
+ }
658
+ /**
659
+ * Apply one or more find→replace pairs to the run text of every paragraph in an
660
+ * OOXML part, matching ACROSS run boundaries. Word/PowerPoint frequently split a
661
+ * single visible word into several `<w:t>`/`<a:t>` runs (spell-check, rsid,
662
+ * proofing marks); matching only within one run misses those. This joins the
663
+ * decoded text of each paragraph's runs, plans replacements over the joined
664
+ * string, writes each replacement into the run that owns the match start, blanks
665
+ * the rest of the matched span, and leaves untouched runs (and all styling,
666
+ * attributes and non-text tags) exactly as they were.
667
+ */
668
+ export function replaceAcrossRuns(xml, pairs) {
669
+ const usable = pairs.filter((p) => typeof p.find === "string" && p.find.length > 0);
670
+ if (usable.length === 0)
671
+ return { xml, count: 0 };
672
+ // Real OOXML always wraps runs in paragraphs (<w:p>/<a:p>); join within each so
673
+ // matches that span runs are caught. If a fragment has NO paragraph wrapper
674
+ // (bare-run input), fall back to treating the whole string as one group so the
675
+ // matcher still works.
676
+ PARAGRAPH_RE.lastIndex = 0;
677
+ if (!PARAGRAPH_RE.test(xml)) {
678
+ const segs = collectRuns(xml);
679
+ if (segs.length === 0)
680
+ return { xml, count: 0 };
681
+ const { segs: newSegs, count } = rewriteParagraphRuns(segs, usable);
682
+ if (count === 0)
683
+ return { xml, count: 0 };
684
+ return { xml: serializeParagraph(xml, newSegs), count };
685
+ }
686
+ let count = 0;
687
+ PARAGRAPH_RE.lastIndex = 0;
688
+ const next = xml.replace(PARAGRAPH_RE, (whole, open, body, close) => {
689
+ const block = `${open}${body}${close}`;
690
+ const segs = collectRuns(block);
691
+ if (segs.length === 0)
692
+ return whole;
693
+ const { segs: newSegs, count: c } = rewriteParagraphRuns(segs, usable);
694
+ if (c === 0)
695
+ return whole;
696
+ count += c;
697
+ return serializeParagraph(block, newSegs);
698
+ });
699
+ return { xml: next, count };
700
+ }
701
+ /**
702
+ * Replace `find` with `replace` inside the TEXT of OOXML runs, matching ACROSS
703
+ * run boundaries (Word/PowerPoint split words into multiple `<w:t>`/`<a:t>`
704
+ * runs). All tags, attributes, styling, images and relationships are preserved;
705
+ * only run text changes and the replacement is re-escaped. Backed by
706
+ * {@link replaceAcrossRuns}; kept as the single-pair entry point used by
707
+ * `replace_text` and (per token) `fill_template`.
708
+ */
709
+ export function replaceInRunText(xml, find, replace) {
710
+ if (!find)
711
+ return { xml, count: 0 };
712
+ return replaceAcrossRuns(xml, [{ find, replace }]);
713
+ }
714
+ /* ─────────────────────────── fill_template (mail-merge) ─────────────────────────── */
715
+ /** Normalize a `fill_template` `values` map into ordered find→replace pairs. */
716
+ function templatePairs(values) {
717
+ if (!values || typeof values !== "object")
718
+ return [];
719
+ const pairs = [];
720
+ for (const [key, val] of Object.entries(values)) {
721
+ const find = String(key ?? "");
722
+ if (!find)
723
+ continue;
724
+ pairs.push({ find, replace: typeof val === "number" ? String(val) : String(val ?? "") });
725
+ }
726
+ return pairs;
727
+ }
728
+ /**
729
+ * Fill `{{token}}`-style placeholders in an OOXML part using the cross-run
730
+ * matcher, then report which tokens were actually filled vs not found. A token
731
+ * is "filled" when at least one occurrence was replaced.
732
+ */
733
+ function fillTemplate(xml, pairs) {
734
+ const { xml: next } = replaceAcrossRuns(xml, pairs);
735
+ // Determine per-token hits by re-running each pair alone against the ORIGINAL
736
+ // (cheap on small docs; avoids threading per-token counts through the joiner).
737
+ const filled = [];
738
+ const notFound = [];
739
+ for (const pair of pairs) {
740
+ const { count } = replaceAcrossRuns(xml, [pair]);
741
+ if (count > 0)
742
+ filled.push(pair.find);
743
+ else
744
+ notFound.push(pair.find);
745
+ }
746
+ return { xml: next, filled, notFound };
747
+ }
748
+ /** Build the `fill_template` tool result (filled/not-found token lists + warning). */
749
+ function templateResult(format, absOut, written, filled, notFound) {
750
+ const details = {
751
+ ok: true,
752
+ action: "fill_template",
753
+ format,
754
+ path: absOut,
755
+ bytes: written,
756
+ replacements: filled.length,
757
+ tokensFilled: filled,
758
+ tokensNotFound: notFound,
759
+ };
760
+ if (notFound.length > 0)
761
+ details.warning = `Tokens not found: ${notFound.join(", ")}.`;
762
+ return jsonResult(details);
763
+ }
764
+ /** Decode the 5 predefined XML entities (enough for run text comparison). */
765
+ function decodeXmlBasic(s) {
766
+ return s
767
+ .replace(/&lt;/g, "<")
768
+ .replace(/&gt;/g, ">")
769
+ .replace(/&quot;/g, '"')
770
+ .replace(/&apos;/g, "'")
771
+ .replace(/&amp;/g, "&");
772
+ }
773
+ /** Escape text for inclusion in XML content. */
774
+ function escapeXml(s) {
775
+ return String(s ?? "")
776
+ .replace(/&/g, "&amp;")
777
+ .replace(/</g, "&lt;")
778
+ .replace(/>/g, "&gt;")
779
+ .replace(/"/g, "&quot;")
780
+ .replace(/'/g, "&apos;");
781
+ }
782
+ /* ─────────────────────────── page-list parsing ─────────────────────────── */
783
+ /**
784
+ * Parse a 1-indexed page list like "1-3,5,8-" into a sorted, de-duped array of
785
+ * page numbers within [1, total]. Invalid fragments are skipped (never throws).
786
+ * Exported for tests.
787
+ */
788
+ export function parsePageList(spec, total) {
789
+ const out = new Set();
790
+ for (const frag of String(spec ?? "").split(",")) {
791
+ const s = frag.trim();
792
+ if (!s)
793
+ continue;
794
+ const m = /^(\d+)?\s*-\s*(\d+)?$/.exec(s);
795
+ if (m && (m[1] || m[2])) {
796
+ const lo = m[1] ? Math.max(1, parseInt(m[1], 10)) : 1;
797
+ const hi = m[2] ? Math.min(total, parseInt(m[2], 10)) : total;
798
+ for (let n = lo; n <= hi; n++)
799
+ if (n >= 1 && n <= total)
800
+ out.add(n);
801
+ continue;
802
+ }
803
+ if (/^\d+$/.test(s)) {
804
+ const n = parseInt(s, 10);
805
+ if (n >= 1 && n <= total)
806
+ out.add(n);
807
+ }
808
+ }
809
+ return [...out].sort((a, b) => a - b);
810
+ }
811
+ /* ─────────────────────────── result helper ─────────────────────────── */
812
+ function ok(d) {
813
+ return jsonResult({ ...d, ok: true });
814
+ }
815
+ //# sourceMappingURL=edit-document-tool.js.map