confluence-cli 1.31.0 → 1.32.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/README.md +54 -2
- package/bin/confluence.js +53 -24
- package/lib/confluence-client.js +179 -588
- package/lib/html-to-markdown.js +150 -0
- package/lib/macro-converter.js +298 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/plugins/confluence/skills/confluence/SKILL.md +6 -4
package/lib/confluence-client.js
CHANGED
|
@@ -4,28 +4,30 @@ 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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
7
|
+
const MacroConverter = require('./macro-converter');
|
|
8
|
+
const { htmlToMarkdown, NAMED_ENTITIES } = require('./html-to-markdown');
|
|
9
|
+
|
|
10
|
+
function createSemaphore(limit) {
|
|
11
|
+
let active = 0;
|
|
12
|
+
const waiters = [];
|
|
13
|
+
return {
|
|
14
|
+
async acquire() {
|
|
15
|
+
if (active < limit) {
|
|
16
|
+
active++;
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
await new Promise(resolve => waiters.push(resolve));
|
|
20
|
+
},
|
|
21
|
+
release() {
|
|
22
|
+
if (waiters.length > 0) {
|
|
23
|
+
const next = waiters.shift();
|
|
24
|
+
next();
|
|
25
|
+
} else {
|
|
26
|
+
active--;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}
|
|
29
31
|
|
|
30
32
|
class ConfluenceClient {
|
|
31
33
|
constructor(config) {
|
|
@@ -39,10 +41,14 @@ class ConfluenceClient {
|
|
|
39
41
|
this.forceCloud = !!config.forceCloud;
|
|
40
42
|
this.mtls = config.mtls;
|
|
41
43
|
this.apiPath = this.sanitizeApiPath(config.apiPath);
|
|
42
|
-
this.webUrlPrefix = this.apiPath.
|
|
44
|
+
this.webUrlPrefix = this.apiPath.includes('/wiki/') ? '/wiki' : '';
|
|
43
45
|
this.baseURL = `${this.protocol}://${this.domain}${this.apiPath}`;
|
|
44
|
-
this.
|
|
45
|
-
|
|
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;
|
|
46
52
|
|
|
47
53
|
const headers = {
|
|
48
54
|
'Content-Type': 'application/json',
|
|
@@ -318,7 +324,7 @@ class ConfluenceClient {
|
|
|
318
324
|
/**
|
|
319
325
|
* Read a Confluence page content
|
|
320
326
|
* @param {string} pageIdOrUrl - Page ID or URL
|
|
321
|
-
* @param {string} format - Output format: 'text', 'html', or 'markdown'
|
|
327
|
+
* @param {string} format - Output format: 'text', 'html', 'storage', or 'markdown'
|
|
322
328
|
* @param {object} options - Additional options
|
|
323
329
|
* @param {boolean} options.resolveUsers - Whether to resolve userkeys to display names (default: true for markdown)
|
|
324
330
|
* @param {boolean} options.extractReferencedAttachments - Whether to extract referenced attachments (default: false)
|
|
@@ -339,7 +345,7 @@ class ConfluenceClient {
|
|
|
339
345
|
this._referencedAttachments = this.extractReferencedAttachments(htmlContent);
|
|
340
346
|
}
|
|
341
347
|
|
|
342
|
-
if (format === 'html') {
|
|
348
|
+
if (format === 'html' || format === 'storage') {
|
|
343
349
|
return htmlContent;
|
|
344
350
|
}
|
|
345
351
|
|
|
@@ -383,17 +389,11 @@ class ConfluenceClient {
|
|
|
383
389
|
|
|
384
390
|
const response = await this.client.get(`/content/${pageId}`, {
|
|
385
391
|
params: {
|
|
386
|
-
expand: 'space'
|
|
392
|
+
expand: 'space,history,version,ancestors'
|
|
387
393
|
}
|
|
388
394
|
});
|
|
389
395
|
|
|
390
|
-
return
|
|
391
|
-
title: response.data.title,
|
|
392
|
-
id: response.data.id,
|
|
393
|
-
type: response.data.type,
|
|
394
|
-
status: response.data.status,
|
|
395
|
-
space: response.data.space
|
|
396
|
-
};
|
|
396
|
+
return this.normalizePage(response.data);
|
|
397
397
|
}
|
|
398
398
|
|
|
399
399
|
/**
|
|
@@ -540,7 +540,7 @@ class ConfluenceClient {
|
|
|
540
540
|
const webui = page._links?.webui || '';
|
|
541
541
|
return {
|
|
542
542
|
title: page.title,
|
|
543
|
-
url: webui ? this.
|
|
543
|
+
url: webui ? this.toAbsoluteUrl(webui, page._links?.base) : ''
|
|
544
544
|
};
|
|
545
545
|
}
|
|
546
546
|
return null;
|
|
@@ -634,7 +634,7 @@ class ConfluenceClient {
|
|
|
634
634
|
// Format: - [Page Title](URL)
|
|
635
635
|
const childPagesList = childPages.map(page => {
|
|
636
636
|
const webui = page._links?.webui || '';
|
|
637
|
-
const url = webui ? this.
|
|
637
|
+
const url = webui ? this.toAbsoluteUrl(webui, page._links?.base) : '';
|
|
638
638
|
if (url) {
|
|
639
639
|
return `- [${page.title}](${url})`;
|
|
640
640
|
} else {
|
|
@@ -1160,550 +1160,32 @@ class ConfluenceClient {
|
|
|
1160
1160
|
return { pageId: String(pageId), key };
|
|
1161
1161
|
}
|
|
1162
1162
|
|
|
1163
|
-
/**
|
|
1164
|
-
* Convert markdown to Confluence storage format
|
|
1165
|
-
*/
|
|
1166
1163
|
markdownToStorage(markdown) {
|
|
1167
|
-
|
|
1168
|
-
const html = this.markdown.render(markdown);
|
|
1169
|
-
|
|
1170
|
-
// Convert HTML to native Confluence storage format elements
|
|
1171
|
-
return this.htmlToConfluenceStorage(html);
|
|
1164
|
+
return this.converter.markdownToStorage(markdown);
|
|
1172
1165
|
}
|
|
1173
1166
|
|
|
1174
|
-
/**
|
|
1175
|
-
* Convert HTML to native Confluence storage format
|
|
1176
|
-
*/
|
|
1177
1167
|
htmlToConfluenceStorage(html) {
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
// Convert headings to native Confluence format
|
|
1181
|
-
storage = storage.replace(/<h([1-6])>(.*?)<\/h[1-6]>/g, '<h$1>$2</h$1>');
|
|
1182
|
-
|
|
1183
|
-
// Convert paragraphs
|
|
1184
|
-
storage = storage.replace(/<p>(.*?)<\/p>/g, '<p>$1</p>');
|
|
1185
|
-
|
|
1186
|
-
// Convert strong/bold text
|
|
1187
|
-
storage = storage.replace(/<strong>(.*?)<\/strong>/g, '<strong>$1</strong>');
|
|
1188
|
-
|
|
1189
|
-
// Convert emphasis/italic text
|
|
1190
|
-
storage = storage.replace(/<em>(.*?)<\/em>/g, '<em>$1</em>');
|
|
1191
|
-
|
|
1192
|
-
// Convert unordered lists
|
|
1193
|
-
storage = storage.replace(/<ul>(.*?)<\/ul>/gs, '<ul>$1</ul>');
|
|
1194
|
-
storage = storage.replace(/<li>(.*?)<\/li>/g, '<li><p>$1</p></li>');
|
|
1195
|
-
|
|
1196
|
-
// Convert ordered lists
|
|
1197
|
-
storage = storage.replace(/<ol>(.*?)<\/ol>/gs, '<ol>$1</ol>');
|
|
1198
|
-
|
|
1199
|
-
// Convert code blocks to Confluence code macro
|
|
1200
|
-
storage = storage.replace(/<pre><code(?:\s+class="language-(\w+)")?>(.*?)<\/code><\/pre>/gs, (_, lang, code) => {
|
|
1201
|
-
const language = lang || 'text';
|
|
1202
|
-
// Trim trailing newline added by markdown-it during HTML rendering,
|
|
1203
|
-
// and decode HTML entities that markdown-it encodes inside <code> blocks
|
|
1204
|
-
// so they appear as literal characters in the CDATA output
|
|
1205
|
-
const decodedCode = code.replace(/\n$/, '')
|
|
1206
|
-
.replace(/"/g, '"')
|
|
1207
|
-
.replace(/</g, '<')
|
|
1208
|
-
.replace(/>/g, '>')
|
|
1209
|
-
.replace(/&/g, '&'); // & last to avoid double-decoding
|
|
1210
|
-
const safeCode = decodedCode.replace(/]]>/g, ']]]]><![CDATA[>');
|
|
1211
|
-
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>`;
|
|
1212
|
-
});
|
|
1213
|
-
|
|
1214
|
-
// Convert inline code
|
|
1215
|
-
storage = storage.replace(/<code>(.*?)<\/code>/g, '<code>$1</code>');
|
|
1216
|
-
|
|
1217
|
-
// Convert blockquotes to appropriate macros based on content
|
|
1218
|
-
storage = storage.replace(/<blockquote>(.*?)<\/blockquote>/gs, (_, content) => {
|
|
1219
|
-
// Check for admonition patterns
|
|
1220
|
-
if (content.includes('<strong>INFO</strong>')) {
|
|
1221
|
-
const cleanContent = content.replace(/<p><strong>INFO<\/strong><\/p>\s*/, '');
|
|
1222
|
-
return `<ac:structured-macro ac:name="info">
|
|
1223
|
-
<ac:rich-text-body>${cleanContent}</ac:rich-text-body>
|
|
1224
|
-
</ac:structured-macro>`;
|
|
1225
|
-
} else if (content.includes('<strong>WARNING</strong>')) {
|
|
1226
|
-
const cleanContent = content.replace(/<p><strong>WARNING<\/strong><\/p>\s*/, '');
|
|
1227
|
-
return `<ac:structured-macro ac:name="warning">
|
|
1228
|
-
<ac:rich-text-body>${cleanContent}</ac:rich-text-body>
|
|
1229
|
-
</ac:structured-macro>`;
|
|
1230
|
-
} else if (content.includes('<strong>NOTE</strong>')) {
|
|
1231
|
-
const cleanContent = content.replace(/<p><strong>NOTE<\/strong><\/p>\s*/, '');
|
|
1232
|
-
return `<ac:structured-macro ac:name="note">
|
|
1233
|
-
<ac:rich-text-body>${cleanContent}</ac:rich-text-body>
|
|
1234
|
-
</ac:structured-macro>`;
|
|
1235
|
-
} else {
|
|
1236
|
-
// Default to info macro for regular blockquotes
|
|
1237
|
-
return `<ac:structured-macro ac:name="info">
|
|
1238
|
-
<ac:rich-text-body>${content}</ac:rich-text-body>
|
|
1239
|
-
</ac:structured-macro>`;
|
|
1240
|
-
}
|
|
1241
|
-
});
|
|
1242
|
-
|
|
1243
|
-
// Convert tables
|
|
1244
|
-
storage = storage.replace(/<table>(.*?)<\/table>/gs, '<table>$1</table>');
|
|
1245
|
-
storage = storage.replace(/<thead>(.*?)<\/thead>/gs, '<thead>$1</thead>');
|
|
1246
|
-
storage = storage.replace(/<tbody>(.*?)<\/tbody>/gs, '<tbody>$1</tbody>');
|
|
1247
|
-
storage = storage.replace(/<tr>(.*?)<\/tr>/gs, '<tr>$1</tr>');
|
|
1248
|
-
storage = storage.replace(/<th>(.*?)<\/th>/g, '<th><p>$1</p></th>');
|
|
1249
|
-
storage = storage.replace(/<td>(.*?)<\/td>/g, '<td><p>$1</p></td>');
|
|
1250
|
-
|
|
1251
|
-
// Convert links
|
|
1252
|
-
// Confluence Cloud does not render ac:link + ri:url; use smart links instead.
|
|
1253
|
-
// Server/Data Center instances continue to use the ac:link storage format.
|
|
1254
|
-
if (this.isCloud()) {
|
|
1255
|
-
storage = storage.replace(/<a href="(.*?)">(.*?)<\/a>/g, '<a href="$1" data-card-appearance="inline">$2</a>');
|
|
1256
|
-
} else {
|
|
1257
|
-
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>');
|
|
1258
|
-
}
|
|
1259
|
-
|
|
1260
|
-
// Convert horizontal rules
|
|
1261
|
-
storage = storage.replace(/<hr\s*\/?>/g, '<hr />');
|
|
1262
|
-
|
|
1263
|
-
// Note: Do NOT globally decode < > & here. These represent literal
|
|
1264
|
-
// characters in user content (e.g. <placeholder> in inline text) and
|
|
1265
|
-
// Confluence storage format renders them correctly as-is. Code block
|
|
1266
|
-
// entities are decoded separately above before CDATA insertion.
|
|
1267
|
-
|
|
1268
|
-
return storage;
|
|
1168
|
+
return this.converter.htmlToConfluenceStorage(html);
|
|
1269
1169
|
}
|
|
1270
1170
|
|
|
1271
|
-
/**
|
|
1272
|
-
* Convert markdown to Confluence storage format using native storage format
|
|
1273
|
-
*/
|
|
1274
1171
|
markdownToNativeStorage(markdown) {
|
|
1275
|
-
|
|
1276
|
-
const html = this.markdown.render(markdown);
|
|
1277
|
-
|
|
1278
|
-
// Delegate to htmlToConfluenceStorage for proper conversion including code blocks
|
|
1279
|
-
return this.htmlToConfluenceStorage(html);
|
|
1172
|
+
return this.converter.markdownToNativeStorage(markdown);
|
|
1280
1173
|
}
|
|
1281
1174
|
|
|
1282
|
-
/**
|
|
1283
|
-
* Setup Confluence-specific markdown extensions
|
|
1284
|
-
*/
|
|
1285
1175
|
setupConfluenceMarkdownExtensions() {
|
|
1286
|
-
|
|
1287
|
-
this.markdown.enable(['table', 'strikethrough', 'linkify']);
|
|
1288
|
-
|
|
1289
|
-
// Add custom rule for Confluence macros in markdown
|
|
1290
|
-
this.markdown.core.ruler.before('normalize', 'confluence_macros', (state) => {
|
|
1291
|
-
const src = state.src;
|
|
1292
|
-
|
|
1293
|
-
// Convert [!info] admonitions to info macro
|
|
1294
|
-
state.src = src.replace(/\[!info\]\s*([\s\S]*?)(?=\n\s*\n|\n\s*\[!|$)/g, (_, content) => {
|
|
1295
|
-
return `> **INFO**\n> ${content.trim().replace(/\n/g, '\n> ')}`;
|
|
1296
|
-
});
|
|
1297
|
-
|
|
1298
|
-
// Convert [!warning] admonitions to warning macro
|
|
1299
|
-
state.src = state.src.replace(/\[!warning\]\s*([\s\S]*?)(?=\n\s*\n|\n\s*\[!|$)/g, (_, content) => {
|
|
1300
|
-
return `> **WARNING**\n> ${content.trim().replace(/\n/g, '\n> ')}`;
|
|
1301
|
-
});
|
|
1302
|
-
|
|
1303
|
-
// Convert [!note] admonitions to note macro
|
|
1304
|
-
state.src = state.src.replace(/\[!note\]\s*([\s\S]*?)(?=\n\s*\n|\n\s*\[!|$)/g, (_, content) => {
|
|
1305
|
-
return `> **NOTE**\n> ${content.trim().replace(/\n/g, '\n> ')}`;
|
|
1306
|
-
});
|
|
1307
|
-
|
|
1308
|
-
// Convert task lists to proper format
|
|
1309
|
-
state.src = state.src.replace(/^(\s*)- \[([ x])\] (.+)$/gm, (_, indent, checked, text) => {
|
|
1310
|
-
return `${indent}- [${checked}] ${text}`;
|
|
1311
|
-
});
|
|
1312
|
-
});
|
|
1176
|
+
this.converter.setupConfluenceMarkdownExtensions();
|
|
1313
1177
|
}
|
|
1314
1178
|
|
|
1315
|
-
/**
|
|
1316
|
-
* Detect language from text content and return appropriate labels
|
|
1317
|
-
* @param {string} text - Text content to analyze
|
|
1318
|
-
* @returns {object} Object with language-specific labels
|
|
1319
|
-
*/
|
|
1320
1179
|
detectLanguageLabels(text) {
|
|
1321
|
-
|
|
1322
|
-
includePage: 'Include Page',
|
|
1323
|
-
sharedBlock: 'Shared Block',
|
|
1324
|
-
includeSharedBlock: 'Include Shared Block',
|
|
1325
|
-
fromPage: 'from page',
|
|
1326
|
-
expandDetails: 'Expand Details'
|
|
1327
|
-
};
|
|
1328
|
-
|
|
1329
|
-
if (/[\u4e00-\u9fa5]/.test(text)) {
|
|
1330
|
-
// Chinese
|
|
1331
|
-
labels.includePage = '包含页面';
|
|
1332
|
-
labels.sharedBlock = '共享块';
|
|
1333
|
-
labels.includeSharedBlock = '包含共享块';
|
|
1334
|
-
labels.fromPage = '来自页面';
|
|
1335
|
-
labels.expandDetails = '展开详情';
|
|
1336
|
-
} else if (/[\u3040-\u309f\u30a0-\u30ff]/.test(text)) {
|
|
1337
|
-
// Japanese
|
|
1338
|
-
labels.includePage = 'ページを含む';
|
|
1339
|
-
labels.sharedBlock = '共有ブロック';
|
|
1340
|
-
labels.includeSharedBlock = '共有ブロックを含む';
|
|
1341
|
-
labels.fromPage = 'ページから';
|
|
1342
|
-
labels.expandDetails = '詳細を表示';
|
|
1343
|
-
} else if (/[\uac00-\ud7af]/.test(text)) {
|
|
1344
|
-
// Korean
|
|
1345
|
-
labels.includePage = '페이지 포함';
|
|
1346
|
-
labels.sharedBlock = '공유 블록';
|
|
1347
|
-
labels.includeSharedBlock = '공유 블록 포함';
|
|
1348
|
-
labels.fromPage = '페이지에서';
|
|
1349
|
-
labels.expandDetails = '상세 보기';
|
|
1350
|
-
} else if (/[\u0400-\u04ff]/.test(text)) {
|
|
1351
|
-
// Russian/Cyrillic
|
|
1352
|
-
labels.includePage = 'Включить страницу';
|
|
1353
|
-
labels.sharedBlock = 'Общий блок';
|
|
1354
|
-
labels.includeSharedBlock = 'Включить общий блок';
|
|
1355
|
-
labels.fromPage = 'со страницы';
|
|
1356
|
-
labels.expandDetails = 'Подробнее';
|
|
1357
|
-
} else if ((text.match(/[àâäéèêëïîôùûüÿœæç]/gi) || []).length >= 2) {
|
|
1358
|
-
// French (requires at least 2 French-specific characters to avoid false positives)
|
|
1359
|
-
labels.includePage = 'Inclure la page';
|
|
1360
|
-
labels.sharedBlock = 'Bloc partagé';
|
|
1361
|
-
labels.includeSharedBlock = 'Inclure le bloc partagé';
|
|
1362
|
-
labels.fromPage = 'de la page';
|
|
1363
|
-
labels.expandDetails = 'Détails';
|
|
1364
|
-
} else if ((text.match(/[äöüß]/gi) || []).length >= 2) {
|
|
1365
|
-
// German (requires at least 2 German-specific characters)
|
|
1366
|
-
// Note: French is checked before German because French regex includes more characters
|
|
1367
|
-
// that overlap with German (ä, ü). The threshold helps distinguish between them.
|
|
1368
|
-
labels.includePage = 'Seite einbinden';
|
|
1369
|
-
labels.sharedBlock = 'Gemeinsamer Block';
|
|
1370
|
-
labels.includeSharedBlock = 'Gemeinsamen Block einbinden';
|
|
1371
|
-
labels.fromPage = 'von Seite';
|
|
1372
|
-
labels.expandDetails = 'Details';
|
|
1373
|
-
} else if ((text.match(/[áéíóúñ¿¡]/gi) || []).length >= 2) {
|
|
1374
|
-
// Spanish (requires at least 2 Spanish-specific characters)
|
|
1375
|
-
labels.includePage = 'Incluir página';
|
|
1376
|
-
labels.sharedBlock = 'Bloque compartido';
|
|
1377
|
-
labels.includeSharedBlock = 'Incluir bloque compartido';
|
|
1378
|
-
labels.fromPage = 'de la página';
|
|
1379
|
-
labels.expandDetails = 'Detalles';
|
|
1380
|
-
}
|
|
1381
|
-
|
|
1382
|
-
return labels;
|
|
1180
|
+
return this.converter.detectLanguageLabels(text);
|
|
1383
1181
|
}
|
|
1384
1182
|
|
|
1385
|
-
/**
|
|
1386
|
-
* Convert Confluence storage format to markdown
|
|
1387
|
-
* @param {string} storage - Confluence storage format HTML
|
|
1388
|
-
* @param {object} options - Conversion options
|
|
1389
|
-
* @param {string} options.attachmentsDir - Directory name for attachments (default: 'attachments')
|
|
1390
|
-
*/
|
|
1391
1183
|
storageToMarkdown(storage, options = {}) {
|
|
1392
|
-
|
|
1393
|
-
let markdown = storage;
|
|
1394
|
-
|
|
1395
|
-
// Detect language from content
|
|
1396
|
-
const labels = this.detectLanguageLabels(markdown);
|
|
1397
|
-
|
|
1398
|
-
// Remove table of contents macro
|
|
1399
|
-
markdown = markdown.replace(/<ac:structured-macro ac:name="toc"[^>]*\s*\/>/g, '');
|
|
1400
|
-
markdown = markdown.replace(/<ac:structured-macro ac:name="toc"[^>]*>[\s\S]*?<\/ac:structured-macro>/g, '');
|
|
1401
|
-
|
|
1402
|
-
// Remove floatmenu macro (floating table of contents)
|
|
1403
|
-
markdown = markdown.replace(/<ac:structured-macro ac:name="floatmenu"[^>]*>[\s\S]*?<\/ac:structured-macro>/g, '');
|
|
1404
|
-
|
|
1405
|
-
// Convert Confluence images to markdown images
|
|
1406
|
-
// Format: <ac:image><ri:attachment ri:filename="image.png" /></ac:image>
|
|
1407
|
-
markdown = markdown.replace(/<ac:image[^>]*>\s*<ri:attachment\s+ri:filename="([^"]+)"[^>]*\s*\/>\s*<\/ac:image>/g, (_, filename) => {
|
|
1408
|
-
return ``;
|
|
1409
|
-
});
|
|
1410
|
-
|
|
1411
|
-
// Also handle self-closing ac:image with ri:attachment
|
|
1412
|
-
markdown = markdown.replace(/<ac:image[^>]*><ri:attachment\s+ri:filename="([^"]+)"[^>]*><\/ri:attachment><\/ac:image>/g, (_, filename) => {
|
|
1413
|
-
return ``;
|
|
1414
|
-
});
|
|
1415
|
-
|
|
1416
|
-
// Convert mermaid macro to mermaid code block
|
|
1417
|
-
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) => {
|
|
1418
|
-
return `\n\`\`\`mermaid\n${code.trim()}\n\`\`\`\n`;
|
|
1419
|
-
});
|
|
1420
|
-
|
|
1421
|
-
// Convert expand macro - extract content from rich-text-body
|
|
1422
|
-
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) => {
|
|
1423
|
-
return `\n<details>\n<summary>${labels.expandDetails}</summary>\n\n${content}\n\n</details>\n`;
|
|
1424
|
-
});
|
|
1425
|
-
|
|
1426
|
-
// Convert Confluence code macros to markdown
|
|
1427
|
-
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) => {
|
|
1428
|
-
return `\n\`\`\`${lang}\n${code}\n\`\`\`\n`;
|
|
1429
|
-
});
|
|
1430
|
-
|
|
1431
|
-
// Convert code macros without language parameter
|
|
1432
|
-
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) => {
|
|
1433
|
-
return `\n\`\`\`\n${code}\n\`\`\`\n`;
|
|
1434
|
-
});
|
|
1435
|
-
|
|
1436
|
-
// Convert info macro to admonition
|
|
1437
|
-
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) => {
|
|
1438
|
-
const cleanContent = this.htmlToMarkdown(content);
|
|
1439
|
-
return `[!info]\n${cleanContent}`;
|
|
1440
|
-
});
|
|
1441
|
-
|
|
1442
|
-
// Convert warning macro to admonition
|
|
1443
|
-
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) => {
|
|
1444
|
-
const cleanContent = this.htmlToMarkdown(content);
|
|
1445
|
-
return `[!warning]\n${cleanContent}`;
|
|
1446
|
-
});
|
|
1447
|
-
|
|
1448
|
-
// Convert note macro to admonition
|
|
1449
|
-
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) => {
|
|
1450
|
-
const cleanContent = this.htmlToMarkdown(content);
|
|
1451
|
-
return `[!note]\n${cleanContent}`;
|
|
1452
|
-
});
|
|
1453
|
-
|
|
1454
|
-
// Convert task list macros to markdown checkboxes
|
|
1455
|
-
// Note: This is independent of user resolution - it only converts <ac:task> structure to "- [ ]" or "- [x]" format
|
|
1456
|
-
markdown = markdown.replace(/<ac:task-list>([\s\S]*?)<\/ac:task-list>/g, (_, content) => {
|
|
1457
|
-
const tasks = [];
|
|
1458
|
-
// Match each task: <ac:task>...<ac:task-status>xxx</ac:task-status>...<ac:task-body>...</ac:task-body>...</ac:task>
|
|
1459
|
-
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;
|
|
1460
|
-
let match;
|
|
1461
|
-
while ((match = taskRegex.exec(content)) !== null) {
|
|
1462
|
-
const status = match[1];
|
|
1463
|
-
let taskBody = match[2];
|
|
1464
|
-
// Clean up HTML from task body, but preserve @username
|
|
1465
|
-
taskBody = taskBody.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
|
1466
|
-
const checkbox = status === 'complete' ? '[x]' : '[ ]';
|
|
1467
|
-
if (taskBody) {
|
|
1468
|
-
tasks.push(`- ${checkbox} ${taskBody}`);
|
|
1469
|
-
}
|
|
1470
|
-
}
|
|
1471
|
-
return tasks.length > 0 ? '\n' + tasks.join('\n') + '\n' : '';
|
|
1472
|
-
});
|
|
1473
|
-
|
|
1474
|
-
// Convert panel macro to markdown blockquote with title
|
|
1475
|
-
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) => {
|
|
1476
|
-
const cleanContent = this.htmlToMarkdown(content);
|
|
1477
|
-
return `\n> **${title}**\n>\n${cleanContent.split('\n').map(line => line ? `> ${line}` : '>').join('\n')}\n`;
|
|
1478
|
-
});
|
|
1479
|
-
|
|
1480
|
-
// Convert include macro - extract page link and convert to markdown link
|
|
1481
|
-
// Handle both with and without parameter name
|
|
1482
|
-
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) => {
|
|
1483
|
-
// Try to build a proper URL - if spaceKey starts with ~, it's a user space
|
|
1484
|
-
if (spaceKey.startsWith('~')) {
|
|
1485
|
-
const spacePath = `display/${spaceKey}/${encodeURIComponent(title)}`;
|
|
1486
|
-
return `\n> 📄 **${labels.includePage}**: [${title}](${this.buildUrl(`${this.webUrlPrefix}/${spacePath}`)})\n`;
|
|
1487
|
-
} else {
|
|
1488
|
-
// For non-user spaces, we cannot construct a valid link without the page ID.
|
|
1489
|
-
// Document that manual correction is required.
|
|
1490
|
-
return `\n> 📄 **${labels.includePage}**: [${title}](${this.buildUrl(`${this.webUrlPrefix}/spaces/${spaceKey}/pages/[PAGE_ID_HERE]`)}) _(manual link correction required)_\n`;
|
|
1491
|
-
}
|
|
1492
|
-
});
|
|
1493
|
-
|
|
1494
|
-
// Convert shared-block and include-shared-block macros - extract content
|
|
1495
|
-
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) => {
|
|
1496
|
-
const cleanContent = this.htmlToMarkdown(content);
|
|
1497
|
-
return `\n> **${labels.sharedBlock}: ${blockKey}**\n>\n${cleanContent.split('\n').map(line => line ? `> ${line}` : '>').join('\n')}\n`;
|
|
1498
|
-
});
|
|
1499
|
-
|
|
1500
|
-
// Convert include-shared-block with page parameter
|
|
1501
|
-
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) => {
|
|
1502
|
-
// The page ID is not available, so we cannot generate a valid link.
|
|
1503
|
-
// Instead, document that the link needs manual correction.
|
|
1504
|
-
return `\n> 📄 **${labels.includeSharedBlock}**: ${blockKey} (${labels.fromPage}: ${pageTitle} [link needs manual correction])\n`;
|
|
1505
|
-
});
|
|
1506
|
-
|
|
1507
|
-
// Convert view-file macro to file link
|
|
1508
|
-
// Handle both orders: name first or height first
|
|
1509
|
-
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) => {
|
|
1510
|
-
return `\n📎 [${filename}](${attachmentsDir}/${filename})\n`;
|
|
1511
|
-
});
|
|
1512
|
-
|
|
1513
|
-
// Also handle view-file with height parameter (which might appear after name)
|
|
1514
|
-
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) => {
|
|
1515
|
-
return `\n📎 [${filename}](${attachmentsDir}/${filename})\n`;
|
|
1516
|
-
});
|
|
1517
|
-
|
|
1518
|
-
// Remove layout macros but preserve content
|
|
1519
|
-
markdown = markdown.replace(/<ac:layout>/g, '');
|
|
1520
|
-
markdown = markdown.replace(/<\/ac:layout>/g, '');
|
|
1521
|
-
markdown = markdown.replace(/<ac:layout-section[^>]*>/g, '');
|
|
1522
|
-
markdown = markdown.replace(/<\/ac:layout-section>/g, '');
|
|
1523
|
-
markdown = markdown.replace(/<ac:layout-cell[^>]*>/g, '');
|
|
1524
|
-
markdown = markdown.replace(/<\/ac:layout-cell>/g, '');
|
|
1525
|
-
|
|
1526
|
-
// Remove other unhandled macros (replace with empty string for now)
|
|
1527
|
-
markdown = markdown.replace(/<ac:structured-macro[^>]*>[\s\S]*?<\/ac:structured-macro>/g, '');
|
|
1528
|
-
|
|
1529
|
-
// Convert external URL links
|
|
1530
|
-
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)');
|
|
1531
|
-
|
|
1532
|
-
// Convert internal page links - extract page title
|
|
1533
|
-
// Format: <ac:link><ri:page ri:space-key="xxx" ri:content-title="Page Title" /></ac:link>
|
|
1534
|
-
markdown = markdown.replace(/<ac:link>\s*<ri:page[^>]*ri:content-title="([^"]*)"[^>]*\/>\s*<\/ac:link>/g, '[$1]');
|
|
1535
|
-
markdown = markdown.replace(/<ac:link>\s*<ri:page[^>]*ri:content-title="([^"]*)"[^>]*>\s*<\/ri:page>\s*<\/ac:link>/g, '[$1]');
|
|
1536
|
-
|
|
1537
|
-
// Convert internal page links with custom display text (ac:link-body)
|
|
1538
|
-
markdown = markdown.replace(/<ac:link[^>]*>[\s\S]*?<ac:link-body>([\s\S]*?)<\/ac:link-body>[\s\S]*?<\/ac:link>/g, '$1');
|
|
1539
|
-
|
|
1540
|
-
// Remove any remaining ac:link tags that weren't matched (including those with attributes)
|
|
1541
|
-
markdown = markdown.replace(/<ac:link[^>]*>[\s\S]*?<\/ac:link>/g, '');
|
|
1542
|
-
|
|
1543
|
-
// Convert remaining HTML to markdown
|
|
1544
|
-
markdown = this.htmlToMarkdown(markdown);
|
|
1545
|
-
|
|
1546
|
-
return markdown;
|
|
1184
|
+
return this.converter.storageToMarkdown(storage, options);
|
|
1547
1185
|
}
|
|
1548
1186
|
|
|
1549
|
-
/**
|
|
1550
|
-
* Convert basic HTML to markdown
|
|
1551
|
-
*/
|
|
1552
1187
|
htmlToMarkdown(html) {
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
// Convert time elements to date text BEFORE removing attributes
|
|
1556
|
-
// Format: <time datetime="2025-09-16" /> or <time datetime="2025-09-16"></time>
|
|
1557
|
-
markdown = markdown.replace(/<time\s+datetime="([^"]+)"[^>]*(?:\/>|>\s*<\/time>)/g, '$1');
|
|
1558
|
-
|
|
1559
|
-
// Convert strong/bold BEFORE removing HTML attributes
|
|
1560
|
-
markdown = markdown.replace(/<strong[^>]*>(.*?)<\/strong>/g, '**$1**');
|
|
1561
|
-
|
|
1562
|
-
// Convert emphasis/italic BEFORE removing HTML attributes
|
|
1563
|
-
markdown = markdown.replace(/<em[^>]*>(.*?)<\/em>/g, '*$1*');
|
|
1564
|
-
|
|
1565
|
-
// Convert code BEFORE removing HTML attributes
|
|
1566
|
-
markdown = markdown.replace(/<code[^>]*>(.*?)<\/code>/g, '`$1`');
|
|
1567
|
-
|
|
1568
|
-
// Remove HTML attributes from tags (but preserve content formatting)
|
|
1569
|
-
markdown = markdown.replace(/<(\w+)[^>]*>/g, '<$1>');
|
|
1570
|
-
markdown = markdown.replace(/<\/(\w+)[^>]*>/g, '</$1>');
|
|
1571
|
-
|
|
1572
|
-
// Convert headings first (they don't contain other elements typically)
|
|
1573
|
-
markdown = markdown.replace(/<h([1-6])>(.*?)<\/h[1-6]>/g, (_, level, text) => {
|
|
1574
|
-
return '\n' + '#'.repeat(parseInt(level)) + ' ' + text.trim() + '\n';
|
|
1575
|
-
});
|
|
1576
|
-
|
|
1577
|
-
// Convert tables BEFORE paragraphs
|
|
1578
|
-
markdown = markdown.replace(/<table>(.*?)<\/table>/gs, (_, content) => {
|
|
1579
|
-
const rows = [];
|
|
1580
|
-
let isHeader = true;
|
|
1581
|
-
|
|
1582
|
-
// Extract table rows
|
|
1583
|
-
const rowMatches = content.match(/<tr>(.*?)<\/tr>/gs);
|
|
1584
|
-
if (rowMatches) {
|
|
1585
|
-
rowMatches.forEach(rowMatch => {
|
|
1586
|
-
const cells = [];
|
|
1587
|
-
const cellContent = rowMatch.replace(/<tr>(.*?)<\/tr>/s, '$1');
|
|
1588
|
-
|
|
1589
|
-
// Extract cells (th or td)
|
|
1590
|
-
const cellMatches = cellContent.match(/<t[hd]>(.*?)<\/t[hd]>/gs);
|
|
1591
|
-
if (cellMatches) {
|
|
1592
|
-
cellMatches.forEach(cellMatch => {
|
|
1593
|
-
let cellText = cellMatch.replace(/<t[hd]>(.*?)<\/t[hd]>/s, '$1');
|
|
1594
|
-
// Clean up cell content - remove nested HTML but preserve text and some formatting
|
|
1595
|
-
cellText = cellText.replace(/<p>/g, '').replace(/<\/p>/g, ' ');
|
|
1596
|
-
cellText = cellText.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
1597
|
-
cells.push(cellText || ' ');
|
|
1598
|
-
});
|
|
1599
|
-
}
|
|
1600
|
-
|
|
1601
|
-
if (cells.length > 0) {
|
|
1602
|
-
rows.push('| ' + cells.join(' | ') + ' |');
|
|
1603
|
-
|
|
1604
|
-
if (isHeader) {
|
|
1605
|
-
rows.push('| ' + cells.map(() => '---').join(' | ') + ' |');
|
|
1606
|
-
isHeader = false;
|
|
1607
|
-
}
|
|
1608
|
-
}
|
|
1609
|
-
});
|
|
1610
|
-
}
|
|
1611
|
-
|
|
1612
|
-
return rows.length > 0 ? '\n' + rows.join('\n') + '\n' : '';
|
|
1613
|
-
});
|
|
1614
|
-
|
|
1615
|
-
// Convert unordered lists BEFORE paragraphs
|
|
1616
|
-
markdown = markdown.replace(/<ul>(.*?)<\/ul>/gs, (_, content) => {
|
|
1617
|
-
let listItems = '';
|
|
1618
|
-
const itemMatches = content.match(/<li>(.*?)<\/li>/gs);
|
|
1619
|
-
if (itemMatches) {
|
|
1620
|
-
itemMatches.forEach(itemMatch => {
|
|
1621
|
-
let itemText = itemMatch.replace(/<li>(.*?)<\/li>/s, '$1');
|
|
1622
|
-
// Clean up nested HTML but preserve some formatting
|
|
1623
|
-
itemText = itemText.replace(/<p>/g, '').replace(/<\/p>/g, ' ');
|
|
1624
|
-
itemText = itemText.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
1625
|
-
if (itemText) {
|
|
1626
|
-
listItems += '- ' + itemText + '\n';
|
|
1627
|
-
}
|
|
1628
|
-
});
|
|
1629
|
-
}
|
|
1630
|
-
return '\n' + listItems;
|
|
1631
|
-
});
|
|
1632
|
-
|
|
1633
|
-
// Convert ordered lists BEFORE paragraphs
|
|
1634
|
-
markdown = markdown.replace(/<ol>(.*?)<\/ol>/gs, (_, content) => {
|
|
1635
|
-
let listItems = '';
|
|
1636
|
-
let counter = 1;
|
|
1637
|
-
const itemMatches = content.match(/<li>(.*?)<\/li>/gs);
|
|
1638
|
-
if (itemMatches) {
|
|
1639
|
-
itemMatches.forEach(itemMatch => {
|
|
1640
|
-
let itemText = itemMatch.replace(/<li>(.*?)<\/li>/s, '$1');
|
|
1641
|
-
// Clean up nested HTML but preserve some formatting
|
|
1642
|
-
itemText = itemText.replace(/<p>/g, '').replace(/<\/p>/g, ' ');
|
|
1643
|
-
itemText = itemText.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
1644
|
-
if (itemText) {
|
|
1645
|
-
listItems += `${counter++}. ${itemText}\n`;
|
|
1646
|
-
}
|
|
1647
|
-
});
|
|
1648
|
-
}
|
|
1649
|
-
return '\n' + listItems;
|
|
1650
|
-
});
|
|
1651
|
-
|
|
1652
|
-
// Convert paragraphs (after lists and tables)
|
|
1653
|
-
markdown = markdown.replace(/<p>(.*?)<\/p>/gs, (_, content) => {
|
|
1654
|
-
return '\n' + content.trim() + '\n';
|
|
1655
|
-
});
|
|
1656
|
-
|
|
1657
|
-
// Convert line breaks
|
|
1658
|
-
markdown = markdown.replace(/<br\s*\/?>/g, '\n');
|
|
1659
|
-
|
|
1660
|
-
// Convert horizontal rules
|
|
1661
|
-
markdown = markdown.replace(/<hr\s*\/?>/g, '\n---\n');
|
|
1662
|
-
|
|
1663
|
-
// Remove any remaining HTML tags, but preserve <details> and <summary> for GFM compatibility
|
|
1664
|
-
markdown = markdown.replace(/<(?!\/?(details|summary)\b)[^>]+>/g, ' ');
|
|
1665
|
-
|
|
1666
|
-
// Clean up whitespace and HTML entities
|
|
1667
|
-
markdown = markdown.replace(/ /g, ' ');
|
|
1668
|
-
markdown = markdown.replace(/</g, '<');
|
|
1669
|
-
markdown = markdown.replace(/>/g, '>');
|
|
1670
|
-
markdown = markdown.replace(/&/g, '&');
|
|
1671
|
-
markdown = markdown.replace(/"/g, '"');
|
|
1672
|
-
markdown = markdown.replace(/'/g, '\'');
|
|
1673
|
-
// Smart quotes and special characters
|
|
1674
|
-
markdown = markdown.replace(/“/g, '"');
|
|
1675
|
-
markdown = markdown.replace(/”/g, '"');
|
|
1676
|
-
markdown = markdown.replace(/‘/g, '\'');
|
|
1677
|
-
markdown = markdown.replace(/’/g, '\'');
|
|
1678
|
-
markdown = markdown.replace(/—/g, '—');
|
|
1679
|
-
markdown = markdown.replace(/–/g, '–');
|
|
1680
|
-
markdown = markdown.replace(/…/g, '...');
|
|
1681
|
-
markdown = markdown.replace(/•/g, '•');
|
|
1682
|
-
markdown = markdown.replace(/©/g, '©');
|
|
1683
|
-
markdown = markdown.replace(/®/g, '®');
|
|
1684
|
-
markdown = markdown.replace(/™/g, '™');
|
|
1685
|
-
// Numeric HTML entities
|
|
1686
|
-
markdown = markdown.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10)));
|
|
1687
|
-
markdown = markdown.replace(/&#x([0-9a-fA-F]+);/g, (_, code) => String.fromCharCode(parseInt(code, 16)));
|
|
1688
|
-
|
|
1689
|
-
// Clean up nordic alphabets and other named entities
|
|
1690
|
-
markdown = markdown.replace(/&([a-zA-Z]+);/g, (match, name) => NAMED_ENTITIES[name] || match);
|
|
1691
|
-
|
|
1692
|
-
// Clean up extra whitespace for standard Markdown format
|
|
1693
|
-
// Remove trailing spaces from each line
|
|
1694
|
-
markdown = markdown.replace(/[ \t]+$/gm, '');
|
|
1695
|
-
// Remove leading spaces from lines (except for code blocks, blockquotes, and list items)
|
|
1696
|
-
markdown = markdown.replace(/^[ \t]+(?!([`>]|[*+-] |\d+[.)] ))/gm, '');
|
|
1697
|
-
// Ensure proper spacing after headings (# Title should be followed by blank line or content)
|
|
1698
|
-
markdown = markdown.replace(/^(#{1,6}[^\n]+)\n(?!\n)/gm, '$1\n\n');
|
|
1699
|
-
// Normalize multiple blank lines to double newline
|
|
1700
|
-
markdown = markdown.replace(/\n\s*\n\s*\n+/g, '\n\n');
|
|
1701
|
-
// Collapse multiple spaces to single space (but preserve newlines)
|
|
1702
|
-
markdown = markdown.replace(/[ \t]+/g, ' ');
|
|
1703
|
-
// Final trim
|
|
1704
|
-
markdown = markdown.trim();
|
|
1705
|
-
|
|
1706
|
-
return markdown;
|
|
1188
|
+
return htmlToMarkdown(html);
|
|
1707
1189
|
}
|
|
1708
1190
|
|
|
1709
1191
|
/**
|
|
@@ -1936,48 +1418,61 @@ class ConfluenceClient {
|
|
|
1936
1418
|
/**
|
|
1937
1419
|
* Get child pages of a given page
|
|
1938
1420
|
*/
|
|
1939
|
-
async getChildPages(pageId, limit = 500) {
|
|
1421
|
+
async getChildPages(pageId, limit = 500, options = {}) {
|
|
1422
|
+
const includeAncestors = Boolean(options.includeAncestors);
|
|
1940
1423
|
const response = await this.client.get(`/content/${pageId}/child/page`, {
|
|
1941
1424
|
params: {
|
|
1942
1425
|
limit: limit,
|
|
1943
1426
|
// Fetch lightweight payload; content fetched on-demand when copying
|
|
1944
|
-
expand: 'space,version'
|
|
1427
|
+
expand: includeAncestors ? 'space,version,ancestors' : 'space,version'
|
|
1945
1428
|
}
|
|
1946
1429
|
});
|
|
1947
1430
|
|
|
1948
|
-
return response.data.results.map(page => ({
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
type: page.type,
|
|
1952
|
-
status: page.status,
|
|
1953
|
-
space: page.space,
|
|
1954
|
-
version: page.version?.number || 1
|
|
1431
|
+
return response.data.results.map(page => this.normalizePage(page, {
|
|
1432
|
+
parentId: pageId,
|
|
1433
|
+
depth: 1
|
|
1955
1434
|
}));
|
|
1956
1435
|
}
|
|
1957
1436
|
|
|
1958
1437
|
/**
|
|
1959
1438
|
* Get all descendant pages recursively
|
|
1960
1439
|
*/
|
|
1961
|
-
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
|
+
}
|
|
1445
|
+
const semaphore = createSemaphore(10);
|
|
1446
|
+
return this._collectDescendants(pageId, maxDepth, currentDepth, semaphore, options);
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
async _collectDescendants(pageId, maxDepth, currentDepth, semaphore, options = {}) {
|
|
1962
1450
|
if (currentDepth >= maxDepth) {
|
|
1963
1451
|
return [];
|
|
1964
1452
|
}
|
|
1965
1453
|
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
const grandChildren = await this.getAllDescendantPages(
|
|
1973
|
-
child.id,
|
|
1974
|
-
maxDepth,
|
|
1975
|
-
currentDepth + 1
|
|
1976
|
-
);
|
|
1977
|
-
allDescendants = allDescendants.concat(grandChildren);
|
|
1454
|
+
await semaphore.acquire();
|
|
1455
|
+
let children;
|
|
1456
|
+
try {
|
|
1457
|
+
children = await this.getChildPages(pageId, 500, options);
|
|
1458
|
+
} finally {
|
|
1459
|
+
semaphore.release();
|
|
1978
1460
|
}
|
|
1979
1461
|
|
|
1980
|
-
|
|
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
|
+
}));
|
|
1468
|
+
|
|
1469
|
+
const grandChildrenLists = await Promise.all(
|
|
1470
|
+
children.map(child =>
|
|
1471
|
+
this._collectDescendants(child.id, maxDepth, currentDepth + 1, semaphore, options)
|
|
1472
|
+
)
|
|
1473
|
+
);
|
|
1474
|
+
|
|
1475
|
+
return childrenWithDepth.concat(...grandChildrenLists);
|
|
1981
1476
|
}
|
|
1982
1477
|
|
|
1983
1478
|
/**
|
|
@@ -2197,12 +1692,104 @@ class ConfluenceClient {
|
|
|
2197
1692
|
};
|
|
2198
1693
|
}
|
|
2199
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
|
+
|
|
2200
1777
|
buildUrl(path) {
|
|
2201
1778
|
const normalized = path && !path.startsWith('/') ? `/${path}` : (path || '');
|
|
2202
1779
|
return `${this.protocol}://${this.domain}${normalized}`;
|
|
2203
1780
|
}
|
|
2204
1781
|
|
|
2205
|
-
|
|
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) {
|
|
2206
1793
|
if (!pathOrUrl) {
|
|
2207
1794
|
return null;
|
|
2208
1795
|
}
|
|
@@ -2211,6 +1798,10 @@ class ConfluenceClient {
|
|
|
2211
1798
|
return pathOrUrl;
|
|
2212
1799
|
}
|
|
2213
1800
|
|
|
1801
|
+
if (baseUrl) {
|
|
1802
|
+
return this.joinBaseUrl(baseUrl, pathOrUrl);
|
|
1803
|
+
}
|
|
1804
|
+
|
|
2214
1805
|
const pathWithPrefix = this.webUrlPrefix && !pathOrUrl.startsWith(this.webUrlPrefix)
|
|
2215
1806
|
? `${this.webUrlPrefix}${pathOrUrl}`
|
|
2216
1807
|
: pathOrUrl;
|
|
@@ -2242,8 +1833,8 @@ class ConfluenceClient {
|
|
|
2242
1833
|
|
|
2243
1834
|
ConfluenceClient.createLocalConverter = function () {
|
|
2244
1835
|
const instance = Object.create(ConfluenceClient.prototype);
|
|
2245
|
-
instance.
|
|
2246
|
-
instance.
|
|
1836
|
+
instance.converter = new MacroConverter();
|
|
1837
|
+
instance.markdown = instance.converter.markdown;
|
|
2247
1838
|
return instance;
|
|
2248
1839
|
};
|
|
2249
1840
|
|