confluence-cli 1.9.0 → 1.10.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.10.1](https://github.com/pchuri/confluence-cli/compare/v1.10.0...v1.10.1) (2025-12-08)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * improve markdown export and attachment download ([#19](https://github.com/pchuri/confluence-cli/issues/19)) ([978275d](https://github.com/pchuri/confluence-cli/commit/978275dbe71eea83138bbd537ce7d4edda8180f8))
7
+
8
+ # [1.10.0](https://github.com/pchuri/confluence-cli/compare/v1.9.0...v1.10.0) (2025-12-05)
9
+
10
+
11
+ ### Features
12
+
13
+ * export page with attachments ([#18](https://github.com/pchuri/confluence-cli/issues/18)) ([bdd9da4](https://github.com/pchuri/confluence-cli/commit/bdd9da474f13a8b6f96e64836443f65f846257a2))
14
+
1
15
  # [1.9.0](https://github.com/pchuri/confluence-cli/compare/v1.8.0...v1.9.0) (2025-12-04)
2
16
 
3
17
 
package/README.md CHANGED
@@ -11,6 +11,7 @@ A powerful command-line interface for Atlassian Confluence that allows you to re
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
13
  - 📎 **Attachments** - List or download page attachments
14
+ - 📦 **Export** - Save a page and its attachments to a local folder
14
15
  - 🛠️ **Edit workflow** - Export page content for editing and re-import
15
16
  - 🔧 **Easy setup** - Simple configuration with environment variables or interactive setup
16
17
 
@@ -120,6 +121,18 @@ confluence attachments 123456789 --pattern "*.png" --limit 5
120
121
  confluence attachments 123456789 --pattern "*.png" --download --dest ./downloads
121
122
  ```
122
123
 
124
+ ### Export a Page with Attachments
125
+ ```bash
126
+ # Export page content (markdown by default) and all attachments
127
+ confluence export 123456789 --dest ./exports
128
+
129
+ # Custom content format/filename and attachment filtering
130
+ confluence export 123456789 --format html --file content.html --pattern "*.png"
131
+
132
+ # Skip attachments if you only need the content file
133
+ confluence export 123456789 --skip-attachments
134
+ ```
135
+
123
136
  ### List Spaces
124
137
  ```bash
125
138
  confluence spaces
package/bin/confluence.js CHANGED
@@ -404,7 +404,8 @@ program
404
404
  let downloaded = 0;
405
405
  for (const attachment of filtered) {
406
406
  const targetPath = uniquePathFor(destDir, attachment.title);
407
- const dataStream = await client.downloadAttachment(pageId, attachment.id);
407
+ // Pass the full attachment object so downloadAttachment can use downloadLink directly
408
+ const dataStream = await client.downloadAttachment(pageId, attachment);
408
409
  await writeStream(dataStream, targetPath);
409
410
  downloaded += 1;
410
411
  console.log(`⬇️ ${chalk.green(attachment.title)} -> ${chalk.gray(targetPath)}`);
@@ -421,6 +422,108 @@ program
421
422
  }
422
423
  });
423
424
 
425
+ // Export page content with attachments
426
+ program
427
+ .command('export <pageId>')
428
+ .description('Export a page to a directory with its attachments')
429
+ .option('--format <format>', 'Content format (html, text, markdown)', 'markdown')
430
+ .option('--dest <directory>', 'Base directory to export into', '.')
431
+ .option('--file <filename>', 'Content filename (default: page.<ext>)')
432
+ .option('--attachments-dir <name>', 'Subdirectory for attachments', 'attachments')
433
+ .option('--pattern <glob>', 'Filter attachments by filename (e.g., "*.png")')
434
+ .option('--skip-attachments', 'Do not download attachments')
435
+ .action(async (pageId, options) => {
436
+ const analytics = new Analytics();
437
+ try {
438
+ const config = getConfig();
439
+ const client = new ConfluenceClient(config);
440
+ const fs = require('fs');
441
+ const path = require('path');
442
+
443
+ const format = (options.format || 'markdown').toLowerCase();
444
+ const formatExt = { markdown: 'md', html: 'html', text: 'txt' };
445
+ const contentExt = formatExt[format] || 'txt';
446
+
447
+ const pageInfo = await client.getPageInfo(pageId);
448
+ const content = await client.readPage(pageId, format);
449
+
450
+ const baseDir = path.resolve(options.dest || '.');
451
+ const folderName = sanitizeTitle(pageInfo.title || 'page');
452
+ const exportDir = path.join(baseDir, folderName);
453
+ fs.mkdirSync(exportDir, { recursive: true });
454
+
455
+ const contentFile = options.file || `page.${contentExt}`;
456
+ const contentPath = path.join(exportDir, contentFile);
457
+ fs.writeFileSync(contentPath, content);
458
+
459
+ console.log(chalk.green('✅ Page exported'));
460
+ console.log(`Title: ${chalk.blue(pageInfo.title)}`);
461
+ console.log(`Content: ${chalk.gray(contentPath)}`);
462
+
463
+ if (!options.skipAttachments) {
464
+ 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
+
468
+ if (filtered.length === 0) {
469
+ console.log(chalk.yellow('No attachments to download.'));
470
+ } else {
471
+ const attachmentsDirName = options.attachmentsDir || 'attachments';
472
+ const attachmentsDir = path.join(exportDir, attachmentsDirName);
473
+ fs.mkdirSync(attachmentsDir, { recursive: true });
474
+
475
+ const uniquePathFor = (dir, filename) => {
476
+ const parsed = path.parse(filename);
477
+ let attempt = path.join(dir, filename);
478
+ let counter = 1;
479
+ while (fs.existsSync(attempt)) {
480
+ const suffix = ` (${counter})`;
481
+ const nextName = `${parsed.name}${suffix}${parsed.ext}`;
482
+ attempt = path.join(dir, nextName);
483
+ counter += 1;
484
+ }
485
+ return attempt;
486
+ };
487
+
488
+ const writeStream = (stream, targetPath) => new Promise((resolve, reject) => {
489
+ const writer = fs.createWriteStream(targetPath);
490
+ stream.pipe(writer);
491
+ stream.on('error', reject);
492
+ writer.on('error', reject);
493
+ writer.on('finish', resolve);
494
+ });
495
+
496
+ let downloaded = 0;
497
+ for (const attachment of filtered) {
498
+ const targetPath = uniquePathFor(attachmentsDir, attachment.title);
499
+ // Pass the full attachment object so downloadAttachment can use downloadLink directly
500
+ const dataStream = await client.downloadAttachment(pageId, attachment);
501
+ await writeStream(dataStream, targetPath);
502
+ downloaded += 1;
503
+ console.log(`⬇️ ${chalk.green(attachment.title)} -> ${chalk.gray(targetPath)}`);
504
+ }
505
+
506
+ console.log(chalk.green(`Downloaded ${downloaded} attachment${downloaded === 1 ? '' : 's'} to ${attachmentsDir}`));
507
+ }
508
+ }
509
+
510
+ analytics.track('export', true);
511
+ } catch (error) {
512
+ analytics.track('export', false);
513
+ console.error(chalk.red('Error:'), error.message);
514
+ process.exit(1);
515
+ }
516
+ });
517
+
518
+ function sanitizeTitle(value) {
519
+ const fallback = 'page';
520
+ if (!value || typeof value !== 'string') {
521
+ return fallback;
522
+ }
523
+ const cleaned = value.replace(/[\\/:*?"<>|]/g, ' ').trim();
524
+ return cleaned || fallback;
525
+ }
526
+
424
527
  // Copy page tree command
425
528
  program
426
529
  .command('copy-tree <sourcePageId> <targetParentId> [newTitle]')
@@ -74,8 +74,12 @@ class ConfluenceClient {
74
74
 
75
75
  /**
76
76
  * Read a Confluence page content
77
+ * @param {string} pageIdOrUrl - Page ID or URL
78
+ * @param {string} format - Output format: 'text', 'html', or 'markdown'
79
+ * @param {object} options - Additional options
80
+ * @param {boolean} options.resolveUsers - Whether to resolve userkeys to display names (default: true for markdown)
77
81
  */
78
- async readPage(pageIdOrUrl, format = 'text') {
82
+ async readPage(pageIdOrUrl, format = 'text', options = {}) {
79
83
  const pageId = this.extractPageId(pageIdOrUrl);
80
84
 
81
85
  const response = await this.client.get(`/content/${pageId}`, {
@@ -84,13 +88,26 @@ class ConfluenceClient {
84
88
  }
85
89
  });
86
90
 
87
- const htmlContent = response.data.body.storage.value;
91
+ let htmlContent = response.data.body.storage.value;
88
92
 
89
93
  if (format === 'html') {
90
94
  return htmlContent;
91
95
  }
92
96
 
93
97
  if (format === 'markdown') {
98
+ // Resolve userkeys to display names before converting to markdown
99
+ const resolveUsers = options.resolveUsers !== false;
100
+ if (resolveUsers) {
101
+ const { html: resolvedHtml } = await this.resolveUserKeysInHtml(htmlContent);
102
+ htmlContent = resolvedHtml;
103
+ }
104
+
105
+ // Resolve page links to full URLs
106
+ const resolvePageLinks = options.resolvePageLinks !== false;
107
+ if (resolvePageLinks) {
108
+ htmlContent = await this.resolvePageLinksInHtml(htmlContent);
109
+ }
110
+
94
111
  return this.storageToMarkdown(htmlContent);
95
112
  }
96
113
 
@@ -167,6 +184,153 @@ class ConfluenceClient {
167
184
  }));
168
185
  }
169
186
 
187
+ /**
188
+ * Get user information by userkey
189
+ * @param {string} userKey - The user key (e.g., "8ad05c43962471ed0196c26107d7000c")
190
+ * @returns {Promise<{key: string, displayName: string, username: string}>}
191
+ */
192
+ async getUserByKey(userKey) {
193
+ try {
194
+ const response = await this.client.get('/user', {
195
+ params: { key: userKey }
196
+ });
197
+ return {
198
+ key: userKey,
199
+ displayName: response.data.displayName || response.data.username || userKey,
200
+ username: response.data.username || ''
201
+ };
202
+ } catch (error) {
203
+ // Return full userkey as fallback if user not found
204
+ return {
205
+ key: userKey,
206
+ displayName: userKey,
207
+ username: ''
208
+ };
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Resolve all userkeys in HTML to display names
214
+ * @param {string} html - HTML content with ri:user elements
215
+ * @returns {Promise<{html: string, userMap: Map<string, string>}>}
216
+ */
217
+ async resolveUserKeysInHtml(html) {
218
+ // Extract all unique userkeys
219
+ const userKeyRegex = /ri:userkey="([^"]+)"/g;
220
+ const userKeys = new Set();
221
+ let match;
222
+ while ((match = userKeyRegex.exec(html)) !== null) {
223
+ userKeys.add(match[1]);
224
+ }
225
+
226
+ if (userKeys.size === 0) {
227
+ return { html, userMap: new Map() };
228
+ }
229
+
230
+ // Fetch user info for all keys in parallel
231
+ const userPromises = Array.from(userKeys).map(key => this.getUserByKey(key));
232
+ const users = await Promise.all(userPromises);
233
+
234
+ // Build userkey -> displayName map
235
+ const userMap = new Map();
236
+ users.forEach(user => {
237
+ userMap.set(user.key, user.displayName);
238
+ });
239
+
240
+ // Replace userkey references with display names in HTML
241
+ let resolvedHtml = html;
242
+ userMap.forEach((displayName, userKey) => {
243
+ // Replace <ac:link><ri:user ri:userkey="xxx" /></ac:link> with @displayName
244
+ const userLinkRegex = new RegExp(
245
+ `<ac:link>\\s*<ri:user\\s+ri:userkey="${userKey}"\\s*/>\\s*</ac:link>`,
246
+ 'g'
247
+ );
248
+ resolvedHtml = resolvedHtml.replace(userLinkRegex, `@${displayName}`);
249
+ });
250
+
251
+ return { html: resolvedHtml, userMap };
252
+ }
253
+
254
+ /**
255
+ * Find a page by title and space key, return page info with URL
256
+ * @param {string} spaceKey - Space key (e.g., "~huotui" or "TECH")
257
+ * @param {string} title - Page title
258
+ * @returns {Promise<{title: string, url: string} | null>}
259
+ */
260
+ async findPageByTitleAndSpace(spaceKey, title) {
261
+ try {
262
+ const response = await this.client.get('/content', {
263
+ params: {
264
+ spaceKey: spaceKey,
265
+ title: title,
266
+ limit: 1
267
+ }
268
+ });
269
+
270
+ if (response.data.results && response.data.results.length > 0) {
271
+ const page = response.data.results[0];
272
+ const webui = page._links?.webui || '';
273
+ return {
274
+ title: page.title,
275
+ url: webui ? `https://${this.domain}/wiki${webui}` : ''
276
+ };
277
+ }
278
+ return null;
279
+ } catch (error) {
280
+ return null;
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Resolve all page links in HTML to full URLs
286
+ * @param {string} html - HTML content with ri:page elements
287
+ * @returns {Promise<string>} - HTML with resolved page links
288
+ */
289
+ async resolvePageLinksInHtml(html) {
290
+ // Extract all page links: <ri:page ri:space-key="xxx" ri:content-title="yyy" />
291
+ const pageLinkRegex = /<ac:link>\s*<ri:page\s+ri:space-key="([^"]+)"\s+ri:content-title="([^"]+)"[^>]*(?:\/>|><\/ri:page>)\s*<\/ac:link>/g;
292
+ const pageLinks = [];
293
+ let match;
294
+
295
+ while ((match = pageLinkRegex.exec(html)) !== null) {
296
+ pageLinks.push({
297
+ fullMatch: match[0],
298
+ spaceKey: match[1],
299
+ title: match[2]
300
+ });
301
+ }
302
+
303
+ if (pageLinks.length === 0) {
304
+ return html;
305
+ }
306
+
307
+ // Fetch page info for all links in parallel
308
+ const pagePromises = pageLinks.map(async (link) => {
309
+ const pageInfo = await this.findPageByTitleAndSpace(link.spaceKey, link.title);
310
+ return {
311
+ ...link,
312
+ pageInfo
313
+ };
314
+ });
315
+
316
+ const resolvedLinks = await Promise.all(pagePromises);
317
+
318
+ // Replace page link references with markdown links
319
+ let resolvedHtml = html;
320
+ resolvedLinks.forEach(({ fullMatch, title, pageInfo }) => {
321
+ let replacement;
322
+ if (pageInfo && pageInfo.url) {
323
+ replacement = `[${title}](${pageInfo.url})`;
324
+ } else {
325
+ // Fallback to just the title if page not found
326
+ replacement = `[${title}]`;
327
+ }
328
+ resolvedHtml = resolvedHtml.replace(fullMatch, replacement);
329
+ });
330
+
331
+ return resolvedHtml;
332
+ }
333
+
170
334
  /**
171
335
  * List attachments for a page with pagination support
172
336
  */
@@ -228,13 +392,40 @@ class ConfluenceClient {
228
392
 
229
393
  /**
230
394
  * Download an attachment's data stream
395
+ * Now uses the download link from attachment metadata instead of the broken REST API endpoint
231
396
  */
232
- async downloadAttachment(pageIdOrUrl, attachmentId, options = {}) {
233
- const pageId = this.extractPageId(pageIdOrUrl);
234
- const response = await this.client.get(`/content/${pageId}/child/attachment/${attachmentId}/data`, {
235
- responseType: options.responseType || 'stream'
397
+ async downloadAttachment(pageIdOrUrl, attachmentIdOrAttachment, options = {}) {
398
+ let downloadUrl;
399
+
400
+ // If the second argument is an attachment object with downloadLink, use it directly
401
+ if (typeof attachmentIdOrAttachment === 'object' && attachmentIdOrAttachment.downloadLink) {
402
+ downloadUrl = attachmentIdOrAttachment.downloadLink;
403
+ } else {
404
+ // Otherwise, fetch attachment info to get the download link
405
+ const pageId = this.extractPageId(pageIdOrUrl);
406
+ const attachmentId = attachmentIdOrAttachment;
407
+ const response = await this.client.get(`/content/${pageId}/child/attachment`, {
408
+ params: { limit: 500 }
409
+ });
410
+ const attachment = response.data.results.find(att => att.id === String(attachmentId));
411
+ if (!attachment) {
412
+ throw new Error(`Attachment with ID ${attachmentId} not found on page ${pageId}`);
413
+ }
414
+ downloadUrl = this.toAbsoluteUrl(attachment._links?.download);
415
+ }
416
+
417
+ if (!downloadUrl) {
418
+ throw new Error('Unable to determine download URL for attachment');
419
+ }
420
+
421
+ // Download directly using axios with the same auth headers
422
+ const downloadResponse = await axios.get(downloadUrl, {
423
+ responseType: options.responseType || 'stream',
424
+ headers: {
425
+ 'Authorization': this.authType === 'basic' ? this.buildBasicAuthHeader() : `Bearer ${this.token}`
426
+ }
236
427
  });
237
- return response.data;
428
+ return downloadResponse.data;
238
429
  }
239
430
 
240
431
  /**
@@ -402,14 +593,54 @@ class ConfluenceClient {
402
593
 
403
594
  /**
404
595
  * Convert Confluence storage format to markdown
596
+ * @param {string} storage - Confluence storage format HTML
597
+ * @param {object} options - Conversion options
598
+ * @param {string} options.attachmentsDir - Directory name for attachments (default: 'attachments')
405
599
  */
406
- storageToMarkdown(storage) {
600
+ storageToMarkdown(storage, options = {}) {
601
+ const attachmentsDir = options.attachmentsDir || 'attachments';
407
602
  let markdown = storage;
408
603
 
409
604
  // Remove table of contents macro
410
605
  markdown = markdown.replace(/<ac:structured-macro ac:name="toc"[^>]*\s*\/>/g, '');
411
606
  markdown = markdown.replace(/<ac:structured-macro ac:name="toc"[^>]*>[\s\S]*?<\/ac:structured-macro>/g, '');
412
607
 
608
+ // Remove floatmenu macro (floating table of contents)
609
+ markdown = markdown.replace(/<ac:structured-macro ac:name="floatmenu"[^>]*>[\s\S]*?<\/ac:structured-macro>/g, '');
610
+
611
+ // Convert Confluence images to markdown images
612
+ // Format: <ac:image><ri:attachment ri:filename="image.png" /></ac:image>
613
+ markdown = markdown.replace(/<ac:image[^>]*>\s*<ri:attachment\s+ri:filename="([^"]+)"[^>]*\s*\/>\s*<\/ac:image>/g, (_, filename) => {
614
+ return `![${filename}](${attachmentsDir}/${filename})`;
615
+ });
616
+
617
+ // Also handle self-closing ac:image with ri:attachment
618
+ markdown = markdown.replace(/<ac:image[^>]*><ri:attachment\s+ri:filename="([^"]+)"[^>]*><\/ri:attachment><\/ac:image>/g, (_, filename) => {
619
+ return `![${filename}](${attachmentsDir}/${filename})`;
620
+ });
621
+
622
+ // Convert mermaid macro to mermaid code block
623
+ markdown = markdown.replace(/<ac:structured-macro ac:name="mermaid-macro"[^>]*>[\s\S]*?<ac:plain-text-body><!\[CDATA\[([\s\S]*?)\]\]><\/ac:plain-text-body>[\s\S]*?<\/ac:structured-macro>/g, (_, code) => {
624
+ return `\n\`\`\`mermaid\n${code.trim()}\n\`\`\`\n`;
625
+ });
626
+
627
+ // 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
+ 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`;
642
+ });
643
+
413
644
  // Convert Confluence code macros to markdown
414
645
  markdown = markdown.replace(/<ac:structured-macro ac:name="code"[^>]*>[\s\S]*?<ac:parameter ac:name="language">([^<]*)<\/ac:parameter>[\s\S]*?<ac:plain-text-body><!\[CDATA\[([\s\S]*?)\]\]><\/ac:plain-text-body>[\s\S]*?<\/ac:structured-macro>/g, (_, lang, code) => {
415
646
  return `\`\`\`${lang}\n${code}\n\`\`\``;
@@ -438,12 +669,40 @@ class ConfluenceClient {
438
669
  return `[!note]\n${cleanContent}`;
439
670
  });
440
671
 
672
+ // Convert task list macros to markdown checkboxes
673
+ // Note: This is independent of user resolution - it only converts <ac:task> structure to "- [ ]" or "- [x]" format
674
+ markdown = markdown.replace(/<ac:task-list>([\s\S]*?)<\/ac:task-list>/g, (_, content) => {
675
+ const tasks = [];
676
+ // Match each task: <ac:task>...<ac:task-status>xxx</ac:task-status>...<ac:task-body>...</ac:task-body>...</ac:task>
677
+ const taskRegex = /<ac:task>[\s\S]*?<ac:task-status>([^<]*)<\/ac:task-status>[\s\S]*?<ac:task-body>([\s\S]*?)<\/ac:task-body>[\s\S]*?<\/ac:task>/g;
678
+ let match;
679
+ while ((match = taskRegex.exec(content)) !== null) {
680
+ const status = match[1];
681
+ let taskBody = match[2];
682
+ // Clean up HTML from task body, but preserve @username
683
+ taskBody = taskBody.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
684
+ const checkbox = status === 'complete' ? '[x]' : '[ ]';
685
+ if (taskBody) {
686
+ tasks.push(`- ${checkbox} ${taskBody}`);
687
+ }
688
+ }
689
+ return tasks.length > 0 ? '\n' + tasks.join('\n') + '\n' : '';
690
+ });
691
+
441
692
  // Remove other unhandled macros (replace with empty string for now)
442
693
  markdown = markdown.replace(/<ac:structured-macro[^>]*>[\s\S]*?<\/ac:structured-macro>/g, '');
443
694
 
444
- // Convert links
695
+ // Convert external URL links
445
696
  markdown = markdown.replace(/<ac:link><ri:url ri:value="([^"]*)" \/><ac:plain-text-link-body><!\[CDATA\[([^\]]*)\]\]><\/ac:plain-text-link-body><\/ac:link>/g, '[$2]($1)');
446
697
 
698
+ // Convert internal page links - extract page title
699
+ // Format: <ac:link><ri:page ri:space-key="xxx" ri:content-title="Page Title" /></ac:link>
700
+ markdown = markdown.replace(/<ac:link>\s*<ri:page[^>]*ri:content-title="([^"]*)"[^>]*\/>\s*<\/ac:link>/g, '[$1]');
701
+ markdown = markdown.replace(/<ac:link>\s*<ri:page[^>]*ri:content-title="([^"]*)"[^>]*>\s*<\/ri:page>\s*<\/ac:link>/g, '[$1]');
702
+
703
+ // Remove any remaining ac:link tags that weren't matched
704
+ markdown = markdown.replace(/<ac:link>[\s\S]*?<\/ac:link>/g, '');
705
+
447
706
  // Convert remaining HTML to markdown
448
707
  markdown = this.htmlToMarkdown(markdown);
449
708
 
@@ -456,6 +715,10 @@ class ConfluenceClient {
456
715
  htmlToMarkdown(html) {
457
716
  let markdown = html;
458
717
 
718
+ // Convert time elements to date text BEFORE removing attributes
719
+ // Format: <time datetime="2025-09-16" /> or <time datetime="2025-09-16"></time>
720
+ markdown = markdown.replace(/<time\s+datetime="([^"]+)"[^>]*(?:\/>|>\s*<\/time>)/g, '$1');
721
+
459
722
  // Convert strong/bold BEFORE removing HTML attributes
460
723
  markdown = markdown.replace(/<strong[^>]*>(.*?)<\/strong>/g, '**$1**');
461
724
 
@@ -560,8 +823,8 @@ class ConfluenceClient {
560
823
  // Convert horizontal rules
561
824
  markdown = markdown.replace(/<hr\s*\/?>/g, '\n---\n');
562
825
 
563
- // Remove any remaining HTML tags
564
- markdown = markdown.replace(/<[^>]+>/g, ' ');
826
+ // Remove any remaining HTML tags, but preserve <details> and <summary> for GFM compatibility
827
+ markdown = markdown.replace(/<(?!\/?(details|summary)\b)[^>]+>/g, ' ');
565
828
 
566
829
  // Clean up whitespace and HTML entities
567
830
  markdown = markdown.replace(/&nbsp;/g, ' ');
@@ -569,10 +832,35 @@ class ConfluenceClient {
569
832
  markdown = markdown.replace(/&gt;/g, '>');
570
833
  markdown = markdown.replace(/&amp;/g, '&');
571
834
  markdown = markdown.replace(/&quot;/g, '"');
835
+ markdown = markdown.replace(/&apos;/g, '\'');
836
+ // Smart quotes and special characters
837
+ markdown = markdown.replace(/&ldquo;/g, '"');
838
+ markdown = markdown.replace(/&rdquo;/g, '"');
839
+ markdown = markdown.replace(/&lsquo;/g, '\'');
840
+ markdown = markdown.replace(/&rsquo;/g, '\'');
841
+ markdown = markdown.replace(/&mdash;/g, '—');
842
+ markdown = markdown.replace(/&ndash;/g, '–');
843
+ markdown = markdown.replace(/&hellip;/g, '...');
844
+ markdown = markdown.replace(/&bull;/g, '•');
845
+ markdown = markdown.replace(/&copy;/g, '©');
846
+ markdown = markdown.replace(/&reg;/g, '®');
847
+ markdown = markdown.replace(/&trade;/g, '™');
848
+ // Numeric HTML entities
849
+ markdown = markdown.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10)));
850
+ markdown = markdown.replace(/&#x([0-9a-fA-F]+);/g, (_, code) => String.fromCharCode(parseInt(code, 16)));
572
851
 
573
- // Clean up extra whitespace
852
+ // Clean up extra whitespace for standard Markdown format
853
+ // Remove trailing spaces from each line
854
+ markdown = markdown.replace(/[ \t]+$/gm, '');
855
+ // Remove leading spaces from lines (except for code blocks, blockquotes, and list items)
856
+ markdown = markdown.replace(/^[ \t]+(?!([`>]|[*+-] |\d+[.)] ))/gm, '');
857
+ // Ensure proper spacing after headings (# Title should be followed by blank line or content)
858
+ markdown = markdown.replace(/^(#{1,6}[^\n]+)\n(?!\n)/gm, '$1\n\n');
859
+ // Normalize multiple blank lines to double newline
574
860
  markdown = markdown.replace(/\n\s*\n\s*\n+/g, '\n\n');
861
+ // Collapse multiple spaces to single space (but preserve newlines)
575
862
  markdown = markdown.replace(/[ \t]+/g, ' ');
863
+ // Final trim
576
864
  markdown = markdown.trim();
577
865
 
578
866
  return markdown;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "confluence-cli",
3
- "version": "1.9.0",
3
+ "version": "1.10.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": {