confluence-cli 1.10.1 → 1.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## [1.11.1](https://github.com/pchuri/confluence-cli/compare/v1.11.0...v1.11.1) (2025-12-17)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * support children macro, improve macro handling, and filter attachments ([#23](https://github.com/pchuri/confluence-cli/issues/23)) ([15b721a](https://github.com/pchuri/confluence-cli/commit/15b721acdc296e72470ee438c9fe3470e09ae52e))
7
+
8
+ # [1.11.0](https://github.com/pchuri/confluence-cli/compare/v1.10.1...v1.11.0) (2025-12-12)
9
+
10
+
11
+ ### Features
12
+
13
+ * Support for Confluence display URLs ([#20](https://github.com/pchuri/confluence-cli/issues/20)) ([3bda7c2](https://github.com/pchuri/confluence-cli/commit/3bda7c2aad8ec02dac60f3b7c34c31b549a31cce))
14
+
1
15
  ## [1.10.1](https://github.com/pchuri/confluence-cli/compare/v1.10.0...v1.10.1) (2025-12-08)
2
16
 
3
17
 
package/bin/confluence.js CHANGED
@@ -445,7 +445,9 @@ program
445
445
  const contentExt = formatExt[format] || 'txt';
446
446
 
447
447
  const pageInfo = await client.getPageInfo(pageId);
448
- const content = await client.readPage(pageId, format);
448
+ // Read page with attachment extraction enabled
449
+ const content = await client.readPage(pageId, format, { extractReferencedAttachments: true });
450
+ const referencedAttachments = client._referencedAttachments || new Set();
449
451
 
450
452
  const baseDir = path.resolve(options.dest || '.');
451
453
  const folderName = sanitizeTitle(pageInfo.title || 'page');
@@ -462,8 +464,16 @@ program
462
464
 
463
465
  if (!options.skipAttachments) {
464
466
  const pattern = options.pattern ? options.pattern.trim() : null;
465
- const attachments = await client.getAllAttachments(pageId);
466
- const filtered = pattern ? attachments.filter(att => client.matchesPattern(att.title, pattern)) : attachments;
467
+ const allAttachments = await client.getAllAttachments(pageId);
468
+
469
+ // Filter: only referenced attachments (unless pattern is specified, then use pattern)
470
+ let filtered;
471
+ if (pattern) {
472
+ filtered = allAttachments.filter(att => client.matchesPattern(att.title, pattern));
473
+ } else {
474
+ // Only download attachments that are referenced in the page content
475
+ filtered = allAttachments.filter(att => referencedAttachments.has(att.title));
476
+ }
467
477
 
468
478
  if (filtered.length === 0) {
469
479
  console.log(chalk.yellow('No attachments to download.'));
@@ -49,7 +49,7 @@ class ConfluenceClient {
49
49
  /**
50
50
  * Extract page ID from URL or return the ID if it's already a number
51
51
  */
52
- extractPageId(pageIdOrUrl) {
52
+ async extractPageId(pageIdOrUrl) {
53
53
  if (typeof pageIdOrUrl === 'number' || /^\d+$/.test(pageIdOrUrl)) {
54
54
  return pageIdOrUrl;
55
55
  }
@@ -62,25 +62,83 @@ class ConfluenceClient {
62
62
  return pageIdMatch[1];
63
63
  }
64
64
 
65
- // Handle display URLs - would need to search by space and title
65
+ // Handle display URLs - search by space and title
66
66
  const displayMatch = pageIdOrUrl.match(/\/display\/([^/]+)\/(.+)/);
67
67
  if (displayMatch) {
68
- throw new Error('Display URLs not yet supported. Please use page ID or viewpage URL with pageId parameter.');
68
+ const spaceKey = displayMatch[1];
69
+ // Confluence friendly URLs for child pages might look like /display/SPACE/Parent/Child
70
+ // We only want the last part as the title
71
+ const urlPath = displayMatch[2];
72
+ const lastSegment = urlPath.split('/').pop();
73
+
74
+ // Confluence uses + for spaces in URL titles, but decodeURIComponent doesn't convert + to space
75
+ const rawTitle = lastSegment.replace(/\+/g, '%20');
76
+ const title = decodeURIComponent(rawTitle);
77
+
78
+ try {
79
+ const response = await this.client.get('/content', {
80
+ params: {
81
+ spaceKey: spaceKey,
82
+ title: title,
83
+ limit: 1
84
+ }
85
+ });
86
+
87
+ if (response.data.results && response.data.results.length > 0) {
88
+ return response.data.results[0].id;
89
+ }
90
+ } catch (error) {
91
+ // Ignore error and fall through
92
+ console.error('Error resolving page ID from display URL:', error);
93
+ }
94
+
95
+ throw new Error(`Could not resolve page ID from display URL: ${pageIdOrUrl}`);
69
96
  }
70
97
  }
71
98
 
72
99
  return pageIdOrUrl;
73
100
  }
74
101
 
102
+ /**
103
+ * Extract referenced attachment filenames from HTML content
104
+ * @param {string} htmlContent - HTML content in storage format
105
+ * @returns {Set<string>} Set of referenced attachment filenames
106
+ */
107
+ extractReferencedAttachments(htmlContent) {
108
+ const referenced = new Set();
109
+
110
+ // Extract from ac:image with ri:attachment
111
+ const imageRegex = /<ac:image[^>]*>[\s\S]*?<ri:attachment\s+ri:filename="([^"]+)"[^>]*\/?>[\s\S]*?<\/ac:image>/g;
112
+ let match;
113
+ while ((match = imageRegex.exec(htmlContent)) !== null) {
114
+ referenced.add(match[1]);
115
+ }
116
+
117
+ // Extract from view-file macro
118
+ const viewFileRegex = /<ac:structured-macro ac:name="view-file"[^>]*>[\s\S]*?<ri:attachment\s+ri:filename="([^"]+)"[^>]*\/?>[\s\S]*?<\/ac:structured-macro>/g;
119
+ while ((match = viewFileRegex.exec(htmlContent)) !== null) {
120
+ referenced.add(match[1]);
121
+ }
122
+
123
+ // Extract from any ri:attachment references
124
+ const attachmentRegex = /<ri:attachment\s+ri:filename="([^"]+)"[^>]*\/?>/g;
125
+ while ((match = attachmentRegex.exec(htmlContent)) !== null) {
126
+ referenced.add(match[1]);
127
+ }
128
+
129
+ return referenced;
130
+ }
131
+
75
132
  /**
76
133
  * Read a Confluence page content
77
134
  * @param {string} pageIdOrUrl - Page ID or URL
78
135
  * @param {string} format - Output format: 'text', 'html', or 'markdown'
79
136
  * @param {object} options - Additional options
80
137
  * @param {boolean} options.resolveUsers - Whether to resolve userkeys to display names (default: true for markdown)
138
+ * @param {boolean} options.extractReferencedAttachments - Whether to extract referenced attachments (default: false)
81
139
  */
