confluence-cli 1.10.1 ā 1.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/bin/confluence.js +13 -3
- package/lib/confluence-client.js +254 -48
- package/package.json +2 -1
- package/tests/confluence-client.test.js +70 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## [1.11.1](https://github.com/pchuri/confluence-cli/compare/v1.11.0...v1.11.1) (2025-12-17)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* support children macro, improve macro handling, and filter attachments ([#23](https://github.com/pchuri/confluence-cli/issues/23)) ([15b721a](https://github.com/pchuri/confluence-cli/commit/15b721acdc296e72470ee438c9fe3470e09ae52e))
|
|
7
|
+
|
|
8
|
+
# [1.11.0](https://github.com/pchuri/confluence-cli/compare/v1.10.1...v1.11.0) (2025-12-12)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* Support for Confluence display URLs ([#20](https://github.com/pchuri/confluence-cli/issues/20)) ([3bda7c2](https://github.com/pchuri/confluence-cli/commit/3bda7c2aad8ec02dac60f3b7c34c31b549a31cce))
|
|
14
|
+
|
|
1
15
|
## [1.10.1](https://github.com/pchuri/confluence-cli/compare/v1.10.0...v1.10.1) (2025-12-08)
|
|
2
16
|
|
|
3
17
|
|
package/bin/confluence.js
CHANGED
|
@@ -445,7 +445,9 @@ program
|
|
|
445
445
|
const contentExt = formatExt[format] || 'txt';
|
|
446
446
|
|
|
447
447
|
const pageInfo = await client.getPageInfo(pageId);
|
|
448
|
-
|
|
448
|
+
// Read page with attachment extraction enabled
|
|
449
|
+
const content = await client.readPage(pageId, format, { extractReferencedAttachments: true });
|
|
450
|
+
const referencedAttachments = client._referencedAttachments || new Set();
|
|
449
451
|
|
|
450
452
|
const baseDir = path.resolve(options.dest || '.');
|
|
451
453
|
const folderName = sanitizeTitle(pageInfo.title || 'page');
|
|
@@ -462,8 +464,16 @@ program
|
|
|
462
464
|
|
|
463
465
|
if (!options.skipAttachments) {
|
|
464
466
|
const pattern = options.pattern ? options.pattern.trim() : null;
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
+
const allAttachments = await client.getAllAttachments(pageId);
|
|
468
|
+
|
|
469
|
+
// Filter: only referenced attachments (unless pattern is specified, then use pattern)
|
|
470
|
+
let filtered;
|
|
471
|
+
if (pattern) {
|
|
472
|
+
filtered = allAttachments.filter(att => client.matchesPattern(att.title, pattern));
|
|
473
|
+
} else {
|
|
474
|
+
// Only download attachments that are referenced in the page content
|
|
475
|
+
filtered = allAttachments.filter(att => referencedAttachments.has(att.title));
|
|
476
|
+
}
|
|
467
477
|
|
|
468
478
|
if (filtered.length === 0) {
|
|
469
479
|
console.log(chalk.yellow('No attachments to download.'));
|
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,25 +62,83 @@ 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
|
|
|
72
99
|
return pageIdOrUrl;
|
|
73
100
|
}
|
|
74
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
|
+
|
|
75
132
|
/**
|
|
76
133
|
* Read a Confluence page content
|
|
77
134
|
* @param {string} pageIdOrUrl - Page ID or URL
|
|
78
135
|
* @param {string} format - Output format: 'text', 'html', or 'markdown'
|
|
79
136
|
* @param {object} options - Additional options
|
|
80
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)
|
|
81
139
|
*/
|
|
82
140
|
async readPage(pageIdOrUrl, format = 'text', options = {}) {
|
|
83
|
-
const pageId = this.extractPageId(pageIdOrUrl);
|
|
141
|
+
const pageId = await this.extractPageId(pageIdOrUrl);
|
|
84
142
|
|
|
85
143
|
const response = await this.client.get(`/content/${pageId}`, {
|
|
86
144
|
params: {
|
|
@@ -90,6 +148,11 @@ class ConfluenceClient {
|
|
|
90
148
|
|
|
91
149
|
let htmlContent = response.data.body.storage.value;
|
|
92
150
|
|
|
151
|
+
// Extract referenced attachments if requested
|
|
152
|
+
if (options.extractReferencedAttachments) {
|
|
153
|
+
this._referencedAttachments = this.extractReferencedAttachments(htmlContent);
|
|
154
|
+
}
|
|
155
|
+
|
|
93
156
|
if (format === 'html') {
|
|
94
157
|
return htmlContent;
|
|
95
158
|
}
|
|
@@ -108,6 +171,9 @@ class ConfluenceClient {
|
|
|
108
171
|
htmlContent = await this.resolvePageLinksInHtml(htmlContent);
|
|
109
172
|
}
|
|
110
173
|
|
|
174
|
+
// Resolve children macro to child pages list
|
|
175
|
+
htmlContent = await this.resolveChildrenMacro(htmlContent, pageId);
|
|
176
|
+
|
|
111
177
|
return this.storageToMarkdown(htmlContent);
|
|
112
178
|
}
|
|
113
179
|
|
|
@@ -127,7 +193,7 @@ class ConfluenceClient {
|
|
|
127
193
|
* Get page information
|
|
128
194
|
*/
|
|
129
195
|
async getPageInfo(pageIdOrUrl) {
|
|
130
|
-
const pageId = this.extractPageId(pageIdOrUrl);
|
|
196
|
+
const pageId = await this.extractPageId(pageIdOrUrl);
|
|
131
197
|
|
|
132
198
|
const response = await this.client.get(`/content/${pageId}`, {
|
|
133
199
|
params: {
|
|
@@ -331,11 +397,63 @@ class ConfluenceClient {
|
|
|
331
397
|
return resolvedHtml;
|
|
332
398
|
}
|
|
333
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
|
+
|
|
334
452
|
/**
|
|
335
453
|
* List attachments for a page with pagination support
|
|
336
454
|
*/
|
|
337
455
|
async listAttachments(pageIdOrUrl, options = {}) {
|
|
338
|
-
const pageId = this.extractPageId(pageIdOrUrl);
|
|
456
|
+
const pageId = await this.extractPageId(pageIdOrUrl);
|
|
339
457
|
const limit = this.parsePositiveInt(options.limit, 50);
|
|
340
458
|
const start = this.parsePositiveInt(options.start, 0);
|
|
341
459
|
const params = {
|
|
@@ -402,7 +520,7 @@ class ConfluenceClient {
|
|
|
402
520
|
downloadUrl = attachmentIdOrAttachment.downloadLink;
|
|
403
521
|
} else {
|
|
404
522
|
// Otherwise, fetch attachment info to get the download link
|
|
405
|
-
const pageId = this.extractPageId(pageIdOrUrl);
|
|
523
|
+
const pageId = await this.extractPageId(pageIdOrUrl);
|
|
406
524
|
const attachmentId = attachmentIdOrAttachment;
|
|
407
525
|
const response = await this.client.get(`/content/${pageId}/child/attachment`, {
|
|
408
526
|
params: { limit: 500 }
|
|
@@ -529,33 +647,8 @@ class ConfluenceClient {
|
|
|
529
647
|
// Convert markdown to HTML first
|
|
530
648
|
const html = this.markdown.render(markdown);
|
|
531
649
|
|
|
532
|
-
//
|
|
533
|
-
|
|
534
|
-
let storage = html
|
|
535
|
-
.replace(/<h1>/g, '<h1>')
|
|
536
|
-
.replace(/<\/h1>/g, '</h1>')
|
|
537
|
-
.replace(/<h2>/g, '<h2>')
|
|
538
|
-
.replace(/<\/h2>/g, '</h2>')
|
|
539
|
-
.replace(/<h3>/g, '<h3>')
|
|
540
|
-
.replace(/<\/h3>/g, '</h3>')
|
|
541
|
-
.replace(/<p>/g, '<p>')
|
|
542
|
-
.replace(/<\/p>/g, '</p>')
|
|
543
|
-
.replace(/<strong>/g, '<strong>')
|
|
544
|
-
.replace(/<\/strong>/g, '</strong>')
|
|
545
|
-
.replace(/<em>/g, '<em>')
|
|
546
|
-
.replace(/<\/em>/g, '</em>')
|
|
547
|
-
.replace(/<ul>/g, '<ul>')
|
|
548
|
-
.replace(/<\/ul>/g, '</ul>')
|
|
549
|
-
.replace(/<ol>/g, '<ol>')
|
|
550
|
-
.replace(/<\/ol>/g, '</ol>')
|
|
551
|
-
.replace(/<li>/g, '<li>')
|
|
552
|
-
.replace(/<\/li>/g, '</li>')
|
|
553
|
-
.replace(/<code>/g, '<code>')
|
|
554
|
-
.replace(/<\/code>/g, '</code>')
|
|
555
|
-
.replace(/<pre><code>/g, '<ac:structured-macro ac:name="code"><ac:plain-text-body><![CDATA[')
|
|
556
|
-
.replace(/<\/code><\/pre>/g, ']]></ac:plain-text-body></ac:structured-macro>');
|
|
557
|
-
|
|
558
|
-
return storage;
|
|
650
|
+
// Delegate to htmlToConfluenceStorage for proper conversion including code blocks
|
|
651
|
+
return this.htmlToConfluenceStorage(html);
|
|
559
652
|
}
|
|
560
653
|
|
|
561
654
|
/**
|
|
@@ -591,6 +684,76 @@ class ConfluenceClient {
|
|
|
591
684
|
});
|
|
592
685
|
}
|
|
593
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
|
+
|
|
594
757
|
/**
|
|
595
758
|
* Convert Confluence storage format to markdown
|
|
596
759
|
* @param {string} storage - Confluence storage format HTML
|
|
@@ -601,6 +764,9 @@ class ConfluenceClient {
|
|
|
601
764
|
const attachmentsDir = options.attachmentsDir || 'attachments';
|
|
602
765
|
let markdown = storage;
|
|
603
766
|
|
|
767
|
+
// Detect language from content
|
|
768
|
+
const labels = this.detectLanguageLabels(markdown);
|
|
769
|
+
|
|
604
770
|
// Remove table of contents macro
|
|
605
771
|
markdown = markdown.replace(/<ac:structured-macro ac:name="toc"[^>]*\s*\/>/g, '');
|
|
606
772
|
markdown = markdown.replace(/<ac:structured-macro ac:name="toc"[^>]*>[\s\S]*?<\/ac:structured-macro>/g, '');
|
|
@@ -625,20 +791,8 @@ class ConfluenceClient {
|
|
|
625
791
|
});
|
|
626
792
|
|
|
627
793
|
// 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
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) => {
|
|
641
|
-
return `\n<details>\n<summary>${
|
|
795
|
+
return `\n<details>\n<summary>${labels.expandDetails}</summary>\n\n${content}\n\n</details>\n`;
|
|
642
796
|
});
|
|
643
797
|
|
|
644
798
|
// Convert Confluence code macros to markdown
|
|
@@ -689,6 +843,58 @@ class ConfluenceClient {
|
|
|
689
843
|
return tasks.length > 0 ? '\n' + tasks.join('\n') + '\n' : '';
|
|
690
844
|
});
|
|
691
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
|
+
|
|
692
898
|
// Remove other unhandled macros (replace with empty string for now)
|
|
693
899
|
markdown = markdown.replace(/<ac:structured-macro[^>]*>[\s\S]*?<\/ac:structured-macro>/g, '');
|
|
694
900
|
|
|
@@ -985,7 +1191,7 @@ class ConfluenceClient {
|
|
|
985
1191
|
* Get page content for editing
|
|
986
1192
|
*/
|
|
987
1193
|
async getPageForEdit(pageIdOrUrl) {
|
|
988
|
-
const pageId = this.extractPageId(pageIdOrUrl);
|
|
1194
|
+
const pageId = await this.extractPageId(pageIdOrUrl);
|
|
989
1195
|
|
|
990
1196
|
const response = await this.client.get(`/content/${pageId}`, {
|
|
991
1197
|
params: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "confluence-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.1",
|
|
4
4
|
"description": "A command-line interface for Atlassian Confluence with page creation and editing capabilities",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -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
|
|
|
@@ -135,6 +181,23 @@ describe('ConfluenceClient', () => {
|
|
|
135
181
|
});
|
|
136
182
|
});
|
|
137
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
|
+
|
|
138
201
|
describe('storageToMarkdown', () => {
|
|
139
202
|
test('should convert Confluence storage format to markdown', () => {
|
|
140
203
|
const storage = '<h1>Hello World</h1><p>This is a <strong>test</strong> page.</p>';
|