confluence-cli 1.11.0 → 1.12.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [1.12.0](https://github.com/pchuri/confluence-cli/compare/v1.11.1...v1.12.0) (2025-12-31)
2
+
3
+
4
+ ### Features
5
+
6
+ * add page delete command ([#25](https://github.com/pchuri/confluence-cli/issues/25)) ([bc3e412](https://github.com/pchuri/confluence-cli/commit/bc3e412a6ccd0774d62ab0816a6c2735cbd470a4))
7
+
8
+ ## [1.11.1](https://github.com/pchuri/confluence-cli/compare/v1.11.0...v1.11.1) (2025-12-17)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * 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))
14
+
1
15
  # [1.11.0](https://github.com/pchuri/confluence-cli/compare/v1.10.1...v1.11.0) (2025-12-12)
2
16
 
3
17
 
package/README.md CHANGED
@@ -10,6 +10,7 @@ A powerful command-line interface for Atlassian Confluence that allows you to re
10
10
  - šŸ  **List spaces** - View all available Confluence spaces
11
11
  - āœļø **Create pages** - Create new pages with support for Markdown, HTML, or Storage format
12
12
  - šŸ“ **Update pages** - Update existing page content and titles
13
+ - šŸ—‘ļø **Delete pages** - Delete (or move to trash) pages by ID or URL
13
14
  - šŸ“Ž **Attachments** - List or download page attachments
14
15
  - šŸ“¦ **Export** - Save a page and its attachments to a local folder
15
16
  - šŸ› ļø **Edit workflow** - Export page content for editing and re-import
@@ -209,6 +210,18 @@ confluence update 123456789 --file ./updated-content.md --format markdown
209
210
  confluence update 123456789 --title "New Title" --content "And new content"
210
211
  ```
211
212
 
213
+ ### Delete a Page
214
+ ```bash
215
+ # Delete by page ID (prompts for confirmation)
216
+ confluence delete 123456789
217
+
218
+ # Delete by URL
219
+ confluence delete "https://your-domain.atlassian.net/wiki/viewpage.action?pageId=123456789"
220
+
221
+ # Skip confirmation (useful for scripts)
222
+ confluence delete 123456789 --yes
223
+ ```
224
+
212
225
  ### Edit Workflow
213
226
  The `edit` and `update` commands work together to create a seamless editing workflow.
214
227
  ```bash
package/bin/confluence.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { program } = require('commander');
4
4
  const chalk = require('chalk');
5
+ const inquirer = require('inquirer');
5
6
  const ConfluenceClient = require('../lib/confluence-client');
6
7
  const { getConfig, initConfig } = require('../lib/config');
7
8
  const Analytics = require('../lib/analytics');
@@ -274,6 +275,49 @@ program
274
275
  }
275
276
  });
276
277
 
278
+ // Delete command
279
+ program
280
+ .command('delete <pageIdOrUrl>')
281
+ .description('Delete a Confluence page by ID or URL')
282
+ .option('-y, --yes', 'Skip confirmation prompt')
283
+ .action(async (pageIdOrUrl, options) => {
284
+ const analytics = new Analytics();
285
+ try {
286
+ const config = getConfig();
287
+ const client = new ConfluenceClient(config);
288
+ const pageInfo = await client.getPageInfo(pageIdOrUrl);
289
+
290
+ if (!options.yes) {
291
+ const spaceLabel = pageInfo.space?.key ? ` (${pageInfo.space.key})` : '';
292
+ const { confirmed } = await inquirer.prompt([
293
+ {
294
+ type: 'confirm',
295
+ name: 'confirmed',
296
+ default: false,
297
+ message: `Delete "${pageInfo.title}" (ID: ${pageInfo.id})${spaceLabel}?`
298
+ }
299
+ ]);
300
+
301
+ if (!confirmed) {
302
+ console.log(chalk.yellow('Cancelled.'));
303
+ analytics.track('delete_cancel', true);
304
+ return;
305
+ }
306
+ }
307
+
308
+ const result = await client.deletePage(pageInfo.id);
309
+
310
+ console.log(chalk.green('āœ… Page deleted successfully!'));
311
+ console.log(`Title: ${chalk.blue(pageInfo.title)}`);
312
+ console.log(`ID: ${chalk.blue(result.id)}`);
313
+ analytics.track('delete', true);
314
+ } catch (error) {
315
+ analytics.track('delete', false);
316
+ console.error(chalk.red('Error:'), error.message);
317
+ process.exit(1);
318
+ }
319
+ });
320
+
277
321
  // Edit command - opens page content for editing
