confluence-cli 1.11.0 → 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,10 @@
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
+
1
8
  # [1.11.0](https://github.com/pchuri/confluence-cli/compare/v1.10.1...v1.11.0) (2025-12-12)
2
9
 
3
10
 
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.'));
@@ -99,12 +99,43 @@ class ConfluenceClient {
99
99
  return pageIdOrUrl;
100
100
  }
101
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
+
102
132
  /**
103
133
  * Read a Confluence page content
104
134
  * @param {string} pageIdOrUrl - Page ID or URL
105
135
  * @param {string} format - Output format: 'text', 'html', or 'markdown'
106
136
  * @param {object} options - Additional options
107
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)
108
139
  */
109
140
  async readPage(pageIdOrUrl, format = 'text', options = {}) {
110
141
  const pageId = await this.extractPageId(pageIdOrUrl);
@@ -117,6 +148,11 @@ class ConfluenceClient {
117
148
 
118
149
  let htmlContent = response.data.body.storage.value;
119
150
 
151
+ // Extract referenced attachments if requested
152
+ if (options.extractReferencedAttachments) {
153
+ this._referencedAttachments = this.extractReferencedAttachments(htmlContent);
154
+ }
155
+
120
156
  if (format === 'html') {
121
157
  return htmlContent;
122
158
  }
@@ -135,6 +171,9 @@ class ConfluenceClient {
135
171
  htmlContent = await this.resolvePageLinksInHtml(htmlContent);
136
172
  }
137
173
 
174
+ // Resolve children macro to child pages list
175
+ htmlContent = await this.resolveChildrenMacro(htmlContent, pageId);
176
+
138
177
  return this.storageToMarkdown(htmlContent);
139
178
  }
140
179
 
@@ -358,6 +397,58 @@ class ConfluenceClient {
358
397
  return resolvedHtml;
359
398
  }
360
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
+
361
452
  /**
362
453
  * List attachments for a page with pagination support
363
454
  */
@@ -556,33 +647,8 @@ class ConfluenceClient {
556
647
  // Convert markdown to HTML first
557
648
  const html = this.markdown.render(markdown);
558
649
 
559
- // Simple HTML to Storage format conversion
560
- // This is a basic implementation - for full support, we'd need a more sophisticated converter
561
- let storage = html
562
- .replace(/<h1>/g, '<h1>')
563
- .replace(/<\/h1>/g, '</h1>')
564
- .replace(/<h2>/g, '<h2>')
565
- .replace(/<\/h2>/g, '</h2>')
566
- .replace(/<h3>/g, '<h3>')
567
- .replace(/<\/h3>/g, '</h3>')
568
- .replace(/<p>/g, '<p>')
569
- .replace(/<\/p>/g, '</p>')
570
- .replace(/<strong>/g, '<strong>')
571
- .replace(/<\/strong>/g, '</strong>')
572
- .replace(/<em>/g, '<em>')
573
- .replace(/<\/em>/g, '</em>')
574
- .replace(/<ul>/g, '<ul>')
575
- .replace(/<\/ul>/g, '</ul>')
576
- .replace(/<ol>/g, '<ol>')
577
- .replace(/<\/ol>/g, '</ol>')
578
- .replace(/<li>/g, '<li>')
579
- .replace(/<\/li>/g, '</li>')
580
- .replace(/<code>/g, '<code>')
581
- .replace(/<\/code>/g, '</code>')
582
- .replace(/<pre><code>/g, '<ac:structured-macro ac:name="code"><ac:plain-text-body><![CDATA[')
583
- .replace(/<\/code><\/pre>/g, ']]></ac:plain-text-body></ac:structured-macro>');
584
-
585
- return storage;
650
+ // Delegate to htmlToConfluenceStorage for proper conversion including code blocks
651
+ return this.htmlToConfluenceStorage(html);
586
652
  }
587
653
 
588
654
  /**
@@ -618,6 +684,76 @@ class ConfluenceClient {
618
684
  });
619
685
  }
620
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
+
621
757
  /**
622
758
  * Convert Confluence storage format to markdown
623
759
  * @param {string} storage - Confluence storage format HTML
@@ -628,6 +764,9 @@ class ConfluenceClient {
628
764
  const attachmentsDir = options.attachmentsDir || 'attachments';
629
765
  let markdown = storage;
630
766
 
767
+ // Detect language from content
768
+ const labels = this.detectLanguageLabels(markdown);
769
+
631
770
  // Remove table of contents macro
632
771
  markdown = markdown.replace(/<ac:structured-macro ac:name="toc"[^>]*\s*\/>/g, '');
633
772
  markdown = markdown.replace(/<ac:structured-macro ac:name="toc"[^>]*>[\s\S]*?<\/ac:structured-macro>/g, '');
@@ -652,20 +791,8 @@ class ConfluenceClient {
652
791
  });
653
792
 
654
793
  // Convert expand macro - extract content from rich-text-body
655
- // Detect language based on content for the expand summary text
656
- const detectExpandSummary = (text) => {
657
- if (/[\u4e00-\u9fa5]/.test(text)) return '展开详情'; // Chinese
658
- if (/[\u3040-\u309f\u30a0-\u30ff]/.test(text)) return '詳細を表示'; // Japanese
659
- if (/[\uac00-\ud7af]/.test(text)) return '상세 보기'; // Korean
660
- if (/[\u0400-\u04ff]/.test(text)) return 'Подробнее'; // Russian/Cyrillic
661
- if (/[àâäéèêëïîôùûüÿœæç]/i.test(text)) return 'Détails'; // French
662
- if (/[äöüß]/i.test(text)) return 'Details'; // German
663
- if (/[áéíóúñ¿¡]/i.test(text)) return 'Detalles'; // Spanish
664
- return 'Expand Details'; // Default: English
665
- };
666
- const expandSummary = detectExpandSummary(markdown);
667
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) => {
668
- 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`;
669
796
  });
670
797
 
671
798
  // Convert Confluence code macros to markdown
@@ -716,6 +843,58 @@ class ConfluenceClient {
716
843
  return tasks.length > 0 ? '\n' + tasks.join('\n') + '\n' : '';
717
844
  });
718
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
+
719
898
  // Remove other unhandled macros (replace with empty string for now)
720
899
  markdown = markdown.replace(/<ac:structured-macro[^>]*>[\s\S]*?<\/ac:structured-macro>/g, '');
721
900
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "confluence-cli",
3
- "version": "1.11.0",
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": {
@@ -181,6 +181,23 @@ describe('ConfluenceClient', () => {
181
181
  });
182
182
  });
183
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
+
184
201
  describe('storageToMarkdown', () => {
185
202
  test('should convert Confluence storage format to markdown', () => {
186
203
  const storage = '<h1>Hello World</h1><p>This is a <strong>test</strong> page.</p>';