confluence-cli 1.31.1 → 1.32.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/README.md +54 -2
- package/bin/confluence.js +53 -24
- package/lib/confluence-client.js +143 -582
- package/lib/html-to-markdown.js +150 -0
- package/lib/macro-converter.js +298 -0
- package/npm-shrinkwrap.json +499 -4754
- package/package.json +1 -1
- package/plugins/confluence/skills/confluence/SKILL.md +6 -4
package/lib/confluence-client.js
CHANGED
|
@@ -4,28 +4,8 @@ const https = require('https');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const FormData = require('form-data');
|
|
6
6
|
const { convert } = require('html-to-text');
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
const NAMED_ENTITIES = {
|
|
10
|
-
// uppercase variants
|
|
11
|
-
aring: 'å', auml: 'ä', ouml: 'ö',
|
|
12
|
-
eacute: 'é', egrave: 'è', ecirc: 'ê', euml: 'ë',
|
|
13
|
-
aacute: 'á', agrave: 'à', acirc: 'â', atilde: 'ã',
|
|
14
|
-
oacute: 'ó', ograve: 'ò', ocirc: 'ô', otilde: 'õ',
|
|
15
|
-
uacute: 'ú', ugrave: 'ù', ucirc: 'û', uuml: 'ü',
|
|
16
|
-
iacute: 'í', igrave: 'ì', icirc: 'î', iuml: 'ï',
|
|
17
|
-
ntilde: 'ñ', ccedil: 'ç', szlig: 'ß', yuml: 'ÿ',
|
|
18
|
-
eth: 'ð', thorn: 'þ',
|
|
19
|
-
// uppercase variants
|
|
20
|
-
Aring: 'Å', Auml: 'Ä', Ouml: 'Ö',
|
|
21
|
-
Eacute: 'É', Egrave: 'È', Ecirc: 'Ê', Euml: 'Ë',
|
|
22
|
-
Aacute: 'Á', Agrave: 'À', Acirc: 'Â', Atilde: 'Ã',
|
|
23
|
-
Oacute: 'Ó', Ograve: 'Ò', Ocirc: 'Ô', Otilde: 'Õ',
|
|
24
|
-
Uacute: 'Ú', Ugrave: 'Ù', Ucirc: 'Û', Uuml: 'Ü',
|
|
25
|
-
Iacute: 'Í', Igrave: 'Ì', Icirc: 'Î', Iuml: 'Ï',
|
|
26
|
-
Ntilde: 'Ñ', Ccedil: 'Ç', Szlig: 'SS', Yuml: 'Ÿ',
|
|
27
|
-
Eth: 'Ð', Thorn: 'Þ'
|
|
28
|
-
};
|
|
7
|
+
const MacroConverter = require('./macro-converter');
|
|
8
|
+
const { htmlToMarkdown, NAMED_ENTITIES } = require('./html-to-markdown');
|
|
29
9
|
|
|
30
10
|
function createSemaphore(limit) {
|
|
31
11
|
let active = 0;
|
|
@@ -61,10 +41,14 @@ class ConfluenceClient {
|
|
|
61
41
|
this.forceCloud = !!config.forceCloud;
|
|
62
42
|
this.mtls = config.mtls;
|
|
63
43
|
this.apiPath = this.sanitizeApiPath(config.apiPath);
|
|
64
|
-
this.webUrlPrefix = this.apiPath.
|
|
44
|
+
this.webUrlPrefix = this.apiPath.includes('/wiki/') ? '/wiki' : '';
|
|
65
45
|
this.baseURL = `${this.protocol}://${this.domain}${this.apiPath}`;
|
|
66
|
-
this.
|
|
67
|
-
|
|
46
|
+
this.converter = new MacroConverter({
|
|
47
|
+
isCloud: this.isCloud(),
|
|
48
|
+
webUrlPrefix: this.webUrlPrefix,
|
|
49
|
+
buildUrl: (pathOrUrl) => this.buildUrl(pathOrUrl),
|
|
50
|
+
});
|
|
51
|
+
this.markdown = this.converter.markdown;
|
|
68
52
|
|
|
69
53
|
const headers = {
|
|
70
54
|
'Content-Type': 'application/json',
|
|
@@ -340,7 +324,7 @@ class ConfluenceClient {
|
|
|
340
324
|
/**
|
|
341
325
|
* Read a Confluence page content
|
|
342
326
|
* @param {string} pageIdOrUrl - Page ID or URL
|
|
343
|
-
* @param {string} format - Output format: 'text', 'html', or 'markdown'
|
|
327
|
+
* @param {string} format - Output format: 'text', 'html', 'storage', or 'markdown'
|
|
344
328
|
* @param {object} options - Additional options
|
|
345
329
|
* @param {boolean} options.resolveUsers - Whether to resolve userkeys to display names (default: true for markdown)
|
|
346
330
|
* @param {boolean} options.extractReferencedAttachments - Whether to extract referenced attachments (default: false)
|
|
@@ -361,7 +345,7 @@ class ConfluenceClient {
|
|
|
361
345
|
this._referencedAttachments = this.extractReferencedAttachments(htmlContent);
|
|
362
346
|
}
|
|
363
347
|
|
|
364
|
-
if (format === 'html') {
|
|
348
|
+
if (format === 'html' || format === 'storage') {
|
|
365
349
|
return htmlContent;
|
|
366
350
|
}
|
|
367
351
|
|
|
@@ -405,17 +389,11 @@ class ConfluenceClient {
|
|
|
405
389
|
|
|
406
390
|
const response = await this.client.get(`/content/${pageId}`, {
|
|
407
391
|
params: {
|
|
408
|
-
expand: 'space'
|
|
392
|
+
expand: 'space,history,version,ancestors'
|
|
409
393
|
}
|
|
410
394
|
});
|
|
411
395
|
|
|
412
|
-
return
|
|
413
|
-
title: response.data.title,
|
|
414
|
-
id: response.data.id,
|
|
415
|
-
type: response.data.type,
|
|
416
|
-
status: response.data.status,
|
|
417
|
-
space: response.data.space
|
|
418
|
-
};
|
|
396
|
+
return this.normalizePage(response.data);
|
|
419
397
|
}
|
|
420
398
|
|
|
421
399
|
/**
|
|
@@ -562,7 +540,7 @@ class ConfluenceClient {
|
|
|
562
540
|
const webui = page._links?.webui || '';
|
|
563
541
|
return {
|
|
564
542
|
title: page.title,
|
|
565
|
-
url: webui ? this.
|
|
543
|
+
url: webui ? this.toAbsoluteUrl(webui, page._links?.base) : ''
|
|
566
544
|
};
|
|
567
545
|
}
|
|
568
546
|
return null;
|
|
@@ -656,7 +634,7 @@ class ConfluenceClient {
|
|
|
656
634
|
// Format: - [Page Title](URL)
|
|
657
635
|
const childPagesList = childPages.map(page => {
|
|
658
636
|
const webui = page._links?.webui || '';
|
|
659
|
-
const url = webui ? this.
|
|
637
|
+
const url = webui ? this.toAbsoluteUrl(webui, page._links?.base) : '';
|
|
660
638
|
if (url) {
|
|
661
639
|
return `- [${page.title}](${url})`;
|
|
662
640
|
} else {
|
|
@@ -1182,550 +1160,32 @@ class ConfluenceClient {
|
|
|
1182
1160
|
return { pageId: String(pageId), key };
|
|
1183
1161
|
}
|
|
1184
1162
|
|
|
1185
|
-
/**
|
|
1186
|
-
* Convert markdown to Confluence storage format
|
|
1187
|
-
*/
|
|
1188
1163
|
markdownToStorage(markdown) {
|
|
1189
|
-
|
|
1190
|
-
const html = this.markdown.render(markdown);
|
|
1191
|
-
|
|
1192
|
-
// Convert HTML to native Confluence storage format elements
|
|
1193
|
-
return this.htmlToConfluenceStorage(html);
|
|
1164
|
+
return this.converter.markdownToStorage(markdown);
|
|
1194
1165
|
}
|
|
1195
1166
|
|
|
1196
|
-
/**
|
|
1197
|
-
* Convert HTML to native Confluence storage format
|
|
1198
|
-
*/
|
|
1199
1167
|
htmlToConfluenceStorage(html) {
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
// Convert headings to native Confluence format
|
|
1203
|
-
storage = storage.replace(/<h([1-6])>(.*?)<\/h[1-6]>/g, '<h$1>$2</h$1>');
|
|
1204
|
-
|
|
1205
|
-
// Convert paragraphs
|
|
1206
|
-
storage = storage.replace(/<p>(.*?)<\/p>/g, '<p>$1</p>');
|
|
1207
|
-
|
|
1208
|
-
// Convert strong/bold text
|
|
1209
|
-
storage = storage.replace(/<strong>(.*?)<\/strong>/g, '<strong>$1</strong>');
|
|
1210
|
-
|
|
1211
|
-
// Convert emphasis/italic text
|
|
1212
|
-
storage = storage.replace(/<em>(.*?)<\/em>/g, '<em>$1</em>');
|
|
1213
|
-
|
|
1214
|
-
// Convert unordered lists
|
|
1215
|
-
storage = storage.replace(/<ul>(.*?)<\/ul>/gs, '<ul>$1</ul>');
|
|
1216
|
-
storage = storage.replace(/<li>(.*?)<\/li>/g, '<li><p>$1</p></li>');
|
|
1217
|
-
|
|
1218
|
-
// Convert ordered lists
|
|
1219
|
-
storage = storage.replace(/<ol>(.*?)<\/ol>/gs, '<ol>$1</ol>');
|
|
1220
|
-
|
|
1221
|
-
// Convert code blocks to Confluence code macro
|
|
1222
|
-
storage = storage.replace(/<pre><code(?:\s+class="language-(\w+)")?>(.*?)<\/code><\/pre>/gs, (_, lang, code) => {
|
|
1223
|
-
const language = lang || 'text';
|
|
1224
|
-
// Trim trailing newline added by markdown-it during HTML rendering,
|
|
1225
|
-
// and decode HTML entities that markdown-it encodes inside <code> blocks
|
|
1226
|
-
// so they appear as literal characters in the CDATA output
|
|
1227
|
-
const decodedCode = code.replace(/\n$/, '')
|
|
1228
|
-
.replace(/"/g, '"')
|
|
1229
|
-
.replace(/</g, '<')
|
|
1230
|
-
.replace(/>/g, '>')
|
|
1231
|
-
.replace(/&/g, '&'); // & last to avoid double-decoding
|
|
1232
|
-
const safeCode = decodedCode.replace(/]]>/g, ']]]]><![CDATA[>');
|
|
1233
|
-
return `<ac:structured-macro ac:name="code"><ac:parameter ac:name="language">${language}</ac:parameter><ac:plain-text-body><![CDATA[${safeCode}]]></ac:plain-text-body></ac:structured-macro>`;
|
|
1234
|
-
});
|
|
1235
|
-
|
|
1236
|
-
// Convert inline code
|
|
1237
|
-
storage = storage.replace(/<code>(.*?)<\/code>/g, '<code>$1</code>');
|
|
1238
|
-
|
|
1239
|
-
// Convert blockquotes to appropriate macros based on content
|
|
1240
|
-
storage = storage.replace(/<blockquote>(.*?)<\/blockquote>/gs, (_, content) => {
|
|
1241
|
-
// Check for admonition patterns
|
|
1242
|
-
if (content.includes('<strong>INFO</strong>')) {
|
|
1243
|
-
const cleanContent = content.replace(/<p><strong>INFO<\/strong><\/p>\s*/, '');
|
|
1244
|
-
return `<ac:structured-macro ac:name="info">
|
|
1245
|
-
<ac:rich-text-body>${cleanContent}</ac:rich-text-body>
|
|
1246
|
-
</ac:structured-macro>`;
|
|
1247
|
-
} else if (content.includes('<strong>WARNING</strong>')) {
|
|
1248
|
-
const cleanContent = content.replace(/<p><strong>WARNING<\/strong><\/p>\s*/, '');
|
|
1249
|
-
return `<ac:structured-macro ac:name="warning">
|
|
1250
|
-
<ac:rich-text-body>${cleanContent}</ac:rich-text-body>
|
|
1251
|
-
</ac:structured-macro>`;
|
|
1252
|
-
} else if (content.includes('<strong>NOTE</strong>')) {
|
|
1253
|
-
const cleanContent = content.replace(/<p><strong>NOTE<\/strong><\/p>\s*/, '');
|
|
1254
|
-
return `<ac:structured-macro ac:name="note">
|
|
1255
|
-
<ac:rich-text-body>${cleanContent}</ac:rich-text-body>
|
|
1256
|
-
</ac:structured-macro>`;
|
|
1257
|
-
} else {
|
|
1258
|
-
// Default to info macro for regular blockquotes
|
|
1259
|
-
return `<ac:structured-macro ac:name="info">
|
|
1260
|
-
<ac:rich-text-body>${content}</ac:rich-text-body>
|
|
1261
|
-
</ac:structured-macro>`;
|
|
1262
|
-
}
|
|
1263
|
-
});
|
|
1264
|
-
|
|
1265
|
-
// Convert tables
|
|
1266
|
-
storage = storage.replace(/<table>(.*?)<\/table>/gs, '<table>$1</table>');
|
|
1267
|
-
storage = storage.replace(/<thead>(.*?)<\/thead>/gs, '<thead>$1</thead>');
|
|
1268
|
-
storage = storage.replace(/<tbody>(.*?)<\/tbody>/gs, '<tbody>$1</tbody>');
|
|
1269
|
-
storage = storage.replace(/<tr>(.*?)<\/tr>/gs, '<tr>$1</tr>');
|
|
1270
|
-
storage = storage.replace(/<th>(.*?)<\/th>/g, '<th><p>$1</p></th>');
|
|
1271
|
-
storage = storage.replace(/<td>(.*?)<\/td>/g, '<td><p>$1</p></td>');
|
|
1272
|
-
|
|
1273
|
-
// Convert links
|
|
1274
|
-
// Confluence Cloud does not render ac:link + ri:url; use smart links instead.
|
|
1275
|
-
// Server/Data Center instances continue to use the ac:link storage format.
|
|
1276
|
-
if (this.isCloud()) {
|
|
1277
|
-
storage = storage.replace(/<a href="(.*?)">(.*?)<\/a>/g, '<a href="$1" data-card-appearance="inline">$2</a>');
|
|
1278
|
-
} else {
|
|
1279
|
-
storage = storage.replace(/<a href="(.*?)">(.*?)<\/a>/g, '<ac:link><ri:url ri:value="$1" /><ac:plain-text-link-body><![CDATA[$2]]></ac:plain-text-link-body></ac:link>');
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
// Convert horizontal rules
|
|
1283
|
-
storage = storage.replace(/<hr\s*\/?>/g, '<hr />');
|
|
1284
|
-
|
|
1285
|
-
// Note: Do NOT globally decode < > & here. These represent literal
|
|
1286
|
-
// characters in user content (e.g. <placeholder> in inline text) and
|
|
1287
|
-
// Confluence storage format renders them correctly as-is. Code block
|
|
1288
|
-
// entities are decoded separately above before CDATA insertion.
|
|
1289
|
-
|
|
1290
|
-
return storage;
|
|
1168
|
+
return this.converter.htmlToConfluenceStorage(html);
|
|
1291
1169
|
}
|
|
1292
1170
|
|
|
1293
|
-
/**
|
|
1294
|
-
* Convert markdown to Confluence storage format using native storage format
|
|
1295
|
-
*/
|
|
1296
1171
|
markdownToNativeStorage(markdown) {
|
|
1297
|
-
|
|
1298
|
-
const html = this.markdown.render(markdown);
|
|
1299
|
-
|
|
1300
|
-
// Delegate to htmlToConfluenceStorage for proper conversion including code blocks
|
|
1301
|
-
return this.htmlToConfluenceStorage(html);
|
|
1172
|
+
return this.converter.markdownToNativeStorage(markdown);
|
|
1302
1173
|
}
|
|
1303
1174
|
|
|
1304
|
-
/**
|
|
1305
|
-
* Setup Confluence-specific markdown extensions
|
|
1306
|
-
*/
|
|
1307
1175
|
setupConfluenceMarkdownExtensions() {
|
|
1308
|
-
|
|
1309
|
-
this.markdown.enable(['table', 'strikethrough', 'linkify']);
|
|
1310
|
-
|
|
1311
|
-
// Add custom rule for Confluence macros in markdown
|
|
1312
|
-
this.markdown.core.ruler.before('normalize', 'confluence_macros', (state) => {
|
|
1313
|
-
const src = state.src;
|
|
1314
|
-
|
|
1315
|
-
// Convert [!info] admonitions to info macro
|
|
1316
|
-
state.src = src.replace(/\[!info\]\s*([\s\S]*?)(?=\n\s*\n|\n\s*\[!|$)/g, (_, content) => {
|
|
1317
|
-
return `> **INFO**\n> ${content.trim().replace(/\n/g, '\n> ')}`;
|
|
1318
|
-
});
|
|
1319
|
-
|
|
1320
|
-
// Convert [!warning] admonitions to warning macro
|
|
1321
|
-
state.src = state.src.replace(/\[!warning\]\s*([\s\S]*?)(?=\n\s*\n|\n\s*\[!|$)/g, (_, content) => {
|
|
1322
|
-
return `> **WARNING**\n> ${content.trim().replace(/\n/g, '\n> ')}`;
|
|
1323
|
-
});
|
|
1324
|
-
|
|
1325
|
-
// Convert [!note] admonitions to note macro
|
|
1326
|
-
state.src = state.src.replace(/\[!note\]\s*([\s\S]*?)(?=\n\s*\n|\n\s*\[!|$)/g, (_, content) => {
|
|
1327
|
-
return `> **NOTE**\n> ${content.trim().replace(/\n/g, '\n> ')}`;
|
|
1328
|
-
});
|
|
1329
|
-
|
|
1330
|
-
// Convert task lists to proper format
|
|
1331
|
-
state.src = state.src.replace(/^(\s*)- \[([ x])\] (.+)$/gm, (_, indent, checked, text) => {
|
|
1332
|
-
return `${indent}- [${checked}] ${text}`;
|
|
1333
|
-
});
|
|
1334
|
-
});
|
|
1176
|
+
this.converter.setupConfluenceMarkdownExtensions();
|
|
1335
1177
|
}
|
|
1336
1178
|
|
|
1337
|
-
/**
|
|
1338
|
-
* Detect language from text content and return appropriate labels
|
|
1339
|
-
* @param {string} text - Text content to analyze
|
|
1340
|
-
* @returns {object} Object with language-specific labels
|
|
1341
|
-
*/
|
|
1342
1179
|
detectLanguageLabels(text) {
|
|
1343
|
-
|
|
1344
|
-
includePage: 'Include Page',
|
|
1345
|
-
sharedBlock: 'Shared Block',
|
|
1346
|
-
includeSharedBlock: 'Include Shared Block',
|
|
1347
|
-
fromPage: 'from page',
|
|
1348
|
-
expandDetails: 'Expand Details'
|
|
1349
|
-
};
|
|
1350
|
-
|
|
1351
|
-
if (/[\u4e00-\u9fa5]/.test(text)) {
|
|
1352
|
-
// Chinese
|
|
1353
|
-
labels.includePage = '包含页面';
|
|
1354
|
-
labels.sharedBlock = '共享块';
|
|
1355
|
-
labels.includeSharedBlock = '包含共享块';
|
|
1356
|
-
labels.fromPage = '来自页面';
|
|
1357
|
-
labels.expandDetails = '展开详情';
|
|
1358
|
-
} else if (/[\u3040-\u309f\u30a0-\u30ff]/.test(text)) {
|
|
1359
|
-
// Japanese
|
|
1360
|
-
labels.includePage = 'ページを含む';
|
|
1361
|
-
labels.sharedBlock = '共有ブロック';
|
|
1362
|
-
labels.includeSharedBlock = '共有ブロックを含む';
|
|
1363
|
-
labels.fromPage = 'ページから';
|
|
1364
|
-
labels.expandDetails = '詳細を表示';
|
|
1365
|
-
} else if (/[\uac00-\ud7af]/.test(text)) {
|
|
1366
|
-
// Korean
|
|
1367
|
-
labels.includePage = '페이지 포함';
|
|
1368
|
-
labels.sharedBlock = '공유 블록';
|
|
1369
|
-
labels.includeSharedBlock = '공유 블록 포함';
|
|
1370
|
-
labels.fromPage = '페이지에서';
|
|
1371
|
-
labels.expandDetails = '상세 보기';
|
|
1372
|
-
} else if (/[\u0400-\u04ff]/.test(text)) {
|
|
1373
|
-
// Russian/Cyrillic
|
|
1374
|
-
labels.includePage = 'Включить страницу';
|
|
1375
|
-
labels.sharedBlock = 'Общий блок';
|
|
1376
|
-
labels.includeSharedBlock = 'Включить общий блок';
|
|
1377
|
-
labels.fromPage = 'со страницы';
|
|
1378
|
-
labels.expandDetails = 'Подробнее';
|
|
1379
|
-
} else if ((text.match(/[àâäéèêëïîôùûüÿœæç]/gi) || []).length >= 2) {
|
|
1380
|
-
// French (requires at least 2 French-specific characters to avoid false positives)
|
|
1381
|
-
labels.includePage = 'Inclure la page';
|
|
1382
|
-
labels.sharedBlock = 'Bloc partagé';
|
|
1383
|
-
labels.includeSharedBlock = 'Inclure le bloc partagé';
|
|
1384
|
-
labels.fromPage = 'de la page';
|
|
1385
|
-
labels.expandDetails = 'Détails';
|
|
1386
|
-
} else if ((text.match(/[äöüß]/gi) || []).length >= 2) {
|
|
1387
|
-
// German (requires at least 2 German-specific characters)
|
|
1388
|
-
// Note: French is checked before German because French regex includes more characters
|
|
1389
|
-
// that overlap with German (ä, ü). The threshold helps distinguish between them.
|
|
1390
|
-
labels.includePage = 'Seite einbinden';
|
|
1391
|
-
labels.sharedBlock = 'Gemeinsamer Block';
|
|
1392
|
-
labels.includeSharedBlock = 'Gemeinsamen Block einbinden';
|
|
1393
|
-
labels.fromPage = 'von Seite';
|
|
1394
|
-
labels.expandDetails = 'Details';
|
|
1395
|
-
} else if ((text.match(/[áéíóúñ¿¡]/gi) || []).length >= 2) {
|
|
1396
|
-
// Spanish (requires at least 2 Spanish-specific characters)
|
|
1397
|
-
labels.includePage = 'Incluir página';
|
|
1398
|
-
labels.sharedBlock = 'Bloque compartido';
|
|
1399
|
-
labels.includeSharedBlock = 'Incluir bloque compartido';
|
|
1400
|
-
labels.fromPage = 'de la página';
|
|
1401
|
-
labels.expandDetails = 'Detalles';
|
|
1402
|
-
}
|
|
1403
|
-
|
|
1404
|
-
return labels;
|
|
1180
|
+
return this.converter.detectLanguageLabels(text);
|
|
1405
1181
|
}
|
|
1406
1182
|
|
|
1407
|
-
/**
|
|
1408
|
-
* Convert Confluence storage format to markdown
|
|
1409
|
-
* @param {string} storage - Confluence storage format HTML
|
|
1410
|
-
* @param {object} options - Conversion options
|
|
1411
|
-
* @param {string} options.attachmentsDir - Directory name for attachments (default: 'attachments')
|
|
1412
|
-
*/
|
|
1413
1183
|
storageToMarkdown(storage, options = {}) {
|
|
1414
|
-
|
|
1415
|
-
let markdown = storage;
|
|
1416
|
-
|
|
1417
|
-
// Detect language from content
|
|
1418
|
-
const labels = this.detectLanguageLabels(markdown);
|
|
1419
|
-
|
|
1420
|
-
// Remove table of contents macro
|
|
1421
|
-
markdown = markdown.replace(/<ac:structured-macro ac:name="toc"[^>]*\s*\/>/g, '');
|
|
1422
|
-
markdown = markdown.replace(/<ac:structured-macro ac:name="toc"[^>]*>[\s\S]*?<\/ac:structured-macro>/g, '');
|
|
1423
|
-
|
|
1424
|
-
// Remove floatmenu macro (floating table of contents)
|
|
1425
|
-
markdown = markdown.replace(/<ac:structured-macro ac:name="floatmenu"[^>]*>[\s\S]*?<\/ac:structured-macro>/g, '');
|
|
1426
|
-
|
|
1427
|
-
// Convert Confluence images to markdown images
|
|
1428
|
-
// Format: <ac:image><ri:attachment ri:filename="image.png" /></ac:image>
|
|
1429
|
-
markdown = markdown.replace(/<ac:image[^>]*>\s*<ri:attachment\s+ri:filename="([^"]+)"[^>]*\s*\/>\s*<\/ac:image>/g, (_, filename) => {
|
|
1430
|
-
return ``;
|
|
1431
|
-
});
|
|
1432
|
-
|
|
1433
|
-
// Also handle self-closing ac:image with ri:attachment
|
|
1434
|
-
markdown = markdown.replace(/<ac:image[^>]*><ri:attachment\s+ri:filename="([^"]+)"[^>]*><\/ri:attachment><\/ac:image>/g, (_, filename) => {
|
|
1435
|
-
return ``;
|
|
1436
|
-
});
|
|
1437
|
-
|
|
1438
|
-
// Convert mermaid macro to mermaid code block
|
|
1439
|
-
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) => {
|
|
1440
|
-
return `\n\`\`\`mermaid\n${code.trim()}\n\`\`\`\n`;
|
|
1441
|
-
});
|
|
1442
|
-
|
|
1443
|
-
// Convert expand macro - extract content from rich-text-body
|
|
1444
|
-
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) => {
|
|
1445
|
-
return `\n<details>\n<summary>${labels.expandDetails}</summary>\n\n${content}\n\n</details>\n`;
|
|
1446
|
-
});
|
|
1447
|
-
|
|
1448
|
-
// Convert Confluence code macros to markdown
|
|
1449
|
-
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) => {
|
|
1450
|
-
return `\n\`\`\`${lang}\n${code}\n\`\`\`\n`;
|
|
1451
|
-
});
|
|
1452
|
-
|
|
1453
|
-
// Convert code macros without language parameter
|
|
1454
|
-
markdown = markdown.replace(/<ac:structured-macro ac:name="code"[^>]*>[\s\S]*?<ac:plain-text-body><!\[CDATA\[([\s\S]*?)\]\]><\/ac:plain-text-body>[\s\S]*?<\/ac:structured-macro>/g, (_, code) => {
|
|
1455
|
-
return `\n\`\`\`\n${code}\n\`\`\`\n`;
|
|
1456
|
-
});
|
|
1457
|
-
|
|
1458
|
-
// Convert info macro to admonition
|
|
1459
|
-
markdown = markdown.replace(/<ac:structured-macro ac:name="info"[^>]*>[\s\S]*?<ac:rich-text-body>([\s\S]*?)<\/ac:rich-text-body>[\s\S]*?<\/ac:structured-macro>/g, (_, content) => {
|
|
1460
|
-
const cleanContent = this.htmlToMarkdown(content);
|
|
1461
|
-
return `[!info]\n${cleanContent}`;
|
|
1462
|
-
});
|
|
1463
|
-
|
|
1464
|
-
// Convert warning macro to admonition
|
|
1465
|
-
markdown = markdown.replace(/<ac:structured-macro ac:name="warning"[^>]*>[\s\S]*?<ac:rich-text-body>([\s\S]*?)<\/ac:rich-text-body>[\s\S]*?<\/ac:structured-macro>/g, (_, content) => {
|
|
1466
|
-
const cleanContent = this.htmlToMarkdown(content);
|
|
1467
|
-
return `[!warning]\n${cleanContent}`;
|
|
1468
|
-
});
|
|
1469
|
-
|
|
1470
|
-
// Convert note macro to admonition
|
|
1471
|
-
markdown = markdown.replace(/<ac:structured-macro ac:name="note"[^>]*>[\s\S]*?<ac:rich-text-body>([\s\S]*?)<\/ac:rich-text-body>[\s\S]*?<\/ac:structured-macro>/g, (_, content) => {
|
|
1472
|
-
const cleanContent = this.htmlToMarkdown(content);
|
|
1473
|
-
return `[!note]\n${cleanContent}`;
|
|
1474
|
-
});
|
|
1475
|
-
|
|
1476
|
-
// Convert task list macros to markdown checkboxes
|
|
1477
|
-
// Note: This is independent of user resolution - it only converts <ac:task> structure to "- [ ]" or "- [x]" format
|
|
1478
|
-
markdown = markdown.replace(/<ac:task-list>([\s\S]*?)<\/ac:task-list>/g, (_, content) => {
|
|
1479
|
-
const tasks = [];
|
|
1480
|
-
// Match each task: <ac:task>...<ac:task-status>xxx</ac:task-status>...<ac:task-body>...</ac:task-body>...</ac:task>
|
|
1481
|
-
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;
|
|
1482
|
-
let match;
|
|
1483
|
-
while ((match = taskRegex.exec(content)) !== null) {
|
|
1484
|
-
const status = match[1];
|
|
1485
|
-
let taskBody = match[2];
|
|
1486
|
-
// Clean up HTML from task body, but preserve @username
|
|
1487
|
-
taskBody = taskBody.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
|
1488
|
-
const checkbox = status === 'complete' ? '[x]' : '[ ]';
|
|
1489
|
-
if (taskBody) {
|
|
1490
|
-
tasks.push(`- ${checkbox} ${taskBody}`);
|
|
1491
|
-
}
|
|
1492
|
-
}
|
|
1493
|
-
return tasks.length > 0 ? '\n' + tasks.join('\n') + '\n' : '';
|
|
1494
|
-
});
|
|
1495
|
-
|
|
1496
|
-
// Convert panel macro to markdown blockquote with title
|
|
1497
|
-
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) => {
|
|
1498
|
-
const cleanContent = this.htmlToMarkdown(content);
|
|
1499
|
-
return `\n> **${title}**\n>\n${cleanContent.split('\n').map(line => line ? `> ${line}` : '>').join('\n')}\n`;
|
|
1500
|
-
});
|
|
1501
|
-
|
|
1502
|
-
// Convert include macro - extract page link and convert to markdown link
|
|
1503
|
-
// Handle both with and without parameter name
|
|
1504
|
-
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) => {
|
|
1505
|
-
// Try to build a proper URL - if spaceKey starts with ~, it's a user space
|
|
1506
|
-
if (spaceKey.startsWith('~')) {
|
|
1507
|
-
const spacePath = `display/${spaceKey}/${encodeURIComponent(title)}`;
|
|
1508
|
-
return `\n> 📄 **${labels.includePage}**: [${title}](${this.buildUrl(`${this.webUrlPrefix}/${spacePath}`)})\n`;
|
|
1509
|
-
} else {
|
|
1510
|
-
// For non-user spaces, we cannot construct a valid link without the page ID.
|
|
1511
|
-
// Document that manual correction is required.
|
|
1512
|
-
return `\n> 📄 **${labels.includePage}**: [${title}](${this.buildUrl(`${this.webUrlPrefix}/spaces/${spaceKey}/pages/[PAGE_ID_HERE]`)}) _(manual link correction required)_\n`;
|
|
1513
|
-
}
|
|
1514
|
-
});
|
|
1515
|
-
|
|
1516
|
-
// Convert shared-block and include-shared-block macros - extract content
|
|
1517
|
-
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) => {
|
|
1518
|
-
const cleanContent = this.htmlToMarkdown(content);
|
|
1519
|
-
return `\n> **${labels.sharedBlock}: ${blockKey}**\n>\n${cleanContent.split('\n').map(line => line ? `> ${line}` : '>').join('\n')}\n`;
|
|
1520
|
-
});
|
|
1521
|
-
|
|
1522
|
-
// Convert include-shared-block with page parameter
|
|
1523
|
-
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) => {
|
|
1524
|
-
// The page ID is not available, so we cannot generate a valid link.
|
|
1525
|
-
// Instead, document that the link needs manual correction.
|
|
1526
|
-
return `\n> 📄 **${labels.includeSharedBlock}**: ${blockKey} (${labels.fromPage}: ${pageTitle} [link needs manual correction])\n`;
|
|
1527
|
-
});
|
|
1528
|
-
|
|
1529
|
-
// Convert view-file macro to file link
|
|
1530
|
-
// Handle both orders: name first or height first
|
|
1531
|
-
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) => {
|
|
1532
|
-
return `\n📎 [${filename}](${attachmentsDir}/${filename})\n`;
|
|
1533
|
-
});
|
|
1534
|
-
|
|
1535
|
-
// Also handle view-file with height parameter (which might appear after name)
|
|
1536
|
-
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) => {
|
|
1537
|
-
return `\n📎 [${filename}](${attachmentsDir}/${filename})\n`;
|
|
1538
|
-
});
|
|
1539
|
-
|
|
1540
|
-
// Remove layout macros but preserve content
|
|
1541
|
-
markdown = markdown.replace(/<ac:layout>/g, '');
|
|
1542
|
-
markdown = markdown.replace(/<\/ac:layout>/g, '');
|
|
1543
|
-
markdown = markdown.replace(/<ac:layout-section[^>]*>/g, '');
|
|
1544
|
-
markdown = markdown.replace(/<\/ac:layout-section>/g, '');
|
|
1545
|
-
markdown = markdown.replace(/<ac:layout-cell[^>]*>/g, '');
|
|
1546
|
-
markdown = markdown.replace(/<\/ac:layout-cell>/g, '');
|
|
1547
|
-
|
|
1548
|
-
// Remove other unhandled macros (replace with empty string for now)
|
|
1549
|
-
markdown = markdown.replace(/<ac:structured-macro[^>]*>[\s\S]*?<\/ac:structured-macro>/g, '');
|
|
1550
|
-
|
|
1551
|
-
// Convert external URL links
|
|
1552
|
-
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)');
|
|
1553
|
-
|
|
1554
|
-
// Convert internal page links - extract page title
|
|
1555
|
-
// Format: <ac:link><ri:page ri:space-key="xxx" ri:content-title="Page Title" /></ac:link>
|
|
1556
|
-
markdown = markdown.replace(/<ac:link>\s*<ri:page[^>]*ri:content-title="([^"]*)"[^>]*\/>\s*<\/ac:link>/g, '[$1]');
|
|
1557
|
-
markdown = markdown.replace(/<ac:link>\s*<ri:page[^>]*ri:content-title="([^"]*)"[^>]*>\s*<\/ri:page>\s*<\/ac:link>/g, '[$1]');
|
|
1558
|
-
|
|
1559
|
-
// Convert internal page links with custom display text (ac:link-body)
|
|
1560
|
-
markdown = markdown.replace(/<ac:link[^>]*>[\s\S]*?<ac:link-body>([\s\S]*?)<\/ac:link-body>[\s\S]*?<\/ac:link>/g, '$1');
|
|
1561
|
-
|
|
1562
|
-
// Remove any remaining ac:link tags that weren't matched (including those with attributes)
|
|
1563
|
-
markdown = markdown.replace(/<ac:link[^>]*>[\s\S]*?<\/ac:link>/g, '');
|
|
1564
|
-
|
|
1565
|
-
// Convert remaining HTML to markdown
|
|
1566
|
-
markdown = this.htmlToMarkdown(markdown);
|
|
1567
|
-
|
|
1568
|
-
return markdown;
|
|
1184
|
+
return this.converter.storageToMarkdown(storage, options);
|
|
1569
1185
|
}
|
|
1570
1186
|
|
|
1571
|
-
/**
|
|
1572
|
-
* Convert basic HTML to markdown
|
|
1573
|
-
*/
|
|
1574
1187
|
htmlToMarkdown(html) {
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
// Convert time elements to date text BEFORE removing attributes
|
|
1578
|
-
// Format: <time datetime="2025-09-16" /> or <time datetime="2025-09-16"></time>
|
|
1579
|
-
markdown = markdown.replace(/<time\s+datetime="([^"]+)"[^>]*(?:\/>|>\s*<\/time>)/g, '$1');
|
|
1580
|
-
|
|
1581
|
-
// Convert strong/bold BEFORE removing HTML attributes
|
|
1582
|
-
markdown = markdown.replace(/<strong[^>]*>(.*?)<\/strong>/g, '**$1**');
|
|
1583
|
-
|
|
1584
|
-
// Convert emphasis/italic BEFORE removing HTML attributes
|
|
1585
|
-
markdown = markdown.replace(/<em[^>]*>(.*?)<\/em>/g, '*$1*');
|
|
1586
|
-
|
|
1587
|
-
// Convert code BEFORE removing HTML attributes
|
|
1588
|
-
markdown = markdown.replace(/<code[^>]*>(.*?)<\/code>/g, '`$1`');
|
|
1589
|
-
|
|
1590
|
-
// Remove HTML attributes from tags (but preserve content formatting)
|
|
1591
|
-
markdown = markdown.replace(/<(\w+)[^>]*>/g, '<$1>');
|
|
1592
|
-
markdown = markdown.replace(/<\/(\w+)[^>]*>/g, '</$1>');
|
|
1593
|
-
|
|
1594
|
-
// Convert headings first (they don't contain other elements typically)
|
|
1595
|
-
markdown = markdown.replace(/<h([1-6])>(.*?)<\/h[1-6]>/g, (_, level, text) => {
|
|
1596
|
-
return '\n' + '#'.repeat(parseInt(level)) + ' ' + text.trim() + '\n';
|
|
1597
|
-
});
|
|
1598
|
-
|
|
1599
|
-
// Convert tables BEFORE paragraphs
|
|
1600
|
-
markdown = markdown.replace(/<table>(.*?)<\/table>/gs, (_, content) => {
|
|
1601
|
-
const rows = [];
|
|
1602
|
-
let isHeader = true;
|
|
1603
|
-
|
|
1604
|
-
// Extract table rows
|
|
1605
|
-
const rowMatches = content.match(/<tr>(.*?)<\/tr>/gs);
|
|
1606
|
-
if (rowMatches) {
|
|
1607
|
-
rowMatches.forEach(rowMatch => {
|
|
1608
|
-
const cells = [];
|
|
1609
|
-
const cellContent = rowMatch.replace(/<tr>(.*?)<\/tr>/s, '$1');
|
|
1610
|
-
|
|
1611
|
-
// Extract cells (th or td)
|
|
1612
|
-
const cellMatches = cellContent.match(/<t[hd]>(.*?)<\/t[hd]>/gs);
|
|
1613
|
-
if (cellMatches) {
|
|
1614
|
-
cellMatches.forEach(cellMatch => {
|
|
1615
|
-
let cellText = cellMatch.replace(/<t[hd]>(.*?)<\/t[hd]>/s, '$1');
|
|
1616
|
-
// Clean up cell content - remove nested HTML but preserve text and some formatting
|
|
1617
|
-
cellText = cellText.replace(/<p>/g, '').replace(/<\/p>/g, ' ');
|
|
1618
|
-
cellText = cellText.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
1619
|
-
cells.push(cellText || ' ');
|
|
1620
|
-
});
|
|
1621
|
-
}
|
|
1622
|
-
|
|
1623
|
-
if (cells.length > 0) {
|
|
1624
|
-
rows.push('| ' + cells.join(' | ') + ' |');
|
|
1625
|
-
|
|
1626
|
-
if (isHeader) {
|
|
1627
|
-
rows.push('| ' + cells.map(() => '---').join(' | ') + ' |');
|
|
1628
|
-
isHeader = false;
|
|
1629
|
-
}
|
|
1630
|
-
}
|
|
1631
|
-
});
|
|
1632
|
-
}
|
|
1633
|
-
|
|
1634
|
-
return rows.length > 0 ? '\n' + rows.join('\n') + '\n' : '';
|
|
1635
|
-
});
|
|
1636
|
-
|
|
1637
|
-
// Convert unordered lists BEFORE paragraphs
|
|
1638
|
-
markdown = markdown.replace(/<ul>(.*?)<\/ul>/gs, (_, content) => {
|
|
1639
|
-
let listItems = '';
|
|
1640
|
-
const itemMatches = content.match(/<li>(.*?)<\/li>/gs);
|
|
1641
|
-
if (itemMatches) {
|
|
1642
|
-
itemMatches.forEach(itemMatch => {
|
|
1643
|
-
let itemText = itemMatch.replace(/<li>(.*?)<\/li>/s, '$1');
|
|
1644
|
-
// Clean up nested HTML but preserve some formatting
|
|
1645
|
-
itemText = itemText.replace(/<p>/g, '').replace(/<\/p>/g, ' ');
|
|
1646
|
-
itemText = itemText.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
1647
|
-
if (itemText) {
|
|
1648
|
-
listItems += '- ' + itemText + '\n';
|
|
1649
|
-
}
|
|
1650
|
-
});
|
|
1651
|
-
}
|
|
1652
|
-
return '\n' + listItems;
|
|
1653
|
-
});
|
|
1654
|
-
|
|
1655
|
-
// Convert ordered lists BEFORE paragraphs
|
|
1656
|
-
markdown = markdown.replace(/<ol>(.*?)<\/ol>/gs, (_, content) => {
|
|
1657
|
-
let listItems = '';
|
|
1658
|
-
let counter = 1;
|
|
1659
|
-
const itemMatches = content.match(/<li>(.*?)<\/li>/gs);
|
|
1660
|
-
if (itemMatches) {
|
|
1661
|
-
itemMatches.forEach(itemMatch => {
|
|
1662
|
-
let itemText = itemMatch.replace(/<li>(.*?)<\/li>/s, '$1');
|
|
1663
|
-
// Clean up nested HTML but preserve some formatting
|
|
1664
|
-
itemText = itemText.replace(/<p>/g, '').replace(/<\/p>/g, ' ');
|
|
1665
|
-
itemText = itemText.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
1666
|
-
if (itemText) {
|
|
1667
|
-
listItems += `${counter++}. ${itemText}\n`;
|
|
1668
|
-
}
|
|
1669
|
-
});
|
|
1670
|
-
}
|
|
1671
|
-
return '\n' + listItems;
|
|
1672
|
-
});
|
|
1673
|
-
|
|
1674
|
-
// Convert paragraphs (after lists and tables)
|
|
1675
|
-
markdown = markdown.replace(/<p>(.*?)<\/p>/gs, (_, content) => {
|
|
1676
|
-
return '\n' + content.trim() + '\n';
|
|
1677
|
-
});
|
|
1678
|
-
|
|
1679
|
-
// Convert line breaks
|
|
1680
|
-
markdown = markdown.replace(/<br\s*\/?>/g, '\n');
|
|
1681
|
-
|
|
1682
|
-
// Convert horizontal rules
|
|
1683
|
-
markdown = markdown.replace(/<hr\s*\/?>/g, '\n---\n');
|
|
1684
|
-
|
|
1685
|
-
// Remove any remaining HTML tags, but preserve <details> and <summary> for GFM compatibility
|
|
1686
|
-
markdown = markdown.replace(/<(?!\/?(details|summary)\b)[^>]+>/g, ' ');
|
|
1687
|
-
|
|
1688
|
-
// Clean up whitespace and HTML entities
|
|
1689
|
-
markdown = markdown.replace(/ /g, ' ');
|
|
1690
|
-
markdown = markdown.replace(/</g, '<');
|
|
1691
|
-
markdown = markdown.replace(/>/g, '>');
|
|
1692
|
-
markdown = markdown.replace(/&/g, '&');
|
|
1693
|
-
markdown = markdown.replace(/"/g, '"');
|
|
1694
|
-
markdown = markdown.replace(/'/g, '\'');
|
|
1695
|
-
// Smart quotes and special characters
|
|
1696
|
-
markdown = markdown.replace(/“/g, '"');
|
|
1697
|
-
markdown = markdown.replace(/”/g, '"');
|
|
1698
|
-
markdown = markdown.replace(/‘/g, '\'');
|
|
1699
|
-
markdown = markdown.replace(/’/g, '\'');
|
|
1700
|
-
markdown = markdown.replace(/—/g, '—');
|
|
1701
|
-
markdown = markdown.replace(/–/g, '–');
|
|
1702
|
-
markdown = markdown.replace(/…/g, '...');
|
|
1703
|
-
markdown = markdown.replace(/•/g, '•');
|
|
1704
|
-
markdown = markdown.replace(/©/g, '©');
|
|
1705
|
-
markdown = markdown.replace(/®/g, '®');
|
|
1706
|
-
markdown = markdown.replace(/™/g, '™');
|
|
1707
|
-
// Numeric HTML entities
|
|
1708
|
-
markdown = markdown.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10)));
|
|
1709
|
-
markdown = markdown.replace(/&#x([0-9a-fA-F]+);/g, (_, code) => String.fromCharCode(parseInt(code, 16)));
|
|
1710
|
-
|
|
1711
|
-
// Clean up nordic alphabets and other named entities
|
|
1712
|
-
markdown = markdown.replace(/&([a-zA-Z]+);/g, (match, name) => NAMED_ENTITIES[name] || match);
|
|
1713
|
-
|
|
1714
|
-
// Clean up extra whitespace for standard Markdown format
|
|
1715
|
-
// Remove trailing spaces from each line
|
|
1716
|
-
markdown = markdown.replace(/[ \t]+$/gm, '');
|
|
1717
|
-
// Remove leading spaces from lines (except for code blocks, blockquotes, and list items)
|
|
1718
|
-
markdown = markdown.replace(/^[ \t]+(?!([`>]|[*+-] |\d+[.)] ))/gm, '');
|
|
1719
|
-
// Ensure proper spacing after headings (# Title should be followed by blank line or content)
|
|
1720
|
-
markdown = markdown.replace(/^(#{1,6}[^\n]+)\n(?!\n)/gm, '$1\n\n');
|
|
1721
|
-
// Normalize multiple blank lines to double newline
|
|
1722
|
-
markdown = markdown.replace(/\n\s*\n\s*\n+/g, '\n\n');
|
|
1723
|
-
// Collapse multiple spaces to single space (but preserve newlines)
|
|
1724
|
-
markdown = markdown.replace(/[ \t]+/g, ' ');
|
|
1725
|
-
// Final trim
|
|
1726
|
-
markdown = markdown.trim();
|
|
1727
|
-
|
|
1728
|
-
return markdown;
|
|
1188
|
+
return htmlToMarkdown(html);
|
|
1729
1189
|
}
|
|
1730
1190
|
|
|
1731
1191
|
/**
|
|
@@ -1958,34 +1418,35 @@ class ConfluenceClient {
|
|
|
1958
1418
|
/**
|
|
1959
1419
|
* Get child pages of a given page
|
|
1960
1420
|
*/
|
|
1961
|
-
async getChildPages(pageId, limit = 500) {
|
|
1421
|
+
async getChildPages(pageId, limit = 500, options = {}) {
|
|
1422
|
+
const includeAncestors = Boolean(options.includeAncestors);
|
|
1962
1423
|
const response = await this.client.get(`/content/${pageId}/child/page`, {
|
|
1963
1424
|
params: {
|
|
1964
1425
|
limit: limit,
|
|
1965
1426
|
// Fetch lightweight payload; content fetched on-demand when copying
|
|
1966
|
-
expand: 'space,version'
|
|
1427
|
+
expand: includeAncestors ? 'space,version,ancestors' : 'space,version'
|
|
1967
1428
|
}
|
|
1968
1429
|
});
|
|
1969
1430
|
|
|
1970
|
-
return response.data.results.map(page => ({
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
type: page.type,
|
|
1974
|
-
status: page.status,
|
|
1975
|
-
space: page.space,
|
|
1976
|
-
version: page.version?.number || 1
|
|
1431
|
+
return response.data.results.map(page => this.normalizePage(page, {
|
|
1432
|
+
parentId: pageId,
|
|
1433
|
+
depth: 1
|
|
1977
1434
|
}));
|
|
1978
1435
|
}
|
|
1979
1436
|
|
|
1980
1437
|
/**
|
|
1981
1438
|
* Get all descendant pages recursively
|
|
1982
1439
|
*/
|
|
1983
|
-
async getAllDescendantPages(pageId, maxDepth = 10, currentDepth = 0) {
|
|
1440
|
+
async getAllDescendantPages(pageId, maxDepth = 10, currentDepth = 0, options = {}) {
|
|
1441
|
+
if (typeof currentDepth === 'object' && currentDepth !== null) {
|
|
1442
|
+
options = currentDepth;
|
|
1443
|
+
currentDepth = 0;
|
|
1444
|
+
}
|
|
1984
1445
|
const semaphore = createSemaphore(10);
|
|
1985
|
-
return this._collectDescendants(pageId, maxDepth, currentDepth, semaphore);
|
|
1446
|
+
return this._collectDescendants(pageId, maxDepth, currentDepth, semaphore, options);
|
|
1986
1447
|
}
|
|
1987
1448
|
|
|
1988
|
-
async _collectDescendants(pageId, maxDepth, currentDepth, semaphore) {
|
|
1449
|
+
async _collectDescendants(pageId, maxDepth, currentDepth, semaphore, options = {}) {
|
|
1989
1450
|
if (currentDepth >= maxDepth) {
|
|
1990
1451
|
return [];
|
|
1991
1452
|
}
|
|
@@ -1993,21 +1454,25 @@ class ConfluenceClient {
|
|
|
1993
1454
|
await semaphore.acquire();
|
|
1994
1455
|
let children;
|
|
1995
1456
|
try {
|
|
1996
|
-
children = await this.getChildPages(pageId);
|
|
1457
|
+
children = await this.getChildPages(pageId, 500, options);
|
|
1997
1458
|
} finally {
|
|
1998
1459
|
semaphore.release();
|
|
1999
1460
|
}
|
|
2000
1461
|
|
|
2001
|
-
//
|
|
2002
|
-
const
|
|
1462
|
+
// Track depth for recursive JSON output while preserving direct parent linkage.
|
|
1463
|
+
const childrenWithDepth = children.map(child => ({
|
|
1464
|
+
...child,
|
|
1465
|
+
parentId: child.parentId || String(pageId),
|
|
1466
|
+
depth: currentDepth + 1
|
|
1467
|
+
}));
|
|
2003
1468
|
|
|
2004
1469
|
const grandChildrenLists = await Promise.all(
|
|
2005
1470
|
children.map(child =>
|
|
2006
|
-
this._collectDescendants(child.id, maxDepth, currentDepth + 1, semaphore)
|
|
1471
|
+
this._collectDescendants(child.id, maxDepth, currentDepth + 1, semaphore, options)
|
|
2007
1472
|
)
|
|
2008
1473
|
);
|
|
2009
1474
|
|
|
2010
|
-
return
|
|
1475
|
+
return childrenWithDepth.concat(...grandChildrenLists);
|
|
2011
1476
|
}
|
|
2012
1477
|
|
|
2013
1478
|
/**
|
|
@@ -2227,12 +1692,104 @@ class ConfluenceClient {
|
|
|
2227
1692
|
};
|
|
2228
1693
|
}
|
|
2229
1694
|
|
|
1695
|
+
normalizeUser(user) {
|
|
1696
|
+
if (!user) {
|
|
1697
|
+
return null;
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
return {
|
|
1701
|
+
displayName: user.displayName || user.publicName || user.username || user.userKey || user.accountId || 'Unknown',
|
|
1702
|
+
accountId: user.accountId,
|
|
1703
|
+
userKey: user.userKey,
|
|
1704
|
+
username: user.username,
|
|
1705
|
+
email: user.email
|
|
1706
|
+
};
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
normalizeAncestors(rawAncestors = []) {
|
|
1710
|
+
if (!Array.isArray(rawAncestors)) {
|
|
1711
|
+
return [];
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
return rawAncestors.map((ancestor) => {
|
|
1715
|
+
const id = ancestor?.id ?? ancestor;
|
|
1716
|
+
return {
|
|
1717
|
+
id: id !== undefined && id !== null ? String(id) : null,
|
|
1718
|
+
type: ancestor?.type || null,
|
|
1719
|
+
title: ancestor?.title || null
|
|
1720
|
+
};
|
|
1721
|
+
}).filter((ancestor) => ancestor.id);
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
normalizeSpace(space) {
|
|
1725
|
+
if (!space) {
|
|
1726
|
+
return null;
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
return {
|
|
1730
|
+
key: space.key || null,
|
|
1731
|
+
name: space.name || null
|
|
1732
|
+
};
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
getPageParentId(ancestors = []) {
|
|
1736
|
+
if (!Array.isArray(ancestors) || ancestors.length === 0) {
|
|
1737
|
+
return null;
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
return ancestors[ancestors.length - 1].id || null;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
normalizePage(raw, overrides = {}) {
|
|
1744
|
+
const space = overrides.space === undefined
|
|
1745
|
+
? this.normalizeSpace(raw?.space)
|
|
1746
|
+
: this.normalizeSpace(overrides.space);
|
|
1747
|
+
const ancestors = overrides.ancestors || this.normalizeAncestors(raw?.ancestors);
|
|
1748
|
+
const spaceKey = overrides.spaceKey || space?.key || null;
|
|
1749
|
+
const id = raw?.id !== undefined && raw?.id !== null ? String(raw.id) : null;
|
|
1750
|
+
const webui = raw?._links?.webui || null;
|
|
1751
|
+
const linksBase = raw?._links?.base || null;
|
|
1752
|
+
const fallbackUrl = (spaceKey && id)
|
|
1753
|
+
? `${this.webUrlPrefix}/spaces/${spaceKey}/pages/${id}`
|
|
1754
|
+
: null;
|
|
1755
|
+
|
|
1756
|
+
return {
|
|
1757
|
+
id,
|
|
1758
|
+
title: raw?.title || '',
|
|
1759
|
+
type: raw?.type || null,
|
|
1760
|
+
status: raw?.status || null,
|
|
1761
|
+
space,
|
|
1762
|
+
spaceKey,
|
|
1763
|
+
parentId: overrides.parentId === undefined
|
|
1764
|
+
? this.getPageParentId(ancestors)
|
|
1765
|
+
: (overrides.parentId === null ? null : String(overrides.parentId)),
|
|
1766
|
+
version: overrides.version !== undefined ? overrides.version : (raw?.version?.number || null),
|
|
1767
|
+
url: overrides.url || this.toAbsoluteUrl(webui, linksBase) || (fallbackUrl ? this.buildUrl(fallbackUrl) : null),
|
|
1768
|
+
ancestors,
|
|
1769
|
+
depth: overrides.depth,
|
|
1770
|
+
author: overrides.author !== undefined ? overrides.author : this.normalizeUser(raw?.history?.createdBy),
|
|
1771
|
+
lastUpdatedBy: overrides.lastUpdatedBy !== undefined ? overrides.lastUpdatedBy : this.normalizeUser(raw?.version?.by),
|
|
1772
|
+
createdAt: overrides.createdAt !== undefined ? overrides.createdAt : (raw?.history?.createdDate || null),
|
|
1773
|
+
updatedAt: overrides.updatedAt !== undefined ? overrides.updatedAt : (raw?.version?.when || null)
|
|
1774
|
+
};
|
|
1775
|
+
}
|
|
1776
|
+
|
|
2230
1777
|
buildUrl(path) {
|
|
2231
1778
|
const normalized = path && !path.startsWith('/') ? `/${path}` : (path || '');
|
|
2232
1779
|
return `${this.protocol}://${this.domain}${normalized}`;
|
|
2233
1780
|
}
|
|
2234
1781
|
|
|
2235
|
-
|
|
1782
|
+
joinBaseUrl(baseUrl, path) {
|
|
1783
|
+
if (!baseUrl) {
|
|
1784
|
+
return null;
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
const normalizedBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
1788
|
+
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
1789
|
+
return `${normalizedBase}${normalizedPath}`;
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
toAbsoluteUrl(pathOrUrl, baseUrl = null) {
|
|
2236
1793
|
if (!pathOrUrl) {
|
|
2237
1794
|
return null;
|
|
2238
1795
|
}
|
|
@@ -2241,6 +1798,10 @@ class ConfluenceClient {
|
|
|
2241
1798
|
return pathOrUrl;
|
|
2242
1799
|
}
|
|
2243
1800
|
|
|
1801
|
+
if (baseUrl) {
|
|
1802
|
+
return this.joinBaseUrl(baseUrl, pathOrUrl);
|
|
1803
|
+
}
|
|
1804
|
+
|
|
2244
1805
|
const pathWithPrefix = this.webUrlPrefix && !pathOrUrl.startsWith(this.webUrlPrefix)
|
|
2245
1806
|
? `${this.webUrlPrefix}${pathOrUrl}`
|
|
2246
1807
|
: pathOrUrl;
|
|
@@ -2272,8 +1833,8 @@ class ConfluenceClient {
|
|
|
2272
1833
|
|
|
2273
1834
|
ConfluenceClient.createLocalConverter = function () {
|
|
2274
1835
|
const instance = Object.create(ConfluenceClient.prototype);
|
|
2275
|
-
instance.
|
|
2276
|
-
instance.
|
|
1836
|
+
instance.converter = new MacroConverter();
|
|
1837
|
+
instance.markdown = instance.converter.markdown;
|
|
2277
1838
|
return instance;
|
|
2278
1839
|
};
|
|
2279
1840
|
|