@wonderwhy-er/desktop-commander 0.2.36 → 0.2.37

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 (43) hide show
  1. package/README.md +1 -0
  2. package/dist/remote-device/remote-channel.d.ts +8 -3
  3. package/dist/remote-device/remote-channel.js +68 -21
  4. package/dist/search-manager.d.ts +13 -0
  5. package/dist/search-manager.js +146 -0
  6. package/dist/server.js +26 -0
  7. package/dist/test-docx.d.ts +1 -0
  8. package/dist/tools/docx/builders/table.d.ts +2 -0
  9. package/dist/tools/docx/builders/table.js +60 -16
  10. package/dist/tools/docx/dom.d.ts +74 -1
  11. package/dist/tools/docx/dom.js +221 -1
  12. package/dist/tools/docx/index.d.ts +2 -2
  13. package/dist/tools/docx/ops/index.js +3 -0
  14. package/dist/tools/docx/ops/replace-paragraph-text-exact.d.ts +15 -3
  15. package/dist/tools/docx/ops/replace-paragraph-text-exact.js +25 -10
  16. package/dist/tools/docx/ops/replace-table-cell-text.d.ts +25 -0
  17. package/dist/tools/docx/ops/replace-table-cell-text.js +85 -0
  18. package/dist/tools/docx/ops/set-color-for-paragraph-exact.d.ts +2 -1
  19. package/dist/tools/docx/ops/set-color-for-paragraph-exact.js +9 -8
  20. package/dist/tools/docx/ops/set-color-for-style.d.ts +4 -0
  21. package/dist/tools/docx/ops/set-color-for-style.js +11 -7
  22. package/dist/tools/docx/ops/table-set-cell-text.js +8 -40
  23. package/dist/tools/docx/read.d.ts +2 -2
  24. package/dist/tools/docx/read.js +137 -17
  25. package/dist/tools/docx/types.d.ts +32 -3
  26. package/dist/tools/docx/xml-view-test.d.ts +1 -0
  27. package/dist/tools/docx/xml-view-test.js +63 -0
  28. package/dist/tools/docx/xml-view.d.ts +56 -0
  29. package/dist/tools/docx/xml-view.js +169 -0
  30. package/dist/tools/edit.js +57 -27
  31. package/dist/tools/schemas.js +1 -1
  32. package/dist/ui/file-preview/preview-runtime.js +7 -1
  33. package/dist/utils/capture.js +171 -9
  34. package/dist/utils/files/base.d.ts +3 -1
  35. package/dist/utils/files/docx.d.ts +28 -15
  36. package/dist/utils/files/docx.js +622 -88
  37. package/dist/utils/files/factory.d.ts +6 -5
  38. package/dist/utils/files/factory.js +18 -6
  39. package/dist/utils/system-info.js +1 -1
  40. package/dist/utils/usageTracker.js +5 -0
  41. package/dist/version.d.ts +1 -1
  42. package/dist/version.js +1 -1
  43. package/package.json +3 -2
package/README.md CHANGED
@@ -2,6 +2,7 @@
2
2
  ### Search, update, manage files and run terminal commands with AI
3
3
 
