mcp-word-bridge 3.2.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.
@@ -0,0 +1,1329 @@
1
+ /* MCP Word Bridge — Taskpane Application */
2
+ const logEl = document.getElementById('log');
3
+ const wordStatus = document.getElementById('word-status');
4
+ const wsStatus = document.getElementById('ws-status');
5
+
6
+ function log(msg, cls) {
7
+ const line = document.createElement('div');
8
+ line.className = cls || '';
9
+ line.textContent = new Date().toLocaleTimeString() + ' ' + msg;
10
+ logEl.appendChild(line);
11
+ logEl.scrollTop = logEl.scrollHeight;
12
+ if (logEl.children.length > 200) logEl.removeChild(logEl.firstChild);
13
+ }
14
+
15
+ // --- Word initialization ---
16
+ let wordReady = false;
17
+ Office.onReady(function(info) {
18
+ if (info.host === Office.HostType.Word) {
19
+ wordReady = true;
20
+ wordStatus.textContent = 'Word: connected ✓';
21
+ wordStatus.className = 'status ok';
22
+ log('Office.js ready (WordApi 1.9, Desktop 1.5)', 'log-ok');
23
+ connectWebSocket();
24
+ } else {
25
+ wordStatus.textContent = 'Word: wrong host';
26
+ wordStatus.className = 'status err';
27
+ }
28
+ });
29
+
30
+ // --- WebSocket ---
31
+ let ws = null;
32
+ function connectWebSocket() {
33
+ ws = new WebSocket('wss://localhost:3000/taskpane');
34
+ ws.onopen = function() {
35
+ wsStatus.textContent = 'WebSocket: connected ✓';
36
+ wsStatus.className = 'status ok';
37
+ log('WebSocket connected', 'log-ok');
38
+ };
39
+ ws.onclose = function() {
40
+ wsStatus.textContent = 'WebSocket: disconnected';
41
+ wsStatus.className = 'status warn';
42
+ setTimeout(connectWebSocket, 3000);
43
+ };
44
+ ws.onerror = function() { log('WebSocket error', 'log-err'); };
45
+ ws.onmessage = function(event) { handleCommand(JSON.parse(event.data)); };
46
+ }
47
+
48
+ function sendResponse(id, result) {
49
+ if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'response', id, result }));
50
+ }
51
+ function sendError(id, error) {
52
+ if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'response', id, error }));
53
+ }
54
+
55
+ // --- Command dispatcher ---
56
+ async function handleCommand(cmd) {
57
+ log('← ' + cmd.action, 'log-cmd');
58
+ try {
59
+ const handler = commands[cmd.action];
60
+ if (!handler) throw new Error('Unknown action: ' + cmd.action);
61
+ const result = await handler(cmd.params || {});
62
+ log('→ ok', 'log-ok');
63
+ sendResponse(cmd.id, result);
64
+ } catch (e) {
65
+ log('→ ERR: ' + e.message, 'log-err');
66
+ sendError(cmd.id, e.message);
67
+ }
68
+ }
69
+
70
+ // --- Command registry ---
71
+ const commands = {};
72
+
73
+ // == BASIC ==
74
+ commands.ping = async () => ({ status: 'ok', wordReady });
75
+
76
+ commands.getApiVersion = async () => {
77
+ const sets = [
78
+ 'WordApi 1.9','WordApi 1.8','WordApi 1.7','WordApi 1.6','WordApi 1.5',
79
+ 'WordApi 1.4','WordApi 1.3','WordApi 1.2','WordApi 1.1',
80
+ 'WordApiDesktop 1.5','WordApiDesktop 1.4','WordApiDesktop 1.3',
81
+ 'WordApiDesktop 1.2','WordApiDesktop 1.1'
82
+ ];
83
+ const supported = {};
84
+ sets.forEach(s => {
85
+ const [name, ver] = s.split(' ');
86
+ supported[s] = Office.context.requirements.isSetSupported(name, ver);
87
+ });
88
+ return { platform: Office.context.platform, host: Office.context.host, diagnostics: Office.context.diagnostics, supportedSets: supported };
89
+ };
90
+
91
+ // == DOCUMENT TEXT ==
92
+ commands.getDocumentText = () => Word.run(async (ctx) => {
93
+ const body = ctx.document.body;
94
+ body.load('text');
95
+ await ctx.sync();
96
+ return { text: body.text };
97
+ });
98
+
99
+ commands.getDocumentProperties = () => Word.run(async (ctx) => {
100
+ const props = ctx.document.properties;
101
+ props.load('title,subject,author,keywords,comments,lastAuthor,revisionNumber,creationDate,lastSaveTime,category,company,manager,format,applicationName,template,lastPrintDate,security');
102
+ ctx.document.load('changeTrackingMode');
103
+ await ctx.sync();
104
+ // Get file path using getFilePropertiesAsync
105
+ // Note: On macOS, this returns a sandbox container path with a content-derived name
106
+ // for documents that haven't been explicitly saved to a user-chosen location.
107
+ // We validate the path ends with a document extension to filter out garbage paths.
108
+ let path = null;
109
+ try {
110
+ const fileProps = await new Promise((resolve) => {
111
+ Office.context.document.getFilePropertiesAsync(resolve);
112
+ });
113
+ if (fileProps.status === Office.AsyncResultStatus.Succeeded && fileProps.value.url) {
114
+ const url = fileProps.value.url;
115
+ if (/\.(docx?|docm|dotx?|dotm)$/i.test(url)) {
116
+ path = url;
117
+ }
118
+ // else: path looks like a container-derived name (no extension) — discard it
119
+ }
120
+ } catch (e) {}
121
+ return { title: props.title, subject: props.subject, author: props.author, keywords: props.keywords, comments: props.comments, category: props.category, company: props.company, manager: props.manager, format: props.format, applicationName: props.applicationName, template: props.template, lastAuthor: props.lastAuthor, revisionNumber: props.revisionNumber, creationDate: props.creationDate, lastSaveTime: props.lastSaveTime, lastPrintDate: props.lastPrintDate, security: props.security, changeTrackingMode: ctx.document.changeTrackingMode, path: path };
122
+ });
123
+
124
+ commands.setDocumentProperties = (p) => Word.run(async (ctx) => {
125
+ const props = ctx.document.properties;
126
+ if (p.title !== undefined) props.title = p.title;
127
+ if (p.subject !== undefined) props.subject = p.subject;
128
+ if (p.author !== undefined) props.author = p.author;
129
+ if (p.keywords !== undefined) props.keywords = p.keywords;
130
+ if (p.comments !== undefined) props.comments = p.comments;
131
+ if (p.category !== undefined) props.category = p.category;
132
+ if (p.company !== undefined) props.company = p.company;
133
+ if (p.manager !== undefined) props.manager = p.manager;
134
+ if (p.format !== undefined) props.format = p.format;
135
+ await ctx.sync();
136
+ return { success: true };
137
+ });
138
+
139
+ commands.setChangeTracking = (p) => Word.run(async (ctx) => {
140
+ // mode: 'TrackAll', 'TrackMineOnly', or 'Off'
141
+ ctx.document.changeTrackingMode = p.mode || 'TrackAll';
142
+ await ctx.sync();
143
+ return { success: true, mode: p.mode || 'TrackAll' };
144
+ });
145
+
146
+ // == PARAGRAPHS ==
147
+ commands.getParagraphs = (p) => Word.run(async (ctx) => {
148
+ const paragraphs = ctx.document.body.paragraphs;
149
+ paragraphs.load('text,style,alignment,firstLineIndent,leftIndent,lineSpacing,isListItem,parentTableCellOrNullObject');
150
+ await ctx.sync();
151
+ const start = p.start || 0;
152
+ const end = p.end || paragraphs.items.length;
153
+ const items = [];
154
+ for (let i = start; i < Math.min(end, paragraphs.items.length); i++) {
155
+ const para = paragraphs.items[i];
156
+ const inTable = para.parentTableCellOrNullObject && !para.parentTableCellOrNullObject.isNullObject;
157
+ const isTocEntry = !!(para.style && para.style.startsWith('TOC')) || (para.style === '' && /\t\d+$/.test(para.text));
158
+ items.push({ index: i, text: para.text, style: para.style, alignment: para.alignment, isListItem: para.isListItem, inTable: inTable, isTocEntry: isTocEntry });
159
+ }
160
+ return { count: paragraphs.items.length, paragraphs: items };
161
+ });
162
+
163
+ commands.insertParagraph = (p) => Word.run(async (ctx) => {
164
+ // Normalize and validate alignment before any document mutation
165
+ // Official Word.Alignment enum: Left, Centered, Right, Justified (Mixed/Unknown are read-only)
166
+ const ALIGNMENT_MAP = { 'Left': 'Left', 'Center': 'Centered', 'Centered': 'Centered', 'Right': 'Right', 'Justify': 'Justified', 'Justified': 'Justified' };
167
+ let alignment = null;
168
+ if (p.alignment) {
169
+ alignment = ALIGNMENT_MAP[p.alignment];
170
+ if (!alignment) {
171
+ throw new Error('Invalid alignment: "' + p.alignment + '". Valid values: Left, Center, Right, Justified');
172
+ }
173
+ }
174
+ // Validate style exists (if provided)
175
+ const styleName = p.style || 'Normal';
176
+ if (p.style) {
177
+ const styleObj = ctx.document.getStyles().getByNameOrNullObject(p.style);
178
+ styleObj.load('nameLocal');
179
+ await ctx.sync();
180
+ if (styleObj.isNullObject) {
181
+ throw new Error('Style not found: "' + p.style + '". Use word_get_styles to see available styles.');
182
+ }
183
+ }
184
+ // Insert paragraph and apply style
185
+ const para = ctx.document.body.insertParagraph(p.text, p.location || 'End');
186
+ para.style = styleName;
187
+ await ctx.sync();
188
+ // Apply alignment in a separate sync
189
+ if (alignment) {
190
+ para.alignment = alignment;
191
+ await ctx.sync();
192
+ }
193
+ // For non-heading styles, reset font to prevent inheritance from prior formatted text
194
+ if (!styleName.startsWith('Heading')) {
195
+ let defaultSize = 12;
196
+ try {
197
+ const styleObj2 = ctx.document.getStyles().getByNameOrNullObject(styleName);
198
+ styleObj2.load('font/size');
199
+ await ctx.sync();
200
+ if (!styleObj2.isNullObject && styleObj2.font.size) defaultSize = styleObj2.font.size;
201
+ } catch (e) {
202
+ // Fallback: use 12pt if style lookup not supported
203
+ }
204
+ para.font.size = defaultSize;
205
+ await ctx.sync();
206
+ }
207
+ // Move cursor to end of inserted paragraph
208
+ const range = para.getRange('End');
209
+ range.select();
210
+ await ctx.sync();
211
+ const result = { success: true };
212
+ if (!p.text || p.text.trim() === '') result.warning = 'Empty paragraph inserted. Use delete_paragraph to remove if unintended.';
213
+ return result;
214
+ });
215
+
216
+ commands.deleteParagraph = (p) => Word.run(async (ctx) => {
217
+ if (p.index < 0) throw new Error('Index must be non-negative');
218
+ const paragraphs = ctx.document.body.paragraphs;
219
+ paragraphs.load('text');
220
+ await ctx.sync();
221
+ if (p.index >= paragraphs.items.length) throw new Error('Index out of range. Document has ' + paragraphs.items.length + ' paragraphs (0-indexed).');
222
+ paragraphs.items[p.index].delete();
223
+ await ctx.sync();
224
+ return { success: true };
225
+ });
226
+
227
+ commands.setParagraphStyle = (p) => Word.run(async (ctx) => {
228
+ if (p.index < 0) throw new Error('Index must be non-negative');
229
+ // Normalize and validate alignment
230
+ // Official Word.Alignment enum: Left, Centered, Right, Justified (Mixed/Unknown are read-only)
231
+ const ALIGNMENT_MAP = { 'Left': 'Left', 'Center': 'Centered', 'Centered': 'Centered', 'Right': 'Right', 'Justify': 'Justified', 'Justified': 'Justified' };
232
+ let alignment = null;
233
+ if (p.alignment) {
234
+ alignment = ALIGNMENT_MAP[p.alignment];
235
+ if (!alignment) {
236
+ throw new Error('Invalid alignment: "' + p.alignment + '". Valid values: Left, Center, Right, Justified');
237
+ }
238
+ }
239
+ if (!p.style && !p.alignment) {
240
+ throw new Error('At least one of "style" or "alignment" must be provided.');
241
+ }
242
+ const paragraphs = ctx.document.body.paragraphs;
243
+ paragraphs.load('text');
244
+ await ctx.sync();
245
+ if (p.index >= paragraphs.items.length) throw new Error('Paragraph index out of range. Document has ' + paragraphs.items.length + ' paragraphs (0-indexed).');
246
+ const para = paragraphs.items[p.index];
247
+ // Validate and apply style (if provided)
248
+ if (p.style) {
249
+ const styleObj = ctx.document.getStyles().getByNameOrNullObject(p.style);
250
+ styleObj.load('nameLocal');
251
+ await ctx.sync();
252
+ if (styleObj.isNullObject) {
253
+ throw new Error('Style not found: "' + p.style + '". Use word_get_styles to see available styles.');
254
+ }
255
+ para.style = p.style;
256
+ await ctx.sync();
257
+ }
258
+ // Apply alignment in a separate sync
259
+ if (alignment) {
260
+ para.alignment = alignment;
261
+ await ctx.sync();
262
+ }
263
+ return { success: true };
264
+ });
265
+
266
+ // == SEARCH & REPLACE ==
267
+ commands.search = (p) => Word.run(async (ctx) => {
268
+ const results = ctx.document.body.search(p.query, { matchCase: p.matchCase || false, matchWholeWord: p.matchWholeWord || false });
269
+ results.load('text');
270
+ await ctx.sync();
271
+ return { count: results.items.length, matches: results.items.slice(0, 30).map((r, i) => ({ index: i, text: r.text })) };
272
+ });
273
+
274
+ commands.searchAndReplace = (p) => Word.run(async (ctx) => {
275
+ const results = ctx.document.body.search(p.find, { matchCase: p.matchCase || false, matchWholeWord: p.matchWholeWord || false });
276
+ results.load('text');
277
+ await ctx.sync();
278
+ const count = results.items.length;
279
+ let lastRange = null;
280
+ for (let i = 0; i < count; i++) {
281
+ lastRange = results.items[i].insertText(p.replace, Word.InsertLocation.replace);
282
+ }
283
+ if (lastRange) {
284
+ lastRange.getRange('End').select();
285
+ }
286
+ await ctx.sync();
287
+ return { replacements: count };
288
+ });
289
+
290
+ // == TEXT INSERTION ==
291
+ commands.insertText = (p) => Word.run(async (ctx) => {
292
+ if (p.after && p.before) throw new Error('Provide only one of "after" or "before", not both.');
293
+ const anchor = p.after || p.before;
294
+ if (!anchor) throw new Error('Either "after" or "before" anchor text must be provided');
295
+ const results = ctx.document.body.search(anchor, { matchCase: p.matchCase || false });
296
+ results.load('text');
297
+ await ctx.sync();
298
+ if (results.items.length === 0) throw new Error('Anchor not found: ' + anchor);
299
+ const idx = p.occurrence || 0;
300
+ if (idx >= results.items.length) throw new Error('Occurrence index ' + idx + ' is out of range (valid: 0 to ' + (results.items.length - 1) + ' for ' + results.items.length + ' match' + (results.items.length === 1 ? '' : 'es') + ')');
301
+ const target = results.items[idx];
302
+ const loc = p.after ? Word.InsertLocation.after : Word.InsertLocation.before;
303
+ const inserted = target.insertText(p.text, loc);
304
+ inserted.getRange('End').select();
305
+ await ctx.sync();
306
+ return { success: true };
307
+ });
308
+
309
+ commands.getSelection = () => Word.run(async (ctx) => {
310
+ const sel = ctx.document.getSelection();
311
+ sel.load('text,style');
312
+ await ctx.sync();
313
+ return { text: sel.text, style: sel.style };
314
+ });
315
+
316
+ commands.replaceSelection = (p) => Word.run(async (ctx) => {
317
+ const sel = ctx.document.getSelection();
318
+ const inserted = sel.insertText(p.text, Word.InsertLocation.replace);
319
+ inserted.getRange('End').select();
320
+ await ctx.sync();
321
+ return { success: true };
322
+ });
323
+
324
+ // == FORMATTING ==
325
+ commands.formatRange = (p) => Word.run(async (ctx) => {
326
+ const results = ctx.document.body.search(p.text, { matchCase: p.matchCase || false });
327
+ results.load('font');
328
+ await ctx.sync();
329
+ if (results.items.length === 0) throw new Error('Text not found: ' + p.text);
330
+ const idx = p.occurrence || 0;
331
+ if (idx >= results.items.length) throw new Error('Occurrence ' + idx + ' not found (only ' + results.items.length + ' match' + (results.items.length === 1 ? '' : 'es') + ')');
332
+ const target = results.items[idx];
333
+ const font = target.font;
334
+ if (p.bold !== undefined) font.bold = p.bold;
335
+ if (p.italic !== undefined) font.italic = p.italic;
336
+ if (p.underline !== undefined) font.underline = p.underline ? 'Single' : 'None';
337
+ if (p.strikeThrough !== undefined) font.strikeThrough = p.strikeThrough;
338
+ if (p.color) font.color = p.color;
339
+ if (p.highlightColor) font.highlightColor = p.highlightColor;
340
+ if (p.size) font.size = p.size;
341
+ if (p.name) font.name = p.name;
342
+ target.getRange('End').select();
343
+ await ctx.sync();
344
+ return { success: true };
345
+ });
346
+
347
+ // == TABLES ==
348
+ commands.insertTable = (p) => Word.run(async (ctx) => {
349
+ const body = ctx.document.body;
350
+ const table = body.insertTable(p.rows, p.cols, p.location || 'End', p.data || null);
351
+ if (p.style) table.style = p.style;
352
+ table.headerRowCount = p.headerRowCount || 1;
353
+ await ctx.sync();
354
+ // Move cursor after the table
355
+ const tableRange = table.getRange('End');
356
+ tableRange.select();
357
+ await ctx.sync();
358
+ return { success: true };
359
+ });
360
+
361
+ commands.getTables = () => Word.run(async (ctx) => {
362
+ const tables = ctx.document.body.tables;
363
+ tables.load('rowCount,values,style,headerRowCount');
364
+ await ctx.sync();
365
+ const items = tables.items.map((t, i) => ({
366
+ index: i, rowCount: t.rowCount, style: t.style, headerRowCount: t.headerRowCount,
367
+ values: t.values
368
+ }));
369
+ return { count: items.length, tables: items };
370
+ });
371
+
372
+ commands.getTableData = (p) => Word.run(async (ctx) => {
373
+ const tables = ctx.document.body.tables;
374
+ tables.load('rowCount,values');
375
+ await ctx.sync();
376
+ if (p.index >= tables.items.length) throw new Error('Table index out of range. Document has ' + tables.items.length + ' table(s).');
377
+ return { values: tables.items[p.index].values };
378
+ });
379
+
380
+ commands.setTableCell = (p) => Word.run(async (ctx) => {
381
+ const tables = ctx.document.body.tables;
382
+ tables.load('rowCount');
383
+ await ctx.sync();
384
+ if (p.tableIndex >= tables.items.length) throw new Error('Table index out of range. Document has ' + tables.items.length + ' table(s).');
385
+ const table = tables.items[p.tableIndex];
386
+ try {
387
+ const cell = table.getCell(p.row, p.col);
388
+ cell.body.clear();
389
+ const inserted = cell.body.insertText(p.text, Word.InsertLocation.start);
390
+ inserted.getRange('End').select();
391
+ await ctx.sync();
392
+ } catch (e) {
393
+ if (e.message && e.message.includes('ItemNotFound')) {
394
+ throw new Error('Cell not found at row ' + p.row + ', col ' + p.col + '. Table has ' + table.rowCount + ' rows. Use word_get_table_data to inspect the table.');
395
+ }
396
+ throw e;
397
+ }
398
+ return { success: true };
399
+ });
400
+
401
+ commands.addTableRow = (p) => Word.run(async (ctx) => {
402
+ const tables = ctx.document.body.tables;
403
+ tables.load('rowCount');
404
+ await ctx.sync();
405
+ if (p.tableIndex >= tables.items.length) throw new Error('Table index out of range');
406
+ const table = tables.items[p.tableIndex];
407
+ table.addRows(p.location || 'End', 1, p.values ? [p.values] : undefined);
408
+ await ctx.sync();
409
+ // Move cursor to end of table after adding row
410
+ const tableRange = table.getRange('End');
411
+ tableRange.select();
412
+ await ctx.sync();
413
+ return { success: true };
414
+ });
415
+
416
+ commands.deleteTableRow = (p) => Word.run(async (ctx) => {
417
+ const tables = ctx.document.body.tables;
418
+ tables.load('rowCount');
419
+ await ctx.sync();
420
+ if (p.tableIndex >= tables.items.length) throw new Error('Table index out of range. Document has ' + tables.items.length + ' table(s).');
421
+ const rows = tables.items[p.tableIndex].rows;
422
+ rows.load('items');
423
+ await ctx.sync();
424
+ if (p.rowIndex >= rows.items.length) throw new Error('Row index out of range. Table has ' + rows.items.length + ' rows (0-indexed).');
425
+ rows.items[p.rowIndex].delete();
426
+ await ctx.sync();
427
+ return { success: true };
428
+ });
429
+
430
+ // == LISTS ==
431
+ commands.insertList = (p) => Word.run(async (ctx) => {
432
+ if (!p.items || p.items.length === 0) throw new Error('items array must not be empty');
433
+ const body = ctx.document.body;
434
+ // Check if last paragraph is a list item — if so, insert a non-list separator
435
+ const lastPara = body.paragraphs.getLast();
436
+ lastPara.load('isListItem');
437
+ await ctx.sync();
438
+ if (lastPara.isListItem && (p.location || 'End') === 'End') {
439
+ const sep = body.insertParagraph('', 'End');
440
+ sep.style = 'Normal';
441
+ sep.detachFromList();
442
+ await ctx.sync();
443
+ }
444
+ const para = body.insertParagraph(p.items[0], p.location || 'End');
445
+ para.style = 'Normal';
446
+ await ctx.sync();
447
+ const list = para.startNewList();
448
+ for (let i = 1; i < p.items.length; i++) {
449
+ list.insertParagraph(p.items[i], Word.InsertLocation.end);
450
+ }
451
+ if (p.numbered) {
452
+ list.setLevelNumbering(0, Word.ListNumbering.arabic);
453
+ } else {
454
+ list.setLevelBullet(0, Word.ListBullet.solid);
455
+ }
456
+ await ctx.sync();
457
+ // Move cursor to end of last list paragraph
458
+ const endPara = body.paragraphs.getLast();
459
+ endPara.getRange('End').select();
460
+ await ctx.sync();
461
+ return { success: true };
462
+ });
463
+
464
+ // == COMMENTS ==
465
+ commands.addComment = (p) => Word.run(async (ctx) => {
466
+ const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
467
+ results.load('text');
468
+ await ctx.sync();
469
+ if (results.items.length === 0) throw new Error('Anchor not found: ' + p.anchorText);
470
+ const idx = p.occurrence || 0;
471
+ if (idx >= results.items.length) throw new Error('Occurrence ' + idx + ' not found (only ' + results.items.length + ' match' + (results.items.length === 1 ? '' : 'es') + ')');
472
+ const target = results.items[idx];
473
+ target.insertComment(p.comment);
474
+ await ctx.sync();
475
+ return { success: true };
476
+ });
477
+
478
+ commands.getComments = () => Word.run(async (ctx) => {
479
+ const comments = ctx.document.body.getComments();
480
+ comments.load('id,authorName,content,creationDate,resolved');
481
+ await ctx.sync();
482
+ const items = comments.items.map(c => ({ id: c.id, author: c.authorName, content: c.content, date: c.creationDate, resolved: c.resolved }));
483
+ return { count: items.length, comments: items };
484
+ });
485
+
486
+ commands.replyToComment = (p) => Word.run(async (ctx) => {
487
+ const comments = ctx.document.body.getComments();
488
+ comments.load('id');
489
+ await ctx.sync();
490
+ let target = null;
491
+ for (const c of comments.items) {
492
+ if (String(c.id) === String(p.commentId)) { target = c; break; }
493
+ }
494
+ if (!target) throw new Error('Comment not found: ' + p.commentId);
495
+ target.reply(p.text);
496
+ await ctx.sync();
497
+ return { success: true };
498
+ });
499
+
500
+ commands.resolveComment = (p) => Word.run(async (ctx) => {
501
+ const comments = ctx.document.body.getComments();
502
+ comments.load('id');
503
+ await ctx.sync();
504
+ for (const c of comments.items) {
505
+ if (String(c.id) === String(p.commentId)) {
506
+ c.resolved = true;
507
+ await ctx.sync();
508
+ return { success: true };
509
+ }
510
+ }
511
+ throw new Error('Comment not found: ' + p.commentId);
512
+ });
513
+
514
+ commands.deleteComment = (p) => Word.run(async (ctx) => {
515
+ const comments = ctx.document.body.getComments();
516
+ comments.load('id');
517
+ await ctx.sync();
518
+ for (const c of comments.items) {
519
+ if (String(c.id) === String(p.commentId)) {
520
+ c.delete();
521
+ await ctx.sync();
522
+ return { success: true };
523
+ }
524
+ }
525
+ throw new Error('Comment not found: ' + p.commentId);
526
+ });
527
+
528
+ // == FOOTNOTES & ENDNOTES ==
529
+ commands.insertFootnote = (p) => Word.run(async (ctx) => {
530
+ const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
531
+ results.load('text');
532
+ await ctx.sync();
533
+ if (results.items.length === 0) throw new Error('Anchor not found: ' + p.anchorText);
534
+ const idx = p.occurrence || 0;
535
+ if (idx >= results.items.length) throw new Error('Occurrence ' + idx + ' not found (only ' + results.items.length + ' match' + (results.items.length === 1 ? '' : 'es') + ')');
536
+ const target = results.items[idx];
537
+ target.insertFootnote(p.text);
538
+ target.getRange('End').select();
539
+ await ctx.sync();
540
+ return { success: true };
541
+ });
542
+
543
+ commands.insertEndnote = (p) => Word.run(async (ctx) => {
544
+ const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
545
+ results.load('text');
546
+ await ctx.sync();
547
+ if (results.items.length === 0) throw new Error('Anchor not found: ' + p.anchorText);
548
+ const idx = p.occurrence || 0;
549
+ if (idx >= results.items.length) throw new Error('Occurrence ' + idx + ' not found (only ' + results.items.length + ' match' + (results.items.length === 1 ? '' : 'es') + ')');
550
+ const target = results.items[idx];
551
+ target.insertEndnote(p.text);
552
+ target.getRange('End').select();
553
+ await ctx.sync();
554
+ return { success: true };
555
+ });
556
+
557
+ commands.getFootnotes = () => Word.run(async (ctx) => {
558
+ const footnotes = ctx.document.body.footnotes;
559
+ footnotes.load('items');
560
+ await ctx.sync();
561
+ const items = [];
562
+ for (const fn of footnotes.items) {
563
+ fn.body.load('text');
564
+ await ctx.sync();
565
+ items.push({ index: items.length, text: fn.body.text.replace(/^\u0002\s*/, '') });
566
+ }
567
+ return { count: items.length, footnotes: items };
568
+ });
569
+
570
+ commands.getEndnotes = () => Word.run(async (ctx) => {
571
+ const endnotes = ctx.document.body.endnotes;
572
+ endnotes.load('items');
573
+ await ctx.sync();
574
+ const items = [];
575
+ for (const en of endnotes.items) {
576
+ en.body.load('text');
577
+ await ctx.sync();
578
+ items.push({ index: items.length, text: en.body.text.replace(/^\u0002\s*/, '') });
579
+ }
580
+ return { count: items.length, endnotes: items };
581
+ });
582
+
583
+ // == TRACK CHANGES ==
584
+ commands.getTrackedChanges = () => Word.run(async (ctx) => {
585
+ const changes = ctx.document.body.getTrackedChanges();
586
+ changes.load('type,author,date,text');
587
+ await ctx.sync();
588
+ const items = changes.items.map((c, i) => ({ index: i, type: c.type, author: c.author, date: c.date, text: c.text }));
589
+ return { count: items.length, changes: items };
590
+ });
591
+
592
+ commands.acceptTrackedChange = (p) => Word.run(async (ctx) => {
593
+ const changes = ctx.document.body.getTrackedChanges();
594
+ changes.load('items');
595
+ await ctx.sync();
596
+ if (p.index >= changes.items.length) throw new Error('Change index out of range. Document has ' + changes.items.length + ' tracked change(s).');
597
+ changes.items[p.index].accept();
598
+ await ctx.sync();
599
+ return { success: true };
600
+ });
601
+
602
+ commands.rejectTrackedChange = (p) => Word.run(async (ctx) => {
603
+ const changes = ctx.document.body.getTrackedChanges();
604
+ changes.load('items');
605
+ await ctx.sync();
606
+ if (p.index >= changes.items.length) throw new Error('Change index out of range. Document has ' + changes.items.length + ' tracked change(s).');
607
+ changes.items[p.index].reject();
608
+ await ctx.sync();
609
+ return { success: true };
610
+ });
611
+
612
+ commands.acceptAllTrackedChanges = () => Word.run(async (ctx) => {
613
+ const changes = ctx.document.body.getTrackedChanges();
614
+ changes.load('items');
615
+ await ctx.sync();
616
+ changes.acceptAll();
617
+ await ctx.sync();
618
+ return { success: true };
619
+ });
620
+
621
+ commands.rejectAllTrackedChanges = () => Word.run(async (ctx) => {
622
+ const changes = ctx.document.body.getTrackedChanges();
623
+ changes.load('items');
624
+ await ctx.sync();
625
+ changes.rejectAll();
626
+ await ctx.sync();
627
+ return { success: true };
628
+ });
629
+
630
+ // == CONTENT CONTROLS ==
631
+ commands.getContentControls = () => Word.run(async (ctx) => {
632
+ // Use getContentControls with types filter (WordApi 1.5+) to include PlainText and CheckBox
633
+ const ccs = ctx.document.body.getContentControls({ types: [Word.ContentControlType.richText, Word.ContentControlType.plainText, Word.ContentControlType.checkBox] });
634
+ ccs.load('id,tag,title,type,text');
635
+ await ctx.sync();
636
+ return { count: ccs.items.length, controls: ccs.items.map(c => ({ id: c.id, tag: c.tag, title: c.title, type: c.type, text: c.text })) };
637
+ });
638
+
639
+ commands.setContentControlText = (p) => Word.run(async (ctx) => {
640
+ let target = null;
641
+ if (p.tag) {
642
+ // Include CheckBox in query so we can give a proper error instead of "not found"
643
+ const ccs = ctx.document.body.getContentControls({ types: [Word.ContentControlType.richText, Word.ContentControlType.plainText, Word.ContentControlType.checkBox] });
644
+ ccs.load('id,tag,type');
645
+ await ctx.sync();
646
+ for (const cc of ccs.items) {
647
+ if (cc.tag === p.tag) {
648
+ if (cc.type === 'CheckBox') {
649
+ throw new Error('Cannot set text on a CheckBox content control. CheckBox controls only support checked/unchecked state.');
650
+ }
651
+ target = cc;
652
+ break;
653
+ }
654
+ }
655
+ }
656
+ if (!target && p.id) {
657
+ // Search all types to give a better error for CheckBox
658
+ const allCcs = ctx.document.body.getContentControls({ types: [Word.ContentControlType.richText, Word.ContentControlType.plainText, Word.ContentControlType.checkBox] });
659
+ allCcs.load('id,type');
660
+ await ctx.sync();
661
+ for (const cc of allCcs.items) {
662
+ if (cc.id === p.id) {
663
+ if (cc.type === 'CheckBox') {
664
+ throw new Error('Cannot set text on a CheckBox content control. CheckBox controls only support checked/unchecked state.');
665
+ }
666
+ target = cc;
667
+ break;
668
+ }
669
+ }
670
+ }
671
+ if (!target) throw new Error('Content control not found. Use word_get_content_controls to list available controls.');
672
+ const inserted = target.insertText(p.text, Word.InsertLocation.replace);
673
+ inserted.getRange('End').select();
674
+ await ctx.sync();
675
+ return { success: true };
676
+ });
677
+
678
+ // == HEADERS & FOOTERS ==
679
+ commands.getHeaderFooter = (p) => Word.run(async (ctx) => {
680
+ const sections = ctx.document.sections;
681
+ sections.load('items');
682
+ await ctx.sync();
683
+ const section = sections.items[p.sectionIndex || 0];
684
+ const target = p.type === 'footer' ? section.getFooter(p.headerType || 'Primary') : section.getHeader(p.headerType || 'Primary');
685
+ target.load('text');
686
+ await ctx.sync();
687
+ return { text: target.text };
688
+ });
689
+
690
+ commands.setHeaderFooter = (p) => Word.run(async (ctx) => {
691
+ const sections = ctx.document.sections;
692
+ sections.load('items');
693
+ await ctx.sync();
694
+ const section = sections.items[p.sectionIndex || 0];
695
+ const target = p.type === 'footer' ? section.getFooter(p.headerType || 'Primary') : section.getHeader(p.headerType || 'Primary');
696
+ target.clear();
697
+ target.insertText(p.text, Word.InsertLocation.start);
698
+ await ctx.sync();
699
+ return { success: true };
700
+ });
701
+
702
+ // == IMAGES ==
703
+ commands.insertImage = (p) => Word.run(async (ctx) => {
704
+ const body = ctx.document.body;
705
+ const picture = body.insertInlinePictureFromBase64(p.base64, p.location || 'End');
706
+ if (p.width) picture.width = p.width;
707
+ if (p.height) picture.height = p.height;
708
+ if (p.altText) picture.altTextDescription = p.altText;
709
+ try {
710
+ await ctx.sync();
711
+ } catch (e) {
712
+ if (e.message && e.message.includes('GeneralException')) {
713
+ throw new Error('Invalid image data. Ensure the base64 string is a valid PNG, JPEG, or GIF image.');
714
+ }
715
+ throw e;
716
+ }
717
+ // Move cursor after the image
718
+ const imgRange = picture.getRange('End');
719
+ imgRange.select();
720
+ await ctx.sync();
721
+ return { success: true };
722
+ });
723
+
724
+ // == BOOKMARKS ==
725
+ commands.getBookmarks = () => Word.run(async (ctx) => {
726
+ // getBookmarks is available on Range objects (WordApi 1.4+)
727
+ const range = ctx.document.body.getRange();
728
+ try {
729
+ const bookmarks = range.getBookmarks(true, true);
730
+ await ctx.sync();
731
+ return { bookmarks: bookmarks.value };
732
+ } catch (e) {
733
+ // Fallback: if getBookmarks is not available on this platform,
734
+ // try the body-level method
735
+ try {
736
+ const bookmarks = ctx.document.body.getBookmarks(true);
737
+ await ctx.sync();
738
+ return { bookmarks: bookmarks.value };
739
+ } catch (e2) {
740
+ throw new Error('getBookmarks is not supported on this Word platform version. Bookmarks can still be inserted and deleted by name.');
741
+ }
742
+ }
743
+ });
744
+
745
+ commands.insertBookmark = (p) => Word.run(async (ctx) => {
746
+ const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
747
+ results.load('text');
748
+ await ctx.sync();
749
+ if (results.items.length === 0) throw new Error('Anchor not found: ' + p.anchorText);
750
+ const target = results.items[p.occurrence || 0];
751
+ target.insertBookmark(p.name);
752
+ target.getRange('End').select();
753
+ await ctx.sync();
754
+ return { success: true };
755
+ });
756
+
757
+ commands.deleteBookmark = (p) => Word.run(async (ctx) => {
758
+ // Verify bookmark exists before deleting
759
+ const range = ctx.document.body.getRange();
760
+ const bookmarks = range.getBookmarks(true, true);
761
+ await ctx.sync();
762
+ if (!bookmarks.value || !bookmarks.value.includes(p.name)) {
763
+ throw new Error('Bookmark not found: ' + p.name);
764
+ }
765
+ ctx.document.deleteBookmark(p.name);
766
+ await ctx.sync();
767
+ return { success: true };
768
+ });
769
+
770
+ // == STYLES ==
771
+ commands.getStyles = () => Word.run(async (ctx) => {
772
+ const styles = ctx.document.getStyles();
773
+ styles.load('nameLocal,type,builtIn');
774
+ await ctx.sync();
775
+ const items = styles.items.map(s => ({ name: s.nameLocal, type: s.type, builtIn: s.builtIn }));
776
+ return { count: items.length, styles: items.slice(0, 80) };
777
+ });
778
+
779
+ // == COAUTHORING (Desktop only) ==
780
+ commands.getCoauthors = () => Word.run(async (ctx) => {
781
+ const coauthoring = ctx.document.coauthoring;
782
+ coauthoring.load('canCoauthor,canMerge,pendingUpdates');
783
+ const authors = coauthoring.authors;
784
+ authors.load('name,email');
785
+ await ctx.sync();
786
+ const items = authors.items.map(a => ({ name: a.name, email: a.email }));
787
+ return { canCoauthor: coauthoring.canCoauthor, canMerge: coauthoring.canMerge, pendingUpdates: coauthoring.pendingUpdates, authors: items };
788
+ });
789
+
790
+ // == DOCUMENT OPERATIONS ==
791
+ commands.saveDocument = () => Word.run(async (ctx) => {
792
+ ctx.document.save();
793
+ await ctx.sync();
794
+ return { success: true };
795
+ });
796
+
797
+ commands.getWordCount = () => Word.run(async (ctx) => {
798
+ const body = ctx.document.body;
799
+ body.load('text');
800
+ const paragraphs = body.paragraphs;
801
+ paragraphs.load('text');
802
+ await ctx.sync();
803
+ const text = body.text;
804
+ const words = text.split(/\s+/).filter(w => w.length > 0).length;
805
+ const chars = text.length;
806
+ const charsNoSpaces = text.replace(/\s/g, '').length;
807
+ return { words, characters: chars, charactersNoSpaces: charsNoSpaces, paragraphs: paragraphs.items.length };
808
+ });
809
+
810
+ // == PAGE & SECTION BREAKS ==
811
+ commands.insertPageBreak = (p) => Word.run(async (ctx) => {
812
+ const body = ctx.document.body;
813
+ if (p.paragraphIndex !== undefined) {
814
+ if (p.paragraphIndex < 0) throw new Error('Index must be non-negative');
815
+ const paragraphs = body.paragraphs;
816
+ paragraphs.load('text');
817
+ await ctx.sync();
818
+ if (p.paragraphIndex >= paragraphs.items.length) throw new Error('Paragraph index out of range. Document has ' + paragraphs.items.length + ' paragraphs (0-indexed).');
819
+ const para = paragraphs.items[p.paragraphIndex];
820
+ para.insertBreak('Page', 'After');
821
+ para.getRange('End').select();
822
+ } else {
823
+ const lastPara = body.paragraphs.getLast();
824
+ lastPara.insertBreak('Page', 'After');
825
+ lastPara.getRange('End').select();
826
+ }
827
+ await ctx.sync();
828
+ return { success: true };
829
+ });
830
+
831
+ commands.insertSectionBreak = (p) => Word.run(async (ctx) => {
832
+ const body = ctx.document.body;
833
+ const breakType = p.breakType || 'SectionNext';
834
+ if (p.paragraphIndex !== undefined) {
835
+ if (p.paragraphIndex < 0) throw new Error('Index must be non-negative');
836
+ const paragraphs = body.paragraphs;
837
+ paragraphs.load('text');
838
+ await ctx.sync();
839
+ if (p.paragraphIndex >= paragraphs.items.length) throw new Error('Paragraph index out of range. Document has ' + paragraphs.items.length + ' paragraphs (0-indexed).');
840
+ const para = paragraphs.items[p.paragraphIndex];
841
+ para.insertBreak(breakType, 'After');
842
+ para.getRange('End').select();
843
+ try {
844
+ await ctx.sync();
845
+ } catch (e) {
846
+ if (e.message && e.message.includes('GeneralException')) {
847
+ throw new Error('Cannot insert section break at paragraph ' + p.paragraphIndex + '. The paragraph may be inside a table cell (section/page breaks are not allowed inside tables).');
848
+ }
849
+ throw e;
850
+ }
851
+ } else {
852
+ const lastPara = body.paragraphs.getLast();
853
+ lastPara.insertBreak(breakType, 'After');
854
+ lastPara.getRange('End').select();
855
+ await ctx.sync();
856
+ }
857
+ return { success: true };
858
+ });
859
+
860
+ // == HYPERLINKS ==
861
+ commands.insertHyperlink = (p) => Word.run(async (ctx) => {
862
+ const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
863
+ results.load('text');
864
+ await ctx.sync();
865
+ if (results.items.length === 0) throw new Error('Anchor not found: ' + p.anchorText);
866
+ const target = results.items[p.occurrence || 0];
867
+ target.hyperlink = p.url;
868
+ target.getRange('End').select();
869
+ await ctx.sync();
870
+ return { success: true };
871
+ });
872
+
873
+ // == FIELDS ==
874
+ commands.getFields = () => Word.run(async (ctx) => {
875
+ const fields = ctx.document.body.fields;
876
+ fields.load('code,result,type');
877
+ await ctx.sync();
878
+ const items = fields.items.map((f, i) => ({ index: i, code: (f.code || '').replace(/[\u0001\u0013\u0014\u0015]/g, '').trim(), result: f.result, type: f.type }));
879
+ return { count: items.length, fields: items };
880
+ });
881
+
882
+ // == CONTENT CONTROL INSERTION ==
883
+ commands.insertContentControl = (p) => Word.run(async (ctx) => {
884
+ const ccType = p.type || 'RichText';
885
+ let range;
886
+ if (p.anchorText) {
887
+ const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
888
+ results.load('text');
889
+ await ctx.sync();
890
+ if (results.items.length === 0) throw new Error('Anchor not found: ' + p.anchorText);
891
+ range = results.items[p.occurrence || 0];
892
+ } else {
893
+ range = ctx.document.getSelection();
894
+ }
895
+ const cc = range.insertContentControl(ccType);
896
+ if (p.title) cc.title = p.title;
897
+ if (p.tag) cc.tag = p.tag;
898
+ if (p.color) cc.color = p.color;
899
+ cc.getRange('End').select();
900
+ await ctx.sync();
901
+ const result = { success: true };
902
+ if (ccType === 'CheckBox') {
903
+ result.warning = 'CheckBox content controls replace the anchor text with a checkbox widget. The original text is not preserved.';
904
+ }
905
+ return result;
906
+ });
907
+
908
+ // == PAGE LAYOUT ==
909
+ commands.setPageLayout = (p) => Word.run(async (ctx) => {
910
+ const sections = ctx.document.sections;
911
+ sections.load('items');
912
+ await ctx.sync();
913
+ const section = sections.items[p.sectionIndex || 0];
914
+ const pageSetup = section.pageSetup;
915
+ if (p.orientation) pageSetup.orientation = p.orientation; // 'Portrait' or 'Landscape'
916
+ if (p.topMargin !== undefined) pageSetup.topMargin = p.topMargin;
917
+ if (p.bottomMargin !== undefined) pageSetup.bottomMargin = p.bottomMargin;
918
+ if (p.leftMargin !== undefined) pageSetup.leftMargin = p.leftMargin;
919
+ if (p.rightMargin !== undefined) pageSetup.rightMargin = p.rightMargin;
920
+ if (p.paperSize) pageSetup.paperSize = p.paperSize; // 'Letter', 'A4', etc.
921
+ await ctx.sync();
922
+ return { success: true };
923
+ });
924
+
925
+ commands.getPageLayout = (p) => Word.run(async (ctx) => {
926
+ const sections = ctx.document.sections;
927
+ sections.load('items');
928
+ await ctx.sync();
929
+ const section = sections.items[p.sectionIndex || 0];
930
+ const ps = section.pageSetup;
931
+ ps.load('orientation,topMargin,bottomMargin,leftMargin,rightMargin,paperSize,headerDistance,footerDistance');
932
+ await ctx.sync();
933
+ return { orientation: ps.orientation, topMargin: ps.topMargin, bottomMargin: ps.bottomMargin, leftMargin: ps.leftMargin, rightMargin: ps.rightMargin, paperSize: ps.paperSize, headerDistance: ps.headerDistance, footerDistance: ps.footerDistance };
934
+ });
935
+
936
+ // == FONT INFO ==
937
+ commands.getFontInfo = (p) => Word.run(async (ctx) => {
938
+ const results = ctx.document.body.search(p.text, { matchCase: p.matchCase || false });
939
+ results.load('font');
940
+ await ctx.sync();
941
+ if (results.items.length === 0) throw new Error('Text not found: ' + p.text);
942
+ const idx = p.occurrence || 0;
943
+ if (idx >= results.items.length) throw new Error('Occurrence ' + idx + ' not found (only ' + results.items.length + ' match' + (results.items.length === 1 ? '' : 'es') + ')');
944
+ const target = results.items[idx];
945
+ const font = target.font;
946
+ font.load('name,size,bold,italic,underline,strikeThrough,color,highlightColor');
947
+ await ctx.sync();
948
+ return { name: font.name, size: font.size, bold: font.bold, italic: font.italic, underline: font.underline, strikeThrough: font.strikeThrough, color: font.color, highlightColor: font.highlightColor };
949
+ });
950
+
951
+ // == CUSTOM PROPERTIES ==
952
+ commands.getCustomProperties = () => Word.run(async (ctx) => {
953
+ const customProps = ctx.document.properties.customProperties;
954
+ customProps.load('key,value,type');
955
+ await ctx.sync();
956
+ const items = customProps.items.map(p => ({ key: p.key, value: p.value, type: p.type }));
957
+ return { count: items.length, properties: items };
958
+ });
959
+
960
+ commands.setCustomProperty = (p) => Word.run(async (ctx) => {
961
+ ctx.document.properties.customProperties.add(p.key, p.value);
962
+ await ctx.sync();
963
+ return { success: true };
964
+ });
965
+
966
+ commands.deleteCustomProperty = (p) => Word.run(async (ctx) => {
967
+ const customProps = ctx.document.properties.customProperties;
968
+ customProps.load('key');
969
+ await ctx.sync();
970
+ for (const cp of customProps.items) {
971
+ if (cp.key === p.key) { cp.delete(); await ctx.sync(); return { success: true }; }
972
+ }
973
+ throw new Error('Custom property not found: ' + p.key);
974
+ });
975
+
976
+ // == ADVANCED TEXT INSERTION ==
977
+ commands.insertHtml = (p) => Word.run(async (ctx) => {
978
+ const body = ctx.document.body;
979
+ const loc = p.location || 'End';
980
+ const range = body.insertHtml(p.html, loc);
981
+ range.getRange('End').select();
982
+ await ctx.sync();
983
+ return { success: true };
984
+ });
985
+
986
+ commands.insertOoxml = (p) => Word.run(async (ctx) => {
987
+ const body = ctx.document.body;
988
+ const range = body.insertOoxml(p.ooxml, p.location || 'End');
989
+ range.getRange('End').select();
990
+ try {
991
+ await ctx.sync();
992
+ } catch (e) {
993
+ if (e.message && e.message.includes('GeneralException')) {
994
+ throw new Error('Invalid OOXML. Ensure the XML follows the Office Open XML package structure (pkg:package with word/document.xml part).');
995
+ }
996
+ throw e;
997
+ }
998
+ return { success: true };
999
+ });
1000
+
1001
+ // == PARAGRAPH DETAILS & SPACING ==
1002
+ commands.getParagraphByIndex = (p) => Word.run(async (ctx) => {
1003
+ if (p.index < 0) throw new Error('Index must be non-negative');
1004
+ const paragraphs = ctx.document.body.paragraphs;
1005
+ paragraphs.load('text');
1006
+ await ctx.sync();
1007
+ if (p.index >= paragraphs.items.length) throw new Error('Paragraph index out of range. Document has ' + paragraphs.items.length + ' paragraphs (0-indexed).');
1008
+ const para = paragraphs.items[p.index];
1009
+ para.load('text,style,alignment,firstLineIndent,leftIndent,rightIndent,lineSpacing,spaceBefore,spaceAfter,outlineLevel,isListItem');
1010
+ para.font.load('name,size,bold,italic,color,underline');
1011
+ await ctx.sync();
1012
+ return { text: para.text, style: para.style, alignment: para.alignment, firstLineIndent: para.firstLineIndent, leftIndent: para.leftIndent, rightIndent: para.rightIndent, lineSpacing: para.lineSpacing, spaceBefore: para.spaceBefore, spaceAfter: para.spaceAfter, outlineLevel: para.outlineLevel, isListItem: para.isListItem, font: { name: para.font.name, size: para.font.size, bold: para.font.bold, italic: para.font.italic, color: para.font.color, underline: para.font.underline } };
1013
+ });
1014
+
1015
+ commands.setParagraphSpacing = (p) => Word.run(async (ctx) => {
1016
+ if (p.index < 0) throw new Error('Index must be non-negative');
1017
+ if (p.lineSpacing !== undefined && p.lineSpacing <= 0) throw new Error('lineSpacing must be positive (e.g. 12, 15, 18)');
1018
+ if (p.spaceBefore !== undefined && p.spaceBefore < 0) throw new Error('spaceBefore must be non-negative');
1019
+ if (p.spaceAfter !== undefined && p.spaceAfter < 0) throw new Error('spaceAfter must be non-negative');
1020
+ const paragraphs = ctx.document.body.paragraphs;
1021
+ paragraphs.load('text');
1022
+ await ctx.sync();
1023
+ if (p.index >= paragraphs.items.length) throw new Error('Paragraph index out of range. Document has ' + paragraphs.items.length + ' paragraphs (0-indexed).');
1024
+ const para = paragraphs.items[p.index];
1025
+ if (p.lineSpacing !== undefined) para.lineSpacing = p.lineSpacing;
1026
+ if (p.spaceBefore !== undefined) para.spaceBefore = p.spaceBefore;
1027
+ if (p.spaceAfter !== undefined) para.spaceAfter = p.spaceAfter;
1028
+ if (p.firstLineIndent !== undefined) para.firstLineIndent = p.firstLineIndent;
1029
+ if (p.leftIndent !== undefined) para.leftIndent = p.leftIndent;
1030
+ if (p.rightIndent !== undefined) para.rightIndent = p.rightIndent;
1031
+ para.getRange('End').select();
1032
+ await ctx.sync();
1033
+ return { success: true };
1034
+ });
1035
+
1036
+ // == BOOKMARK NAVIGATION ==
1037
+ commands.goToBookmark = (p) => Word.run(async (ctx) => {
1038
+ const range = ctx.document.getBookmarkRange(p.name);
1039
+ range.load('text');
1040
+ range.select();
1041
+ await ctx.sync();
1042
+ return { success: true, text: range.text };
1043
+ });
1044
+
1045
+ commands.getBookmarkText = (p) => Word.run(async (ctx) => {
1046
+ const range = ctx.document.getBookmarkRange(p.name);
1047
+ range.load('text');
1048
+ await ctx.sync();
1049
+ return { text: range.text };
1050
+ });
1051
+
1052
+ // == SECTIONS ==
1053
+ commands.getSections = () => Word.run(async (ctx) => {
1054
+ const sections = ctx.document.sections;
1055
+ sections.load('items');
1056
+ await ctx.sync();
1057
+ const items = [];
1058
+ for (let i = 0; i < sections.items.length; i++) {
1059
+ const ps = sections.items[i].pageSetup;
1060
+ ps.load('orientation,topMargin,bottomMargin,leftMargin,rightMargin,paperSize');
1061
+ await ctx.sync();
1062
+ items.push({ index: i, orientation: ps.orientation, topMargin: ps.topMargin, bottomMargin: ps.bottomMargin, leftMargin: ps.leftMargin, rightMargin: ps.rightMargin, paperSize: ps.paperSize });
1063
+ }
1064
+ return { count: items.length, sections: items };
1065
+ });
1066
+
1067
+ // == TABLE ADVANCED ==
1068
+ commands.mergeTableCells = (p) => Word.run(async (ctx) => {
1069
+ const tables = ctx.document.body.tables;
1070
+ tables.load('rowCount');
1071
+ await ctx.sync();
1072
+ if (p.tableIndex >= tables.items.length) throw new Error('Table index out of range');
1073
+ tables.items[p.tableIndex].mergeCells(p.topRow, p.firstCell, p.bottomRow, p.lastCell);
1074
+ await ctx.sync();
1075
+ return { success: true };
1076
+ });
1077
+
1078
+ commands.splitTableCell = (p) => Word.run(async (ctx) => {
1079
+ const tables = ctx.document.body.tables;
1080
+ tables.load('rowCount');
1081
+ await ctx.sync();
1082
+ if (p.tableIndex >= tables.items.length) throw new Error('Table index out of range');
1083
+ const cell = tables.items[p.tableIndex].getCell(p.row, p.col);
1084
+ cell.split(p.rowCount || 1, p.colCount || 2);
1085
+ await ctx.sync();
1086
+ return { success: true };
1087
+ });
1088
+
1089
+ commands.setTableStyle = (p) => Word.run(async (ctx) => {
1090
+ const tables = ctx.document.body.tables;
1091
+ tables.load('rowCount');
1092
+ await ctx.sync();
1093
+ if (p.tableIndex >= tables.items.length) throw new Error('Table index out of range');
1094
+ tables.items[p.tableIndex].style = p.style;
1095
+ await ctx.sync();
1096
+ return { success: true };
1097
+ });
1098
+
1099
+ // == COMMENT REPLIES ==
1100
+ commands.getCommentReplies = (p) => Word.run(async (ctx) => {
1101
+ const comments = ctx.document.body.getComments();
1102
+ comments.load('id,replies');
1103
+ await ctx.sync();
1104
+ for (const c of comments.items) {
1105
+ if (String(c.id) === String(p.commentId)) {
1106
+ const replies = c.replies;
1107
+ replies.load('authorName,content,creationDate');
1108
+ await ctx.sync();
1109
+ const items = replies.items.map(r => ({ author: r.authorName, content: r.content, date: r.creationDate }));
1110
+ return { count: items.length, replies: items };
1111
+ }
1112
+ }
1113
+ throw new Error('Comment not found: ' + p.commentId);
1114
+ });
1115
+
1116
+ // == CHANGE TRACKING MODE ==
1117
+ commands.getChangeTrackingMode = () => Word.run(async (ctx) => {
1118
+ const doc = ctx.document;
1119
+ doc.load('changeTrackingMode');
1120
+ await ctx.sync();
1121
+ return { mode: doc.changeTrackingMode };
1122
+ });
1123
+
1124
+ // == CLEAR FORMATTING ==
1125
+ commands.clearFormatting = (p) => Word.run(async (ctx) => {
1126
+ const results = ctx.document.body.search(p.text, { matchCase: p.matchCase || false });
1127
+ results.load('font,style');
1128
+ await ctx.sync();
1129
+ if (results.items.length === 0) throw new Error('Text not found: ' + p.text);
1130
+ const idx = p.occurrence || 0;
1131
+ if (idx >= results.items.length) throw new Error('Occurrence ' + idx + ' not found (only ' + results.items.length + ' match' + (results.items.length === 1 ? '' : 'es') + ')');
1132
+ const target = results.items[idx];
1133
+ // Get the style's default font to restore size and name
1134
+ const styleName = target.style || 'Normal';
1135
+ const styleObj = ctx.document.getStyles().getByNameOrNullObject(styleName);
1136
+ styleObj.load('font/size,font/name');
1137
+ await ctx.sync();
1138
+ const font = target.font;
1139
+ font.bold = false;
1140
+ font.italic = false;
1141
+ font.underline = 'None';
1142
+ font.strikeThrough = false;
1143
+ font.color = '#000000';
1144
+ if (!styleObj.isNullObject) {
1145
+ if (styleObj.font.size) font.size = styleObj.font.size;
1146
+ if (styleObj.font.name) font.name = styleObj.font.name;
1147
+ }
1148
+ target.getRange('End').select();
1149
+ await ctx.sync();
1150
+ return { success: true };
1151
+ });
1152
+
1153
+ // == INSERT TOC ==
1154
+ commands.insertTableOfContents = (p) => Word.run(async (ctx) => {
1155
+ const body = ctx.document.body;
1156
+ // Insert a TOC field at the specified location
1157
+ const range = body.getRange(p.location || 'Start');
1158
+ range.insertField(p.location || 'Start', 'TOC', p.switches || '\\o "1-3" \\h \\z \\u', true);
1159
+ await ctx.sync();
1160
+ return { success: true };
1161
+ });
1162
+
1163
+ // == SELECTION OPERATIONS ==
1164
+ commands.getSelectionInfo = () => Word.run(async (ctx) => {
1165
+ const sel = ctx.document.getSelection();
1166
+ sel.load('text,style,isEmpty');
1167
+ sel.font.load('name,size,bold,italic,color,underline,strikeThrough,highlightColor');
1168
+ await ctx.sync();
1169
+ return { text: sel.text, style: sel.style, isEmpty: sel.isEmpty, font: { name: sel.font.name, size: sel.font.size, bold: sel.font.bold, italic: sel.font.italic, color: sel.font.color, underline: sel.font.underline, strikeThrough: sel.font.strikeThrough, highlightColor: sel.font.highlightColor } };
1170
+ });
1171
+
1172
+ commands.insertTextAtSelection = (p) => Word.run(async (ctx) => {
1173
+ const sel = ctx.document.getSelection();
1174
+ const loc = p.replace ? Word.InsertLocation.replace : Word.InsertLocation.end;
1175
+ const inserted = sel.insertText(p.text, loc);
1176
+ inserted.getRange('End').select();
1177
+ await ctx.sync();
1178
+ return { success: true };
1179
+ });
1180
+
1181
+ // == IMAGES ==
1182
+ commands.getImages = () => Word.run(async (ctx) => {
1183
+ const pics = ctx.document.body.inlinePictures;
1184
+ pics.load('altTextDescription,altTextTitle,width,height,hyperlink');
1185
+ await ctx.sync();
1186
+ const items = pics.items.map((p, i) => ({ index: i, width: p.width, height: p.height, altText: p.altTextDescription, altTitle: p.altTextTitle, hyperlink: p.hyperlink }));
1187
+ return { count: items.length, images: items };
1188
+ });
1189
+
1190
+ commands.deleteImage = (p) => Word.run(async (ctx) => {
1191
+ const pics = ctx.document.body.inlinePictures;
1192
+ pics.load('altTextDescription');
1193
+ await ctx.sync();
1194
+ if (p.index >= pics.items.length) throw new Error('Image index out of range. Document has ' + pics.items.length + ' image(s) (0-indexed).');
1195
+ pics.items[p.index].delete();
1196
+ await ctx.sync();
1197
+ return { success: true };
1198
+ });
1199
+
1200
+ // == TABLE CELL SHADING ==
1201
+ commands.setTableCellShading = (p) => Word.run(async (ctx) => {
1202
+ const tables = ctx.document.body.tables;
1203
+ tables.load('rowCount');
1204
+ await ctx.sync();
1205
+ if (p.tableIndex >= tables.items.length) throw new Error('Table index out of range');
1206
+ const cell = tables.items[p.tableIndex].getCell(p.row, p.col);
1207
+ cell.shadingColor = p.color;
1208
+ cell.body.getRange('End').select();
1209
+ await ctx.sync();
1210
+ return { success: true };
1211
+ });
1212
+
1213
+ // == HYPERLINKS ==
1214
+ commands.getHyperlinks = () => Word.run(async (ctx) => {
1215
+ const body = ctx.document.body;
1216
+ const fields = body.fields;
1217
+ fields.load('code,type');
1218
+ await ctx.sync();
1219
+ const links = [];
1220
+ for (let i = 0; i < fields.items.length; i++) {
1221
+ if (fields.items[i].type === 'Hyperlink') {
1222
+ const code = (fields.items[i].code || '').replace(/[\u0001\u0013\u0014\u0015]/g, '').trim();
1223
+ // Parse URL from field code
1224
+ const match = code.match(/HYPERLINK\s+"([^"]+)"/);
1225
+ const url = match ? match[1] : '';
1226
+ // Skip internal TOC/bookmark links (start with \l)
1227
+ const isInternal = /HYPERLINK\s+\\l\s+/.test(code);
1228
+ fields.items[i].result.load('text');
1229
+ await ctx.sync();
1230
+ // Strip control characters from display text (TOC entries contain embedded PAGEREF fields)
1231
+ const rawText = fields.items[i].result.text || '';
1232
+ const cleanText = rawText.replace(/[\u0001\u0002\u0013\u0014\u0015]/g, '').replace(/\s{2,}/g, ' ').trim();
1233
+ links.push({ index: i, url: url, text: cleanText, internal: isInternal });
1234
+ }
1235
+ }
1236
+ return { count: links.length, hyperlinks: links };
1237
+ });
1238
+
1239
+ commands.removeHyperlink = (p) => Word.run(async (ctx) => {
1240
+ const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
1241
+ results.load('hyperlink');
1242
+ await ctx.sync();
1243
+ if (results.items.length === 0) throw new Error('Text not found: ' + p.anchorText);
1244
+ const target = results.items[p.occurrence || 0];
1245
+ target.hyperlink = '';
1246
+ target.getRange('End').select();
1247
+ await ctx.sync();
1248
+ return { success: true };
1249
+ });
1250
+
1251
+ // == FOOTNOTE/ENDNOTE MANAGEMENT ==
1252
+ commands.insertFootnoteAtIndex = (p) => Word.run(async (ctx) => {
1253
+ const paragraphs = ctx.document.body.paragraphs;
1254
+ paragraphs.load('text');
1255
+ await ctx.sync();
1256
+ if (p.paragraphIndex >= paragraphs.items.length) throw new Error('Paragraph index out of range. Document has ' + paragraphs.items.length + ' paragraphs (0-indexed).');
1257
+ const para = paragraphs.items[p.paragraphIndex];
1258
+ const range = para.getRange('End');
1259
+ range.insertFootnote(p.text);
1260
+ range.select();
1261
+ await ctx.sync();
1262
+ return { success: true };
1263
+ });
1264
+
1265
+ commands.deleteFootnote = (p) => Word.run(async (ctx) => {
1266
+ const footnotes = ctx.document.body.footnotes;
1267
+ footnotes.load('items');
1268
+ await ctx.sync();
1269
+ if (p.index >= footnotes.items.length) throw new Error('Footnote index out of range. Document has ' + footnotes.items.length + ' footnote(s) (0-indexed).');
1270
+ footnotes.items[p.index].delete();
1271
+ await ctx.sync();
1272
+ return { success: true };
1273
+ });
1274
+
1275
+ commands.deleteEndnote = (p) => Word.run(async (ctx) => {
1276
+ const endnotes = ctx.document.body.endnotes;
1277
+ endnotes.load('items');
1278
+ await ctx.sync();
1279
+ if (p.index >= endnotes.items.length) throw new Error('Endnote index out of range. Document has ' + endnotes.items.length + ' endnote(s) (0-indexed).');
1280
+ endnotes.items[p.index].delete();
1281
+ await ctx.sync();
1282
+ return { success: true };
1283
+ });
1284
+
1285
+ // == LIST OPERATIONS ==
1286
+ commands.getListInfo = (p) => Word.run(async (ctx) => {
1287
+ const paragraphs = ctx.document.body.paragraphs;
1288
+ paragraphs.load('text,isListItem');
1289
+ await ctx.sync();
1290
+ if (p.index >= paragraphs.items.length) throw new Error('Paragraph index out of range. Document has ' + paragraphs.items.length + ' paragraphs (0-indexed).');
1291
+ const para = paragraphs.items[p.index];
1292
+ if (!para.isListItem) return { isListItem: false };
1293
+ para.load('listItem');
1294
+ await ctx.sync();
1295
+ const li = para.listItem;
1296
+ li.load('level,listString,siblingIndex');
1297
+ await ctx.sync();
1298
+ return { isListItem: true, level: li.level, listString: li.listString, siblingIndex: li.siblingIndex };
1299
+ });
1300
+
1301
+ commands.setListLevel = (p) => Word.run(async (ctx) => {
1302
+ if (p.index < 0) throw new Error('Index must be non-negative');
1303
+ const paragraphs = ctx.document.body.paragraphs;
1304
+ paragraphs.load('text,isListItem');
1305
+ await ctx.sync();
1306
+ if (p.index >= paragraphs.items.length) throw new Error('Paragraph index out of range. Document has ' + paragraphs.items.length + ' paragraphs (0-indexed).');
1307
+ const para = paragraphs.items[p.index];
1308
+ if (!para.isListItem) throw new Error('Paragraph is not a list item');
1309
+ para.load('listItem');
1310
+ await ctx.sync();
1311
+ para.listItem.level = p.level;
1312
+ para.getRange('End').select();
1313
+ await ctx.sync();
1314
+ return { success: true };
1315
+ });
1316
+
1317
+ // == LINE BREAK ==
1318
+ commands.insertLineBreak = (p) => Word.run(async (ctx) => {
1319
+ const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
1320
+ results.load('text');
1321
+ await ctx.sync();
1322
+ if (results.items.length === 0) throw new Error('Anchor not found: ' + p.anchorText);
1323
+ const target = results.items[p.occurrence || 0];
1324
+ const loc = p.before ? Word.InsertLocation.before : Word.InsertLocation.after;
1325
+ target.insertBreak('Line', loc);
1326
+ target.getRange('End').select();
1327
+ await ctx.sync();
1328
+ return { success: true };
1329
+ });