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 +14 -0
- package/bin/confluence.js +4 -2
- package/lib/confluence-client.js +334 -19
- package/package.json +2 -1
- package/tests/confluence-client.test.js +53 -7
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
|
-
|
|
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
|
-
|
|
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)}`);
|
package/lib/confluence-client.js
CHANGED
|
@@ -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 -
|
|
65
|
+
// Handle display URLs - search by space and title
|
|
66
66
|
const displayMatch = pageIdOrUrl.match(/\/display\/([^/]+)\/(.+)/);
|
|
67
67
|
if (displayMatch) {
|
|
68
|
-
|
|
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
|
-
|
|
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,
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
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 ``;
|
|
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 ``;
|
|
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(/ /g, ' ');
|
|
@@ -569,10 +859,35 @@ class ConfluenceClient {
|
|
|
569
859
|
markdown = markdown.replace(/>/g, '>');
|
|
570
860
|
markdown = markdown.replace(/&/g, '&');
|
|
571
861
|
markdown = markdown.replace(/"/g, '"');
|
|
862
|
+
markdown = markdown.replace(/'/g, '\'');
|
|
863
|
+
// Smart quotes and special characters
|
|
864
|
+
markdown = markdown.replace(/“/g, '"');
|
|
865
|
+
markdown = markdown.replace(/”/g, '"');
|
|
866
|
+
markdown = markdown.replace(/‘/g, '\'');
|
|
867
|
+
markdown = markdown.replace(/’/g, '\'');
|
|
868
|
+
markdown = markdown.replace(/—/g, '—');
|
|
869
|
+
markdown = markdown.replace(/–/g, '–');
|
|
870
|
+
markdown = markdown.replace(/…/g, '...');
|
|
871
|
+
markdown = markdown.replace(/•/g, '•');
|
|
872
|
+
markdown = markdown.replace(/©/g, '©');
|
|
873
|
+
markdown = markdown.replace(/®/g, '®');
|
|
874
|
+
markdown = markdown.replace(/™/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.
|
|
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
|
|
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(
|
|
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
|
|