4
4
  [![npm downloads](https://img.shields.io/npm/dw/@wonderwhy-er/desktop-commander)](https://www.npmjs.com/package/@wonderwhy-er/desktop-commander)
5
+ [![AgentAudit Verified](https://agentaudit.dev/api/badge/desktop-commander)](https://agentaudit.dev/skills/desktop-commander)
5
6
  [![Trust Score](https://archestra.ai/mcp-catalog/api/badge/quality/wonderwhy-er/DesktopCommanderMCP)](https://archestra.ai/mcp-catalog/wonderwhy-er__desktopcommandermcp)
6
7
  [![smithery badge](https://smithery.ai/badge/@wonderwhy-er/desktop-commander)](https://smithery.ai/server/@wonderwhy-er/desktop-commander)
7
8
  [![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-support-yellow.svg)](https://www.buymeacoffee.com/wonderwhyer)
@@ -36,10 +36,15 @@ export declare class RemoteChannel {
36
36
  id: any;
37
37
  device_name: any;
38
38
  } | null>;
39
- updateDevice(deviceId: string, updates: any): Promise<import("@supabase/postgrest-js").PostgrestSingleResponse<null>>;
40
- createDevice(deviceData: DeviceData): Promise<import("@supabase/postgrest-js").PostgrestSingleResponse<any>>;
39
+ updateDevice(deviceId: string, updates: any): Promise<{
40
+ data: any[] | null;
41
+ error: import("@supabase/postgrest-js").PostgrestError | null;
42
+ }>;
43
+ createDevice(deviceData: DeviceData): Promise<{
44
+ data: any;
45
+ error: null;
46
+ }>;
41
47
  registerDevice(capabilities: any, currentDeviceId: string | undefined, deviceName: string, onToolCall: (payload: any) => void): Promise<void>;
42
- subscribe(deviceId: string, onToolCall: (payload: any) => void): Promise<void>;
43
48
  /**
44
49
  * Create and subscribe to the channel.
45
50
  * This is used for both initial subscription and recreation after socket reconnects.
@@ -28,14 +28,26 @@ export class RemoteChannel {
28
28
  access_token: session.access_token,
29
29
  refresh_token: session.refresh_token || ''
30
30
  });
31
+ if (error) {
32
+ console.error('[DEBUG] Failed to set session:', error.message);
33
+ await captureRemote('remote_channel_set_session_error', { error });
34
+ return { error };
35
+ }
31
36
  // Get user info
32
37
  const { data: { user }, error: userError } = await this.client.auth.getUser();
33
38
  if (userError) {
34
- console.debug('[DEBUG] Failed to get user:', userError.message);
39
+ console.error('[DEBUG] Failed to get user:', userError.message);
40
+ await captureRemote('remote_channel_get_user_error', { error: userError });
35
41
  throw userError;
36
42
  }
43
+ if (!user) {
44
+ const noUserError = new Error('No user returned after setSession');
45
+ console.error('[DEBUG] No user returned:', noUserError.message);
46
+ await captureRemote('remote_channel_get_user_empty', {});
47
+ throw noUserError;
48
+ }
37
49
  this._user = user;
38
- console.debug('[DEBUG] Session set successfully, user:', user?.email);
50
+ console.debug('[DEBUG] Session set successfully, user:', user.email);
39
51
  return { error };
40
52
  }
41
53
  async getSession() {
@@ -52,26 +64,45 @@ export class RemoteChannel {
52
64
  .eq('id', deviceId)
53
65
  .eq('user_id', this.user?.id)
54
66
  .maybeSingle();
55
- if (error)
67
+ if (error) {
68
+ console.error('[DEBUG] Failed to find device:', error.message);
69
+ await captureRemote('remote_channel_find_device_error', { error });
56
70
  throw error;
71
+ }
57
72
  return data;
58
73
  }
59
74
  async updateDevice(deviceId, updates) {
60
75
  if (!this.client)
61
76
  throw new Error('Client not initialized');
62
- return await this.client
77
+ const { data, error } = await this.client
63
78
  .from('mcp_devices')
64
79
  .update(updates)
65
- .eq('id', deviceId);
80
+ .eq('id', deviceId)
81
+ .select();
82
+ if (error) {
83
+ console.error('[DEBUG] Failed to update device:', error.message);
84
+ await captureRemote('remote_channel_update_device_error', { error });
85
+ }
86
+ else {
87
+ console.debug('[DEBUG] Device updated successfully');
88
+ }
89
+ return { data, error };
66
90
  }
67
91
  async createDevice(deviceData) {
68
92
  if (!this.client)
69
93
  throw new Error('Client not initialized');
70
- return await this.client
94
+ const { data, error } = await this.client
71
95
  .from('mcp_devices')
72
96
  .insert(deviceData)
73
97
  .select()
74
98
  .single();
99
+ if (error) {
100
+ console.error('[DEBUG] Failed to create device:', error.message);
101
+ await captureRemote('remote_channel_create_device_error', { error });
102
+ throw error;
103
+ }
104
+ console.debug('[DEBUG] Device created successfully');
105
+ return { data, error };
75
106
  }
76
107
  async registerDevice(capabilities, currentDeviceId, deviceName, onToolCall) {
77
108
  console.debug('[DEBUG] RemoteChannel.registerDevice() called, deviceId:', currentDeviceId);
@@ -95,7 +126,10 @@ export class RemoteChannel {
95
126
  console.debug(`⏳ Subscribing to tool call channel...`);
96
127
  // Create and subscribe to the channel
97
128
  console.debug('[DEBUG] Calling createChannel()');
98
- await this.createChannel();
129
+ // ! Ignore silently in Inialization to reconnect after
130
+ await this.createChannel().catch((error) => {
131
+ console.debug('[DEBUG] Failed to create channel, will retry after socket reconnect', error);
132
+ });
99
133
  }
100
134
  else {
101
135
  console.error(` - ❌ Device not found: ${currentDeviceId}`);
@@ -103,16 +137,6 @@ export class RemoteChannel {
103
137
  throw new Error(`Device not found: ${currentDeviceId}`);
104
138
  }
105
139
  }
106
- async subscribe(deviceId, onToolCall) {
107
- if (!this.client)
108
- throw new Error('Client not initialized');
109
- // Store parameters for channel recreation
110
- this.deviceId = deviceId;
111
- this.onToolCall = onToolCall;
112
- console.debug(`⏳ Subscribing to tool call channel...`);
113
- // Create and subscribe to the channel
114
- await this.createChannel();
115
- }
116
140
  /**
117
141
  * Create and subscribe to the channel.
118
142
  * This is used for both initial subscription and recreation after socket reconnects.
@@ -156,7 +180,7 @@ export class RemoteChannel {
156
180
  reject(err || new Error('Failed to initialize tool call channel subscription'));
157
181
  }
158
182
  else if (status === 'TIMED_OUT') {
159
- console.error('⏱️ Channel subscription timed out');
183
+ console.error('⏱️ Channel subscription timed out, Reconnecting...');
160
184
  this.setOnlineStatus(this.deviceId, 'offline');
161
185
  captureRemote('remote_channel_subscription_timeout', {}).catch(() => { });
162
186
  reject(new Error('Tool call channel subscription timed out'));
@@ -213,10 +237,17 @@ export class RemoteChannel {
213
237
  async markCallExecuting(callId) {
214
238
  if (!this.client)
215
239
  throw new Error('Client not initialized');
216
- await this.client
240
+ const { error } = await this.client
217
241
  .from('mcp_remote_calls')
218
242
  .update({ status: 'executing' })
219
243
  .eq('id', callId);
244
+ if (error) {
245
+ console.error('[DEBUG] Failed to mark call executing:', error.message);
246
+ await captureRemote('remote_channel_mark_call_executing_error', { error });
247
+ }
248
+ else {
249
+ console.debug('[DEBUG] Call marked executing:', callId);
250
+ }
220
251
  }
221
252
  async updateCallResult(callId, status, result = null, errorMessage = null) {
222
253
  if (!this.client)
@@ -229,19 +260,31 @@ export class RemoteChannel {
229
260
  updateData.result = result;
230
261
  if (errorMessage !== null)
231
262
  updateData.error_message = errorMessage;
232
- await this.client
263
+ console.debug('[DEBUG] Updating call result:', updateData);
264
+ const { data, error } = await this.client
233
265
  .from('mcp_remote_calls')
234
266
  .update(updateData)
235
267
  .eq('id', callId);
268
+ if (error) {
269
+ console.error('[DEBUG] Failed to update call result:', error.message);
270
+ await captureRemote('remote_channel_update_call_result_error', { error });
271
+ }
272
+ else {
273
+ console.debug('[DEBUG] Call result updated successfully:', data);
274
+ }
236
275
  }
237
276
  async updateHeartbeat(deviceId) {
238
277
  if (!this.client)
239
278
  return;
240
279
  try {
241
- await this.client
280
+ const { error } = await this.client
242
281
  .from('mcp_devices')
243
282
  .update({ last_seen: new Date().toISOString() })
244
283
  .eq('id', deviceId);
284
+ if (error) {
285
+ console.error('[DEBUG] Heartbeat update failed:', error.message);
286
+ await captureRemote('remote_channel_heartbeat_error', { error });
287
+ }
245
288
  // console.log(`🔌 Heartbeat sent for device: ${deviceId}`);
246
289
  }
247
290
  catch (error) {
@@ -283,12 +326,16 @@ export class RemoteChannel {
283
326
  .update({ status: status, last_seen: new Date().toISOString() })
284
327
  .eq('id', deviceId);
285
328
  if (error) {
329
+ console.error(`[DEBUG] Failed to set status ${status}:`, error.message);
286
330
  if (status == "online") {
287
331
  console.error('Failed to update device status:', error.message);
288
332
  }
289
333
  await captureRemote('remote_channel_status_update_error', { error, status });
290
334
  return;
291
335
  }
336
+ else {
337
+ console.debug(`[DEBUG] Device status set to ${status}`);
338
+ }
292
339
  // console.log(status === 'online' ? `🔌 Device marked as ${status}` : `❌ Device marked as ${status}`);
293
340
  }
294
341
  async setOffline(deviceId) {
@@ -97,6 +97,19 @@ export interface SearchSessionOptions {
97
97
  * Find all Excel files in a directory recursively
98
98
  */
99
99
  private findExcelFiles;
100
+ /**
101
+ * Determine if DOCX search should be included based on context
102
+ */
103
+ private shouldIncludeDocxSearch;
104
+ /**
105
+ * Search DOCX files for content matches
106
+ * Extracts <w:t> text from document.xml and searches it
107
+ */
108
+ private searchDocxFiles;
109
+ /**
110
+ * Find all DOCX files in a directory recursively
111
+ */
112
+ private findDocxFiles;
100
113
  /**
101
114
  * Extract context around a match for display (show surrounding text)
102
115
  */
@@ -5,6 +5,7 @@ import { validatePath } from './tools/filesystem.js';
5
5
  import { capture } from './utils/capture.js';
6
6
  import { getRipgrepPath } from './utils/ripgrep-resolver.js';
7
7
  import { isExcelFile } from './utils/files/index.js';
8
+ import PizZip from 'pizzip';
8
9
  /**
9
10
  * Search Session Manager - handles ripgrep processes like terminal sessions
10
11
  * Supports both file search and content search with progressive results
@@ -105,6 +106,19 @@ import { isExcelFile } from './utils/files/index.js';
105
106
  capture('excel_search_error', { error: err instanceof Error ? err.message : String(err) });
106
107
  });
107
108
  }
109
+ // For content searches, also search DOCX files
110
+ const shouldSearchDocx = options.searchType === 'content' &&
111
+ this.shouldIncludeDocxSearch(options.filePattern, validPath);
112
+ if (shouldSearchDocx) {
113
+ this.searchDocxFiles(validPath, options.pattern, options.ignoreCase !== false, options.maxResults, options.filePattern).then(docxResults => {
114
+ for (const result of docxResults) {
115
+ session.results.push(result);
116
+ session.totalMatches++;
117
+ }
118
+ }).catch((err) => {
119
+ capture('docx_search_error', { error: err instanceof Error ? err.message : String(err) });
120
+ });
121
+ }
108
122
  // Wait for first chunk of data or early completion instead of fixed delay
109
123
  // Excel search runs in background and results are merged via readSearchResults
110
124
  const firstChunk = new Promise(resolve => {
@@ -349,6 +363,138 @@ import { isExcelFile } from './utils/files/index.js';
349
363
  }
350
364
  return excelFiles;
351
365
  }
366
+ /**
367
+ * Determine if DOCX search should be included based on context
368
+ */
369
+ shouldIncludeDocxSearch(filePattern, rootPath) {
370
+ const docxExtensions = ['.docx'];
371
+ if (rootPath) {
372
+ const lowerPath = rootPath.toLowerCase();
373
+ if (docxExtensions.some(ext => lowerPath.endsWith(ext))) {
374
+ return true;
375
+ }
376
+ }
377
+ if (filePattern) {
378
+ const lowerPattern = filePattern.toLowerCase();
379
+ if (docxExtensions.some(ext => lowerPattern.includes(`*${ext}`) || lowerPattern.endsWith(ext))) {
380
+ return true;
381
+ }
382
+ }
383
+ return false;
384
+ }
385
+ /**
386
+ * Search DOCX files for content matches
387
+ * Extracts <w:t> text from document.xml and searches it
388
+ */
389
+ async searchDocxFiles(rootPath, pattern, ignoreCase, maxResults, filePattern) {
390
+ const results = [];
391
+ const flags = ignoreCase ? 'i' : '';
392
+ let regex;
393
+ try {
394
+ regex = new RegExp(pattern, flags);
395
+ }
396
+ catch {
397
+ const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
398
+ regex = new RegExp(escaped, flags);
399
+ }
400
+ let docxFiles = await this.findDocxFiles(rootPath);
401
+ if (filePattern) {
402
+ const patterns = filePattern.split('|').map(p => p.trim()).filter(Boolean);
403
+ docxFiles = docxFiles.filter(filePath => {
404
+ const fileName = path.basename(filePath);
405
+ return patterns.some(pat => {
406
+ if (pat.includes('*')) {
407
+ const regexPat = pat.replace(/\./g, '\\.').replace(/\*/g, '.*');
408
+ return new RegExp(`^${regexPat}$`, 'i').test(fileName);
409
+ }
410
+ return fileName.toLowerCase() === pat.toLowerCase();
411
+ });
412
+ });
413
+ }
414
+ for (const filePath of docxFiles) {
415
+ if (maxResults && results.length >= maxResults)
416
+ break;
417
+ try {
418
+ const buf = await fs.readFile(filePath);
419
+ const zip = new PizZip(buf);
420
+ // Search all XML parts that can contain text
421
+ const xmlParts = ['word/document.xml', 'word/header1.xml', 'word/header2.xml',
422
+ 'word/header3.xml', 'word/footer1.xml', 'word/footer2.xml', 'word/footer3.xml'];
423
+ for (const xmlPath of xmlParts) {
424
+ if (maxResults && results.length >= maxResults)
425
+ break;
426
+ const file = zip.file(xmlPath);
427
+ if (!file)
428
+ continue;
429
+ const xml = file.asText();
430
+ // Extract all <w:t> text with position tracking
431
+ const wtRe = /<w:t(?:\s[^>]*)?>([^<]*)<\/w:t>/g;
432
+ let m;
433
+ let lineNum = 0;
434
+ while ((m = wtRe.exec(xml)) !== null) {
435
+ if (maxResults && results.length >= maxResults)
436
+ break;
437
+ const text = m[1];
438
+ if (!text || !text.trim())
439
+ continue;
440
+ lineNum++;
441
+ if (regex.test(text)) {
442
+ const match = text.match(regex);
443
+ const matchContext = match
444
+ ? this.getMatchContext(text, match.index || 0, match[0].length)
445
+ : text.substring(0, 150);
446
+ const partName = xmlPath === 'word/document.xml' ? '' : `:${xmlPath.replace('word/', '')}`;
447
+ results.push({
448
+ file: `${filePath}${partName}`,
449
+ line: lineNum,
450
+ match: matchContext,
451
+ type: 'content'
452
+ });
453
+ }
454
+ }
455
+ }
456
+ }
457
+ catch {
458
+ continue;
459
+ }
460
+ }
461
+ return results;
462
+ }
463
+ /**
464
+ * Find all DOCX files in a directory recursively
465
+ */
466
+ async findDocxFiles(rootPath) {
467
+ const docxFiles = [];
468
+ const isDocx = (name) => name.toLowerCase().endsWith('.docx');
469
+ async function walk(dir) {
470
+ try {
471
+ const entries = await fs.readdir(dir, { withFileTypes: true });
472
+ for (const entry of entries) {
473
+ const fullPath = path.join(dir, entry.name);
474
+ if (entry.isDirectory()) {
475
+ if (!entry.name.startsWith('.') && entry.name !== 'node_modules') {
476
+ await walk(fullPath);
477
+ }
478
+ }
479
+ else if (entry.isFile() && isDocx(entry.name)) {
480
+ docxFiles.push(fullPath);
481
+ }
482
+ }
483
+ }
484
+ catch { /* skip */ }
485
+ }
486
+ try {
487
+ const stats = await fs.stat(rootPath);
488
+ if (stats.isFile() && isDocx(rootPath)) {
489
+ return [rootPath];
490
+ }
491
+ else if (stats.isDirectory()) {
492
+ await walk(rootPath);
493
+ }
494
+ }
495
+ catch { /* skip */ }
496
+ return docxFiles;
497
+ }
352
498
  /**
353
499
  * Extract context around a match for display (show surrounding text)
354
500
  */
package/dist/server.js CHANGED
@@ -242,6 +242,19 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
242
242
  - PDF: Extracts text content as markdown with page structure
243
243
  * offset/length work as page pagination (0-based)
244
244
  * Includes embedded images when available
245
+ - DOCX (.docx): Two modes depending on parameters:
246
+ * DEFAULT (no offset/length): Returns a text-bearing outline — shows paragraphs with text,
247
+ tables with cell content, styles, image refs. Skips shapes/drawings/SVG noise.
248
+ Each element shows its body index [0], [1], etc.
249
+ * WITH offset/length: Returns raw pretty-printed XML with line pagination.
250
+ Use this to drill into specific sections or see the actual XML for editing.
251
+ * EDITING WORKFLOW: 1) read_file to get outline, 2) read_file with offset/length
252
+ to see raw XML around what you want to edit, 3) edit_block with old_string/new_string
253
+ using XML fragments copied from the read output.
254
+ * IMPORTANT: offset MUST be non-zero to get raw XML (use offset=1 to start from line 1).
255
+ offset=0 always returns the outline regardless of length.
256
+ * For BULK changes (translation, mass replacements): use start_process with Python
257
+ zipfile module to find/replace all <w:t> elements at once.
245
258
 
246
259
  ${PATH_GUIDANCE}
247
260
  ${CMD_PREFIX_DESCRIPTION}`,
@@ -279,6 +292,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
279
292
  Write or append to file contents.
280
293
 
281
294
  IMPORTANT: DO NOT use this tool to create PDF files. Use 'write_pdf' for all PDF creation tasks.
295
+ DO NOT use this tool to edit DOCX files. Use 'edit_block' with old_string/new_string instead.
296
+ To CREATE a new DOCX, use write_file with .docx extension — text content with markdown headings (#, ##, ###) is converted to styled DOCX paragraphs.
282
297
 
283
298
  CHUNKING IS STANDARD PRACTICE: Always write files in chunks of 25-30 lines maximum.
284
299
  This is the normal, recommended way to write files - not an emergency measure.
@@ -657,6 +672,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
657
672
  - new_string: Replacement text
658
673
  - expected_replacements: Optional number of replacements (default: 1)
659
674
 
675
+ DOCX FILES (.docx) - XML Find/Replace mode:
676
+ Takes same parameters as text files (old_string, new_string, expected_replacements).
677
+ Operates on the pretty-printed XML inside the DOCX — the same XML you see from
678
+ read_file with offset/length. Copy XML fragments from read output as old_string.
679
+ After editing, the XML is repacked into a valid DOCX.
680
+ Also searches headers/footers if not found in document body.
681
+ Examples:
682
+ - Replace text: old_string="<w:t>Old Text</w:t>" new_string="<w:t>New Text</w:t>"
683
+ - Change style: old_string='<w:pStyle w:val="Normal"/>' new_string='<w:pStyle w:val="Heading1"/>'
684
+ - Add content: include surrounding XML context in old_string, add new elements in new_string
685
+
660
686
  By default, replaces only ONE occurrence of the search text.
661
687
  To replace multiple occurrences, provide expected_replacements with
662
688
  the exact number of matches expected.
@@ -0,0 +1 @@
1
+ export {};
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Table builder — creates w:tbl elements with headers, rows, and styling.
3
+ * Supports multiple paragraphs per cell with different styles.
3
4
  */
4
5
  import type { DocxContentTable, InsertTableOp } from '../types.js';
5
6
  /**
6
7
  * Build a table element from content structure or operation.
8
+ * Supports cells with multiple paragraphs, each with its own style.
7
9
  */
8
10
  export declare function buildTable(doc: Document, spec: DocxContentTable | InsertTableOp): Element;
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * Table builder — creates w:tbl elements with headers, rows, and styling.
3
+ * Supports multiple paragraphs per cell with different styles.
3
4
  */
5
+ import { buildParagraph } from './paragraph.js';
4
6
  /**
5
7
  * Build a table element from content structure or operation.
8
+ * Supports cells with multiple paragraphs, each with its own style.
6
9
  */
7
10
  export function buildTable(doc, spec) {
8
11
  const tbl = doc.createElement('w:tbl');
@@ -29,12 +32,13 @@ export function buildTable(doc, spec) {
29
32
  }
30
33
  tblPr.appendChild(tblBorders);
31
34
  tbl.appendChild(tblPr);
32
- // Table grid
35
+ // Determine column count
33
36
  const colCount = spec.headers
34
37
  ? spec.headers.length
35
38
  : spec.rows.length > 0
36
39
  ? spec.rows[0].length
37
40
  : 0;
41
+ // Table grid
38
42
  if (colCount > 0) {
39
43
  const tblGrid = doc.createElement('w:tblGrid');
40
44
  for (let c = 0; c < colCount; c++) {
@@ -45,9 +49,15 @@ export function buildTable(doc, spec) {
45
49
  }
46
50
  tbl.appendChild(tblGrid);
47
51
  }
48
- // Helper to build a cell
49
- const buildCell = (text, isHeader, widthTwips) => {
52
+ /**
53
+ * Build a cell from content.
54
+ * Content can be:
55
+ * - A string: creates one paragraph with that text
56
+ * - An array of DocxContentParagraph: creates multiple paragraphs, each with its own style
57
+ */
58
+ const buildCell = (content, isHeader, widthTwips) => {
50
59
  const tc = doc.createElement('w:tc');
60
+ // Cell properties (width)
51
61
  if (widthTwips) {
52
62
  const tcPr = doc.createElement('w:tcPr');
53
63
  const tcW = doc.createElement('w:tcW');
@@ -56,20 +66,54 @@ export function buildTable(doc, spec) {
56
66
  tcPr.appendChild(tcW);
57
67
  tc.appendChild(tcPr);
58
68
  }
59
- const p = doc.createElement('w:p');
60
- const r = doc.createElement('w:r');
61
- if (isHeader) {
62
- const rPr = doc.createElement('w:rPr');
63
- const b = doc.createElement('w:b');
64
- rPr.appendChild(b);
65
- r.appendChild(rPr);
69
+ // Handle content: string or array of paragraphs
70
+ if (typeof content === 'string') {
71
+ // Simple case: single paragraph
72
+ const p = doc.createElement('w:p');
73
+ const r = doc.createElement('w:r');
74
+ // Header cells get bold
75
+ if (isHeader) {
76
+ const rPr = doc.createElement('w:rPr');
77
+ const b = doc.createElement('w:b');
78
+ rPr.appendChild(b);
79
+ r.appendChild(rPr);
80
+ }
81
+ const t = doc.createElement('w:t');
82
+ t.setAttribute('xml:space', 'preserve');
83
+ t.textContent = content;
84
+ r.appendChild(t);
85
+ p.appendChild(r);
86
+ tc.appendChild(p);
87
+ }
88
+ else {
89
+ // Complex case: multiple paragraphs with different styles
90
+ for (const paraSpec of content) {
91
+ const p = buildParagraph(doc, paraSpec);
92
+ // If header and first paragraph, ensure bold on runs
93
+ if (isHeader) {
94
+ const runs = p.getElementsByTagName('w:r');
95
+ for (let i = 0; i < runs.length; i++) {
96
+ const run = runs.item(i);
97
+ let rPr = run.getElementsByTagName('w:rPr').item(0);
98
+ if (!rPr) {
99
+ rPr = doc.createElement('w:rPr');
100
+ if (run.firstChild) {
101
+ run.insertBefore(rPr, run.firstChild);
102
+ }
103
+ else {
104
+ run.appendChild(rPr);
105
+ }
106
+ }
107
+ // Add bold if not already present
108
+ if (!rPr.getElementsByTagName('w:b').length) {
109
+ const b = doc.createElement('w:b');
110
+ rPr.appendChild(b);
111
+ }
112
+ }
113
+ }
114
+ tc.appendChild(p);
115
+ }
66
116
  }
67
- const t = doc.createElement('w:t');
68
- t.setAttribute('xml:space', 'preserve');
69
- t.textContent = text;
70
- r.appendChild(t);
71
- p.appendChild(r);
72
- tc.appendChild(p);
73
117
  return tc;
74
118
  };
75
119
  // Header row
@@ -23,6 +23,17 @@ export declare function getBody(doc: Document): Element;
23
23
  * Includes w:p, w:tbl, w:sdt, w:sectPr, etc.
24
24
  */
25
25
  export declare function getBodyChildren(body: Element): Element[];
26
+ /**
27
+ * Return ALL top‑level tables that are logically in the body, including those
28
+ * wrapped in structured document tags (w:sdt / w:sdtContent).
29
+ *
30
+ * Previous logic only saw tables that were direct children of <w:body>. That
31
+ * meant tables inside SDTs were invisible to table operations and readDocxOutline.
32
+ * This helper walks the body tree and collects any <w:tbl> that appears as a
33
+ * *first‑class* block (we do not recurse into tables themselves, so nested
34
+ * tables are not double‑counted).
35
+ */
36
+ export declare function getAllBodyTables(body: Element): Element[];
26
37
  /**
27
38
  * Build a compact signature string from the body children array.
28
39
  * Maps each node's qualified name to a short local name:
@@ -34,13 +45,75 @@ export declare function bodySignature(children: Element[]): string;
34
45
  export declare function getParagraphText(p: Element): string;
35
46
  /** Read the style id from w:pPr/w:pStyle/@w:val, or null if absent. */
36
47
  export declare function getParagraphStyle(p: Element): string | null;
48
+ /**
49
+ * Extract all text content from a table cell (w:tc).
50
+ * Returns the concatenated text from all paragraphs in the cell.
51
+ */
52
+ export declare function getCellText(tc: Element): string;
53
+ /**
54
+ * Extract all rows from a table (w:tbl).
55
+ * Returns an array of rows, where each row is an array of cell text strings.
56
+ * First row is treated as header if it exists.
57
+ */
58
+ export declare function getTableContent(tbl: Element): {
59
+ headers?: string[];
60
+ rows: string[][];
61
+ };
62
+ /**
63
+ * Get table style from w:tblPr/w:tblStyle/@w:val, or null if absent.
64
+ */
65
+ export declare function getTableStyle(tbl: Element): string | null;
66
+ /**
67
+ * Extract image reference from a w:drawing element.
68
+ * Returns the relationship ID (rId) and media file path if found.
69
+ */
70
+ export declare function getImageReference(drawing: Element): {
71
+ rId: string | null;
72
+ mediaPath: string | null;
73
+ };
37
74
  /**
38
75
  * Replace the text of a paragraph with minimal DOM changes.
39
76
  * Sets the FIRST w:t to `text`, clears every subsequent w:t.
40
77
  * Sets xml:space="preserve" so leading/trailing spaces survive.
41
- * Does NOT recreate runs or remove paragraph properties.
78
+ * Does NOT remove/recreate runs or remove paragraph properties.
79
+ *
80
+ * WARNING: This function does NOT preserve multiple runs with different styles.
81
+ * Use setParagraphTextPreservingStyles() for cells with multiple styled runs.
42
82
  */
43
83
  export declare function setParagraphTextMinimal(p: Element, text: string): void;
84
+ /**
85
+ * Replace paragraph text while preserving all run styles.
86
+ *
87
+ * This function preserves the structure of all runs (w:r) and their
88
+ * properties (w:rPr), distributing the new text across existing runs.
89
+ *
90
+ * Strategy:
91
+ * 1. Collect all runs with their properties
92
+ * 2. Distribute new text across runs (preserving run count and styles)
93
+ * 3. If new text is longer, extend the last run
94
+ * 4. If new text is shorter, clear excess runs but keep their structure
95
+ */
96
+ export declare function setParagraphTextPreservingStyles(p: Element, text: string): void;
97
+ /**
98
+ * Replace cell text while preserving ALL paragraphs and their styles.
99
+ *
100
+ * This function works at the cell level:
101
+ * - Preserves ALL paragraphs in the cell (doesn't remove any)
102
+ * - Updates text in the first paragraph while preserving its styles
103
+ * - Keeps all other paragraphs intact with their original text and styles
104
+ *
105
+ * This ensures that cells with multiple paragraphs (each with different
106
+ * styles, font sizes, etc.) maintain their structure after text replacement.
107
+ *
108
+ * Example: If a cell has:
109
+ * - Paragraph 1: "LAWN AND LANDSCAPE" (Heading1 style, large font, red color)
110
+ * - Paragraph 2: "Take your weekends back..." (Normal style, smaller font, black color)
111
+ *
112
+ * Replacing with "EARTH AND MOUNTAIN" will:
113
+ * - Update paragraph 1 to "EARTH AND MOUNTAIN" (preserving Heading1 style, large font, red color)
114
+ * - Keep paragraph 2 completely intact with its original text and style
115
+ */
116
+ export declare function setCellTextPreservingStyles(tc: Element, text: string): void;
44
117
  /**
45
118
  * Ensure a <w:r> element has w:rPr/w:color[@w:val=hex].
46
119
  * Creates w:rPr and w:color if they don't exist.