confluence-cli 1.13.0 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/README.md +33 -1
- package/bin/confluence.js +360 -6
- package/eslint.config.js +33 -0
- package/lib/confluence-client.js +257 -0
- package/package.json +2 -2
- package/tests/confluence-client.test.js +74 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [1.14.0](https://github.com/pchuri/confluence-cli/compare/v1.13.0...v1.14.0) (2026-02-03)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* add comments support to CLI ([d40de55](https://github.com/pchuri/confluence-cli/commit/d40de55573aa71409b3aa2743531f2a4cb5a4eda)), closes [#28](https://github.com/pchuri/confluence-cli/issues/28)
|
|
7
|
+
|
|
1
8
|
# [1.13.0](https://github.com/pchuri/confluence-cli/compare/v1.12.1...v1.13.0) (2026-01-08)
|
|
2
9
|
|
|
3
10
|
|
package/README.md
CHANGED
|
@@ -12,6 +12,7 @@ A powerful command-line interface for Atlassian Confluence that allows you to re
|
|
|
12
12
|
- 📝 **Update pages** - Update existing page content and titles
|
|
13
13
|
- 🗑️ **Delete pages** - Delete (or move to trash) pages by ID or URL
|
|
14
14
|
- 📎 **Attachments** - List or download page attachments
|
|
15
|
+
- 💬 **Comments** - List, create, and delete page comments (footer or inline)
|
|
15
16
|
- 📦 **Export** - Save a page and its attachments to a local folder
|
|
16
17
|
- 🛠️ **Edit workflow** - Export page content for editing and re-import
|
|
17
18
|
- 🔧 **Easy setup** - Simple configuration with environment variables or interactive setup
|
|
@@ -127,6 +128,33 @@ confluence attachments 123456789 --pattern "*.png" --limit 5
|
|
|
127
128
|
confluence attachments 123456789 --pattern "*.png" --download --dest ./downloads
|
|
128
129
|
```
|
|
129
130
|
|
|
131
|
+
### Comments
|
|
132
|
+
```bash
|
|
133
|
+
# List all comments (footer + inline)
|
|
134
|
+
confluence comments 123456789
|
|
135
|
+
|
|
136
|
+
# List inline comments as markdown
|
|
137
|
+
confluence comments 123456789 --location inline --format markdown
|
|
138
|
+
|
|
139
|
+
# Create a footer comment
|
|
140
|
+
confluence comment 123456789 --content "Looks good to me!"
|
|
141
|
+
|
|
142
|
+
# Create an inline comment
|
|
143
|
+
confluence comment 123456789 \
|
|
144
|
+
--location inline \
|
|
145
|
+
--content "Consider renaming this" \
|
|
146
|
+
--inline-selection "foo" \
|
|
147
|
+
--inline-original-selection "foo"
|
|
148
|
+
|
|
149
|
+
# Reply to a comment
|
|
150
|
+
confluence comment 123456789 --parent 998877 --content "Agree with this"
|
|
151
|
+
|
|
152
|
+
# Delete a comment
|
|
153
|
+
confluence comment-delete 998877
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Inline comment creation note (Confluence Cloud): Creating inline comments requires editor-generated highlight metadata (`matchIndex`, `lastFetchTime`, `serializedHighlights`, plus the selection text). The public REST API does not provide these fields, so inline creation and inline replies can fail with a 400 unless you supply the full `--inline-properties` payload captured from the editor. Footer comments and replies are fully supported.
|
|
157
|
+
|
|
130
158
|
### Export a Page with Attachments
|
|
131
159
|
```bash
|
|
132
160
|
# Export page content (markdown by default) and all attachments
|
|
@@ -284,6 +312,9 @@ confluence stats
|
|
|
284
312
|
| `delete <pageId_or_url>` | Delete a page by ID or URL | `--yes` |
|
|
285
313
|
| `edit <pageId>` | Export page content for editing | `--output <file>` |
|
|
286
314
|
| `attachments <pageId_or_url>` | List or download attachments for a page | `--limit <number>`, `--pattern <glob>`, `--download`, `--dest <directory>` |
|
|
315
|
+
| `comments <pageId_or_url>` | List comments for a page | `--format <text\|markdown\|json>`, `--limit <number>`, `--start <number>`, `--location <inline\|footer\|resolved>`, `--depth <root\|all>`, `--all` |
|
|
316
|
+
| `comment <pageId_or_url>` | Create a comment on a page | `--content <string>`, `--file <path>`, `--format <storage\|html\|markdown>`, `--parent <commentId>`, `--location <inline\|footer>`, `--inline-selection <text>`, `--inline-original-selection <text>`, `--inline-marker-ref <ref>`, `--inline-properties <json>` |
|
|
317
|
+
| `comment-delete <commentId>` | Delete a comment by ID | `--yes` |
|
|
287
318
|
| `export <pageId_or_url>` | Export a page to a directory with its attachments | `--format <html\|text\|markdown>`, `--dest <directory>`, `--file <filename>`, `--attachments-dir <name>`, `--pattern <glob>`, `--referenced-only`, `--skip-attachments` |
|
|
288
319
|
| `stats` | View your usage statistics | |
|
|
289
320
|
|
|
@@ -352,7 +383,8 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
|
|
352
383
|
- [ ] Export pages to different formats
|
|
353
384
|
- [ ] Integration with other Atlassian tools (Jira)
|
|
354
385
|
- [ ] Page attachments management
|
|
355
|
-
- [
|
|
386
|
+
- [x] Comments
|
|
387
|
+
- [ ] Reviews
|
|
356
388
|
|
|
357
389
|
## Support & Feedback
|
|
358
390
|
|
package/bin/confluence.js
CHANGED
|
@@ -29,8 +29,7 @@ program
|
|
|
29
29
|
.action(async (pageId, options) => {
|
|
30
30
|
const analytics = new Analytics();
|
|
31
31
|
try {
|
|
32
|
-
const
|
|
33
|
-
const client = new ConfluenceClient(config);
|
|
32
|
+
const client = new ConfluenceClient(getConfig());
|
|
34
33
|
const content = await client.readPage(pageId, options.format);
|
|
35
34
|
console.log(content);
|
|
36
35
|
analytics.track('read', true);
|
|
@@ -48,8 +47,7 @@ program
|
|
|
48
47
|
.action(async (pageId) => {
|
|
49
48
|
const analytics = new Analytics();
|
|
50
49
|
try {
|
|
51
|
-
const
|
|
52
|
-
const client = new ConfluenceClient(config);
|
|
50
|
+
const client = new ConfluenceClient(getConfig());
|
|
53
51
|
const info = await client.getPageInfo(pageId);
|
|
54
52
|
console.log(chalk.blue('Page Information:'));
|
|
55
53
|
console.log(`Title: ${chalk.green(info.title)}`);
|
|
@@ -75,8 +73,7 @@ program
|
|
|
75
73
|
.action(async (query, options) => {
|
|
76
74
|
const analytics = new Analytics();
|
|
77
75
|
try {
|
|
78
|
-
const
|
|
79
|
-
const client = new ConfluenceClient(config);
|
|
76
|
+
const client = new ConfluenceClient(getConfig());
|
|
80
77
|
const results = await client.search(query, parseInt(options.limit));
|
|
81
78
|
|
|
82
79
|
if (results.length === 0) {
|
|
@@ -466,6 +463,320 @@ program
|
|
|
466
463
|
}
|
|
467
464
|
});
|
|
468
465
|
|
|
466
|
+
// Comments command
|
|
467
|
+
program
|
|
468
|
+
.command('comments <pageId>')
|
|
469
|
+
.description('List comments for a page by ID or URL')
|
|
470
|
+
.option('-f, --format <format>', 'Output format (text, markdown, json)', 'text')
|
|
471
|
+
.option('-l, --limit <limit>', 'Maximum number of comments to fetch (default: 25)')
|
|
472
|
+
.option('--start <start>', 'Start index for results (default: 0)', '0')
|
|
473
|
+
.option('--location <location>', 'Filter by location (inline, footer, resolved). Comma-separated')
|
|
474
|
+
.option('--depth <depth>', 'Comment depth ("" for root only, "all")')
|
|
475
|
+
.option('--all', 'Fetch all comments (ignores pagination)')
|
|
476
|
+
.action(async (pageId, options) => {
|
|
477
|
+
const analytics = new Analytics();
|
|
478
|
+
try {
|
|
479
|
+
const config = getConfig();
|
|
480
|
+
const client = new ConfluenceClient(config);
|
|
481
|
+
|
|
482
|
+
const format = (options.format || 'text').toLowerCase();
|
|
483
|
+
if (!['text', 'markdown', 'json'].includes(format)) {
|
|
484
|
+
throw new Error('Format must be one of: text, markdown, json');
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const limit = options.limit ? parseInt(options.limit, 10) : null;
|
|
488
|
+
if (options.limit && (Number.isNaN(limit) || limit <= 0)) {
|
|
489
|
+
throw new Error('Limit must be a positive number.');
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const start = options.start ? parseInt(options.start, 10) : 0;
|
|
493
|
+
if (options.start && (Number.isNaN(start) || start < 0)) {
|
|
494
|
+
throw new Error('Start must be a non-negative number.');
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const locationValues = parseLocationOptions(options.location);
|
|
498
|
+
const invalidLocations = locationValues.filter(value => !['inline', 'footer', 'resolved'].includes(value));
|
|
499
|
+
if (invalidLocations.length > 0) {
|
|
500
|
+
throw new Error(`Invalid location value(s): ${invalidLocations.join(', ')}`);
|
|
501
|
+
}
|
|
502
|
+
const locationParam = locationValues.length === 0
|
|
503
|
+
? null
|
|
504
|
+
: (locationValues.length === 1 ? locationValues[0] : locationValues);
|
|
505
|
+
|
|
506
|
+
let comments = [];
|
|
507
|
+
let nextStart = null;
|
|
508
|
+
|
|
509
|
+
if (options.all) {
|
|
510
|
+
comments = await client.getAllComments(pageId, {
|
|
511
|
+
maxResults: limit || null,
|
|
512
|
+
start,
|
|
513
|
+
location: locationParam,
|
|
514
|
+
depth: options.depth
|
|
515
|
+
});
|
|
516
|
+
} else {
|
|
517
|
+
const response = await client.listComments(pageId, {
|
|
518
|
+
limit: limit || undefined,
|
|
519
|
+
start,
|
|
520
|
+
location: locationParam,
|
|
521
|
+
depth: options.depth
|
|
522
|
+
});
|
|
523
|
+
comments = response.results;
|
|
524
|
+
nextStart = response.nextStart;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (comments.length === 0) {
|
|
528
|
+
console.log(chalk.yellow('No comments found.'));
|
|
529
|
+
analytics.track('comments', true);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (format === 'json') {
|
|
534
|
+
const resolvedPageId = await client.extractPageId(pageId);
|
|
535
|
+
const output = {
|
|
536
|
+
pageId: resolvedPageId,
|
|
537
|
+
commentCount: comments.length,
|
|
538
|
+
comments: comments.map(comment => ({
|
|
539
|
+
...comment,
|
|
540
|
+
bodyStorage: comment.body,
|
|
541
|
+
bodyText: client.formatCommentBody(comment.body, 'text')
|
|
542
|
+
}))
|
|
543
|
+
};
|
|
544
|
+
if (!options.all) {
|
|
545
|
+
output.nextStart = nextStart;
|
|
546
|
+
}
|
|
547
|
+
console.log(JSON.stringify(output, null, 2));
|
|
548
|
+
analytics.track('comments', true);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const commentTree = buildCommentTree(comments);
|
|
553
|
+
console.log(chalk.blue(`Found ${comments.length} comment${comments.length === 1 ? '' : 's'}:`));
|
|
554
|
+
|
|
555
|
+
const renderComments = (nodes, path = []) => {
|
|
556
|
+
nodes.forEach((comment, index) => {
|
|
557
|
+
const currentPath = [...path, index + 1];
|
|
558
|
+
const level = currentPath.length - 1;
|
|
559
|
+
const indent = ' '.repeat(level);
|
|
560
|
+
const branchGlyph = level > 0 ? (index === nodes.length - 1 ? '└─ ' : '├─ ') : '';
|
|
561
|
+
const headerPrefix = `${indent}${chalk.dim(branchGlyph)}`;
|
|
562
|
+
const bodyIndent = level === 0
|
|
563
|
+
? ' '
|
|
564
|
+
: `${indent}${' '.repeat(branchGlyph.length)}`;
|
|
565
|
+
|
|
566
|
+
const isReply = Boolean(comment.parentId);
|
|
567
|
+
const location = comment.location || 'unknown';
|
|
568
|
+
const author = comment.author?.displayName || 'Unknown';
|
|
569
|
+
const createdAt = comment.createdAt || 'unknown date';
|
|
570
|
+
const metaParts = [`Created: ${createdAt}`];
|
|
571
|
+
if (comment.status) metaParts.push(`Status: ${comment.status}`);
|
|
572
|
+
if (comment.version) metaParts.push(`Version: ${comment.version}`);
|
|
573
|
+
if (!isReply && comment.resolution) metaParts.push(`Resolution: ${comment.resolution}`);
|
|
574
|
+
|
|
575
|
+
const label = isReply ? chalk.gray('[reply]') : chalk.cyan(`[${location}]`);
|
|
576
|
+
console.log(`${headerPrefix}${currentPath.join('.')}. ${chalk.green(author)} ${chalk.gray(`(ID: ${comment.id})`)} ${label}`);
|
|
577
|
+
console.log(chalk.dim(`${bodyIndent}${metaParts.join(' • ')}`));
|
|
578
|
+
|
|
579
|
+
if (!isReply) {
|
|
580
|
+
const inlineProps = comment.inlineProperties || {};
|
|
581
|
+
const selectionText = inlineProps.selection || inlineProps.originalSelection;
|
|
582
|
+
if (selectionText) {
|
|
583
|
+
const selectionLabel = inlineProps.selection ? 'Highlight' : 'Highlight (original)';
|
|
584
|
+
console.log(chalk.dim(`${bodyIndent}${selectionLabel}: ${selectionText}`));
|
|
585
|
+
}
|
|
586
|
+
if (inlineProps.markerRef) {
|
|
587
|
+
console.log(chalk.dim(`${bodyIndent}Marker ref: ${inlineProps.markerRef}`));
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const body = client.formatCommentBody(comment.body, format);
|
|
592
|
+
if (body) {
|
|
593
|
+
console.log(`${bodyIndent}${chalk.yellowBright('Body:')}`);
|
|
594
|
+
console.log(formatBodyBlock(body, `${bodyIndent} `));
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (comment.children && comment.children.length > 0) {
|
|
598
|
+
renderComments(comment.children, currentPath);
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
renderComments(commentTree);
|
|
604
|
+
|
|
605
|
+
if (!options.all && nextStart !== null && nextStart !== undefined) {
|
|
606
|
+
console.log(chalk.gray(`Next start: ${nextStart}`));
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
analytics.track('comments', true);
|
|
610
|
+
} catch (error) {
|
|
611
|
+
analytics.track('comments', false);
|
|
612
|
+
console.error(chalk.red('Error:'), error.message);
|
|
613
|
+
process.exit(1);
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
// Comment creation command
|
|
618
|
+
program
|
|
619
|
+
.command('comment <pageId>')
|
|
620
|
+
.description('Create a comment on a page by ID or URL (footer or inline)')
|
|
621
|
+
.option('-f, --file <file>', 'Read content from file')
|
|
622
|
+
.option('-c, --content <content>', 'Comment content as string')
|
|
623
|
+
.option('--format <format>', 'Content format (storage, html, markdown)', 'storage')
|
|
624
|
+
.option('--parent <commentId>', 'Reply to a comment by ID')
|
|
625
|
+
.option('--location <location>', 'Comment location (inline or footer)', 'footer')
|
|
626
|
+
.option('--inline-selection <text>', 'Inline selection text')
|
|
627
|
+
.option('--inline-original-selection <text>', 'Original inline selection text')
|
|
628
|
+
.option('--inline-marker-ref <ref>', 'Inline marker reference (optional)')
|
|
629
|
+
.option('--inline-properties <json>', 'Inline properties JSON (advanced)')
|
|
630
|
+
.action(async (pageId, options) => {
|
|
631
|
+
const analytics = new Analytics();
|
|
632
|
+
let location = null;
|
|
633
|
+
try {
|
|
634
|
+
const config = getConfig();
|
|
635
|
+
const client = new ConfluenceClient(config);
|
|
636
|
+
|
|
637
|
+
let content = '';
|
|
638
|
+
|
|
639
|
+
if (options.file) {
|
|
640
|
+
const fs = require('fs');
|
|
641
|
+
if (!fs.existsSync(options.file)) {
|
|
642
|
+
throw new Error(`File not found: ${options.file}`);
|
|
643
|
+
}
|
|
644
|
+
content = fs.readFileSync(options.file, 'utf8');
|
|
645
|
+
} else if (options.content) {
|
|
646
|
+
content = options.content;
|
|
647
|
+
} else {
|
|
648
|
+
throw new Error('Either --file or --content option is required');
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
location = (options.location || 'footer').toLowerCase();
|
|
652
|
+
if (!['inline', 'footer'].includes(location)) {
|
|
653
|
+
throw new Error('Location must be either "inline" or "footer".');
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
let inlineProperties = {};
|
|
657
|
+
if (options.inlineProperties) {
|
|
658
|
+
try {
|
|
659
|
+
const parsed = JSON.parse(options.inlineProperties);
|
|
660
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
661
|
+
throw new Error('Inline properties must be a JSON object.');
|
|
662
|
+
}
|
|
663
|
+
inlineProperties = { ...parsed };
|
|
664
|
+
} catch (error) {
|
|
665
|
+
throw new Error(`Invalid --inline-properties JSON: ${error.message}`);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (options.inlineSelection) {
|
|
670
|
+
inlineProperties.selection = options.inlineSelection;
|
|
671
|
+
}
|
|
672
|
+
if (options.inlineOriginalSelection) {
|
|
673
|
+
inlineProperties.originalSelection = options.inlineOriginalSelection;
|
|
674
|
+
}
|
|
675
|
+
if (options.inlineMarkerRef) {
|
|
676
|
+
inlineProperties.markerRef = options.inlineMarkerRef;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (Object.keys(inlineProperties).length > 0 && location !== 'inline') {
|
|
680
|
+
throw new Error('Inline properties can only be used with --location inline.');
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const parentId = options.parent;
|
|
684
|
+
|
|
685
|
+
if (location === 'inline') {
|
|
686
|
+
const hasSelection = inlineProperties.selection || inlineProperties.originalSelection;
|
|
687
|
+
if (!hasSelection && !parentId) {
|
|
688
|
+
throw new Error('Inline comments require --inline-selection or --inline-original-selection when starting a new inline thread.');
|
|
689
|
+
}
|
|
690
|
+
if (hasSelection) {
|
|
691
|
+
if (!inlineProperties.originalSelection && inlineProperties.selection) {
|
|
692
|
+
inlineProperties.originalSelection = inlineProperties.selection;
|
|
693
|
+
}
|
|
694
|
+
if (!inlineProperties.markerRef) {
|
|
695
|
+
inlineProperties.markerRef = `comment-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const result = await client.createComment(pageId, content, options.format, {
|
|
701
|
+
parentId,
|
|
702
|
+
location,
|
|
703
|
+
inlineProperties: location === 'inline' ? inlineProperties : null
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
console.log(chalk.green('✅ Comment created successfully!'));
|
|
707
|
+
console.log(`ID: ${chalk.blue(result.id)}`);
|
|
708
|
+
if (result.container?.id) {
|
|
709
|
+
console.log(`Page ID: ${chalk.blue(result.container.id)}`);
|
|
710
|
+
}
|
|
711
|
+
if (result._links?.webui) {
|
|
712
|
+
const url = client.toAbsoluteUrl(result._links.webui);
|
|
713
|
+
console.log(`URL: ${chalk.gray(url)}`);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
analytics.track('comment_create', true);
|
|
717
|
+
} catch (error) {
|
|
718
|
+
analytics.track('comment_create', false);
|
|
719
|
+
console.error(chalk.red('Error:'), error.message);
|
|
720
|
+
if (error.response?.data) {
|
|
721
|
+
const detail = typeof error.response.data === 'string'
|
|
722
|
+
? error.response.data
|
|
723
|
+
: JSON.stringify(error.response.data, null, 2);
|
|
724
|
+
console.error(chalk.red('API response:'), detail);
|
|
725
|
+
}
|
|
726
|
+
const apiErrors = error.response?.data?.data?.errors || error.response?.data?.errors || [];
|
|
727
|
+
const errorKeys = apiErrors
|
|
728
|
+
.map((entry) => entry?.message?.key || entry?.message || entry?.key)
|
|
729
|
+
.filter(Boolean);
|
|
730
|
+
const needsInlineMeta = ['matchIndex', 'lastFetchTime', 'serializedHighlights']
|
|
731
|
+
.every((key) => errorKeys.includes(key));
|
|
732
|
+
if (location === 'inline' && needsInlineMeta) {
|
|
733
|
+
console.error(chalk.yellow('Inline comment creation requires editor highlight metadata (matchIndex, lastFetchTime, serializedHighlights).'));
|
|
734
|
+
console.error(chalk.yellow('Try replying to an existing inline comment or use footer comments instead.'));
|
|
735
|
+
}
|
|
736
|
+
process.exit(1);
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
// Comment delete command
|
|
741
|
+
program
|
|
742
|
+
.command('comment-delete <commentId>')
|
|
743
|
+
.description('Delete a comment by ID')
|
|
744
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
745
|
+
.action(async (commentId, options) => {
|
|
746
|
+
const analytics = new Analytics();
|
|
747
|
+
try {
|
|
748
|
+
const config = getConfig();
|
|
749
|
+
const client = new ConfluenceClient(config);
|
|
750
|
+
|
|
751
|
+
if (!options.yes) {
|
|
752
|
+
const { confirmed } = await inquirer.prompt([
|
|
753
|
+
{
|
|
754
|
+
type: 'confirm',
|
|
755
|
+
name: 'confirmed',
|
|
756
|
+
default: false,
|
|
757
|
+
message: `Delete comment ${commentId}?`
|
|
758
|
+
}
|
|
759
|
+
]);
|
|
760
|
+
|
|
761
|
+
if (!confirmed) {
|
|
762
|
+
console.log(chalk.yellow('Cancelled.'));
|
|
763
|
+
analytics.track('comment_delete_cancel', true);
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const result = await client.deleteComment(commentId);
|
|
769
|
+
|
|
770
|
+
console.log(chalk.green('✅ Comment deleted successfully!'));
|
|
771
|
+
console.log(`ID: ${chalk.blue(result.id)}`);
|
|
772
|
+
analytics.track('comment_delete', true);
|
|
773
|
+
} catch (error) {
|
|
774
|
+
analytics.track('comment_delete', false);
|
|
775
|
+
console.error(chalk.red('Error:'), error.message);
|
|
776
|
+
process.exit(1);
|
|
777
|
+
}
|
|
778
|
+
});
|
|
779
|
+
|
|
469
780
|
// Export page content with attachments
|
|
470
781
|
program
|
|
471
782
|
.command('export <pageId>')
|
|
@@ -584,6 +895,49 @@ function sanitizeTitle(value) {
|
|
|
584
895
|
return cleaned || fallback;
|
|
585
896
|
}
|
|
586
897
|
|
|
898
|
+
function parseLocationOptions(raw) {
|
|
899
|
+
if (!raw) {
|
|
900
|
+
return [];
|
|
901
|
+
}
|
|
902
|
+
if (Array.isArray(raw)) {
|
|
903
|
+
return raw.flatMap(item => String(item).split(','))
|
|
904
|
+
.map(value => value.trim().toLowerCase())
|
|
905
|
+
.filter(Boolean);
|
|
906
|
+
}
|
|
907
|
+
return String(raw).split(',').map(value => value.trim().toLowerCase()).filter(Boolean);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function formatBodyBlock(text, indent = '') {
|
|
911
|
+
return text.split('\n').map(line => `${indent}${chalk.white(line)}`).join('\n');
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function buildCommentTree(comments) {
|
|
915
|
+
const nodes = comments.map((comment, index) => ({
|
|
916
|
+
...comment,
|
|
917
|
+
_order: index,
|
|
918
|
+
children: []
|
|
919
|
+
}));
|
|
920
|
+
const byId = new Map(nodes.map(node => [String(node.id), node]));
|
|
921
|
+
const roots = [];
|
|
922
|
+
|
|
923
|
+
nodes.forEach((node) => {
|
|
924
|
+
const parentId = node.parentId ? String(node.parentId) : null;
|
|
925
|
+
if (parentId && byId.has(parentId)) {
|
|
926
|
+
byId.get(parentId).children.push(node);
|
|
927
|
+
} else {
|
|
928
|
+
roots.push(node);
|
|
929
|
+
}
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
const sortNodes = (list) => {
|
|
933
|
+
list.sort((a, b) => a._order - b._order);
|
|
934
|
+
list.forEach((child) => sortNodes(child.children));
|
|
935
|
+
};
|
|
936
|
+
|
|
937
|
+
sortNodes(roots);
|
|
938
|
+
return roots;
|
|
939
|
+
}
|
|
940
|
+
|
|
587
941
|
// Copy page tree command
|
|
588
942
|
program
|
|
589
943
|
.command('copy-tree <sourcePageId> <targetParentId> [newTitle]')
|
package/eslint.config.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const util = require('util');
|
|
2
|
+
const js = require('@eslint/js');
|
|
3
|
+
const globals = require('globals');
|
|
4
|
+
|
|
5
|
+
if (typeof global.structuredClone !== 'function') {
|
|
6
|
+
global.structuredClone = typeof util.structuredClone === 'function'
|
|
7
|
+
? util.structuredClone
|
|
8
|
+
: (value) => JSON.parse(JSON.stringify(value));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
module.exports = [
|
|
12
|
+
js.configs.recommended,
|
|
13
|
+
{
|
|
14
|
+
files: ['**/*.js'],
|
|
15
|
+
languageOptions: {
|
|
16
|
+
ecmaVersion: 2021,
|
|
17
|
+
sourceType: 'module',
|
|
18
|
+
globals: {
|
|
19
|
+
...globals.node,
|
|
20
|
+
...globals.jest
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
rules: {
|
|
24
|
+
indent: ['error', 2],
|
|
25
|
+
'linebreak-style': ['error', 'unix'],
|
|
26
|
+
quotes: ['error', 'single'],
|
|
27
|
+
semi: ['error', 'always'],
|
|
28
|
+
'no-unused-vars': ['error', { argsIgnorePattern: '^_', caughtErrors: 'none' }],
|
|
29
|
+
'no-console': 'off',
|
|
30
|
+
'no-process-exit': 'off'
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
];
|
package/lib/confluence-client.js
CHANGED
|
@@ -449,6 +449,263 @@ class ConfluenceClient {
|
|
|
449
449
|
}
|
|
450
450
|
}
|
|
451
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
|
+
|
|
452
709
|
/**
|
|
453
710
|
* List attachments for a page with pagination support
|
|
454
711
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "confluence-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.0",
|
|
4
4
|
"description": "A command-line interface for Atlassian Confluence with page creation and editing capabilities",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@types/node": "^20.10.0",
|
|
36
36
|
"axios-mock-adapter": "^2.1.0",
|
|
37
|
-
"eslint": "^
|
|
37
|
+
"eslint": "^9.39.2",
|
|
38
38
|
"jest": "^29.7.0"
|
|
39
39
|
},
|
|
40
40
|
"overrides": {
|
|
@@ -361,6 +361,80 @@ describe('ConfluenceClient', () => {
|
|
|
361
361
|
});
|
|
362
362
|
});
|
|
363
363
|
|
|
364
|
+
describe('comments', () => {
|
|
365
|
+
test('should list comments with location filter', async () => {
|
|
366
|
+
const mock = new MockAdapter(client.client);
|
|
367
|
+
mock.onGet('/content/123/child/comment').reply(config => {
|
|
368
|
+
expect(config.params.location).toBe('inline');
|
|
369
|
+
expect(config.params.expand).toContain('body.storage');
|
|
370
|
+
expect(config.params.expand).toContain('ancestors');
|
|
371
|
+
return [200, {
|
|
372
|
+
results: [
|
|
373
|
+
{
|
|
374
|
+
id: 'c1',
|
|
375
|
+
status: 'current',
|
|
376
|
+
body: { storage: { value: '<p>Hello</p>' } },
|
|
377
|
+
history: { createdBy: { displayName: 'Ada' }, createdDate: '2025-01-01' },
|
|
378
|
+
version: { number: 1 },
|
|
379
|
+
ancestors: [{ id: 'c0', type: 'comment' }],
|
|
380
|
+
extensions: {
|
|
381
|
+
location: 'inline',
|
|
382
|
+
inlineProperties: { selection: 'Hello', originalSelection: 'Hello' },
|
|
383
|
+
resolution: { status: 'open' }
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
],
|
|
387
|
+
_links: { next: '/rest/api/content/123/child/comment?start=2' }
|
|
388
|
+
}];
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
const page = await client.listComments('123', { location: 'inline' });
|
|
392
|
+
expect(page.results).toHaveLength(1);
|
|
393
|
+
expect(page.results[0].location).toBe('inline');
|
|
394
|
+
expect(page.results[0].resolution).toBe('open');
|
|
395
|
+
expect(page.results[0].parentId).toBe('c0');
|
|
396
|
+
expect(page.nextStart).toBe(2);
|
|
397
|
+
|
|
398
|
+
mock.restore();
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test('should create inline comment with inline properties', async () => {
|
|
402
|
+
const mock = new MockAdapter(client.client);
|
|
403
|
+
mock.onPost('/content').reply(config => {
|
|
404
|
+
const payload = JSON.parse(config.data);
|
|
405
|
+
expect(payload.type).toBe('comment');
|
|
406
|
+
expect(payload.container.id).toBe('123');
|
|
407
|
+
expect(payload.body.storage.value).toBe('<p>Hi</p>');
|
|
408
|
+
expect(payload.ancestors[0].id).toBe('c0');
|
|
409
|
+
expect(payload.extensions.location).toBe('inline');
|
|
410
|
+
expect(payload.extensions.inlineProperties.originalSelection).toBe('Hi');
|
|
411
|
+
expect(payload.extensions.inlineProperties.markerRef).toBe('comment-1');
|
|
412
|
+
return [200, { id: 'c1', type: 'comment' }];
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
await client.createComment('123', '<p>Hi</p>', 'storage', {
|
|
416
|
+
parentId: 'c0',
|
|
417
|
+
location: 'inline',
|
|
418
|
+
inlineProperties: {
|
|
419
|
+
selection: 'Hi',
|
|
420
|
+
originalSelection: 'Hi',
|
|
421
|
+
markerRef: 'comment-1'
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
mock.restore();
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test('should delete a comment by ID', async () => {
|
|
429
|
+
const mock = new MockAdapter(client.client);
|
|
430
|
+
mock.onDelete('/content/456').reply(204);
|
|
431
|
+
|
|
432
|
+
await expect(client.deleteComment('456')).resolves.toEqual({ id: '456' });
|
|
433
|
+
|
|
434
|
+
mock.restore();
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
|
|
364
438
|
describe('attachments', () => {
|
|
365
439
|
test('should have required methods for attachment handling', () => {
|
|
366
440
|
expect(typeof client.listAttachments).toBe('function');
|