confluence-cli 1.10.0 → 1.11.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.11.0](https://github.com/pchuri/confluence-cli/compare/v1.10.1...v1.11.0) (2025-12-12)
2
+
3
+
4
+ ### Features
5
+
6
+ * Support for Confluence display URLs ([#20](https://github.com/pchuri/confluence-cli/issues/20)) ([3bda7c2](https://github.com/pchuri/confluence-cli/commit/3bda7c2aad8ec02dac60f3b7c34c31b549a31cce))
7
+
8
+ ## [1.10.1](https://github.com/pchuri/confluence-cli/compare/v1.10.0...v1.10.1) (2025-12-08)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * improve markdown export and attachment download ([#19](https://github.com/pchuri/confluence-cli/issues/19)) ([978275d](https://github.com/pchuri/confluence-cli/commit/978275dbe71eea83138bbd537ce7d4edda8180f8))
14
+
1
15
  # [1.10.0](https://github.com/pchuri/confluence-cli/compare/v1.9.0...v1.10.0) (2025-12-05)
2
16
 
3
17
 
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)}`);
@@ -495,7 +496,8 @@ program
495
496
  let downloaded = 0;
496
497
  for (const attachment of filtered) {
497
498
  const targetPath = uniquePathFor(attachmentsDir, attachment.title);
498
- const dataStream = await client.downloadAttachment(pageId, attachment.id);
499
+ // Pass the full attachment object so downloadAttachment can use downloadLink directly
500
+ const dataStream = await client.downloadAttachment(pageId, attachment);
499
501
  await writeStream(dataStream, targetPath);
500
502
  downloaded += 1;
501
503
  console.log(`⬇️ ${chalk.green(attachment.title)} -> ${chalk.gray(targetPath)}`);
@@ -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,10 +62,37 @@ 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
 
@@ -74,9 +101,13 @@ class ConfluenceClient {
74
101
 
75
102
  /**
76
103
  * Read a Confluence page content
104
+ * @param {string} pageIdOrUrl - Page ID or URL
105
+ * @param {string} format - Output format: 'text', 'html', or 'markdown'
106
+ * @param {object} options - Additional options
107
+ * @param {boolean} options.resolveUsers - Whether to resolve userkeys to display names (default: true for markdown)
77
108
  */
78
- async readPage(pageIdOrUrl, format = 'text') {
79
- const pageId = this.extractPageId(pageIdOrUrl);
109
+ async readPage(pageIdOrUrl, format = 'text', options = {}) {
110
+ const pageId = await this.extractPageId(pageIdOrUrl);
80
111
 
81
112
  const response = await this.client.get(`/content/${pageId}`, {
82
113
  params: {
@@ -84,13 +115,26 @@ class ConfluenceClient {
84
115
  }
85
116
  });
86
117
 
87
- const htmlContent = response.data.body.storage.value;
118
+ let htmlContent = response.data.body.storage.value;
88
119
 
89
120
  if (format === 'html') {
90
121
  return htmlContent;
91
122
  }
92
123
 
93
124
  if (format === 'markdown') {
125
+ // Resolve userkeys to display names before converting to markdown
126
+ const resolveUsers = options.resolveUsers !== false;
127
+ if (resolveUsers) {
128
+ const { html: resolvedHtml } = await this.resolveUserKeysInHtml(htmlContent);
129
+ htmlContent = resolvedHtml;
130
+ }
131
+
132
+ // Resolve page links to full URLs
133
+ const resolvePageLinks = options.resolvePageLinks !== false;
134
+ if (resolvePageLinks) {
135
+ htmlContent = await this.resolvePageLinksInHtml(htmlContent);
136
+ }
137
+
94
138
  return this.storageToMarkdown(htmlContent);
95
139
  }
96
140
 
@@ -110,7 +154,7 @@ class ConfluenceClient {
110
154
  * Get page information
111
155
  */
112
156
  async getPageInfo(pageIdOrUrl) {
113
- const pageId = this.extractPageId(pageIdOrUrl);
157
+ const pageId = await this.extractPageId(pageIdOrUrl);
114
158
 
115
159
  const response = await this.client.get(`/content/${pageId}`, {
116
160
  params: {
@@ -167,11 +211,158 @@ class ConfluenceClient {
167
211
  }));
168
212
  }
169
213
 
214
+ /**
215
+ * Get user information by userkey
216
+ * @param {string} userKey - The user key (e.g., "8ad05c43962471ed0196c26107d7000c")
217
+ * @returns {Promise<{key: string, displayName: string, username: string}>}
218
+ */
219
+ async getUserByKey(userKey) {
220
+ try {
221
+ const response = await this.client.get('/user', {
222
+ params: { key: userKey }
223
+ });
224
+ return {
225
+ key: userKey,
226
+ displayName: response.data.displayName || response.data.username || userKey,
227
+ username: response.data.username || ''
228
+ };
229
+ } catch (error) {
230
+ // Return full userkey as fallback if user not found
231
+ return {
232
+ key: userKey,
233
+ displayName: userKey,
234
+ username: ''
235
+ };
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Resolve all userkeys in HTML to display names
241
+ * @param {string} html - HTML content with ri:user elements
242
+ * @returns {Promise<{html: string, userMap: Map<string, string>}>}
243
+ */
244
+ async resolveUserKeysInHtml(html) {
245
+ // Extract all unique userkeys
246
+ const userKeyRegex = /ri:userkey="([^"]+)"/g;
247
+ const userKeys = new Set();
248
+ let match;
249
+ while ((match = userKeyRegex.exec(html)) !== null) {
250
+ userKeys.add(match[1]);
251
+ }
252
+
253
+ if (userKeys.size === 0) {
254
+ return { html, userMap: new Map() };
255
+ }
256
+
257
+ // Fetch user info for all keys in parallel
258
+ const userPromises = Array.from(userKeys).map(key => this.getUserByKey(key));
259
+ const users = await Promise.all(userPromises);
260
+
261
+ // Build userkey -> displayName map
262
+ const userMap = new Map();
263
+ users.forEach(user => {
264
+ userMap.set(user.key, user.displayName);
265
+ });
266
+
267
+ // Replace userkey references with display names in HTML
268
+ let resolvedHtml = html;
269
+ userMap.forEach((displayName, userKey) => {
270
+ // Replace <ac:link><ri:user ri:userkey="xxx" /></ac:link> with @displayName
271
+ const userLinkRegex = new RegExp(
272
+ `<ac:link>\\s*<ri:user\\s+ri:userkey="${userKey}"\\s*/>\\s*</ac:link>`,
273
+ 'g'
274
+ );
275
+ resolvedHtml = resolvedHtml.replace(userLinkRegex, `@${displayName}`);
276
+ });
277
+
278
+ return { html: resolvedHtml, userMap };
279
+ }
280
+
281
+ /**
282
+ * Find a page by title and space key, return page info with URL
283
+ * @param {string} spaceKey - Space key (e.g., "~huotui" or "TECH")
284
+ * @param {string} title - Page title
285
+ * @returns {Promise<{title: string, url: string} | null>}
286
+ */
287
+ async findPageByTitleAndSpace(spaceKey, title) {
288
+ try {
289
+ const response = await this.client.get('/content', {
290
+ params: {
291
+ spaceKey: spaceKey,
292
+ title: title,
293
+ limit: 1
294
+ }
295
+ });
296
+
297
+ if (response.data.results && response.data.results.length > 0) {
298
+ const page = response.data.results[0];
299
+ const webui = page._links?.webui || '';
300
+ return {
301
+ title: page.title,
302
+ url: webui ? `https://${this.domain}/wiki${webui}` : ''
303
+ };
304
+ }
305
+ return null;
306
+ } catch (error) {
307
+ return null;
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Resolve all page links in HTML to full URLs
313
+ * @param {string} html - HTML content with ri:page elements
314
+ * @returns {Promise<string>} - HTML with resolved page links
315
+ */
316
+ async resolvePageLinksInHtml(html) {
317
+ // Extract all page links: <ri:page ri:space-key="xxx" ri:content-title="yyy" />
318
+ const pageLinkRegex = /<ac:link>\s*<ri:page\s+ri:space-key="([^"]+)"\s+ri:content-title="([^"]+)"[^>]*(?:\/>|><\/ri:page>)\s*<\/ac:link>/g;
319
+ const pageLinks = [];
320
+ let match;
321
+
322
+ while ((match = pageLinkRegex.exec(html)) !== null) {
323
+ pageLinks.push({
324
+ fullMatch: match[0],
325
+ spaceKey: match[1],
326
+ title: match[2]
327
+ });
328
+ }
329
+
330
+ if (pageLinks.length === 0) {
331
+ return html;
332
+ }
333
+
334
+ // Fetch page info for all links in parallel
335
+ const pagePromises = pageLinks.map(async (link) => {
336
+ const pageInfo = await this.findPageByTitleAndSpace(link.spaceKey, link.title);
337
+ return {
338
+ ...link,
339
+ pageInfo
340
+ };
341
+ });
342
+
343
+ const resolvedLinks = await Promise.all(pagePromises);
344
+
345
+ // Replace page link references with markdown links
346
+ let resolvedHtml = html;
347
+ resolvedLinks.forEach(({ fullMatch, title, pageInfo }) => {
348
+ let replacement;
349
+ if (pageInfo && pageInfo.url) {
350
+ replacement = `[${title}](${pageInfo.url})`;
351
+ } else {
352
+ // Fallback to just the title if page not found
353
+ replacement = `[${title}]`;
354
+ }
355
+ resolvedHtml = resolvedHtml.replace(fullMatch, replacement);
356
+ });
357
+
358
+ return resolvedHtml;
359
+ }
360
+
170
361
  /**
171
362
  * List attachments for a page with pagination support
172
363
  */
173
364
  async listAttachments(pageIdOrUrl, options = {}) {
174
- const pageId = this.extractPageId(pageIdOrUrl);
365
+ const pageId = await this.extractPageId(pageIdOrUrl);
175
366
  const limit = this.parsePositiveInt(options.limit, 50);
176
367
  const start = this.parsePositiveInt(options.start, 0);
177
368
  const params = {
@@ -228,13 +419,40 @@ class ConfluenceClient {
228
419
 
229
420
  /**
230
421
  * Download an attachment's data stream
422
+ * Now uses the download link from attachment metadata instead of the broken REST API endpoint
231
423
  */
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'
424
+ async downloadAttachment(pageIdOrUrl, attachmentIdOrAttachment, options = {}) {
425
+ let downloadUrl;
426
+
427
+ // If the second argument is an attachment object with downloadLink, use it directly
428
+ if (typeof attachmentIdOrAttachment === 'object' && attachmentIdOrAttachment.downloadLink) {
429
+ downloadUrl = attachmentIdOrAttachment.downloadLink;
430
+ } else {
431
+ // Otherwise, fetch attachment info to get the download link
432
+ const pageId = await this.extractPageId(pageIdOrUrl);
433
+ const attachmentId = attachmentIdOrAttachment;
434
+ const response = await this.client.get(`/content/${pageId}/child/attachment`, {
435
+ params: { limit: 500 }
436
+ });
437
+ const attachment = response.data.results.find(att => att.id === String(attachmentId));
438
+ if (!attachment) {
439
+ throw new Error(`Attachment with ID ${attachmentId} not found on page ${pageId}`);
440
+ }
441
+ downloadUrl = this.toAbsoluteUrl(attachment._links?.download);
442
+ }
443
+
444
+ if (!downloadUrl) {
445
+ throw new Error('Unable to determine download URL for attachment');
446
+ }
447
+
448
+ // Download directly using axios with the same auth headers
449
+ const downloadResponse = await axios.get(downloadUrl, {
450
+ responseType: options.responseType || 'stream',
451
+ headers: {
452
+ 'Authorization': this.authType === 'basic' ? this.buildBasicAuthHeader() : `Bearer ${this.token}`
453
+ }
236
454
  });
237
- return response.data;
455
+ return downloadResponse.data;
238
456
  }
239
457
 
240
458
  /**
@@ -402,14 +620,54 @@ class ConfluenceClient {
402
620
 
403
621
  /**
404
622
  * Convert Confluence storage format to markdown
623
+ * @param {string} storage - Confluence storage format HTML
624
+ * @param {object} options - Conversion options
625
+ * @param {string} options.attachmentsDir - Directory name for attachments (default: 'attachments')
405
626
  */
406
- storageToMarkdown(storage) {
627
+ storageToMarkdown(storage, options = {}) {
628
+ const attachmentsDir = options.attachmentsDir || 'attachments';
407
629
  let markdown = storage;
408
630
 
409
631
  // Remove table of contents macro
410
632
  markdown = markdown.replace(/<ac:structured-macro ac:name="toc"[^>]*\s*\/>/g, '');
411
633
  markdown = markdown.replace(/<ac:structured-macro ac:name="toc"[^>]*>[\s\S]*?<\/ac:structured-macro>/g, '');
412
634
 
635
+ // Remove floatmenu macro (floating table of contents)
636
+ markdown = markdown.replace(/<ac:structured-macro ac:name="floatmenu"[^>]*>[\s\S]*?<\/ac:structured-macro>/g, '');
637
+
638
+ // Convert Confluence images to markdown images
639
+ // Format: <ac:image><ri:attachment ri:filename="image.png" /></ac:image>
640
+ markdown = markdown.replace(/<ac:image[^>]*>\s*<ri:attachment\s+ri:filename="([^"]+)"[^>]*\s*\/>\s*<\/ac:image>/g, (_, filename) => {
641
+ return `![${filename}](${attachmentsDir}/${filename})`;
642
+ });
643
+
644
+ // Also handle self-closing ac:image with ri:attachment
645
+ markdown = markdown.replace(/<ac:image[^>]*><ri:attachment\s+ri:filename="([^"]+)"[^>]*><\/ri:attachment><\/ac:image>/g, (_, filename) => {
646
+ return `![${filename}](${attachmentsDir}/${filename})`;
647
+ });
648
+
649
+ // Convert mermaid macro to mermaid code block
650
+ 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) => {
651
+ return `\n\`\`\`mermaid\n${code.trim()}\n\`\`\`\n`;
652
+ });
653
+
654
+ // 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
+ 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`;
669
+ });
670
+
413
671
  // Convert Confluence code macros to markdown
414
672
  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
673
  return `\`\`\`${lang}\n${code}\n\`\`\``;
