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 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
- - [ ] Comments and reviews
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 config = getConfig();
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 config = getConfig();
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 config = getConfig();
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]')
@@ -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
+ ];
@@ -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.13.0",
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": "^8.55.0",
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');