82
140
  async readPage(pageIdOrUrl, format = 'text', options = {}) {
83
- const pageId = this.extractPageId(pageIdOrUrl);
141
+ const pageId = await this.extractPageId(pageIdOrUrl);
84
142
 
85
143
  const response = await this.client.get(`/content/${pageId}`, {
86
144
  params: {
@@ -90,6 +148,11 @@ class ConfluenceClient {
90
148
 
91
149
  let htmlContent = response.data.body.storage.value;
92
150
 
151
+ // Extract referenced attachments if requested
152
+ if (options.extractReferencedAttachments) {
153
+ this._referencedAttachments = this.extractReferencedAttachments(htmlContent);
154
+ }
155
+
93
156
  if (format === 'html') {
94
157
  return htmlContent;
95
158
  }
@@ -108,6 +171,9 @@ class ConfluenceClient {
108
171
  htmlContent = await this.resolvePageLinksInHtml(htmlContent);
109
172
  }
110
173
 
174
+ // Resolve children macro to child pages list
175
+ htmlContent = await this.resolveChildrenMacro(htmlContent, pageId);
176
+
111
177
  return this.storageToMarkdown(htmlContent);
112
178
  }
113
179
 
@@ -127,7 +193,7 @@ class ConfluenceClient {
127
193
  * Get page information
128
194
  */
129
195
  async getPageInfo(pageIdOrUrl) {
130
- const pageId = this.extractPageId(pageIdOrUrl);
196
+ const pageId = await this.extractPageId(pageIdOrUrl);
131
197
 
132
198
  const response = await this.client.get(`/content/${pageId}`, {
133
199
  params: {
@@ -331,11 +397,63 @@ class ConfluenceClient {
331
397
  return resolvedHtml;
332
398
  }
333
399
 
400
+ /**
401
+ * Resolve children macro to child pages list
402
+ * @param {string} html - HTML content with children macro
403
+ * @param {string} pageId - Page ID to get children from
404
+ * @returns {Promise<string>} - HTML with children macro replaced by markdown list
405
+ */
406
+ async resolveChildrenMacro(html, pageId) {
407
+ // Check if there's a children macro (self-closing or with closing tag)
408
+ const childrenMacroRegex = /<ac:structured-macro\s+ac:name="children"[^>]*(?:\/>|>[\s\S]*?<\/ac:structured-macro>)/g;
409
+ const hasChildrenMacro = childrenMacroRegex.test(html);
410
+
411
+ if (!hasChildrenMacro) {
412
+ return html;
413
+ }
414
+
415
+ try {
416
+ // Get child pages with full info including _links
417
+ const response = await this.client.get(`/content/${pageId}/child/page`, {
418
+ params: {
419
+ limit: 500,
420
+ expand: 'space,version'
421
+ }
422
+ });
423
+
424
+ const childPages = response.data.results || [];
425
+
426
+ if (childPages.length === 0) {
427
+ // No children, remove the macro
428
+ return html.replace(childrenMacroRegex, '');
429
+ }
430
+
431
+ // Convert child pages to markdown list
432
+ // Format: - [Page Title](URL)
433
+ const childPagesList = childPages.map(page => {
434
+ const webui = page._links?.webui || '';
435
+ const url = webui ? `https://${this.domain}/wiki${webui}` : '';
436
+ if (url) {
437
+ return `- [${page.title}](${url})`;
438
+ } else {
439
+ return `- ${page.title}`;
440
+ }
441
+ }).join('\n');
442
+
443
+ // Replace children macro with markdown list
444
+ return html.replace(childrenMacroRegex, `\n${childPagesList}\n`);
445
+ } catch (error) {
446
+ // If error getting children, just remove the macro
447
+ console.error(`Error resolving children macro: ${error.message}`);
448
+ return html.replace(childrenMacroRegex, '');
449
+ }
450
+ }
451
+
334
452
  /**
335
453
  * List attachments for a page with pagination support
336
454
  */
337
455
  async listAttachments(pageIdOrUrl, options = {}) {
338
- const pageId = this.extractPageId(pageIdOrUrl);
456
+ const pageId = await this.extractPageId(pageIdOrUrl);
339
457
  const limit = this.parsePositiveInt(options.limit, 50);
340
458
  const start = this.parsePositiveInt(options.start, 0);
341
459
  const params = {
@@ -402,7 +520,7 @@ class ConfluenceClient {
402
520
  downloadUrl = attachmentIdOrAttachment.downloadLink;
403
521
  } else {
404
522
  // Otherwise, fetch attachment info to get the download link
405
- const pageId = this.extractPageId(pageIdOrUrl);
523
+ const pageId = await this.extractPageId(pageIdOrUrl);
406
524
  const attachmentId = attachmentIdOrAttachment;
407
525
  const response = await this.client.get(`/content/${pageId}/child/attachment`, {
408
526
  params: { limit: 500 }
@@ -529,33 +647,8 @@ class ConfluenceClient {
529
647
  // Convert markdown to HTML first
530
648
  const html = this.markdown.render(markdown);
531
649
 
532
- // Simple HTML to Storage format conversion
533
- // This is a basic implementation - for full support, we'd need a more sophisticated converter
534
- let storage = html
535
- .replace(/<h1>/g, '<h1>')
536
- .replace(/<\/h1>/g, '</h1>')
537
- .replace(/<h2>/g, '<h2>')
538
- .replace(/<\/h2>/g, '</h2>')
539
- .replace(/<h3>/g, '<h3>')
540
- .replace(/<\/h3>/g, '</h3>')
541
- .replace(/<p>/g, '<p>')
542
- .replace(/<\/p>/g, '</p>')
543
- .replace(/<strong>/g, '<strong>')
544
- .replace(/<\/strong>/g, '</strong>')
545
- .replace(/<em>/g, '<em>')
546
- .replace(/<\/em>/g, '</em>')
547
- .replace(/<ul>/g, '<ul>')
548
- .replace(/<\/ul>/g, '</ul>')
549
- .replace(/<ol>/g, '<ol>')
550
- .replace(/<\/ol>/g, '</ol>')
551
- .replace(/<li>/g, '<li>')
552
- .replace(/<\/li>/g, '</li>')
553
- .replace(/<code>/g, '<code>')
554
- .replace(/<\/code>/g, '</code>')
555
- .replace(/<pre><code>/g, '<ac:structured-macro ac:name="code"><ac:plain-text-body><![CDATA[')
556
- .replace(/<\/code><\/pre>/g, ']]></ac:plain-text-body></ac:structured-macro>');
557
-
558
- return storage;
650
+ // Delegate to htmlToConfluenceStorage for proper conversion including code blocks
651
+ return this.htmlToConfluenceStorage(html);
559
652
  }
560
653
 
561
654
  /**
@@ -591,6 +684,76 @@ class ConfluenceClient {
591
684
  });
592
685
  }
593
686
 
687
+ /**
688
+ * Detect language from text content and return appropriate labels
689
+ * @param {string} text - Text content to analyze
690
+ * @returns {object} Object with language-specific labels
691
+ */
692
+ detectLanguageLabels(text) {
693
+ const labels = {
694
+ includePage: 'Include Page',
695
+ sharedBlock: 'Shared Block',
696
+ includeSharedBlock: 'Include Shared Block',
697
+ fromPage: 'from page',
698
+ expandDetails: 'Expand Details'
699
+ };
700
+
701
+ if (/[\u4e00-\u9fa5]/.test(text)) {
702
+ // Chinese
703
+ labels.includePage = '包含锵面';
704
+ labels.sharedBlock = 'å…±äŗ«å—';
705
+ labels.includeSharedBlock = 'åŒ…å«å…±äŗ«å—';
706
+ labels.fromPage = 'ę„č‡Ŗé”µé¢';
707
+ labels.expandDetails = 'å±•å¼€čÆ¦ęƒ…';
708
+ } else if (/[\u3040-\u309f\u30a0-\u30ff]/.test(text)) {
709
+ // Japanese
710
+ labels.includePage = 'ćƒšćƒ¼ć‚øć‚’å«ć‚€';
711
+ labels.sharedBlock = 'å…±ęœ‰ćƒ–ćƒ­ćƒƒć‚Æ';
712
+ labels.includeSharedBlock = 'å…±ęœ‰ćƒ–ćƒ­ćƒƒć‚Æć‚’å«ć‚€';
713
+ labels.fromPage = 'ćƒšćƒ¼ć‚øć‹ć‚‰';
714
+ labels.expandDetails = '詳瓰を蔨示';
715
+ } else if (/[\uac00-\ud7af]/.test(text)) {
716
+ // Korean
717
+ labels.includePage = 'ķŽ˜ģ“ģ§€ ķ¬ķ•Ø';
718
+ labels.sharedBlock = '공유 ėø”ė”';
719
+ labels.includeSharedBlock = '공유 ėø”ė” ķ¬ķ•Ø';
720
+ labels.fromPage = 'ķŽ˜ģ“ģ§€ģ—ģ„œ';
721
+ labels.expandDetails = 'ģƒģ„ø 볓기';
722
+ } else if (/[\u0400-\u04ff]/.test(text)) {
723
+ // Russian/Cyrillic
724
+ labels.includePage = 'Š’ŠŗŠ»ŃŽŃ‡ŠøŃ‚ŃŒ ŃŃ‚Ń€Š°Š½ŠøŃ†Ńƒ';
725
+ labels.sharedBlock = 'ŠžŠ±Ń‰ŠøŠ¹ блок';
726
+ labels.includeSharedBlock = 'Š’ŠŗŠ»ŃŽŃ‡ŠøŃ‚ŃŒ общий блок';
727
+ labels.fromPage = 'со страницы';
728
+ labels.expandDetails = 'ŠŸŠ¾Š“Ń€Š¾Š±Š½ŠµŠµ';
729
+ } else if ((text.match(/[àâäéèêëïîÓùûüÿœæç]/gi) || []).length >= 2) {
730
+ // French (requires at least 2 French-specific characters to avoid false positives)
731
+ labels.includePage = 'Inclure la page';
732
+ labels.sharedBlock = 'Bloc partagƩ';
733
+ labels.includeSharedBlock = 'Inclure le bloc partagƩ';
734
+ labels.fromPage = 'de la page';
735
+ labels.expandDetails = 'DƩtails';
736
+ } else if ((text.match(/[äöüß]/gi) || []).length >= 2) {
737
+ // German (requires at least 2 German-specific characters)
738
+ // Note: French is checked before German because French regex includes more characters
739
+ // that overlap with German (ä, ü). The threshold helps distinguish between them.
740
+ labels.includePage = 'Seite einbinden';
741
+ labels.sharedBlock = 'Gemeinsamer Block';
742
+ labels.includeSharedBlock = 'Gemeinsamen Block einbinden';
743
+ labels.fromPage = 'von Seite';
744
+ labels.expandDetails = 'Details';
745
+ } else if ((text.match(/[Ôéíóúñ¿”]/gi) || []).length >= 2) {
746
+ // Spanish (requires at least 2 Spanish-specific characters)
747
+ labels.includePage = 'Incluir pƔgina';
748
+ labels.sharedBlock = 'Bloque compartido';
749
+ labels.includeSharedBlock = 'Incluir bloque compartido';
750
+ labels.fromPage = 'de la pƔgina';
751
+ labels.expandDetails = 'Detalles';
752
+ }
753
+
754
+ return labels;
755
+ }
756
+
594
757
  /**
595
758
  * Convert Confluence storage format to markdown
596
759
  * @param {string} storage - Confluence storage format HTML
@@ -601,6 +764,9 @@ class ConfluenceClient {
601
764
  const attachmentsDir = options.attachmentsDir || 'attachments';
602
765
  let markdown = storage;
603
766
 
767
+ // Detect language from content
768
+ const labels = this.detectLanguageLabels(markdown);
769
+
604
770
  // Remove table of contents macro
605
771
  markdown = markdown.replace(/<ac:structured-macro ac:name="toc"[^>]*\s*\/>/g, '');
606
772
  markdown = markdown.replace(/<ac:structured-macro ac:name="toc"[^>]*>[\s\S]*?<\/ac:structured-macro>/g, '');
@@ -625,20 +791,8 @@ class ConfluenceClient {
625
791
  });
626
792
 
627
793
  // Convert expand macro - extract content from rich-text-body
628
- // Detect language based on content for the expand summary text
629
- const detectExpandSummary = (text) => {
630
- if (/[\u4e00-\u9fa5]/.test(text)) return 'å±•å¼€čÆ¦ęƒ…'; // Chinese
631
- if (/[\u3040-\u309f\u30a0-\u30ff]/.test(text)) return '詳瓰を蔨示'; // Japanese
632
- if (/[\uac00-\ud7af]/.test(text)) return 'ģƒģ„ø 볓기'; // Korean
633
- if (/[\u0400-\u04ff]/.test(text)) return 'ŠŸŠ¾Š“Ń€Š¾Š±Š½ŠµŠµ'; // Russian/Cyrillic
634
- if (/[àâäéèêëïîÓùûüÿœæç]/i.test(text)) return 'DĆ©tails'; // French
635
- if (/[äöüß]/i.test(text)) return 'Details'; // German
636
- if (/[Ôéíóúñ¿”]/i.test(text)) return 'Detalles'; // Spanish
637
- return 'Expand Details'; // Default: English
638
- };
639
- const expandSummary = detectExpandSummary(markdown);
640
794
  markdown = markdown.replace(/<ac:structured-macro ac:name="expand"[^>]*>[\s\S]*?<ac:rich-text-body>([\s\S]*?)<\/ac:rich-text-body>[\s\S]*?<\/ac:structured-macro>/g, (_, content) => {
641
- return `\n<details>\n<summary>${expandSummary}</summary>\n\n${content}\n\n</details>\n`;
795
+ return `\n<details>\n<summary>${labels.expandDetails}</summary>\n\n${content}\n\n</details>\n`;
642
796
  });
643
797
 
644
798
  // Convert Confluence code macros to markdown
@@ -689,6 +843,58 @@ class ConfluenceClient {
689
843
  return tasks.length > 0 ? '\n' + tasks.join('\n') + '\n' : '';
690
844
  });
691
845
 
846
+ // Convert panel macro to markdown blockquote with title
847
+ markdown = markdown.replace(/<ac:structured-macro ac:name="panel"[^>]*>[\s\S]*?<ac:parameter ac:name="title">([^<]*)<\/ac:parameter>[\s\S]*?<ac:rich-text-body>([\s\S]*?)<\/ac:rich-text-body>[\s\S]*?<\/ac:structured-macro>/g, (_, title, content) => {
848
+ const cleanContent = this.htmlToMarkdown(content);
849
+ return `\n> **${title}**\n>\n${cleanContent.split('\n').map(line => line ? `> ${line}` : '>').join('\n')}\n`;
850
+ });
851
+
852
+ // Convert include macro - extract page link and convert to markdown link
853
+ // Handle both with and without parameter name
854
+ markdown = markdown.replace(/<ac:structured-macro ac:name="include"[^>]*>[\s\S]*?<ac:parameter ac:name="">[\s\S]*?<ac:link>[\s\S]*?<ri:page\s+ri:space-key="([^"]+)"\s+ri:content-title="([^"]+)"[^>]*\/>[\s\S]*?<\/ac:link>[\s\S]*?<\/ac:parameter>[\s\S]*?<\/ac:structured-macro>/g, (_, spaceKey, title) => {
855
+ // Try to build a proper URL - if spaceKey starts with ~, it's a user space
856
+ if (spaceKey.startsWith('~')) {
857
+ const spacePath = `display/${spaceKey}/${encodeURIComponent(title)}`;
858
+ return `\n> šŸ“„ **${labels.includePage}**: [${title}](https://${this.domain}/wiki/${spacePath})\n`;
859
+ } else {
860
+ // For non-user spaces, we cannot construct a valid link without the page ID.
861
+ // Document that manual correction is required.
862
+ return `\n> šŸ“„ **${labels.includePage}**: [${title}](https://${this.domain}/wiki/spaces/${spaceKey}/pages/[PAGE_ID_HERE]) _(manual link correction required)_\n`;
863
+ }
864
+ });
865
+
866
+ // Convert shared-block and include-shared-block macros - extract content
867
+ markdown = markdown.replace(/<ac:structured-macro ac:name="(shared-block|include-shared-block)"[^>]*>[\s\S]*?<ac:parameter ac:name="shared-block-key">([^<]*)<\/ac:parameter>[\s\S]*?<ac:rich-text-body>([\s\S]*?)<\/ac:rich-text-body>[\s\S]*?<\/ac:structured-macro>/g, (_, macroType, blockKey, content) => {
868
+ const cleanContent = this.htmlToMarkdown(content);
869
+ return `\n> **${labels.sharedBlock}: ${blockKey}**\n>\n${cleanContent.split('\n').map(line => line ? `> ${line}` : '>').join('\n')}\n`;
870
+ });
871
+
872
+ // Convert include-shared-block with page parameter
873
+ markdown = markdown.replace(/<ac:structured-macro ac:name="include-shared-block"[^>]*>[\s\S]*?<ac:parameter ac:name="shared-block-key">([^<]*)<\/ac:parameter>[\s\S]*?<ac:parameter ac:name="page">[\s\S]*?<ac:link>[\s\S]*?<ri:page\s+ri:space-key="([^"]+)"\s+ri:content-title="([^"]+)"[^>]*\/>[\s\S]*?<\/ac:link>[\s\S]*?<\/ac:parameter>[\s\S]*?<\/ac:structured-macro>/g, (_, blockKey, spaceKey, pageTitle) => {
874
+ // The page ID is not available, so we cannot generate a valid link.
875
+ // Instead, document that the link needs manual correction.
876
+ return `\n> šŸ“„ **${labels.includeSharedBlock}**: ${blockKey} (${labels.fromPage}: ${pageTitle} [link needs manual correction])\n`;
877
+ });
878
+
879
+ // Convert view-file macro to file link
880
+ // Handle both orders: name first or height first
881
+ markdown = markdown.replace(/<ac:structured-macro ac:name="view-file"[^>]*>[\s\S]*?<ac:parameter ac:name="name">[\s\S]*?<ri:attachment\s+ri:filename="([^"]+)"[^>]*\/>[\s\S]*?<\/ac:parameter>[\s\S]*?<\/ac:structured-macro>/g, (_, filename) => {
882
+ return `\nšŸ“Ž [${filename}](${attachmentsDir}/${filename})\n`;
883
+ });
884
+
885
+ // Also handle view-file with height parameter (which might appear after name)
886
+ markdown = markdown.replace(/<ac:structured-macro ac:name="view-file"[^>]*>[\s\S]*?<ac:parameter ac:name="name">[\s\S]*?<ri:attachment\s+ri:filename="([^"]+)"[^>]*\/>[\s\S]*?<\/ac:parameter>[\s\S]*?<ac:parameter ac:name="height">([^<]*)<\/ac:parameter>[\s\S]*?<\/ac:structured-macro>/g, (_, filename, _height) => {
887
+ return `\nšŸ“Ž [${filename}](${attachmentsDir}/${filename})\n`;
888
+ });
889
+
890
+ // Remove layout macros but preserve content
891
+ markdown = markdown.replace(/<ac:layout>/g, '');
892
+ markdown = markdown.replace(/<\/ac:layout>/g, '');
893
+ markdown = markdown.replace(/<ac:layout-section[^>]*>/g, '');
894
+ markdown = markdown.replace(/<\/ac:layout-section>/g, '');
895
+ markdown = markdown.replace(/<ac:layout-cell[^>]*>/g, '');
896
+ markdown = markdown.replace(/<\/ac:layout-cell>/g, '');
897
+
692
898
  // Remove other unhandled macros (replace with empty string for now)
693
899
  markdown = markdown.replace(/<ac:structured-macro[^>]*>[\s\S]*?<\/ac:structured-macro>/g, '');
694
900
 
@@ -985,7 +1191,7 @@ class ConfluenceClient {
985
1191
  * Get page content for editing
986
1192
  */
987
1193
  async getPageForEdit(pageIdOrUrl) {
988
- const pageId = this.extractPageId(pageIdOrUrl);
1194
+ const pageId = await this.extractPageId(pageIdOrUrl);
989
1195
 
990
1196
  const response = await this.client.get(`/content/${pageId}`, {
991
1197
  params: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "confluence-cli",
3
- "version": "1.10.1",
3
+ "version": "1.11.1",
4
4
  "description": "A command-line interface for Atlassian Confluence with page creation and editing capabilities",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -32,6 +32,7 @@
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/node": "^20.10.0",
35
+ "axios-mock-adapter": "^2.1.0",
35
36
  "eslint": "^8.55.0",
36
37
  "jest": "^29.7.0"
37
38
  },
@@ -1,4 +1,5 @@
1
1
  const ConfluenceClient = require('../lib/confluence-client');
2
+ const MockAdapter = require('axios-mock-adapter');
2
3
 
3
4
  describe('ConfluenceClient', () => {
4
5
  let client;
@@ -63,19 +64,64 @@ describe('ConfluenceClient', () => {
63
64
  });
64
65
 
65
66
  describe('extractPageId', () => {
66
- test('should return numeric page ID as is', () => {
67
- expect(client.extractPageId('123456789')).toBe('123456789');
68
- expect(client.extractPageId(123456789)).toBe(123456789);
67
+ test('should return numeric page ID as is', async () => {
68
+ expect(await client.extractPageId('123456789')).toBe('123456789');
69
+ expect(await client.extractPageId(123456789)).toBe(123456789);
69
70
  });
70
71
 
71
- test('should extract page ID from URL with pageId parameter', () => {
72
+ test('should extract page ID from URL with pageId parameter', async () => {
72
73
  const url = 'https://test.atlassian.net/wiki/spaces/TEST/pages/123456789/Page+Title';
73
- expect(client.extractPageId(url + '?pageId=987654321')).toBe('987654321');
74
+ expect(await client.extractPageId(url + '?pageId=987654321')).toBe('987654321');
74
75
  });
75
76
 
76
- test('should throw error for display URLs', () => {
77
+ test('should resolve display URLs', async () => {
78
+ // Mock the API response for display URL resolution
79
+ const mock = new MockAdapter(client.client);
80
+
81
+ mock.onGet('/content').reply(200, {
82
+ results: [{
83
+ id: '12345',
84
+ title: 'Page Title',
85
+ _links: { webui: '/display/TEST/Page+Title' }
86
+ }]
87
+ });
88
+
77
89
  const displayUrl = 'https://test.atlassian.net/display/TEST/Page+Title';
78
- expect(() => client.extractPageId(displayUrl)).toThrow('Display URLs not yet supported');
90
+ expect(await client.extractPageId(displayUrl)).toBe('12345');
91
+
92
+ mock.restore();
93
+ });
94
+
95
+ test('should resolve nested display URLs', async () => {
96
+ // Mock the API response for display URL resolution
97
+ const mock = new MockAdapter(client.client);
98
+
99
+ mock.onGet('/content').reply(200, {
100
+ results: [{
101
+ id: '67890',
102
+ title: 'Child Page',
103
+ _links: { webui: '/display/TEST/Parent/Child+Page' }
104
+ }]
105
+ });
106
+
107
+ const displayUrl = 'https://test.atlassian.net/display/TEST/Parent/Child+Page';
108
+ expect(await client.extractPageId(displayUrl)).toBe('67890');
109
+
110
+ mock.restore();
111
+ });
112
+
113
+ test('should throw error when display URL cannot be resolved', async () => {
114
+ const mock = new MockAdapter(client.client);
115
+
116
+ // Mock empty result
117
+ mock.onGet('/content').reply(200, {
118
+ results: []
119
+ });
120
+
121
+ const displayUrl = 'https://test.atlassian.net/display/TEST/NonExistentPage';
122
+ await expect(client.extractPageId(displayUrl)).rejects.toThrow(/Could not resolve page ID/);
123
+
124
+ mock.restore();
79
125
  });
80
126
  });
81
127
 
@@ -135,6 +181,23 @@ describe('ConfluenceClient', () => {
135
181
  });
136
182
  });
137
183
 
184
+ describe('markdownToNativeStorage', () => {
185
+ test('should act as an alias to htmlToConfluenceStorage via markdown render', () => {
186
+ const markdown = '# Native Storage Test';
187
+ const result = client.markdownToNativeStorage(markdown);
188
+
189
+ expect(result).toContain('<h1>Native Storage Test</h1>');
190
+ });
191
+
192
+ test('should handle code blocks correctly', () => {
193
+ const markdown = '```javascript\nconst a = 1;\n```';
194
+ const result = client.markdownToNativeStorage(markdown);
195
+
196
+ expect(result).toContain('<ac:structured-macro ac:name="code">');
197
+ expect(result).toContain('const a = 1;');
198
+ });
199
+ });
200
+
138
201
  describe('storageToMarkdown', () => {
139
202
  test('should convert Confluence storage format to markdown', () => {
140
203
  const storage = '<h1>Hello World</h1><p>This is a <strong>test</strong> page.</p>';