@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.
@@ -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(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/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 `![${filename}](${attachmentsDir}/${filename})`;
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 `![${filename}](${attachmentsDir}/${filename})`;
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(/&nbsp;/g, ' ');
1294
+ markdown = markdown.replace(/&lt;/g, '<');
1295
+ markdown = markdown.replace(/&gt;/g, '>');
1296
+ markdown = markdown.replace(/&amp;/g, '&');
1297
+ markdown = markdown.replace(/&quot;/g, '"');
1298
+ markdown = markdown.replace(/&apos;/g, '\'');
1299
+ // Smart quotes and special characters
1300
+ markdown = markdown.replace(/&ldquo;/g, '"');
1301
+ markdown = markdown.replace(/&rdquo;/g, '"');
1302
+ markdown = markdown.replace(/&lsquo;/g, '\'');
1303
+ markdown = markdown.replace(/&rsquo;/g, '\'');
1304
+ markdown = markdown.replace(/&mdash;/g, '—');
1305
+ markdown = markdown.replace(/&ndash;/g, '–');
1306
+ markdown = markdown.replace(/&hellip;/g, '...');
1307
+ markdown = markdown.replace(/&bull;/g, '•');
1308
+ markdown = markdown.replace(/&copy;/g, '©');
1309
+ markdown = markdown.replace(/&reg;/g, '®');
1310
+ markdown = markdown.replace(/&trade;/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;