@bestend/confluence-cli 1.15.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/.eslintrc.js +23 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +34 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +26 -0
- package/.github/ISSUE_TEMPLATE/feedback.md +37 -0
- package/.github/pull_request_template.md +31 -0
- package/.github/workflows/ci.yml +67 -0
- package/.github/workflows/publish.yml +26 -0
- package/.releaserc +17 -0
- package/CHANGELOG.md +232 -0
- package/CONTRIBUTING.md +246 -0
- package/LICENSE +21 -0
- package/README.md +454 -0
- package/bin/confluence.js +1225 -0
- package/bin/index.js +24 -0
- package/docs/PROMOTION.md +63 -0
- package/eslint.config.js +33 -0
- package/examples/copy-tree-example.sh +117 -0
- package/examples/create-child-page-example.sh +67 -0
- package/examples/demo-page-management.sh +68 -0
- package/examples/demo.sh +43 -0
- package/examples/sample-page.md +30 -0
- package/jest.config.js +13 -0
- package/lib/analytics.js +87 -0
- package/lib/config.js +437 -0
- package/lib/confluence-client.js +1810 -0
- package/llms.txt +46 -0
- package/package.json +57 -0
- package/tests/confluence-client.test.js +459 -0
|
@@ -0,0 +1,1810 @@
|
|
|
1
|
+
const axios = require('axios');
|
|
2
|
+
const { convert } = require('html-to-text');
|
|
3
|
+
const MarkdownIt = require('markdown-it');
|
|
4
|
+
|
|
5
|
+
class ConfluenceClient {
|
|
6
|
+
constructor(config) {
|
|
7
|
+
this.domain = config.domain;
|
|
8
|
+
this.token = config.token;
|
|
9
|
+
this.email = config.email;
|
|
10
|
+
this.authType = (config.authType || (this.email ? 'basic' : 'bearer')).toLowerCase();
|
|
11
|
+
this.apiPath = this.sanitizeApiPath(config.apiPath);
|
|
12
|
+
this.baseURL = `https://${this.domain}${this.apiPath}`;
|
|
13
|
+
this.markdown = new MarkdownIt();
|
|
14
|
+
this.setupConfluenceMarkdownExtensions();
|
|
15
|
+
|
|
16
|
+
const headers = {
|
|
17
|
+
'Content-Type': 'application/json',
|
|
18
|
+
'Authorization': this.authType === 'basic' ? this.buildBasicAuthHeader() : `Bearer ${this.token}`
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
this.client = axios.create({
|
|
22
|
+
baseURL: this.baseURL,
|
|
23
|
+
headers
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
sanitizeApiPath(rawPath) {
|
|
28
|
+
const fallback = '/rest/api';
|
|
29
|
+
const value = (rawPath || '').trim();
|
|
30
|
+
|
|
31
|
+
if (!value) {
|
|
32
|
+
return fallback;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const withoutLeading = value.replace(/^\/+/, '');
|
|
36
|
+
const normalized = `/${withoutLeading}`.replace(/\/+$/, '');
|
|
37
|
+
return normalized || fallback;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
buildBasicAuthHeader() {
|
|
41
|
+
if (!this.email) {
|
|
42
|
+
throw new Error('Basic authentication requires an email address.');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const encodedCredentials = Buffer.from(`${this.email}:${this.token}`).toString('base64');
|
|
46
|
+
return `Basic ${encodedCredentials}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Extract page ID from URL or return the ID if it's already a number
|
|
51
|
+
*/
|
|
52
|
+
async extractPageId(pageIdOrUrl) {
|
|
53
|
+
if (typeof pageIdOrUrl === 'number' || /^\d+$/.test(pageIdOrUrl)) {
|
|
54
|
+
return pageIdOrUrl;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Check if it's a Confluence URL
|
|
58
|
+
if (pageIdOrUrl.includes(this.domain)) {
|
|
59
|
+
// Extract pageId from URL parameter
|
|
60
|
+
const pageIdMatch = pageIdOrUrl.match(/pageId=(\d+)/);
|
|
61
|
+
if (pageIdMatch) {
|
|
62
|
+
return pageIdMatch[1];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Handle display URLs - search by space and title
|
|
66
|
+
const displayMatch = pageIdOrUrl.match(/\/display\/([^/]+)\/(.+)/);
|
|
67
|
+
if (displayMatch) {
|
|
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}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return pageIdOrUrl;
|
|
100
|
+
}
|
|
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
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Read a Confluence page content
|
|
134
|
+
* @param {string} pageIdOrUrl - Page ID or URL
|
|
135
|
+
* @param {string} format - Output format: 'text', 'html', or 'markdown'
|
|
136
|
+
* @param {object} options - Additional options
|
|
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)
|
|
139
|
+
*/
|
|
140
|
+
async readPage(pageIdOrUrl, format = 'text', options = {}) {
|
|
141
|
+
const pageId = await this.extractPageId(pageIdOrUrl);
|
|
142
|
+
|
|
143
|
+
const response = await this.client.get(`/content/${pageId}`, {
|
|
144
|
+
params: {
|
|
145
|
+
expand: 'body.storage'
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
let htmlContent = response.data.body.storage.value;
|
|
150
|
+
|
|
151
|
+
// Extract referenced attachments if requested
|
|
152
|
+
if (options.extractReferencedAttachments) {
|
|
153
|
+
this._referencedAttachments = this.extractReferencedAttachments(htmlContent);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (format === 'html') {
|
|
157
|
+
return htmlContent;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (format === 'markdown') {
|
|
161
|
+
// Resolve userkeys to display names before converting to markdown
|
|
162
|
+
const resolveUsers = options.resolveUsers !== false;
|
|
163
|
+
if (resolveUsers) {
|
|
164
|
+
const { html: resolvedHtml } = await this.resolveUserKeysInHtml(htmlContent);
|
|
165
|
+
htmlContent = resolvedHtml;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Resolve page links to full URLs
|
|
169
|
+
const resolvePageLinks = options.resolvePageLinks !== false;
|
|
170
|
+
if (resolvePageLinks) {
|
|
171
|
+
htmlContent = await this.resolvePageLinksInHtml(htmlContent);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Resolve children macro to child pages list
|
|
175
|
+
htmlContent = await this.resolveChildrenMacro(htmlContent, pageId);
|
|
176
|
+
|
|
177
|
+
return this.storageToMarkdown(htmlContent);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Convert HTML to text
|
|
181
|
+
return convert(htmlContent, {
|
|
182
|
+
wordwrap: 80,
|
|
183
|
+
selectors: [
|
|
184
|
+
{ selector: 'h1', options: { uppercase: false } },
|
|
185
|
+
{ selector: 'h2', options: { uppercase: false } },
|
|
186
|
+
{ selector: 'h3', options: { uppercase: false } },
|
|
187
|
+
{ selector: 'table', options: { uppercaseHeaderCells: false } }
|
|
188
|
+
]
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Get page information
|
|
194
|
+
*/
|
|
195
|
+
async getPageInfo(pageIdOrUrl) {
|
|
196
|
+
const pageId = await this.extractPageId(pageIdOrUrl);
|
|
197
|
+
|
|
198
|
+
const response = await this.client.get(`/content/${pageId}`, {
|
|
199
|
+
params: {
|
|
200
|
+
expand: 'space'
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
title: response.data.title,
|
|
206
|
+
id: response.data.id,
|
|
207
|
+
type: response.data.type,
|
|
208
|
+
status: response.data.status,
|
|
209
|
+
space: response.data.space
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Search for pages
|
|
215
|
+
*/
|
|
216
|
+
async search(query, limit = 10) {
|
|
217
|
+
const response = await this.client.get('/search', {
|
|
218
|
+
params: {
|
|
219
|
+
cql: `text ~ "${query}"`,
|
|
220
|
+
limit: limit
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
return response.data.results.map(result => {
|
|
225
|
+
// Handle different result structures
|
|
226
|
+
const content = result.content || result;
|
|
227
|
+
return {
|
|
228
|
+
id: content.id || 'Unknown',
|
|
229
|
+
title: content.title || 'Untitled',
|
|
230
|
+
type: content.type || 'Unknown',
|
|
231
|
+
excerpt: result.excerpt || content.excerpt || ''
|
|
232
|
+
};
|
|
233
|
+
}).filter(item => item.id !== 'Unknown'); // Filter out items without valid IDs
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Get all spaces
|
|
238
|
+
*/
|
|
239
|
+
async getSpaces() {
|
|
240
|
+
const response = await this.client.get('/space', {
|
|
241
|
+
params: {
|
|
242
|
+
limit: 500
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
return response.data.results.map(space => ({
|
|
247
|
+
key: space.key,
|
|
248
|
+
name: space.name,
|
|
249
|
+
type: space.type
|
|
250
|
+
}));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Get user information by userkey
|
|
255
|
+
* @param {string} userKey - The user key (e.g., "8ad05c43962471ed0196c26107d7000c")
|
|
256
|
+
* @returns {Promise<{key: string, displayName: string, username: string}>}
|
|
257
|
+
*/
|
|
258
|
+
async getUserByKey(userKey) {
|
|
259
|
+
try {
|
|
260
|
+
const response = await this.client.get('/user', {
|
|
261
|
+
params: { key: userKey }
|
|
262
|
+
});
|
|
263
|
+
return {
|
|
264
|
+
key: userKey,
|
|
265
|
+
displayName: response.data.displayName || response.data.username || userKey,
|
|
266
|
+
username: response.data.username || ''
|
|
267
|
+
};
|
|
268
|
+
} catch (error) {
|
|
269
|
+
// Return full userkey as fallback if user not found
|
|
270
|
+
return {
|
|
271
|
+
key: userKey,
|
|
272
|
+
displayName: userKey,
|
|
273
|
+
username: ''
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Resolve all userkeys in HTML to display names
|
|
280
|
+
* @param {string} html - HTML content with ri:user elements
|
|
281
|
+
* @returns {Promise<{html: string, userMap: Map<string, string>}>}
|
|
282
|
+
*/
|
|
283
|
+
async resolveUserKeysInHtml(html) {
|
|
284
|
+
// Extract all unique userkeys
|
|
285
|
+
const userKeyRegex = /ri:userkey="([^"]+)"/g;
|
|
286
|
+
const userKeys = new Set();
|
|
287
|
+
let match;
|
|
288
|
+
while ((match = userKeyRegex.exec(html)) !== null) {
|
|
289
|
+
userKeys.add(match[1]);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (userKeys.size === 0) {
|
|
293
|
+
return { html, userMap: new Map() };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Fetch user info for all keys in parallel
|
|
297
|
+
const userPromises = Array.from(userKeys).map(key => this.getUserByKey(key));
|
|
298
|
+
const users = await Promise.all(userPromises);
|
|
299
|
+
|
|
300
|
+
// Build userkey -> displayName map
|
|
301
|
+
const userMap = new Map();
|
|
302
|
+
users.forEach(user => {
|
|
303
|
+
userMap.set(user.key, user.displayName);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// Replace userkey references with display names in HTML
|
|
307
|
+
let resolvedHtml = html;
|
|
308
|
+
userMap.forEach((displayName, userKey) => {
|
|
309
|
+
// Replace <ac:link><ri:user ri:userkey="xxx" /></ac:link> with @displayName
|
|
310
|
+
const userLinkRegex = new RegExp(
|
|
311
|
+
`<ac:link>\\s*<ri:user\\s+ri:userkey="${userKey}"\\s*/>\\s*</ac:link>`,
|
|
312
|
+
'g'
|
|
313
|
+
);
|
|
314
|
+
resolvedHtml = resolvedHtml.replace(userLinkRegex, `@${displayName}`);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
return { html: resolvedHtml, userMap };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Find a page by title and space key, return page info with URL
|
|
322
|
+
* @param {string} spaceKey - Space key (e.g., "~huotui" or "TECH")
|
|
323
|
+
* @param {string} title - Page title
|
|
324
|
+
* @returns {Promise<{title: string, url: string} | null>}
|
|
325
|
+
*/
|
|
326
|
+
async findPageByTitleAndSpace(spaceKey, title) {
|
|
327
|
+
try {
|
|
328
|
+
const response = await this.client.get('/content', {
|
|
329
|
+
params: {
|
|
330
|
+
spaceKey: spaceKey,
|
|
331
|
+
title: title,
|
|
332
|
+
limit: 1
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
if (response.data.results && response.data.results.length > 0) {
|
|
337
|
+
const page = response.data.results[0];
|
|
338
|
+
const webui = page._links?.webui || '';
|
|
339
|
+
return {
|
|
340
|
+
title: page.title,
|
|
341
|
+
url: webui ? `https://${this.domain}/wiki${webui}` : ''
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
return null;
|
|
345
|
+
} catch (error) {
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Resolve all page links in HTML to full URLs
|
|
352
|
+
* @param {string} html - HTML content with ri:page elements
|
|
353
|
+
* @returns {Promise<string>} - HTML with resolved page links
|
|
354
|
+
*/
|
|
355
|
+
async resolvePageLinksInHtml(html) {
|
|
356
|
+
// Extract all page links: <ri:page ri:space-key="xxx" ri:content-title="yyy" />
|
|
357
|
+
const pageLinkRegex = /<ac:link>\s*<ri:page\s+ri:space-key="([^"]+)"\s+ri:content-title="([^"]+)"[^>]*(?:\/>|><\/ri:page>)\s*<\/ac:link>/g;
|
|
358
|
+
const pageLinks = [];
|
|
359
|
+
let match;
|
|
360
|
+
|
|
361
|
+
while ((match = pageLinkRegex.exec(html)) !== null) {
|
|
362
|
+
pageLinks.push({
|
|
363
|
+
fullMatch: match[0],
|
|
364
|
+
spaceKey: match[1],
|
|
365
|
+
title: match[2]
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (pageLinks.length === 0) {
|
|
370
|
+
return html;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Fetch page info for all links in parallel
|
|
374
|
+
const pagePromises = pageLinks.map(async (link) => {
|
|
375
|
+
const pageInfo = await this.findPageByTitleAndSpace(link.spaceKey, link.title);
|
|
376
|
+
return {
|
|
377
|
+
...link,
|
|
378
|
+
pageInfo
|
|
379
|
+
};
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
const resolvedLinks = await Promise.all(pagePromises);
|
|
383
|
+
|
|
384
|
+
// Replace page link references with markdown links
|
|
385
|
+
let resolvedHtml = html;
|
|
386
|
+
resolvedLinks.forEach(({ fullMatch, title, pageInfo }) => {
|
|
387
|
+
let replacement;
|
|
388
|
+
if (pageInfo && pageInfo.url) {
|
|
389
|
+
replacement = `[${title}](${pageInfo.url})`;
|
|
390
|
+
} else {
|
|
391
|
+
// Fallback to just the title if page not found
|
|
392
|
+
replacement = `[${title}]`;
|
|
393
|
+
}
|
|
394
|
+
resolvedHtml = resolvedHtml.replace(fullMatch, replacement);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
return resolvedHtml;
|
|
398
|
+
}
|
|
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
|
+
|
|
452
|
+
/**
|
|
453
|
+
* List comments for a page with pagination support
|
|
454
|
+
*/
|
|
455
|
+
async listComments(pageIdOrUrl, options = {}) {
|
|
456
|
+
const pageId = await this.extractPageId(pageIdOrUrl);
|
|
457
|
+
const limit = this.parsePositiveInt(options.limit, 25);
|
|
458
|
+
const start = this.parsePositiveInt(options.start, 0);
|
|
459
|
+
const params = {
|
|
460
|
+
limit,
|
|
461
|
+
start
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
const expand = options.expand || 'body.storage,history,version,extensions.inlineProperties,extensions.resolution,ancestors';
|
|
465
|
+
if (expand) {
|
|
466
|
+
params.expand = expand;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (options.parentVersion !== undefined && options.parentVersion !== null) {
|
|
470
|
+
params.parentVersion = options.parentVersion;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (options.location) {
|
|
474
|
+
params.location = options.location;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (options.depth) {
|
|
478
|
+
params.depth = options.depth;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const paramsSerializer = (input) => {
|
|
482
|
+
const searchParams = new URLSearchParams();
|
|
483
|
+
Object.entries(input || {}).forEach(([key, value]) => {
|
|
484
|
+
if (value === undefined || value === null || value === '') {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (Array.isArray(value)) {
|
|
488
|
+
value.forEach((item) => {
|
|
489
|
+
if (item !== undefined && item !== null && item !== '') {
|
|
490
|
+
searchParams.append(key, item);
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
searchParams.append(key, value);
|
|
496
|
+
});
|
|
497
|
+
return searchParams.toString();
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
const response = await this.client.get(`/content/${pageId}/child/comment`, {
|
|
501
|
+
params,
|
|
502
|
+
paramsSerializer
|
|
503
|
+
});
|
|
504
|
+
const results = Array.isArray(response.data?.results)
|
|
505
|
+
? response.data.results.map((item) => this.normalizeComment(item))
|
|
506
|
+
: [];
|
|
507
|
+
|
|
508
|
+
return {
|
|
509
|
+
results,
|
|
510
|
+
nextStart: this.parseNextStart(response.data?._links?.next)
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Fetch all comments for a page, honoring an optional maxResults cap
|
|
516
|
+
*/
|
|
517
|
+
async getAllComments(pageIdOrUrl, options = {}) {
|
|
518
|
+
const pageSize = this.parsePositiveInt(options.pageSize || options.limit, 25);
|
|
519
|
+
const maxResults = this.parsePositiveInt(options.maxResults, null);
|
|
520
|
+
let start = this.parsePositiveInt(options.start, 0);
|
|
521
|
+
const comments = [];
|
|
522
|
+
|
|
523
|
+
let hasNext = true;
|
|
524
|
+
while (hasNext) {
|
|
525
|
+
const page = await this.listComments(pageIdOrUrl, {
|
|
526
|
+
limit: pageSize,
|
|
527
|
+
start,
|
|
528
|
+
expand: options.expand,
|
|
529
|
+
location: options.location,
|
|
530
|
+
depth: options.depth,
|
|
531
|
+
parentVersion: options.parentVersion
|
|
532
|
+
});
|
|
533
|
+
comments.push(...page.results);
|
|
534
|
+
|
|
535
|
+
if (maxResults && comments.length >= maxResults) {
|
|
536
|
+
return comments.slice(0, maxResults);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
hasNext = page.nextStart !== null && page.nextStart !== undefined;
|
|
540
|
+
if (hasNext) {
|
|
541
|
+
start = page.nextStart;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return comments;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
normalizeComment(raw) {
|
|
549
|
+
const history = raw?.history || {};
|
|
550
|
+
const author = history.createdBy || {};
|
|
551
|
+
const extensions = raw?.extensions || {};
|
|
552
|
+
const ancestors = Array.isArray(raw?.ancestors)
|
|
553
|
+
? raw.ancestors.map((ancestor) => {
|
|
554
|
+
const id = ancestor?.id ?? ancestor;
|
|
555
|
+
return {
|
|
556
|
+
id: id !== undefined && id !== null ? String(id) : null,
|
|
557
|
+
type: ancestor?.type || null,
|
|
558
|
+
title: ancestor?.title || null
|
|
559
|
+
};
|
|
560
|
+
}).filter((ancestor) => ancestor.id)
|
|
561
|
+
: [];
|
|
562
|
+
|
|
563
|
+
return {
|
|
564
|
+
id: raw?.id,
|
|
565
|
+
title: raw?.title,
|
|
566
|
+
status: raw?.status,
|
|
567
|
+
body: raw?.body?.storage?.value || '',
|
|
568
|
+
author: {
|
|
569
|
+
displayName: author.displayName || author.publicName || author.username || author.userKey || author.accountId || 'Unknown',
|
|
570
|
+
accountId: author.accountId,
|
|
571
|
+
userKey: author.userKey,
|
|
572
|
+
username: author.username,
|
|
573
|
+
email: author.email
|
|
574
|
+
},
|
|
575
|
+
createdAt: history.createdDate || null,
|
|
576
|
+
version: raw?.version?.number || null,
|
|
577
|
+
location: this.getCommentLocation(extensions),
|
|
578
|
+
inlineProperties: extensions.inlineProperties || null,
|
|
579
|
+
resolution: this.getCommentResolution(extensions),
|
|
580
|
+
parentId: this.getCommentParentId(ancestors),
|
|
581
|
+
ancestors,
|
|
582
|
+
extensions
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
getCommentParentId(ancestors = []) {
|
|
587
|
+
if (!Array.isArray(ancestors) || ancestors.length === 0) {
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
const commentAncestors = ancestors.filter((ancestor) => {
|
|
591
|
+
const type = ancestor?.type ? String(ancestor.type).toLowerCase() : '';
|
|
592
|
+
return type === 'comment';
|
|
593
|
+
});
|
|
594
|
+
if (commentAncestors.length === 0) {
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
return commentAncestors[commentAncestors.length - 1].id || null;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
getCommentLocation(extensions = {}) {
|
|
601
|
+
const location = extensions.location;
|
|
602
|
+
if (!location) {
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
if (typeof location === 'string') {
|
|
606
|
+
return location;
|
|
607
|
+
}
|
|
608
|
+
if (typeof location.value === 'string') {
|
|
609
|
+
return location.value;
|
|
610
|
+
}
|
|
611
|
+
if (typeof location.name === 'string') {
|
|
612
|
+
return location.name;
|
|
613
|
+
}
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
getCommentResolution(extensions = {}) {
|
|
618
|
+
const resolution = extensions.resolution;
|
|
619
|
+
if (!resolution) {
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
if (typeof resolution === 'string') {
|
|
623
|
+
return resolution;
|
|
624
|
+
}
|
|
625
|
+
if (typeof resolution.status === 'string') {
|
|
626
|
+
return resolution.status;
|
|
627
|
+
}
|
|
628
|
+
if (typeof resolution.value === 'string') {
|
|
629
|
+
return resolution.value;
|
|
630
|
+
}
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
formatCommentBody(storageValue, format = 'text') {
|
|
635
|
+
const value = storageValue || '';
|
|
636
|
+
if (format === 'storage' || format === 'html') {
|
|
637
|
+
return value;
|
|
638
|
+
}
|
|
639
|
+
if (format === 'markdown') {
|
|
640
|
+
return this.storageToMarkdown(value);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
return convert(value, {
|
|
644
|
+
wordwrap: 80,
|
|
645
|
+
selectors: [
|
|
646
|
+
{ selector: 'h1', options: { uppercase: false } },
|
|
647
|
+
{ selector: 'h2', options: { uppercase: false } },
|
|
648
|
+
{ selector: 'h3', options: { uppercase: false } },
|
|
649
|
+
{ selector: 'table', options: { uppercaseHeaderCells: false } }
|
|
650
|
+
]
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Create a comment on a page
|
|
656
|
+
*/
|
|
657
|
+
async createComment(pageIdOrUrl, content, format = 'storage', options = {}) {
|
|
658
|
+
const pageId = await this.extractPageId(pageIdOrUrl);
|
|
659
|
+
let storageContent = content;
|
|
660
|
+
|
|
661
|
+
if (format === 'markdown') {
|
|
662
|
+
storageContent = this.markdownToStorage(content);
|
|
663
|
+
} else if (format === 'html') {
|
|
664
|
+
storageContent = this.htmlToConfluenceStorage(content);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const commentData = {
|
|
668
|
+
type: 'comment',
|
|
669
|
+
container: {
|
|
670
|
+
id: pageId,
|
|
671
|
+
type: 'page'
|
|
672
|
+
},
|
|
673
|
+
body: {
|
|
674
|
+
storage: {
|
|
675
|
+
value: storageContent,
|
|
676
|
+
representation: 'storage'
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
if (options.parentId) {
|
|
682
|
+
commentData.ancestors = [{ id: options.parentId }];
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const extensions = {};
|
|
686
|
+
const location = options.location || (options.inlineProperties ? 'inline' : null);
|
|
687
|
+
if (location) {
|
|
688
|
+
extensions.location = location;
|
|
689
|
+
}
|
|
690
|
+
if (options.inlineProperties) {
|
|
691
|
+
extensions.inlineProperties = options.inlineProperties;
|
|
692
|
+
}
|
|
693
|
+
if (Object.keys(extensions).length > 0) {
|
|
694
|
+
commentData.extensions = extensions;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const response = await this.client.post('/content', commentData);
|
|
698
|
+
return response.data;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Delete a comment by ID
|
|
703
|
+
*/
|
|
704
|
+
async deleteComment(commentId) {
|
|
705
|
+
await this.client.delete(`/content/${commentId}`);
|
|
706
|
+
return { id: String(commentId) };
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* List attachments for a page with pagination support
|
|
711
|
+
*/
|
|
712
|
+
async listAttachments(pageIdOrUrl, options = {}) {
|
|
713
|
+
const pageId = await this.extractPageId(pageIdOrUrl);
|
|
714
|
+
const limit = this.parsePositiveInt(options.limit, 50);
|
|
715
|
+
const start = this.parsePositiveInt(options.start, 0);
|
|
716
|
+
const params = {
|
|
717
|
+
limit,
|
|
718
|
+
start
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
if (options.filename) {
|
|
722
|
+
params.filename = options.filename;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const response = await this.client.get(`/content/${pageId}/child/attachment`, { params });
|
|
726
|
+
const results = Array.isArray(response.data.results)
|
|
727
|
+
? response.data.results.map((item) => this.normalizeAttachment(item))
|
|
728
|
+
: [];
|
|
729
|
+
|
|
730
|
+
return {
|
|
731
|
+
results,
|
|
732
|
+
nextStart: this.parseNextStart(response.data?._links?.next)
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Fetch all attachments for a page, honoring an optional maxResults cap
|
|
738
|
+
*/
|
|
739
|
+
async getAllAttachments(pageIdOrUrl, options = {}) {
|
|
740
|
+
const pageSize = this.parsePositiveInt(options.pageSize || options.limit, 50);
|
|
741
|
+
const maxResults = this.parsePositiveInt(options.maxResults, null);
|
|
742
|
+
const filename = options.filename;
|
|
743
|
+
let start = this.parsePositiveInt(options.start, 0);
|
|
744
|
+
const attachments = [];
|
|
745
|
+
|
|
746
|
+
let hasNext = true;
|
|
747
|
+
while (hasNext) {
|
|
748
|
+
const page = await this.listAttachments(pageIdOrUrl, {
|
|
749
|
+
limit: pageSize,
|
|
750
|
+
start,
|
|
751
|
+
filename
|
|
752
|
+
});
|
|
753
|
+
attachments.push(...page.results);
|
|
754
|
+
|
|
755
|
+
if (maxResults && attachments.length >= maxResults) {
|
|
756
|
+
return attachments.slice(0, maxResults);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
hasNext = page.nextStart !== null && page.nextStart !== undefined;
|
|
760
|
+
if (hasNext) {
|
|
761
|
+
start = page.nextStart;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
return attachments;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Download an attachment's data stream
|
|
770
|
+
* Now uses the download link from attachment metadata instead of the broken REST API endpoint
|
|
771
|
+
*/
|
|
772
|
+
async downloadAttachment(pageIdOrUrl, attachmentIdOrAttachment, options = {}) {
|
|
773
|
+
let downloadUrl;
|
|
774
|
+
|
|
775
|
+
// If the second argument is an attachment object with downloadLink, use it directly
|
|
776
|
+
if (typeof attachmentIdOrAttachment === 'object' && attachmentIdOrAttachment.downloadLink) {
|
|
777
|
+
downloadUrl = attachmentIdOrAttachment.downloadLink;
|
|
778
|
+
} else {
|
|
779
|
+
// Otherwise, fetch attachment info to get the download link
|
|
780
|
+
const pageId = await this.extractPageId(pageIdOrUrl);
|
|
781
|
+
const attachmentId = attachmentIdOrAttachment;
|
|
782
|
+
const response = await this.client.get(`/content/${pageId}/child/attachment`, {
|
|
783
|
+
params: { limit: 500 }
|
|
784
|
+
});
|
|
785
|
+
const attachment = response.data.results.find(att => att.id === String(attachmentId));
|
|
786
|
+
if (!attachment) {
|
|
787
|
+
throw new Error(`Attachment with ID ${attachmentId} not found on page ${pageId}`);
|
|
788
|
+
}
|
|
789
|
+
downloadUrl = this.toAbsoluteUrl(attachment._links?.download);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
if (!downloadUrl) {
|
|
793
|
+
throw new Error('Unable to determine download URL for attachment');
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Download directly using axios with the same auth headers
|
|
797
|
+
const downloadResponse = await axios.get(downloadUrl, {
|
|
798
|
+
responseType: options.responseType || 'stream',
|
|
799
|
+
headers: {
|
|
800
|
+
'Authorization': this.authType === 'basic' ? this.buildBasicAuthHeader() : `Bearer ${this.token}`
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
return downloadResponse.data;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Convert markdown to Confluence storage format
|
|
808
|
+
*/
|
|
809
|
+
markdownToStorage(markdown) {
|
|
810
|
+
// Convert markdown to HTML first
|
|
811
|
+
const html = this.markdown.render(markdown);
|
|
812
|
+
|
|
813
|
+
// Convert HTML to native Confluence storage format elements
|
|
814
|
+
return this.htmlToConfluenceStorage(html);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Convert HTML to native Confluence storage format
|
|
819
|
+
*/
|
|
820
|
+
htmlToConfluenceStorage(html) {
|
|
821
|
+
let storage = html;
|
|
822
|
+
|
|
823
|
+
// Convert headings to native Confluence format
|
|
824
|
+
storage = storage.replace(/<h([1-6])>(.*?)<\/h[1-6]>/g, '<h$1>$2</h$1>');
|
|
825
|
+
|
|
826
|
+
// Convert paragraphs
|
|
827
|
+
storage = storage.replace(/<p>(.*?)<\/p>/g, '<p>$1</p>');
|
|
828
|
+
|
|
829
|
+
// Convert strong/bold text
|
|
830
|
+
storage = storage.replace(/<strong>(.*?)<\/strong>/g, '<strong>$1</strong>');
|
|
831
|
+
|
|
832
|
+
// Convert emphasis/italic text
|
|
833
|
+
storage = storage.replace(/<em>(.*?)<\/em>/g, '<em>$1</em>');
|
|
834
|
+
|
|
835
|
+
// Convert unordered lists
|
|
836
|
+
storage = storage.replace(/<ul>(.*?)<\/ul>/gs, '<ul>$1</ul>');
|
|
837
|
+
storage = storage.replace(/<li>(.*?)<\/li>/g, '<li><p>$1</p></li>');
|
|
838
|
+
|
|
839
|
+
// Convert ordered lists
|
|
840
|
+
storage = storage.replace(/<ol>(.*?)<\/ol>/gs, '<ol>$1</ol>');
|
|
841
|
+
|
|
842
|
+
// Convert code blocks to Confluence code macro
|
|
843
|
+
storage = storage.replace(/<pre><code(?:\s+class="language-(\w+)")?>(.*?)<\/code><\/pre>/gs, (_, lang, code) => {
|
|
844
|
+
const language = lang || 'text';
|
|
845
|
+
return `<ac:structured-macro ac:name="code">
|
|
846
|
+
<ac:parameter ac:name="language">${language}</ac:parameter>
|
|
847
|
+
<ac:plain-text-body><![CDATA[${code}]]></ac:plain-text-body>
|
|
848
|
+
</ac:structured-macro>`;
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
// Convert inline code
|
|
852
|
+
storage = storage.replace(/<code>(.*?)<\/code>/g, '<code>$1</code>');
|
|
853
|
+
|
|
854
|
+
// Convert blockquotes to appropriate macros based on content
|
|
855
|
+
storage = storage.replace(/<blockquote>(.*?)<\/blockquote>/gs, (_, content) => {
|
|
856
|
+
// Check for admonition patterns
|
|
857
|
+
if (content.includes('<strong>INFO</strong>')) {
|
|
858
|
+
const cleanContent = content.replace(/<p><strong>INFO<\/strong><\/p>\s*/, '');
|
|
859
|
+
return `<ac:structured-macro ac:name="info">
|
|
860
|
+
<ac:rich-text-body>${cleanContent}</ac:rich-text-body>
|
|
861
|
+
</ac:structured-macro>`;
|
|
862
|
+
} else if (content.includes('<strong>WARNING</strong>')) {
|
|
863
|
+
const cleanContent = content.replace(/<p><strong>WARNING<\/strong><\/p>\s*/, '');
|
|
864
|
+
return `<ac:structured-macro ac:name="warning">
|
|
865
|
+
<ac:rich-text-body>${cleanContent}</ac:rich-text-body>
|
|
866
|
+
</ac:structured-macro>`;
|
|
867
|
+
} else if (content.includes('<strong>NOTE</strong>')) {
|
|
868
|
+
const cleanContent = content.replace(/<p><strong>NOTE<\/strong><\/p>\s*/, '');
|
|
869
|
+
return `<ac:structured-macro ac:name="note">
|
|
870
|
+
<ac:rich-text-body>${cleanContent}</ac:rich-text-body>
|
|
871
|
+
</ac:structured-macro>`;
|
|
872
|
+
} else {
|
|
873
|
+
// Default to info macro for regular blockquotes
|
|
874
|
+
return `<ac:structured-macro ac:name="info">
|
|
875
|
+
<ac:rich-text-body>${content}</ac:rich-text-body>
|
|
876
|
+
</ac:structured-macro>`;
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
// Convert tables
|
|
881
|
+
storage = storage.replace(/<table>(.*?)<\/table>/gs, '<table>$1</table>');
|
|
882
|
+
storage = storage.replace(/<thead>(.*?)<\/thead>/gs, '<thead>$1</thead>');
|
|
883
|
+
storage = storage.replace(/<tbody>(.*?)<\/tbody>/gs, '<tbody>$1</tbody>');
|
|
884
|
+
storage = storage.replace(/<tr>(.*?)<\/tr>/gs, '<tr>$1</tr>');
|
|
885
|
+
storage = storage.replace(/<th>(.*?)<\/th>/g, '<th><p>$1</p></th>');
|
|
886
|
+
storage = storage.replace(/<td>(.*?)<\/td>/g, '<td><p>$1</p></td>');
|
|
887
|
+
|
|
888
|
+
// Convert links
|
|
889
|
+
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>');
|
|
890
|
+
|
|
891
|
+
// Convert horizontal rules
|
|
892
|
+
storage = storage.replace(/<hr\s*\/?>/g, '<hr />');
|
|
893
|
+
|
|
894
|
+
// Clean up any remaining HTML entities and normalize whitespace
|
|
895
|
+
storage = storage.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
|
896
|
+
|
|
897
|
+
return storage;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Convert markdown to Confluence storage format using native storage format
|
|
902
|
+
*/
|
|
903
|
+
markdownToNativeStorage(markdown) {
|
|
904
|
+
// Convert markdown to HTML first
|
|
905
|
+
const html = this.markdown.render(markdown);
|
|
906
|
+
|
|
907
|
+
// Delegate to htmlToConfluenceStorage for proper conversion including code blocks
|
|
908
|
+
return this.htmlToConfluenceStorage(html);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Setup Confluence-specific markdown extensions
|
|
913
|
+
*/
|
|
914
|
+
setupConfluenceMarkdownExtensions() {
|
|
915
|
+
// Enable additional markdown-it features
|
|
916
|
+
this.markdown.enable(['table', 'strikethrough', 'linkify']);
|
|
917
|
+
|
|
918
|
+
// Add custom rule for Confluence macros in markdown
|
|
919
|
+
this.markdown.core.ruler.before('normalize', 'confluence_macros', (state) => {
|
|
920
|
+
const src = state.src;
|
|
921
|
+
|
|
922
|
+
// Convert [!info] admonitions to info macro
|
|
923
|
+
state.src = src.replace(/\[!info\]\s*([\s\S]*?)(?=\n\s*\n|\n\s*\[!|$)/g, (_, content) => {
|
|
924
|
+
return `> **INFO**\n> ${content.trim().replace(/\n/g, '\n> ')}`;
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
// Convert [!warning] admonitions to warning macro
|
|
928
|
+
state.src = state.src.replace(/\[!warning\]\s*([\s\S]*?)(?=\n\s*\n|\n\s*\[!|$)/g, (_, content) => {
|
|
929
|
+
return `> **WARNING**\n> ${content.trim().replace(/\n/g, '\n> ')}`;
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
// Convert [!note] admonitions to note macro
|
|
933
|
+
state.src = state.src.replace(/\[!note\]\s*([\s\S]*?)(?=\n\s*\n|\n\s*\[!|$)/g, (_, content) => {
|
|
934
|
+
return `> **NOTE**\n> ${content.trim().replace(/\n/g, '\n> ')}`;
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
// Convert task lists to proper format
|
|
938
|
+
state.src = state.src.replace(/^(\s*)- \[([ x])\] (.+)$/gm, (_, indent, checked, text) => {
|
|
939
|
+
return `${indent}- [${checked}] ${text}`;
|
|
940
|
+
});
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* Detect language from text content and return appropriate labels
|
|
946
|
+
* @param {string} text - Text content to analyze
|
|
947
|
+
* @returns {object} Object with language-specific labels
|
|
948
|
+
*/
|
|
949
|
+
detectLanguageLabels(text) {
|
|
950
|
+
const labels = {
|
|
951
|
+
includePage: 'Include Page',
|
|
952
|
+
sharedBlock: 'Shared Block',
|
|
953
|
+
includeSharedBlock: 'Include Shared Block',
|
|
954
|
+
fromPage: 'from page',
|
|
955
|
+
expandDetails: 'Expand Details'
|
|
956
|
+
};
|
|
957
|
+
|
|
958
|
+
if (/[\u4e00-\u9fa5]/.test(text)) {
|
|
959
|
+
// Chinese
|
|
960
|
+
labels.includePage = '包含页面';
|
|
961
|
+
labels.sharedBlock = '共享块';
|
|
962
|
+
labels.includeSharedBlock = '包含共享块';
|
|
963
|
+
labels.fromPage = '来自页面';
|
|
964
|
+
labels.expandDetails = '展开详情';
|
|
965
|
+
} else if (/[\u3040-\u309f\u30a0-\u30ff]/.test(text)) {
|
|
966
|
+
// Japanese
|
|
967
|
+
labels.includePage = 'ページを含む';
|
|
968
|
+
labels.sharedBlock = '共有ブロック';
|
|
969
|
+
labels.includeSharedBlock = '共有ブロックを含む';
|
|
970
|
+
labels.fromPage = 'ページから';
|
|
971
|
+
labels.expandDetails = '詳細を表示';
|
|
972
|
+
} else if (/[\uac00-\ud7af]/.test(text)) {
|
|
973
|
+
// Korean
|
|
974
|
+
labels.includePage = '페이지 포함';
|
|
975
|
+
labels.sharedBlock = '공유 블록';
|
|
976
|
+
labels.includeSharedBlock = '공유 블록 포함';
|
|
977
|
+
labels.fromPage = '페이지에서';
|
|
978
|
+
labels.expandDetails = '상세 보기';
|
|
979
|
+
} else if (/[\u0400-\u04ff]/.test(text)) {
|
|
980
|
+
// Russian/Cyrillic
|
|
981
|
+
labels.includePage = 'Включить страницу';
|
|
982
|
+
labels.sharedBlock = 'Общий блок';
|
|
983
|
+
labels.includeSharedBlock = 'Включить общий блок';
|
|
984
|
+
labels.fromPage = 'со страницы';
|
|
985
|
+
labels.expandDetails = 'Подробнее';
|
|
986
|
+
} else if ((text.match(/[àâäéèêëïîôùûüÿœæç]/gi) || []).length >= 2) {
|
|
987
|
+
// French (requires at least 2 French-specific characters to avoid false positives)
|
|
988
|
+
labels.includePage = 'Inclure la page';
|
|
989
|
+
labels.sharedBlock = 'Bloc partagé';
|
|
990
|
+
labels.includeSharedBlock = 'Inclure le bloc partagé';
|
|
991
|
+
labels.fromPage = 'de la page';
|
|
992
|
+
labels.expandDetails = 'Détails';
|
|
993
|
+
} else if ((text.match(/[äöüß]/gi) || []).length >= 2) {
|
|
994
|
+
// German (requires at least 2 German-specific characters)
|
|
995
|
+
// Note: French is checked before German because French regex includes more characters
|
|
996
|
+
// that overlap with German (ä, ü). The threshold helps distinguish between them.
|
|
997
|
+
labels.includePage = 'Seite einbinden';
|
|
998
|
+
labels.sharedBlock = 'Gemeinsamer Block';
|
|
999
|
+
labels.includeSharedBlock = 'Gemeinsamen Block einbinden';
|
|
1000
|
+
labels.fromPage = 'von Seite';
|
|
1001
|
+
labels.expandDetails = 'Details';
|
|
1002
|
+
} else if ((text.match(/[áéíóúñ¿¡]/gi) || []).length >= 2) {
|
|
1003
|
+
// Spanish (requires at least 2 Spanish-specific characters)
|
|
1004
|
+
labels.includePage = 'Incluir página';
|
|
1005
|
+
labels.sharedBlock = 'Bloque compartido';
|
|
1006
|
+
labels.includeSharedBlock = 'Incluir bloque compartido';
|
|
1007
|
+
labels.fromPage = 'de la página';
|
|
1008
|
+
labels.expandDetails = 'Detalles';
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
return labels;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* Convert Confluence storage format to markdown
|
|
1016
|
+
* @param {string} storage - Confluence storage format HTML
|
|
1017
|
+
* @param {object} options - Conversion options
|
|
1018
|
+
* @param {string} options.attachmentsDir - Directory name for attachments (default: 'attachments')
|
|
1019
|
+
*/
|
|
1020
|
+
storageToMarkdown(storage, options = {}) {
|
|
1021
|
+
const attachmentsDir = options.attachmentsDir || 'attachments';
|
|
1022
|
+
let markdown = storage;
|
|
1023
|
+
|
|
1024
|
+
// Detect language from content
|
|
1025
|
+
const labels = this.detectLanguageLabels(markdown);
|
|
1026
|
+
|
|
1027
|
+
// Remove table of contents macro
|
|
1028
|
+
markdown = markdown.replace(/<ac:structured-macro ac:name="toc"[^>]*\s*\/>/g, '');
|
|
1029
|
+
markdown = markdown.replace(/<ac:structured-macro ac:name="toc"[^>]*>[\s\S]*?<\/ac:structured-macro>/g, '');
|
|
1030
|
+
|
|
1031
|
+
// Remove floatmenu macro (floating table of contents)
|
|
1032
|
+
markdown = markdown.replace(/<ac:structured-macro ac:name="floatmenu"[^>]*>[\s\S]*?<\/ac:structured-macro>/g, '');
|
|
1033
|
+
|
|
1034
|
+
// Convert Confluence images to markdown images
|
|
1035
|
+
// Format: <ac:image><ri:attachment ri:filename="image.png" /></ac:image>
|
|
1036
|
+
markdown = markdown.replace(/<ac:image[^>]*>\s*<ri:attachment\s+ri:filename="([^"]+)"[^>]*\s*\/>\s*<\/ac:image>/g, (_, filename) => {
|
|
1037
|
+
return ``;
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
// Also handle self-closing ac:image with ri:attachment
|
|
1041
|
+
markdown = markdown.replace(/<ac:image[^>]*><ri:attachment\s+ri:filename="([^"]+)"[^>]*><\/ri:attachment><\/ac:image>/g, (_, filename) => {
|
|
1042
|
+
return ``;
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
// Convert mermaid macro to mermaid code block
|
|
1046
|
+
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) => {
|
|
1047
|
+
return `\n\`\`\`mermaid\n${code.trim()}\n\`\`\`\n`;
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
// Convert expand macro - extract content from rich-text-body
|
|
1051
|
+
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) => {
|
|
1052
|
+
return `\n<details>\n<summary>${labels.expandDetails}</summary>\n\n${content}\n\n</details>\n`;
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
// Convert Confluence code macros to markdown
|
|
1056
|
+
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) => {
|
|
1057
|
+
return `\`\`\`${lang}\n${code}\n\`\`\``;
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
// Convert code macros without language parameter
|
|
1061
|
+
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) => {
|
|
1062
|
+
return `\`\`\`\n${code}\n\`\`\``;
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
// Convert info macro to admonition
|
|
1066
|
+
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) => {
|
|
1067
|
+
const cleanContent = this.htmlToMarkdown(content);
|
|
1068
|
+
return `[!info]\n${cleanContent}`;
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
// Convert warning macro to admonition
|
|
1072
|
+
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) => {
|
|
1073
|
+
const cleanContent = this.htmlToMarkdown(content);
|
|
1074
|
+
return `[!warning]\n${cleanContent}`;
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
// Convert note macro to admonition
|
|
1078
|
+
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) => {
|
|
1079
|
+
const cleanContent = this.htmlToMarkdown(content);
|
|
1080
|
+
return `[!note]\n${cleanContent}`;
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
// Convert task list macros to markdown checkboxes
|
|
1084
|
+
// Note: This is independent of user resolution - it only converts <ac:task> structure to "- [ ]" or "- [x]" format
|
|
1085
|
+
markdown = markdown.replace(/<ac:task-list>([\s\S]*?)<\/ac:task-list>/g, (_, content) => {
|
|
1086
|
+
const tasks = [];
|
|
1087
|
+
// Match each task: <ac:task>...<ac:task-status>xxx</ac:task-status>...<ac:task-body>...</ac:task-body>...</ac:task>
|
|
1088
|
+
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;
|
|
1089
|
+
let match;
|
|
1090
|
+
while ((match = taskRegex.exec(content)) !== null) {
|
|
1091
|
+
const status = match[1];
|
|
1092
|
+
let taskBody = match[2];
|
|
1093
|
+
// Clean up HTML from task body, but preserve @username
|
|
1094
|
+
taskBody = taskBody.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
|
1095
|
+
const checkbox = status === 'complete' ? '[x]' : '[ ]';
|
|
1096
|
+
if (taskBody) {
|
|
1097
|
+
tasks.push(`- ${checkbox} ${taskBody}`);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
return tasks.length > 0 ? '\n' + tasks.join('\n') + '\n' : '';
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
// Convert panel macro to markdown blockquote with title
|
|
1104
|
+
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) => {
|
|
1105
|
+
const cleanContent = this.htmlToMarkdown(content);
|
|
1106
|
+
return `\n> **${title}**\n>\n${cleanContent.split('\n').map(line => line ? `> ${line}` : '>').join('\n')}\n`;
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
// Convert include macro - extract page link and convert to markdown link
|
|
1110
|
+
// Handle both with and without parameter name
|
|
1111
|
+
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) => {
|
|
1112
|
+
// Try to build a proper URL - if spaceKey starts with ~, it's a user space
|
|
1113
|
+
if (spaceKey.startsWith('~')) {
|
|
1114
|
+
const spacePath = `display/${spaceKey}/${encodeURIComponent(title)}`;
|
|
1115
|
+
return `\n> 📄 **${labels.includePage}**: [${title}](https://${this.domain}/wiki/${spacePath})\n`;
|
|
1116
|
+
} else {
|
|
1117
|
+
// For non-user spaces, we cannot construct a valid link without the page ID.
|
|
1118
|
+
// Document that manual correction is required.
|
|
1119
|
+
return `\n> 📄 **${labels.includePage}**: [${title}](https://${this.domain}/wiki/spaces/${spaceKey}/pages/[PAGE_ID_HERE]) _(manual link correction required)_\n`;
|
|
1120
|
+
}
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
// Convert shared-block and include-shared-block macros - extract content
|
|
1124
|
+
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) => {
|
|
1125
|
+
const cleanContent = this.htmlToMarkdown(content);
|
|
1126
|
+
return `\n> **${labels.sharedBlock}: ${blockKey}**\n>\n${cleanContent.split('\n').map(line => line ? `> ${line}` : '>').join('\n')}\n`;
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
// Convert include-shared-block with page parameter
|
|
1130
|
+
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) => {
|
|
1131
|
+
// The page ID is not available, so we cannot generate a valid link.
|
|
1132
|
+
// Instead, document that the link needs manual correction.
|
|
1133
|
+
return `\n> 📄 **${labels.includeSharedBlock}**: ${blockKey} (${labels.fromPage}: ${pageTitle} [link needs manual correction])\n`;
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
// Convert view-file macro to file link
|
|
1137
|
+
// Handle both orders: name first or height first
|
|
1138
|
+
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) => {
|
|
1139
|
+
return `\n📎 [${filename}](${attachmentsDir}/${filename})\n`;
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
// Also handle view-file with height parameter (which might appear after name)
|
|
1143
|
+
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) => {
|
|
1144
|
+
return `\n📎 [${filename}](${attachmentsDir}/${filename})\n`;
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
// Remove layout macros but preserve content
|
|
1148
|
+
markdown = markdown.replace(/<ac:layout>/g, '');
|
|
1149
|
+
markdown = markdown.replace(/<\/ac:layout>/g, '');
|
|
1150
|
+
markdown = markdown.replace(/<ac:layout-section[^>]*>/g, '');
|
|
1151
|
+
markdown = markdown.replace(/<\/ac:layout-section>/g, '');
|
|
1152
|
+
markdown = markdown.replace(/<ac:layout-cell[^>]*>/g, '');
|
|
1153
|
+
markdown = markdown.replace(/<\/ac:layout-cell>/g, '');
|
|
1154
|
+
|
|
1155
|
+
// Remove other unhandled macros (replace with empty string for now)
|
|
1156
|
+
markdown = markdown.replace(/<ac:structured-macro[^>]*>[\s\S]*?<\/ac:structured-macro>/g, '');
|
|
1157
|
+
|
|
1158
|
+
// Convert external URL links
|
|
1159
|
+
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)');
|
|
1160
|
+
|
|
1161
|
+
// Convert internal page links - extract page title
|
|
1162
|
+
// Format: <ac:link><ri:page ri:space-key="xxx" ri:content-title="Page Title" /></ac:link>
|
|
1163
|
+
markdown = markdown.replace(/<ac:link>\s*<ri:page[^>]*ri:content-title="([^"]*)"[^>]*\/>\s*<\/ac:link>/g, '[$1]');
|
|
1164
|
+
markdown = markdown.replace(/<ac:link>\s*<ri:page[^>]*ri:content-title="([^"]*)"[^>]*>\s*<\/ri:page>\s*<\/ac:link>/g, '[$1]');
|
|
1165
|
+
|
|
1166
|
+
// Remove any remaining ac:link tags that weren't matched
|
|
1167
|
+
markdown = markdown.replace(/<ac:link>[\s\S]*?<\/ac:link>/g, '');
|
|
1168
|
+
|
|
1169
|
+
// Convert remaining HTML to markdown
|
|
1170
|
+
markdown = this.htmlToMarkdown(markdown);
|
|
1171
|
+
|
|
1172
|
+
return markdown;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* Convert basic HTML to markdown
|
|
1177
|
+
*/
|
|
1178
|
+
htmlToMarkdown(html) {
|
|
1179
|
+
let markdown = html;
|
|
1180
|
+
|
|
1181
|
+
// Convert time elements to date text BEFORE removing attributes
|
|
1182
|
+
// Format: <time datetime="2025-09-16" /> or <time datetime="2025-09-16"></time>
|
|
1183
|
+
markdown = markdown.replace(/<time\s+datetime="([^"]+)"[^>]*(?:\/>|>\s*<\/time>)/g, '$1');
|
|
1184
|
+
|
|
1185
|
+
// Convert strong/bold BEFORE removing HTML attributes
|
|
1186
|
+
markdown = markdown.replace(/<strong[^>]*>(.*?)<\/strong>/g, '**$1**');
|
|
1187
|
+
|
|
1188
|
+
// Convert emphasis/italic BEFORE removing HTML attributes
|
|
1189
|
+
markdown = markdown.replace(/<em[^>]*>(.*?)<\/em>/g, '*$1*');
|
|
1190
|
+
|
|
1191
|
+
// Convert code BEFORE removing HTML attributes
|
|
1192
|
+
markdown = markdown.replace(/<code[^>]*>(.*?)<\/code>/g, '`$1`');
|
|
1193
|
+
|
|
1194
|
+
// Remove HTML attributes from tags (but preserve content formatting)
|
|
1195
|
+
markdown = markdown.replace(/<(\w+)[^>]*>/g, '<$1>');
|
|
1196
|
+
markdown = markdown.replace(/<\/(\w+)[^>]*>/g, '</$1>');
|
|
1197
|
+
|
|
1198
|
+
// Convert headings first (they don't contain other elements typically)
|
|
1199
|
+
markdown = markdown.replace(/<h([1-6])>(.*?)<\/h[1-6]>/g, (_, level, text) => {
|
|
1200
|
+
return '\n' + '#'.repeat(parseInt(level)) + ' ' + text.trim() + '\n';
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
// Convert tables BEFORE paragraphs
|
|
1204
|
+
markdown = markdown.replace(/<table>(.*?)<\/table>/gs, (_, content) => {
|
|
1205
|
+
const rows = [];
|
|
1206
|
+
let isHeader = true;
|
|
1207
|
+
|
|
1208
|
+
// Extract table rows
|
|
1209
|
+
const rowMatches = content.match(/<tr>(.*?)<\/tr>/gs);
|
|
1210
|
+
if (rowMatches) {
|
|
1211
|
+
rowMatches.forEach(rowMatch => {
|
|
1212
|
+
const cells = [];
|
|
1213
|
+
const cellContent = rowMatch.replace(/<tr>(.*?)<\/tr>/s, '$1');
|
|
1214
|
+
|
|
1215
|
+
// Extract cells (th or td)
|
|
1216
|
+
const cellMatches = cellContent.match(/<t[hd]>(.*?)<\/t[hd]>/gs);
|
|
1217
|
+
if (cellMatches) {
|
|
1218
|
+
cellMatches.forEach(cellMatch => {
|
|
1219
|
+
let cellText = cellMatch.replace(/<t[hd]>(.*?)<\/t[hd]>/s, '$1');
|
|
1220
|
+
// Clean up cell content - remove nested HTML but preserve text and some formatting
|
|
1221
|
+
cellText = cellText.replace(/<p>/g, '').replace(/<\/p>/g, ' ');
|
|
1222
|
+
cellText = cellText.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
1223
|
+
cells.push(cellText || ' ');
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
if (cells.length > 0) {
|
|
1228
|
+
rows.push('| ' + cells.join(' | ') + ' |');
|
|
1229
|
+
|
|
1230
|
+
if (isHeader) {
|
|
1231
|
+
rows.push('| ' + cells.map(() => '---').join(' | ') + ' |');
|
|
1232
|
+
isHeader = false;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
return rows.length > 0 ? '\n' + rows.join('\n') + '\n' : '';
|
|
1239
|
+
});
|
|
1240
|
+
|
|
1241
|
+
// Convert unordered lists BEFORE paragraphs
|
|
1242
|
+
markdown = markdown.replace(/<ul>(.*?)<\/ul>/gs, (_, content) => {
|
|
1243
|
+
let listItems = '';
|
|
1244
|
+
const itemMatches = content.match(/<li>(.*?)<\/li>/gs);
|
|
1245
|
+
if (itemMatches) {
|
|
1246
|
+
itemMatches.forEach(itemMatch => {
|
|
1247
|
+
let itemText = itemMatch.replace(/<li>(.*?)<\/li>/s, '$1');
|
|
1248
|
+
// Clean up nested HTML but preserve some formatting
|
|
1249
|
+
itemText = itemText.replace(/<p>/g, '').replace(/<\/p>/g, ' ');
|
|
1250
|
+
itemText = itemText.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
1251
|
+
if (itemText) {
|
|
1252
|
+
listItems += '- ' + itemText + '\n';
|
|
1253
|
+
}
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
return '\n' + listItems;
|
|
1257
|
+
});
|
|
1258
|
+
|
|
1259
|
+
// Convert ordered lists BEFORE paragraphs
|
|
1260
|
+
markdown = markdown.replace(/<ol>(.*?)<\/ol>/gs, (_, content) => {
|
|
1261
|
+
let listItems = '';
|
|
1262
|
+
let counter = 1;
|
|
1263
|
+
const itemMatches = content.match(/<li>(.*?)<\/li>/gs);
|
|
1264
|
+
if (itemMatches) {
|
|
1265
|
+
itemMatches.forEach(itemMatch => {
|
|
1266
|
+
let itemText = itemMatch.replace(/<li>(.*?)<\/li>/s, '$1');
|
|
1267
|
+
// Clean up nested HTML but preserve some formatting
|
|
1268
|
+
itemText = itemText.replace(/<p>/g, '').replace(/<\/p>/g, ' ');
|
|
1269
|
+
itemText = itemText.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
1270
|
+
if (itemText) {
|
|
1271
|
+
listItems += `${counter++}. ${itemText}\n`;
|
|
1272
|
+
}
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
return '\n' + listItems;
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
// Convert paragraphs (after lists and tables)
|
|
1279
|
+
markdown = markdown.replace(/<p>(.*?)<\/p>/g, (_, content) => {
|
|
1280
|
+
return content.trim() + '\n';
|
|
1281
|
+
});
|
|
1282
|
+
|
|
1283
|
+
// Convert line breaks
|
|
1284
|
+
markdown = markdown.replace(/<br\s*\/?>/g, '\n');
|
|
1285
|
+
|
|
1286
|
+
// Convert horizontal rules
|
|
1287
|
+
markdown = markdown.replace(/<hr\s*\/?>/g, '\n---\n');
|
|
1288
|
+
|
|
1289
|
+
// Remove any remaining HTML tags, but preserve <details> and <summary> for GFM compatibility
|
|
1290
|
+
markdown = markdown.replace(/<(?!\/?(details|summary)\b)[^>]+>/g, ' ');
|
|
1291
|
+
|
|
1292
|
+
// Clean up whitespace and HTML entities
|
|
1293
|
+
markdown = markdown.replace(/ /g, ' ');
|
|
1294
|
+
markdown = markdown.replace(/</g, '<');
|
|
1295
|
+
markdown = markdown.replace(/>/g, '>');
|
|
1296
|
+
markdown = markdown.replace(/&/g, '&');
|
|
1297
|
+
markdown = markdown.replace(/"/g, '"');
|
|
1298
|
+
markdown = markdown.replace(/'/g, '\'');
|
|
1299
|
+
// Smart quotes and special characters
|
|
1300
|
+
markdown = markdown.replace(/“/g, '"');
|
|
1301
|
+
markdown = markdown.replace(/”/g, '"');
|
|
1302
|
+
markdown = markdown.replace(/‘/g, '\'');
|
|
1303
|
+
markdown = markdown.replace(/’/g, '\'');
|
|
1304
|
+
markdown = markdown.replace(/—/g, '—');
|
|
1305
|
+
markdown = markdown.replace(/–/g, '–');
|
|
1306
|
+
markdown = markdown.replace(/…/g, '...');
|
|
1307
|
+
markdown = markdown.replace(/•/g, '•');
|
|
1308
|
+
markdown = markdown.replace(/©/g, '©');
|
|
1309
|
+
markdown = markdown.replace(/®/g, '®');
|
|
1310
|
+
markdown = markdown.replace(/™/g, '™');
|
|
1311
|
+
// Numeric HTML entities
|
|
1312
|
+
markdown = markdown.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10)));
|
|
1313
|
+
markdown = markdown.replace(/&#x([0-9a-fA-F]+);/g, (_, code) => String.fromCharCode(parseInt(code, 16)));
|
|
1314
|
+
|
|
1315
|
+
// Clean up extra whitespace for standard Markdown format
|
|
1316
|
+
// Remove trailing spaces from each line
|
|
1317
|
+
markdown = markdown.replace(/[ \t]+$/gm, '');
|
|
1318
|
+
// Remove leading spaces from lines (except for code blocks, blockquotes, and list items)
|
|
1319
|
+
markdown = markdown.replace(/^[ \t]+(?!([`>]|[*+-] |\d+[.)] ))/gm, '');
|
|
1320
|
+
// Ensure proper spacing after headings (# Title should be followed by blank line or content)
|
|
1321
|
+
markdown = markdown.replace(/^(#{1,6}[^\n]+)\n(?!\n)/gm, '$1\n\n');
|
|
1322
|
+
// Normalize multiple blank lines to double newline
|
|
1323
|
+
markdown = markdown.replace(/\n\s*\n\s*\n+/g, '\n\n');
|
|
1324
|
+
// Collapse multiple spaces to single space (but preserve newlines)
|
|
1325
|
+
markdown = markdown.replace(/[ \t]+/g, ' ');
|
|
1326
|
+
// Final trim
|
|
1327
|
+
markdown = markdown.trim();
|
|
1328
|
+
|
|
1329
|
+
return markdown;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* Create a new Confluence page
|
|
1334
|
+
*/
|
|
1335
|
+
async createPage(title, spaceKey, content, format = 'storage') {
|
|
1336
|
+
let storageContent = content;
|
|
1337
|
+
|
|
1338
|
+
if (format === 'markdown') {
|
|
1339
|
+
storageContent = this.markdownToStorage(content);
|
|
1340
|
+
} else if (format === 'html') {
|
|
1341
|
+
// Convert HTML directly to storage format (no macro wrapper)
|
|
1342
|
+
storageContent = content;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
const pageData = {
|
|
1346
|
+
type: 'page',
|
|
1347
|
+
title: title,
|
|
1348
|
+
space: {
|
|
1349
|
+
key: spaceKey
|
|
1350
|
+
},
|
|
1351
|
+
body: {
|
|
1352
|
+
storage: {
|
|
1353
|
+
value: storageContent,
|
|
1354
|
+
representation: 'storage'
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
};
|
|
1358
|
+
|
|
1359
|
+
const response = await this.client.post('/content', pageData);
|
|
1360
|
+
return response.data;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/**
|
|
1364
|
+
* Create a new Confluence page as a child of another page
|
|
1365
|
+
*/
|
|
1366
|
+
async createChildPage(title, spaceKey, parentId, content, format = 'storage') {
|
|
1367
|
+
let storageContent = content;
|
|
1368
|
+
|
|
1369
|
+
if (format === 'markdown') {
|
|
1370
|
+
storageContent = this.markdownToStorage(content);
|
|
1371
|
+
} else if (format === 'html') {
|
|
1372
|
+
// Convert HTML directly to storage format (no macro wrapper)
|
|
1373
|
+
storageContent = content;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
const pageData = {
|
|
1377
|
+
type: 'page',
|
|
1378
|
+
title: title,
|
|
1379
|
+
space: {
|
|
1380
|
+
key: spaceKey
|
|
1381
|
+
},
|
|
1382
|
+
ancestors: [
|
|
1383
|
+
{
|
|
1384
|
+
id: parentId
|
|
1385
|
+
}
|
|
1386
|
+
],
|
|
1387
|
+
body: {
|
|
1388
|
+
storage: {
|
|
1389
|
+
value: storageContent,
|
|
1390
|
+
representation: 'storage'
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
};
|
|
1394
|
+
|
|
1395
|
+
const response = await this.client.post('/content', pageData);
|
|
1396
|
+
return response.data;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
/**
|
|
1400
|
+
* Update an existing Confluence page
|
|
1401
|
+
*/
|
|
1402
|
+
async updatePage(pageId, title, content, format = 'storage') {
|
|
1403
|
+
// First, get the current page to get the version number and existing content
|
|
1404
|
+
const currentPage = await this.client.get(`/content/${pageId}`, {
|
|
1405
|
+
params: {
|
|
1406
|
+
expand: 'body.storage,version,space'
|
|
1407
|
+
}
|
|
1408
|
+
});
|
|
1409
|
+
const currentVersion = currentPage.data.version.number;
|
|
1410
|
+
|
|
1411
|
+
let storageContent;
|
|
1412
|
+
|
|
1413
|
+
if (content !== undefined && content !== null) {
|
|
1414
|
+
// If new content is provided, convert it to storage format
|
|
1415
|
+
if (format === 'markdown') {
|
|
1416
|
+
storageContent = this.markdownToStorage(content);
|
|
1417
|
+
} else if (format === 'html') {
|
|
1418
|
+
storageContent = this.htmlToConfluenceStorage(content); // Using the conversion function for robustness
|
|
1419
|
+
} else { // 'storage' format
|
|
1420
|
+
storageContent = content;
|
|
1421
|
+
}
|
|
1422
|
+
} else {
|
|
1423
|
+
// If no new content, use the existing content
|
|
1424
|
+
storageContent = currentPage.data.body.storage.value;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
const pageData = {
|
|
1428
|
+
id: pageId,
|
|
1429
|
+
type: 'page',
|
|
1430
|
+
title: title || currentPage.data.title,
|
|
1431
|
+
space: currentPage.data.space,
|
|
1432
|
+
body: {
|
|
1433
|
+
storage: {
|
|
1434
|
+
value: storageContent,
|
|
1435
|
+
representation: 'storage'
|
|
1436
|
+
}
|
|
1437
|
+
},
|
|
1438
|
+
version: {
|
|
1439
|
+
number: currentVersion + 1
|
|
1440
|
+
}
|
|
1441
|
+
};
|
|
1442
|
+
|
|
1443
|
+
const response = await this.client.put(`/content/${pageId}`, pageData);
|
|
1444
|
+
return response.data;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
/**
|
|
1448
|
+
* Get page content for editing
|
|
1449
|
+
*/
|
|
1450
|
+
async getPageForEdit(pageIdOrUrl) {
|
|
1451
|
+
const pageId = await this.extractPageId(pageIdOrUrl);
|
|
1452
|
+
|
|
1453
|
+
const response = await this.client.get(`/content/${pageId}`, {
|
|
1454
|
+
params: {
|
|
1455
|
+
expand: 'body.storage,version,space'
|
|
1456
|
+
}
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
return {
|
|
1460
|
+
id: response.data.id,
|
|
1461
|
+
title: response.data.title,
|
|
1462
|
+
content: response.data.body.storage.value,
|
|
1463
|
+
version: response.data.version.number,
|
|
1464
|
+
space: response.data.space
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
* Delete a Confluence page
|
|
1470
|
+
* Note: Confluence may move the page to trash depending on instance settings.
|
|
1471
|
+
*/
|
|
1472
|
+
async deletePage(pageIdOrUrl) {
|
|
1473
|
+
const pageId = await this.extractPageId(pageIdOrUrl);
|
|
1474
|
+
await this.client.delete(`/content/${pageId}`);
|
|
1475
|
+
return { id: String(pageId) };
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
/**
|
|
1479
|
+
* Search for a page by title and space
|
|
1480
|
+
*/
|
|
1481
|
+
async findPageByTitle(title, spaceKey = null) {
|
|
1482
|
+
let cql = `title = "${title}"`;
|
|
1483
|
+
if (spaceKey) {
|
|
1484
|
+
cql += ` AND space = "${spaceKey}"`;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
const response = await this.client.get('/search', {
|
|
1488
|
+
params: {
|
|
1489
|
+
cql: cql,
|
|
1490
|
+
limit: 1,
|
|
1491
|
+
expand: 'content.space'
|
|
1492
|
+
}
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
if (response.data.results.length === 0) {
|
|
1496
|
+
throw new Error(`Page not found: "${title}"`);
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
const result = response.data.results[0];
|
|
1500
|
+
const content = result.content || result;
|
|
1501
|
+
|
|
1502
|
+
return {
|
|
1503
|
+
id: content.id,
|
|
1504
|
+
title: content.title,
|
|
1505
|
+
space: content.space || { key: spaceKey || 'Unknown', name: 'Unknown' },
|
|
1506
|
+
url: content._links?.webui || ''
|
|
1507
|
+
};
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
/**
|
|
1511
|
+
* Get child pages of a given page
|
|
1512
|
+
*/
|
|
1513
|
+
async getChildPages(pageId, limit = 500) {
|
|
1514
|
+
const response = await this.client.get(`/content/${pageId}/child/page`, {
|
|
1515
|
+
params: {
|
|
1516
|
+
limit: limit,
|
|
1517
|
+
// Fetch lightweight payload; content fetched on-demand when copying
|
|
1518
|
+
expand: 'space,version'
|
|
1519
|
+
}
|
|
1520
|
+
});
|
|
1521
|
+
|
|
1522
|
+
return response.data.results.map(page => ({
|
|
1523
|
+
id: page.id,
|
|
1524
|
+
title: page.title,
|
|
1525
|
+
type: page.type,
|
|
1526
|
+
status: page.status,
|
|
1527
|
+
space: page.space,
|
|
1528
|
+
version: page.version?.number || 1
|
|
1529
|
+
}));
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
/**
|
|
1533
|
+
* Get all descendant pages recursively
|
|
1534
|
+
*/
|
|
1535
|
+
async getAllDescendantPages(pageId, maxDepth = 10, currentDepth = 0) {
|
|
1536
|
+
if (currentDepth >= maxDepth) {
|
|
1537
|
+
return [];
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
const children = await this.getChildPages(pageId);
|
|
1541
|
+
// Attach parentId so we can later reconstruct hierarchy if needed
|
|
1542
|
+
const childrenWithParent = children.map(child => ({ ...child, parentId: pageId }));
|
|
1543
|
+
let allDescendants = [...childrenWithParent];
|
|
1544
|
+
|
|
1545
|
+
for (const child of children) {
|
|
1546
|
+
const grandChildren = await this.getAllDescendantPages(
|
|
1547
|
+
child.id,
|
|
1548
|
+
maxDepth,
|
|
1549
|
+
currentDepth + 1
|
|
1550
|
+
);
|
|
1551
|
+
allDescendants = allDescendants.concat(grandChildren);
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
return allDescendants;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
/**
|
|
1558
|
+
* Copy a page tree (page and all its descendants) to a new location
|
|
1559
|
+
*/
|
|
1560
|
+
async copyPageTree(sourcePageId, targetParentId, newTitle = null, options = {}) {
|
|
1561
|
+
const {
|
|
1562
|
+
maxDepth = 10,
|
|
1563
|
+
excludePatterns = [],
|
|
1564
|
+
onProgress = null,
|
|
1565
|
+
quiet = false,
|
|
1566
|
+
delayMs = 100,
|
|
1567
|
+
copySuffix = ' (Copy)'
|
|
1568
|
+
} = options;
|
|
1569
|
+
|
|
1570
|
+
// Get source page information
|
|
1571
|
+
const sourcePage = await this.getPageForEdit(sourcePageId);
|
|
1572
|
+
const sourceInfo = await this.getPageInfo(sourcePageId);
|
|
1573
|
+
|
|
1574
|
+
// Determine new title
|
|
1575
|
+
const finalTitle = newTitle || `${sourcePage.title}${copySuffix}`;
|
|
1576
|
+
|
|
1577
|
+
if (!quiet && onProgress) {
|
|
1578
|
+
onProgress(`Copying root: ${sourcePage.title} -> ${finalTitle}`);
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
// Create the root copied page
|
|
1582
|
+
const newRootPage = await this.createChildPage(
|
|
1583
|
+
finalTitle,
|
|
1584
|
+
sourceInfo.space.key,
|
|
1585
|
+
targetParentId,
|
|
1586
|
+
sourcePage.content,
|
|
1587
|
+
'storage'
|
|
1588
|
+
);
|
|
1589
|
+
|
|
1590
|
+
if (!quiet && onProgress) {
|
|
1591
|
+
onProgress(`Root page created: ${newRootPage.title} (ID: ${newRootPage.id})`);
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
const result = {
|
|
1595
|
+
rootPage: newRootPage,
|
|
1596
|
+
copiedPages: [newRootPage],
|
|
1597
|
+
failures: [],
|
|
1598
|
+
totalCopied: 1,
|
|
1599
|
+
};
|
|
1600
|
+
|
|
1601
|
+
// Precompile exclude patterns once for efficiency
|
|
1602
|
+
const compiledExclude = Array.isArray(excludePatterns)
|
|
1603
|
+
? excludePatterns.filter(Boolean).map(p => this.globToRegExp(p))
|
|
1604
|
+
: [];
|
|
1605
|
+
|
|
1606
|
+
await this.copyChildrenRecursive(
|
|
1607
|
+
sourcePageId,
|
|
1608
|
+
newRootPage.id,
|
|
1609
|
+
0,
|
|
1610
|
+
{
|
|
1611
|
+
spaceKey: sourceInfo.space.key,
|
|
1612
|
+
maxDepth,
|
|
1613
|
+
excludePatterns,
|
|
1614
|
+
compiledExclude,
|
|
1615
|
+
onProgress,
|
|
1616
|
+
quiet,
|
|
1617
|
+
delayMs,
|
|
1618
|
+
},
|
|
1619
|
+
result
|
|
1620
|
+
);
|
|
1621
|
+
|
|
1622
|
+
result.totalCopied = result.copiedPages.length;
|
|
1623
|
+
return result;
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
/**
|
|
1627
|
+
* Build a tree structure from flat array of pages
|
|
1628
|
+
*/
|
|
1629
|
+
buildPageTree(pages, rootPageId) {
|
|
1630
|
+
const pageMap = new Map();
|
|
1631
|
+
const tree = [];
|
|
1632
|
+
|
|
1633
|
+
// Create nodes
|
|
1634
|
+
pages.forEach(page => {
|
|
1635
|
+
pageMap.set(page.id, { ...page, children: [] });
|
|
1636
|
+
});
|
|
1637
|
+
|
|
1638
|
+
// Link by parentId if available; otherwise attach to root
|
|
1639
|
+
pages.forEach(page => {
|
|
1640
|
+
const node = pageMap.get(page.id);
|
|
1641
|
+
const parentId = page.parentId;
|
|
1642
|
+
if (parentId && pageMap.has(parentId)) {
|
|
1643
|
+
pageMap.get(parentId).children.push(node);
|
|
1644
|
+
} else if (parentId === rootPageId || !parentId) {
|
|
1645
|
+
tree.push(node);
|
|
1646
|
+
} else {
|
|
1647
|
+
// Parent not present in the list; treat as top-level under root
|
|
1648
|
+
tree.push(node);
|
|
1649
|
+
}
|
|
1650
|
+
});
|
|
1651
|
+
|
|
1652
|
+
return tree;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
/**
|
|
1656
|
+
* Recursively copy pages maintaining hierarchy
|
|
1657
|
+
*/
|
|
1658
|
+
async copyChildrenRecursive(sourceParentId, targetParentId, currentDepth, opts, result) {
|
|
1659
|
+
const { spaceKey, maxDepth, excludePatterns, compiledExclude = [], onProgress, quiet, delayMs = 100 } = opts || {};
|
|
1660
|
+
|
|
1661
|
+
if (currentDepth >= maxDepth) {
|
|
1662
|
+
return;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
const children = await this.getChildPages(sourceParentId);
|
|
1666
|
+
for (let i = 0; i < children.length; i++) {
|
|
1667
|
+
const child = children[i];
|
|
1668
|
+
const patterns = (compiledExclude && compiledExclude.length) ? compiledExclude : excludePatterns;
|
|
1669
|
+
if (this.shouldExcludePage(child.title, patterns)) {
|
|
1670
|
+
if (!quiet && onProgress) {
|
|
1671
|
+
onProgress(`Skipped: ${child.title}`);
|
|
1672
|
+
}
|
|
1673
|
+
continue;
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
if (!quiet && onProgress) {
|
|
1677
|
+
onProgress(`Copying: ${child.title}`);
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
try {
|
|
1681
|
+
// Fetch full content to ensure complete copy
|
|
1682
|
+
const fullChild = await this.getPageForEdit(child.id);
|
|
1683
|
+
const newPage = await this.createChildPage(
|
|
1684
|
+
fullChild.title,
|
|
1685
|
+
spaceKey,
|
|
1686
|
+
targetParentId,
|
|
1687
|
+
fullChild.content,
|
|
1688
|
+
'storage'
|
|
1689
|
+
);
|
|
1690
|
+
|
|
1691
|
+
result.copiedPages.push(newPage);
|
|
1692
|
+
if (!quiet && onProgress) {
|
|
1693
|
+
onProgress(`Created: ${newPage.title} (ID: ${newPage.id})`);
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// Rate limiting safety: only pause between siblings
|
|
1697
|
+
if (delayMs > 0 && i < children.length - 1) {
|
|
1698
|
+
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
// Recurse into this child's subtree
|
|
1702
|
+
await this.copyChildrenRecursive(child.id, newPage.id, currentDepth + 1, opts, result);
|
|
1703
|
+
} catch (error) {
|
|
1704
|
+
if (!quiet && onProgress) {
|
|
1705
|
+
const status = error?.response?.status;
|
|
1706
|
+
const statusText = error?.response?.statusText;
|
|
1707
|
+
const msg = status ? `${status} ${statusText || ''}`.trim() : error.message;
|
|
1708
|
+
onProgress(`Failed: ${child.title} - ${msg}`);
|
|
1709
|
+
}
|
|
1710
|
+
result.failures.push({
|
|
1711
|
+
id: child.id,
|
|
1712
|
+
title: child.title,
|
|
1713
|
+
error: error.message,
|
|
1714
|
+
status: error?.response?.status || null
|
|
1715
|
+
});
|
|
1716
|
+
// Continue with other pages (do not throw)
|
|
1717
|
+
continue;
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
/**
|
|
1723
|
+
* Convert a simple glob pattern to a safe RegExp
|
|
1724
|
+
* Supports '*' → '.*' and '?' → '.', escapes other regex metacharacters.
|
|
1725
|
+
*/
|
|
1726
|
+
globToRegExp(pattern, flags = 'i') {
|
|
1727
|
+
// Escape regex special characters: . + ^ $ { } ( ) | [ ] \
|
|
1728
|
+
// Note: backslash must be escaped properly in string and class contexts
|
|
1729
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
1730
|
+
const regexPattern = escaped
|
|
1731
|
+
.replace(/\*/g, '.*')
|
|
1732
|
+
.replace(/\?/g, '.');
|
|
1733
|
+
return new RegExp(`^${regexPattern}$`, flags);
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
/**
|
|
1737
|
+
* Check if a page should be excluded based on patterns
|
|
1738
|
+
*/
|
|
1739
|
+
shouldExcludePage(title, excludePatterns) {
|
|
1740
|
+
if (!excludePatterns || excludePatterns.length === 0) {
|
|
1741
|
+
return false;
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
return excludePatterns.some(pattern => {
|
|
1745
|
+
if (pattern instanceof RegExp) return pattern.test(title);
|
|
1746
|
+
return this.globToRegExp(pattern).test(title);
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
matchesPattern(value, patterns) {
|
|
1751
|
+
if (!patterns) {
|
|
1752
|
+
return true;
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
const list = Array.isArray(patterns) ? patterns.filter(Boolean) : [patterns];
|
|
1756
|
+
if (list.length === 0) {
|
|
1757
|
+
return true;
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
return list.some((pattern) => this.globToRegExp(pattern).test(value));
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
normalizeAttachment(raw) {
|
|
1764
|
+
return {
|
|
1765
|
+
id: raw.id,
|
|
1766
|
+
title: raw.title,
|
|
1767
|
+
mediaType: raw.metadata?.mediaType || raw.type || '',
|
|
1768
|
+
fileSize: raw.extensions?.fileSize || 0,
|
|
1769
|
+
version: raw.version?.number || 1,
|
|
1770
|
+
downloadLink: this.toAbsoluteUrl(raw._links?.download)
|
|
1771
|
+
};
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
toAbsoluteUrl(pathOrUrl) {
|
|
1775
|
+
if (!pathOrUrl) {
|
|
1776
|
+
return null;
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
if (pathOrUrl.startsWith('http://') || pathOrUrl.startsWith('https://')) {
|
|
1780
|
+
return pathOrUrl;
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
const normalized = pathOrUrl.startsWith('/') ? pathOrUrl : `/${pathOrUrl}`;
|
|
1784
|
+
return `https://${this.domain}${normalized}`;
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
parseNextStart(nextLink) {
|
|
1788
|
+
if (!nextLink) {
|
|
1789
|
+
return null;
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
const match = nextLink.match(/[?&]start=(\d+)/);
|
|
1793
|
+
if (!match) {
|
|
1794
|
+
return null;
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
const value = parseInt(match[1], 10);
|
|
1798
|
+
return Number.isNaN(value) ? null : value;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
parsePositiveInt(value, fallback) {
|
|
1802
|
+
const parsed = parseInt(value, 10);
|
|
1803
|
+
if (Number.isNaN(parsed) || parsed < 0) {
|
|
1804
|
+
return fallback;
|
|
1805
|
+
}
|
|
1806
|
+
return parsed;
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
module.exports = ConfluenceClient;
|