@@ -438,12 +696,40 @@ class ConfluenceClient {
438
696
  return `[!note]\n${cleanContent}`;
439
697
  });
440
698
 
699
+ // Convert task list macros to markdown checkboxes
700
+ // Note: This is independent of user resolution - it only converts <ac:task> structure to "- [ ]" or "- [x]" format
701
+ markdown = markdown.replace(/<ac:task-list>([\s\S]*?)<\/ac:task-list>/g, (_, content) => {
702
+ const tasks = [];
703
+ // Match each task: <ac:task>...<ac:task-status>xxx</ac:task-status>...<ac:task-body>...</ac:task-body>...</ac:task>
704
+ 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;
705
+ let match;
706
+ while ((match = taskRegex.exec(content)) !== null) {
707
+ const status = match[1];
708
+ let taskBody = match[2];
709
+ // Clean up HTML from task body, but preserve @username
710
+ taskBody = taskBody.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
711
+ const checkbox = status === 'complete' ? '[x]' : '[ ]';
712
+ if (taskBody) {
713
+ tasks.push(`- ${checkbox} ${taskBody}`);
714
+ }
715
+ }
716
+ return tasks.length > 0 ? '\n' + tasks.join('\n') + '\n' : '';
717
+ });
718
+
441
719
  // Remove other unhandled macros (replace with empty string for now)