278
322
  program
279
323
  .command('edit <pageId>')
@@ -445,7 +489,9 @@ program
445
489
  const contentExt = formatExt[format] || 'txt';
446
490
 
447
491
  const pageInfo = await client.getPageInfo(pageId);
448
- const content = await client.readPage(pageId, format);
492
+ // Read page with attachment extraction enabled
493
+ const content = await client.readPage(pageId, format, { extractReferencedAttachments: true });
494
+ const referencedAttachments = client._referencedAttachments || new Set();
449
495
 
450
496
  const baseDir = path.resolve(options.dest || '.');
451
497
  const folderName = sanitizeTitle(pageInfo.title || 'page');
@@ -462,8 +508,16 @@ program
462
508
 
463
509
  if (!options.skipAttachments) {
464
510
  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;
511
+ const allAttachments = await client.getAllAttachments(pageId);
512
+
513
+ // Filter: only referenced attachments (unless pattern is specified, then use pattern)
514
+ let filtered;
515
+ if (pattern) {
516
+ filtered = allAttachments.filter(att => client.matchesPattern(att.title, pattern));
517
+ } else {
518
+ // Only download attachments that are referenced in the page content
519
+ filtered = allAttachments.filter(att => referencedAttachments.has(att.title));
520
+ }
467
521
 
468
522
  if (filtered.length === 0) {
469
523
  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
 
@@ -1029,6 +1208,16 @@ class ConfluenceClient {
1029
1208
  };
1030
1209
  }
1031
1210
 
1211
+ /**
1212
+ * Delete a Confluence page
1213
+ * Note: Confluence may move the page to trash depending on instance settings.
1214
+ */
1215
+ async deletePage(pageIdOrUrl) {
1216
+ const pageId = await this.extractPageId(pageIdOrUrl);
1217
+ await this.client.delete(`/content/${pageId}`);
1218
+ return { id: String(pageId) };
1219
+ }
1220
+
1032
1221
  /**
1033
1222
  * Search for a page by title and space
1034
1223
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "confluence-cli",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
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>';
@@ -250,6 +267,29 @@ describe('ConfluenceClient', () => {
250
267
  expect(typeof client.getPageForEdit).toBe('function');
251
268
  expect(typeof client.createChildPage).toBe('function');
252
269
  expect(typeof client.findPageByTitle).toBe('function');
270
+ expect(typeof client.deletePage).toBe('function');
271
+ });
272
+ });
273
+
274
+ describe('deletePage', () => {
275
+ test('should delete a page by ID', async () => {
276
+ const mock = new MockAdapter(client.client);
277
+ mock.onDelete('/content/123456789').reply(204);
278
+
279
+ await expect(client.deletePage('123456789')).resolves.toEqual({ id: '123456789' });
280
+
281
+ mock.restore();
282
+ });
283
+
284
+ test('should delete a page by URL', async () => {
285
+ const mock = new MockAdapter(client.client);
286
+ mock.onDelete('/content/987654321').reply(204);
287
+
288
+ await expect(
289
+ client.deletePage('https://test.atlassian.net/wiki/viewpage.action?pageId=987654321')
290
+ ).resolves.toEqual({ id: '987654321' });
291
+
292
+ mock.restore();
253
293
  });
254
294
  });
255
295