442
720
  markdown = markdown.replace(/<ac:structured-macro[^>]*>[\s\S]*?<\/ac:structured-macro>/g, '');
443
721
 
444
- // Convert links
722
+ // Convert external URL links
445
723
  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
724
 
725
+ // Convert internal page links - extract page title
726
+ // Format: <ac:link><ri:page ri:space-key="xxx" ri:content-title="Page Title" /></ac:link>
727
+ markdown = markdown.replace(/<ac:link>\s*<ri:page[^>]*ri:content-title="([^"]*)"[^>]*\/>\s*<\/ac:link>/g, '[$1]');
728
+ markdown = markdown.replace(/<ac:link>\s*<ri:page[^>]*ri:content-title="([^"]*)"[^>]*>\s*<\/ri:page>\s*<\/ac:link>/g, '[$1]');
729
+
730
+ // Remove any remaining ac:link tags that weren't matched
731
+ markdown = markdown.replace(/<ac:link>[\s\S]*?<\/ac:link>/g, '');
732
+
447
733
  // Convert remaining HTML to markdown
448
734
  markdown = this.htmlToMarkdown(markdown);
449
735
 
@@ -456,6 +742,10 @@ class ConfluenceClient {
456
742
  htmlToMarkdown(html) {
457
743
  let markdown = html;
458
744
 
745
+ // Convert time elements to date text BEFORE removing attributes
746
+ // Format: <time datetime="2025-09-16" /> or <time datetime="2025-09-16"></time>
747
+ markdown = markdown.replace(/<time\s+datetime="([^"]+)"[^>]*(?:\/>|>\s*<\/time>)/g, '$1');
748
+
459
749
  // Convert strong/bold BEFORE removing HTML attributes
460
750
  markdown = markdown.replace(/<strong[^>]*>(.*?)<\/strong>/g, '**$1**');
461
751
 
@@ -560,8 +850,8 @@ class ConfluenceClient {
560
850
  // Convert horizontal rules
561
851
  markdown = markdown.replace(/<hr\s*\/?>/g, '\n---\n');
562
852
 
563
- // Remove any remaining HTML tags
564
- markdown = markdown.replace(/<[^>]+>/g, ' ');
853
+ // Remove any remaining HTML tags, but preserve <details> and <summary> for GFM compatibility
854
+ markdown = markdown.replace(/<(?!\/?(details|summary)\b)[^>]+>/g, ' ');
565
855
 
566
856
  // Clean up whitespace and HTML entities
567
857
  markdown = markdown.replace(/&nbsp;/g, ' ');
@@ -569,10 +859,35 @@ class ConfluenceClient {
569
859
  markdown = markdown.replace(/&gt;/g, '>');
570
860
  markdown = markdown.replace(/&amp;/g, '&');
571
861
  markdown = markdown.replace(/&quot;/g, '"');
862
+ markdown = markdown.replace(/&apos;/g, '\'');
863
+ // Smart quotes and special characters
864
+ markdown = markdown.replace(/&ldquo;/g, '"');
865
+ markdown = markdown.replace(/&rdquo;/g, '"');
866
+ markdown = markdown.replace(/&lsquo;/g, '\'');
867
+ markdown = markdown.replace(/&rsquo;/g, '\'');
868
+ markdown = markdown.replace(/&mdash;/g, '—');
869
+ markdown = markdown.replace(/&ndash;/g, '–');
870
+ markdown = markdown.replace(/&hellip;/g, '...');
871
+ markdown = markdown.replace(/&bull;/g, '•');
872
+ markdown = markdown.replace(/&copy;/g, '©');
873
+ markdown = markdown.replace(/&reg;/g, '®');
874
+ markdown = markdown.replace(/&trade;/g, '™');
875
+ // Numeric HTML entities
876
+ markdown = markdown.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10)));
877
+ markdown = markdown.replace(/&#x([0-9a-fA-F]+);/g, (_, code) => String.fromCharCode(parseInt(code, 16)));
572
878
 
573
- // Clean up extra whitespace
879
+ // Clean up extra whitespace for standard Markdown format
880
+ // Remove trailing spaces from each line
881
+ markdown = markdown.replace(/[ \t]+$/gm, '');
882
+ // Remove leading spaces from lines (except for code blocks, blockquotes, and list items)
883
+ markdown = markdown.replace(/^[ \t]+(?!([`>]|[*+-] |\d+[.)] ))/gm, '');
884
+ // Ensure proper spacing after headings (# Title should be followed by blank line or content)
885
+ markdown = markdown.replace(/^(#{1,6}[^\n]+)\n(?!\n)/gm, '$1\n\n');
886
+ // Normalize multiple blank lines to double newline
574
887
  markdown = markdown.replace(/\n\s*\n\s*\n+/g, '\n\n');
888
+ // Collapse multiple spaces to single space (but preserve newlines)
575
889
  markdown = markdown.replace(/[ \t]+/g, ' ');
890
+ // Final trim
576
891
  markdown = markdown.trim();
577
892
 
578
893
  return markdown;
@@ -697,7 +1012,7 @@ class ConfluenceClient {
697
1012
  * Get page content for editing
698
1013
  */
699
1014
  async getPageForEdit(pageIdOrUrl) {
700
- const pageId = this.extractPageId(pageIdOrUrl);
1015
+ const pageId = await this.extractPageId(pageIdOrUrl);
701
1016
 
702
1017
  const response = await this.client.get(`/content/${pageId}`, {
703
1018
  params: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "confluence-cli",
3
- "version": "1.10.0",
3
+ "version": "1.11.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": {
@@ -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