confluence-cli 1.13.0 → 1.15.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,17 @@
1
+ # [1.15.0](https://github.com/pchuri/confluence-cli/compare/v1.14.0...v1.15.0) (2026-02-06)
2
+
3
+
4
+ ### Features
5
+
6
+ * Add CLI flags to confluence init for non-interactive setup ([#30](https://github.com/pchuri/confluence-cli/issues/30)) ([09b6b85](https://github.com/pchuri/confluence-cli/commit/09b6b85a243da5ab86eb61a1a2376a64ce6979c7))
7
+
8
+ # [1.14.0](https://github.com/pchuri/confluence-cli/compare/v1.13.0...v1.14.0) (2026-02-03)
9
+
10
+
11
+ ### Features
12
+
13
+ * add comments support to CLI ([d40de55](https://github.com/pchuri/confluence-cli/commit/d40de55573aa71409b3aa2743531f2a4cb5a4eda)), closes [#28](https://github.com/pchuri/confluence-cli/issues/28)
14
+
1
15
  # [1.13.0](https://github.com/pchuri/confluence-cli/compare/v1.12.1...v1.13.0) (2026-01-08)
2
16
 
3
17
 
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
@@ -66,13 +67,45 @@ npx confluence-cli
66
67
  confluence init
67
68
  ```
68
69
 
69
- The wizard now helps you choose the right API endpoint and authentication method. It recommends `/wiki/rest/api` for Atlassian Cloud domains (e.g., `*.atlassian.net`) and `/rest/api` for self-hosted/Data Center instances, then prompts for Basic (email + token) or Bearer authentication.
70
+ The wizard helps you choose the right API endpoint and authentication method. It recommends `/wiki/rest/api` for Atlassian Cloud domains (e.g., `*.atlassian.net`) and `/rest/api` for self-hosted/Data Center instances, then prompts for Basic (email + token) or Bearer authentication.
70
71
 
71
- ### Option 2: Environment Variables
72
+ ### Option 2: Non-interactive Setup (CLI Flags)
73
+
74
+ Provide all required configuration via command-line flags. Perfect for CI/CD pipelines, Docker builds, and AI coding agents.
75
+
76
+ **Complete non-interactive mode** (all required fields provided):
77
+ ```bash
78
+ confluence init \
79
+ --domain "company.atlassian.net" \
80
+ --api-path "/wiki/rest/api" \
81
+ --auth-type "basic" \
82
+ --email "user@example.com" \
83
+ --token "your-api-token"
84
+ ```
85
+
86
+ **Hybrid mode** (some fields provided, rest via prompts):
87
+ ```bash
88
+ # Domain and token provided, will prompt for auth method and email
89
+ confluence init --domain "company.atlassian.net" --token "your-api-token"
90
+
91
+ # Email indicates basic auth, will prompt for domain and token
92
+ confluence init --email "user@example.com" --token "your-api-token"
93
+ ```
94
+
95
+ **Available flags:**
96
+ - `-d, --domain <domain>` - Confluence domain (e.g., `company.atlassian.net`)
97
+ - `-p, --api-path <path>` - REST API path (e.g., `/wiki/rest/api`)
98
+ - `-a, --auth-type <type>` - Authentication type: `basic` or `bearer`
99
+ - `-e, --email <email>` - Email for basic authentication
100
+ - `-t, --token <token>` - API token
101
+
102
+ ⚠️ **Security note:** While flags work, storing tokens in shell history is risky. Prefer environment variables (Option 3) for production environments.
103
+
104
+ ### Option 3: Environment Variables
72
105
  ```bash
73
106
  export CONFLUENCE_DOMAIN="your-domain.atlassian.net"
74
107
  export CONFLUENCE_API_TOKEN="your-api-token"
75
- export CONFLUENCE_EMAIL="your.email@example.com" # required when using Atlassian Cloud
108
+ export CONFLUENCE_EMAIL="your.email@example.com" # required when using basic auth
76
109
  export CONFLUENCE_API_PATH="/wiki/rest/api" # Cloud default; use /rest/api for Server/DC
77
110
  # Optional: set to 'bearer' for self-hosted/Data Center instances
78
111
  export CONFLUENCE_AUTH_TYPE="basic"
@@ -127,6 +160,33 @@ confluence attachments 123456789 --pattern "*.png" --limit 5
127
160
  confluence attachments 123456789 --pattern "*.png" --download --dest ./downloads
128
161
  ```
129
162
 
163
+ ### Comments
164
+ ```bash
165
+ # List all comments (footer + inline)
166
+ confluence comments 123456789
167
+
168
+ # List inline comments as markdown
169
+ confluence comments 123456789 --location inline --format markdown
170
+
171
+ # Create a footer comment
172
+ confluence comment 123456789 --content "Looks good to me!"
173
+
174
+ # Create an inline comment
175
+ confluence comment 123456789 \
176
+ --location inline \
177
+ --content "Consider renaming this" \
178
+ --inline-selection "foo" \
179
+ --inline-original-selection "foo"
180
+
181
+ # Reply to a comment
182
+ confluence comment 123456789 --parent 998877 --content "Agree with this"
183
+
184
+ # Delete a comment
185
+ confluence comment-delete 998877
186
+ ```
187
+
188
+ 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.
189
+
130
190
  ### Export a Page with Attachments
131
191
  ```bash
132
192
  # Export page content (markdown by default) and all attachments
@@ -284,6 +344,9 @@ confluence stats
284
344
  | `delete <pageId_or_url>` | Delete a page by ID or URL | `--yes` |
285
345
  | `edit <pageId>` | Export page content for editing | `--output <file>` |
286
346
  | `attachments <pageId_or_url>` | List or download attachments for a page | `--limit <number>`, `--pattern <glob>`, `--download`, `--dest <directory>` |
347
+ | `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` |
348
+ | `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>` |
349
+ | `comment-delete <commentId>` | Delete a comment by ID | `--yes` |
287
350
  | `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
351
  | `stats` | View your usage statistics | |
289
352
 
@@ -352,7 +415,8 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
352
415
  - [ ] Export pages to different formats
353
416
  - [ ] Integration with other Atlassian tools (Jira)
354
417
  - [ ] Page attachments management
355
- - [ ] Comments and reviews
418
+ - [x] Comments
419
+ - [ ] Reviews
356
420
 
357
421
  ## Support & Feedback
358
422
 
package/bin/confluence.js CHANGED
@@ -17,8 +17,13 @@ program
17
17
  program
18
18
  .command('init')
19
19
  .description('Initialize Confluence CLI configuration')
20
- .action(async () => {
21
- await initConfig();
20
+ .option('-d, --domain <domain>', 'Confluence domain')
21
+ .option('-p, --api-path <path>', 'REST API path')
22
+ .option('-a, --auth-type <type>', 'Authentication type (basic or bearer)')
23
+ .option('-e, --email <email>', 'Email for basic auth')
24
+ .option('-t, --token <token>', 'API token')
25
+ .action(async (options) => {
26
+ await initConfig(options);
22
27
  });
23
28
 
24
29
  // Read command
@@ -29,8 +34,7 @@ program
29
34
  .action(async (pageId, options) => {
30
35
  const analytics = new Analytics();
31
36
  try {
32
- const config = getConfig();
33
- const client = new ConfluenceClient(config);
37
+ const client = new ConfluenceClient(getConfig());
34
38
  const content = await client.readPage(pageId, options.format);
35
39
  console.log(content);
36
40
  analytics.track('read', true);
@@ -48,8 +52,7 @@ program
48
52
  .action(async (pageId) => {
49
53
  const analytics = new Analytics();
50
54
  try {
51
- const config = getConfig();
52
- const client = new ConfluenceClient(config);
55
+ const client = new ConfluenceClient(getConfig());
53
56
  const info = await client.getPageInfo(pageId);
54
57
  console.log(chalk.blue('Page Information:'));
55
58
  console.log(`Title: ${chalk.green(info.title)}`);
@@ -75,8 +78,7 @@ program
75
78
  .action(async (query, options) => {
76
79
  const analytics = new Analytics();
77
80
  try {
78
- const config = getConfig();
79
- const client = new ConfluenceClient(config);
81
+ const client = new ConfluenceClient(getConfig());
80
82
  const results = await client.search(query, parseInt(options.limit));
81
83
 
82
84
  if (results.length === 0) {
@@ -466,6 +468,320 @@ program
466
468
  }
467
469
  });
468
470
 
471
+ // Comments command
472
+ program
473
+ .command('comments <pageId>')
474
+ .description('List comments for a page by ID or URL')
475
+ .option('-f, --format <format>', 'Output format (text, markdown, json)', 'text')
476
+ .option('-l, --limit <limit>', 'Maximum number of comments to fetch (default: 25)')
477
+ .option('--start <start>', 'Start index for results (default: 0)', '0')
478
+ .option('--location <location>', 'Filter by location (inline, footer, resolved). Comma-separated')
479
+ .option('--depth <depth>', 'Comment depth ("" for root only, "all")')
480
+ .option('--all', 'Fetch all comments (ignores pagination)')
481
+ .action(async (pageId, options) => {
482
+ const analytics = new Analytics();
483
+ try {
484
+ const config = getConfig();
485
+ const client = new ConfluenceClient(config);
486
+
487
+ const format = (options.format || 'text').toLowerCase();
488
+ if (!['text', 'markdown', 'json'].includes(format)) {
489
+ throw new Error('Format must be one of: text, markdown, json');
490
+ }
491
+
492
+ const limit = options.limit ? parseInt(options.limit, 10) : null;
493
+ if (options.limit && (Number.isNaN(limit) || limit <= 0)) {
494
+ throw new Error('Limit must be a positive number.');
495
+ }
496
+
497
+ const start = options.start ? parseInt(options.start, 10) : 0;
498
+ if (options.start && (Number.isNaN(start) || start < 0)) {
499
+ throw new Error('Start must be a non-negative number.');
500
+ }
501
+
502
+ const locationValues = parseLocationOptions(options.location);
503
+ const invalidLocations = locationValues.filter(value => !['inline', 'footer', 'resolved'].includes(value));
504
+ if (invalidLocations.length > 0) {
505
+ throw new Error(`Invalid location value(s): ${invalidLocations.join(', ')}`);
506
+ }
507
+ const locationParam = locationValues.length === 0
508
+ ? null
509
+ : (locationValues.length === 1 ? locationValues[0] : locationValues);
510
+
511
+ let comments = [];
512
+ let nextStart = null;
513
+
514
+ if (options.all) {
515
+ comments = await client.getAllComments(pageId, {
516
+ maxResults: limit || null,
517
+ start,
518
+ location: locationParam,
519
+ depth: options.depth
520
+ });
521
+ } else {
522
+ const response = await client.listComments(pageId, {
523
+ limit: limit || undefined,
524
+ start,
525
+ location: locationParam,
526
+ depth: options.depth
527
+ });
528
+ comments = response.results;
529
+ nextStart = response.nextStart;
530
+ }
531
+
532
+ if (comments.length === 0) {
533
+ console.log(chalk.yellow('No comments found.'));
534
+ analytics.track('comments', true);
535
+ return;
536
+ }
537
+
538
+ if (format === 'json') {
539
+ const resolvedPageId = await client.extractPageId(pageId);
540
+ const output = {
541
+ pageId: resolvedPageId,
542
+ commentCount: comments.length,
543
+ comments: comments.map(comment => ({
544
+ ...comment,
545
+ bodyStorage: comment.body,
546
+ bodyText: client.formatCommentBody(comment.body, 'text')
547
+ }))
548
+ };
549
+ if (!options.all) {
550
+ output.nextStart = nextStart;
551
+ }
552
+ console.log(JSON.stringify(output, null, 2));
553
+ analytics.track('comments', true);
554
+ return;
555
+ }
556
+
557
+ const commentTree = buildCommentTree(comments);
558
+ console.log(chalk.blue(`Found ${comments.length} comment${comments.length === 1 ? '' : 's'}:`));
559
+
560
+ const renderComments = (nodes, path = []) => {
561
+ nodes.forEach((comment, index) => {
562
+ const currentPath = [...path, index + 1];
563
+ const level = currentPath.length - 1;
564
+ const indent = ' '.repeat(level);
565
+ const branchGlyph = level > 0 ? (index === nodes.length - 1 ? '└─ ' : '├─ ') : '';
566
+ const headerPrefix = `${indent}${chalk.dim(branchGlyph)}`;
567
+ const bodyIndent = level === 0
568
+ ? ' '
569
+ : `${indent}${' '.repeat(branchGlyph.length)}`;
570
+
571
+ const isReply = Boolean(comment.parentId);
572
+ const location = comment.location || 'unknown';
573
+ const author = comment.author?.displayName || 'Unknown';
574
+ const createdAt = comment.createdAt || 'unknown date';
575
+ const metaParts = [`Created: ${createdAt}`];
576
+ if (comment.status) metaParts.push(`Status: ${comment.status}`);
577
+ if (comment.version) metaParts.push(`Version: ${comment.version}`);
578
+ if (!isReply && comment.resolution) metaParts.push(`Resolution: ${comment.resolution}`);
579
+
580
+ const label = isReply ? chalk.gray('[reply]') : chalk.cyan(`[${location}]`);
581
+ console.log(`${headerPrefix}${currentPath.join('.')}. ${chalk.green(author)} ${chalk.gray(`(ID: ${comment.id})`)} ${label}`);
582
+ console.log(chalk.dim(`${bodyIndent}${metaParts.join(' • ')}`));
583
+
584
+ if (!isReply) {
585
+ const inlineProps = comment.inlineProperties || {};
586
+ const selectionText = inlineProps.selection || inlineProps.originalSelection;
587
+ if (selectionText) {
588
+ const selectionLabel = inlineProps.selection ? 'Highlight' : 'Highlight (original)';
589
+ console.log(chalk.dim(`${bodyIndent}${selectionLabel}: ${selectionText}`));
590
+ }
591
+ if (inlineProps.markerRef) {
592
+ console.log(chalk.dim(`${bodyIndent}Marker ref: ${inlineProps.markerRef}`));
593
+ }
594
+ }
595
+
596
+ const body = client.formatCommentBody(comment.body, format);
597
+ if (body) {
598
+ console.log(`${bodyIndent}${chalk.yellowBright('Body:')}`);
599
+ console.log(formatBodyBlock(body, `${bodyIndent} `));
600
+ }
601
+
602
+ if (comment.children && comment.children.length > 0) {
603
+ renderComments(comment.children, currentPath);
604
+ }
605
+ });
606
+ };
607
+
608
+ renderComments(commentTree);
609
+
610
+ if (!options.all && nextStart !== null && nextStart !== undefined) {
611
+ console.log(chalk.gray(`Next start: ${nextStart}`));
612
+ }
613
+
614
+ analytics.track('comments', true);
615
+ } catch (error) {
616
+ analytics.track('comments', false);
617
+ console.error(chalk.red('Error:'), error.message);
618
+ process.exit(1);
619
+ }
620
+ });
621
+
622
+ // Comment creation command
623
+ program
624
+ .command('comment <pageId>')
625
+ .description('Create a comment on a page by ID or URL (footer or inline)')
626
+ .option('-f, --file <file>', 'Read content from file')
627
+ .option('-c, --content <content>', 'Comment content as string')
628
+ .option('--format <format>', 'Content format (storage, html, markdown)', 'storage')
629
+ .option('--parent <commentId>', 'Reply to a comment by ID')
630
+ .option('--location <location>', 'Comment location (inline or footer)', 'footer')
631
+ .option('--inline-selection <text>', 'Inline selection text')
632
+ .option('--inline-original-selection <text>', 'Original inline selection text')
633
+ .option('--inline-marker-ref <ref>', 'Inline marker reference (optional)')
634
+ .option('--inline-properties <json>', 'Inline properties JSON (advanced)')
635
+ .action(async (pageId, options) => {
636
+ const analytics = new Analytics();
637
+ let location = null;
638
+ try {
639
+ const config = getConfig();
640
+ const client = new ConfluenceClient(config);
641
+
642
+ let content = '';
643
+
644
+ if (options.file) {
645
+ const fs = require('fs');
646
+ if (!fs.existsSync(options.file)) {
647
+ throw new Error(`File not found: ${options.file}`);
648
+ }
649
+ content = fs.readFileSync(options.file, 'utf8');
650
+ } else if (options.content) {
651
+ content = options.content;
652
+ } else {
653
+ throw new Error('Either --file or --content option is required');
654
+ }
655
+
656
+ location = (options.location || 'footer').toLowerCase();
657
+ if (!['inline', 'footer'].includes(location)) {
658
+ throw new Error('Location must be either "inline" or "footer".');
659
+ }
660
+
661
+ let inlineProperties = {};
662
+ if (options.inlineProperties) {
663
+ try {
664
+ const parsed = JSON.parse(options.inlineProperties);
665
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
666
+ throw new Error('Inline properties must be a JSON object.');
667
+ }
668
+ inlineProperties = { ...parsed };
669
+ } catch (error) {
670
+ throw new Error(`Invalid --inline-properties JSON: ${error.message}`);
671
+ }
672
+ }
673
+
674
+ if (options.inlineSelection) {
675
+ inlineProperties.selection = options.inlineSelection;
676
+ }
677
+ if (options.inlineOriginalSelection) {
678
+ inlineProperties.originalSelection = options.inlineOriginalSelection;
679
+ }
680
+ if (options.inlineMarkerRef) {
681
+ inlineProperties.markerRef = options.inlineMarkerRef;
682
+ }
683
+
684
+ if (Object.keys(inlineProperties).length > 0 && location !== 'inline') {
685
+ throw new Error('Inline properties can only be used with --location inline.');
686
+ }
687
+
688
+ const parentId = options.parent;
689
+
690
+ if (location === 'inline') {
691
+ const hasSelection = inlineProperties.selection || inlineProperties.originalSelection;
692
+ if (!hasSelection && !parentId) {
693
+ throw new Error('Inline comments require --inline-selection or --inline-original-selection when starting a new inline thread.');
694
+ }
695
+ if (hasSelection) {
696
+ if (!inlineProperties.originalSelection && inlineProperties.selection) {
697
+ inlineProperties.originalSelection = inlineProperties.selection;
698
+ }
699
+ if (!inlineProperties.markerRef) {
700
+ inlineProperties.markerRef = `comment-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
701
+ }
702
+ }
703
+ }
704
+
705
+ const result = await client.createComment(pageId, content, options.format, {
706
+ parentId,
707
+ location,
708
+ inlineProperties: location === 'inline' ? inlineProperties : null
709
+ });
710
+
711
+ console.log(chalk.green('✅ Comment created successfully!'));
712
+ console.log(`ID: ${chalk.blue(result.id)}`);
713
+ if (result.container?.id) {
714
+ console.log(`Page ID: ${chalk.blue(result.container.id)}`);
715
+ }
716
+ if (result._links?.webui) {
717
+ const url = client.toAbsoluteUrl(result._links.webui);
718
+ console.log(`URL: ${chalk.gray(url)}`);
719
+ }
720
+
721
+ analytics.track('comment_create', true);
722
+ } catch (error) {
723
+ analytics.track('comment_create', false);
724
+ console.error(chalk.red('Error:'), error.message);
725
+ if (error.response?.data) {
726
+ const detail = typeof error.response.data === 'string'
727
+ ? error.response.data
728
+ : JSON.stringify(error.response.data, null, 2);
729
+ console.error(chalk.red('API response:'), detail);
730
+ }
731
+ const apiErrors = error.response?.data?.data?.errors || error.response?.data?.errors || [];
732
+ const errorKeys = apiErrors
733
+ .map((entry) => entry?.message?.key || entry?.message || entry?.key)
734
+ .filter(Boolean);
735
+ const needsInlineMeta = ['matchIndex', 'lastFetchTime', 'serializedHighlights']
736
+ .every((key) => errorKeys.includes(key));
737
+ if (location === 'inline' && needsInlineMeta) {
738
+ console.error(chalk.yellow('Inline comment creation requires editor highlight metadata (matchIndex, lastFetchTime, serializedHighlights).'));
739
+ console.error(chalk.yellow('Try replying to an existing inline comment or use footer comments instead.'));
740
+ }
741
+ process.exit(1);
742
+ }
743
+ });
744
+
745
+ // Comment delete command
746
+ program
747
+ .command('comment-delete <commentId>')
748
+ .description('Delete a comment by ID')
749
+ .option('-y, --yes', 'Skip confirmation prompt')
750
+ .action(async (commentId, options) => {
751
+ const analytics = new Analytics();
752
+ try {
753
+ const config = getConfig();
754
+ const client = new ConfluenceClient(config);
755
+
756
+ if (!options.yes) {
757
+ const { confirmed } = await inquirer.prompt([
758
+ {
759
+ type: 'confirm',
760
+ name: 'confirmed',
761
+ default: false,
762
+ message: `Delete comment ${commentId}?`
763
+ }
764
+ ]);
765
+
766
+ if (!confirmed) {
767
+ console.log(chalk.yellow('Cancelled.'));
768
+ analytics.track('comment_delete_cancel', true);
769
+ return;
770
+ }
771
+ }
772
+
773
+ const result = await client.deleteComment(commentId);
774
+
775
+ console.log(chalk.green('✅ Comment deleted successfully!'));
776
+ console.log(`ID: ${chalk.blue(result.id)}`);
777
+ analytics.track('comment_delete', true);
778
+ } catch (error) {
779
+ analytics.track('comment_delete', false);
780
+ console.error(chalk.red('Error:'), error.message);
781
+ process.exit(1);
782
+ }
783
+ });
784
+
469
785
  // Export page content with attachments
470
786
  program
471
787
  .command('export <pageId>')
@@ -584,6 +900,49 @@ function sanitizeTitle(value) {
584
900
  return cleaned || fallback;
585
901
  }
586
902
 
903
+ function parseLocationOptions(raw) {
904
+ if (!raw) {
905
+ return [];
906
+ }
907
+ if (Array.isArray(raw)) {
908
+ return raw.flatMap(item => String(item).split(','))
909
+ .map(value => value.trim().toLowerCase())
910
+ .filter(Boolean);
911
+ }
912
+ return String(raw).split(',').map(value => value.trim().toLowerCase()).filter(Boolean);
913
+ }
914
+
915
+ function formatBodyBlock(text, indent = '') {
916
+ return text.split('\n').map(line => `${indent}${chalk.white(line)}`).join('\n');
917
+ }
918
+
919
+ function buildCommentTree(comments) {
920
+ const nodes = comments.map((comment, index) => ({
921
+ ...comment,
922
+ _order: index,
923
+ children: []
924
+ }));
925
+ const byId = new Map(nodes.map(node => [String(node.id), node]));
926
+ const roots = [];
927
+
928
+ nodes.forEach((node) => {
929
+ const parentId = node.parentId ? String(node.parentId) : null;
930
+ if (parentId && byId.has(parentId)) {
931
+ byId.get(parentId).children.push(node);
932
+ } else {
933
+ roots.push(node);
934
+ }
935
+ });
936
+
937
+ const sortNodes = (list) => {
938
+ list.sort((a, b) => a._order - b._order);
939
+ list.forEach((child) => sortNodes(child.children));
940
+ };
941
+
942
+ sortNodes(roots);
943
+ return roots;
944
+ }
945
+
587
946
  // Copy page tree command
588
947
  program
589
948
  .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
+ ];
package/lib/config.js CHANGED
@@ -55,22 +55,90 @@ const normalizeApiPath = (rawValue, domain) => {
55
55
  return withoutTrailing || inferApiPath(domain);
56
56
  };
57
57
 
58
- async function initConfig() {
59
- console.log(chalk.blue('🚀 Confluence CLI Configuration'));
60
- console.log('Please provide your Confluence connection details:\n');
58
+ // Helper function to validate CLI-provided options
59
+ const validateCliOptions = (options) => {
60
+ const errors = [];
61
61
 
62
- const answers = await inquirer.prompt([
63
- {
62
+ if (options.domain && !options.domain.trim()) {
63
+ errors.push('--domain cannot be empty');
64
+ }
65
+
66
+ if (options.token && !options.token.trim()) {
67
+ errors.push('--token cannot be empty');
68
+ }
69
+
70
+ if (options.email && !options.email.trim()) {
71
+ errors.push('--email cannot be empty');
72
+ }
73
+
74
+ if (options.apiPath) {
75
+ if (!options.apiPath.startsWith('/')) {
76
+ errors.push('--api-path must start with "/"');
77
+ } else {
78
+ // Validate API path format
79
+ try {
80
+ normalizeApiPath(options.apiPath, options.domain || 'example.com');
81
+ } catch (error) {
82
+ errors.push(`--api-path is invalid: ${error.message}`);
83
+ }
84
+ }
85
+ }
86
+
87
+ if (options.authType && !['basic', 'bearer'].includes(options.authType.toLowerCase())) {
88
+ errors.push('--auth-type must be "basic" or "bearer"');
89
+ }
90
+
91
+ // Check if basic auth is provided with email
92
+ const normAuthType = options.authType ? normalizeAuthType(options.authType, Boolean(options.email)) : null;
93
+ if (normAuthType === 'basic' && !options.email) {
94
+ errors.push('--email is required when using basic authentication');
95
+ }
96
+
97
+ return errors;
98
+ };
99
+
100
+ // Helper function to save configuration with validation
101
+ const saveConfig = (configData) => {
102
+ if (!fs.existsSync(CONFIG_DIR)) {
103
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
104
+ }
105
+
106
+ const config = {
107
+ domain: configData.domain.trim(),
108
+ apiPath: normalizeApiPath(configData.apiPath, configData.domain),
109
+ token: configData.token.trim(),
110
+ authType: configData.authType,
111
+ email: configData.authType === 'basic' && configData.email ? configData.email.trim() : undefined
112
+ };
113
+
114
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
115
+
116
+ console.log(chalk.green('✅ Configuration saved successfully!'));
117
+ console.log(`Config file location: ${chalk.gray(CONFIG_FILE)}`);
118
+ console.log(chalk.yellow('\n💡 Tip: You can regenerate this config anytime by running "confluence init"'));
119
+ };
120
+
121
+ // Helper function to prompt for missing values
122
+ const promptForMissingValues = async (providedValues) => {
123
+ const questions = [];
124
+
125
+ // Domain question
126
+ if (!providedValues.domain) {
127
+ questions.push({
64
128
  type: 'input',
65
129
  name: 'domain',
66
130
  message: 'Confluence domain (e.g., yourcompany.atlassian.net):',
67
131
  validate: requiredInput('Domain')
68
- },
69
- {
132
+ });
133
+ }
134
+
135
+ // API Path question
136
+ if (!providedValues.apiPath) {
137
+ questions.push({
70
138
  type: 'input',
71
139
  name: 'apiPath',
72
140
  message: 'REST API path (Cloud: /wiki/rest/api, Server: /rest/api):',
73
- default: (responses) => inferApiPath(responses.domain),
141
+ default: (responses) => inferApiPath(providedValues.domain || responses.domain),
74
142
  validate: (input, responses) => {
75
143
  const value = (input || '').trim();
76
144
  if (!value) {
@@ -80,52 +148,205 @@ async function initConfig() {
80
148
  return 'API path must start with "/"';
81
149
  }
82
150
  try {
83
- normalizeApiPath(value, responses.domain);
151
+ const domain = providedValues.domain || responses.domain;
152
+ normalizeApiPath(value, domain);
84
153
  return true;
85
154
  } catch (error) {
86
155
  return error.message;
87
156
  }
88
157
  }
89
- },
90
- {
158
+ });
159
+ }
160
+
161
+ // Auth Type question
162
+ const hasEmail = Boolean(providedValues.email);
163
+ if (!providedValues.authType) {
164
+ questions.push({
91
165
  type: 'list',
92
166
  name: 'authType',
93
167
  message: 'Authentication method:',
94
168
  choices: AUTH_CHOICES,
95
- default: 'basic'
96
- },
97
- {
169
+ default: hasEmail ? 'basic' : 'bearer'
170
+ });
171
+ }
172
+
173
+ // Email question (conditional on authType)
174
+ if (!providedValues.email) {
175
+ questions.push({
98
176
  type: 'input',
99
177
  name: 'email',
100
178
  message: 'Confluence email (used with API token):',
101
- when: (responses) => responses.authType === 'basic',
179
+ when: (responses) => {
180
+ const authType = providedValues.authType || responses.authType;
181
+ return authType === 'basic';
182
+ },
102
183
  validate: requiredInput('Email')
103
- },
104
- {
184
+ });
185
+ }
186
+
187
+ // Token question
188
+ if (!providedValues.token) {
189
+ questions.push({
105
190
  type: 'password',
106
191
  name: 'token',
107
192
  message: 'API Token:',
108
193
  validate: requiredInput('API Token')
109
- }
110
- ]);
194
+ });
195
+ }
111
196
 
112
- if (!fs.existsSync(CONFIG_DIR)) {
113
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
197
+ if (questions.length === 0) {
198
+ return providedValues;
114
199
  }
115
200
 
116
- const config = {
117
- domain: answers.domain.trim(),
118
- apiPath: normalizeApiPath(answers.apiPath, answers.domain),
119
- token: answers.token.trim(),
120
- authType: answers.authType,
121
- email: answers.authType === 'basic' ? answers.email.trim() : undefined
201
+ const answers = await inquirer.prompt(questions);
202
+ return { ...providedValues, ...answers };
203
+ };
204
+
205
+ async function initConfig(cliOptions = {}) {
206
+ // Extract provided values from CLI options
207
+ const providedValues = {
208
+ domain: cliOptions.domain,
209
+ apiPath: cliOptions.apiPath,
210
+ authType: cliOptions.authType,
211
+ email: cliOptions.email,
212
+ token: cliOptions.token
122
213
  };
123
214
 
124
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
215
+ // Check if any CLI options were provided
216
+ const hasCliOptions = Object.values(providedValues).some(v => v);
125
217
 
126
- console.log(chalk.green('✅ Configuration saved successfully!'));
127
- console.log(`Config file location: ${chalk.gray(CONFIG_FILE)}`);
128
- console.log(chalk.yellow('\n💡 Tip: You can regenerate this config anytime by running "confluence init"'));
218
+ if (!hasCliOptions) {
219
+ // Interactive mode: no CLI options provided
220
+ console.log(chalk.blue('🚀 Confluence CLI Configuration'));
221
+ console.log('Please provide your Confluence connection details:\n');
222
+
223
+ const answers = await inquirer.prompt([
224
+ {
225
+ type: 'input',
226
+ name: 'domain',
227
+ message: 'Confluence domain (e.g., yourcompany.atlassian.net):',
228
+ validate: requiredInput('Domain')
229
+ },
230
+ {
231
+ type: 'input',
232
+ name: 'apiPath',
233
+ message: 'REST API path (Cloud: /wiki/rest/api, Server: /rest/api):',
234
+ default: (responses) => inferApiPath(responses.domain),
235
+ validate: (input, responses) => {
236
+ const value = (input || '').trim();
237
+ if (!value) {
238
+ return true;
239
+ }
240
+ if (!value.startsWith('/')) {
241
+ return 'API path must start with "/"';
242
+ }
243
+ try {
244
+ normalizeApiPath(value, responses.domain);
245
+ return true;
246
+ } catch (error) {
247
+ return error.message;
248
+ }
249
+ }
250
+ },
251
+ {
252
+ type: 'list',
253
+ name: 'authType',
254
+ message: 'Authentication method:',
255
+ choices: AUTH_CHOICES,
256
+ default: 'basic'
257
+ },
258
+ {
259
+ type: 'input',
260
+ name: 'email',
261
+ message: 'Confluence email (used with API token):',
262
+ when: (responses) => responses.authType === 'basic',
263
+ validate: requiredInput('Email')
264
+ },
265
+ {
266
+ type: 'password',
267
+ name: 'token',
268
+ message: 'API Token:',
269
+ validate: requiredInput('API Token')
270
+ }
271
+ ]);
272
+
273
+ saveConfig(answers);
274
+ return;
275
+ }
276
+
277
+ // Non-interactive or hybrid mode: CLI options provided
278
+ // Validate provided options
279
+ const validationErrors = validateCliOptions(providedValues);
280
+ if (validationErrors.length > 0) {
281
+ console.error(chalk.red('❌ Configuration Error:'));
282
+ validationErrors.forEach(error => {
283
+ console.error(chalk.red(` • ${error}`));
284
+ });
285
+ process.exit(1);
286
+ }
287
+
288
+ // Check if all required values are provided for non-interactive mode
289
+ // Non-interactive requires: domain, token, and either authType or email (for inference)
290
+ const hasRequiredValues = Boolean(
291
+ providedValues.domain &&
292
+ providedValues.token &&
293
+ (providedValues.authType || providedValues.email)
294
+ );
295
+
296
+ if (hasRequiredValues) {
297
+ // Non-interactive mode: all required values provided
298
+ try {
299
+ // Infer authType if not provided
300
+ let inferredAuthType = providedValues.authType;
301
+ if (!inferredAuthType) {
302
+ inferredAuthType = providedValues.email ? 'basic' : 'bearer';
303
+ }
304
+
305
+ const normalizedAuthType = normalizeAuthType(inferredAuthType, Boolean(providedValues.email));
306
+ const normalizedDomain = providedValues.domain.trim();
307
+
308
+ // Verify basic auth has email
309
+ if (normalizedAuthType === 'basic' && !providedValues.email) {
310
+ console.error(chalk.red('❌ Email is required for basic authentication'));
311
+ process.exit(1);
312
+ }
313
+
314
+ // Verify API path format if provided
315
+ if (providedValues.apiPath) {
316
+ normalizeApiPath(providedValues.apiPath, normalizedDomain);
317
+ }
318
+
319
+ const configData = {
320
+ domain: normalizedDomain,
321
+ apiPath: providedValues.apiPath || inferApiPath(normalizedDomain),
322
+ token: providedValues.token,
323
+ authType: normalizedAuthType,
324
+ email: providedValues.email
325
+ };
326
+
327
+ saveConfig(configData);
328
+ } catch (error) {
329
+ console.error(chalk.red(`❌ ${error.message}`));
330
+ process.exit(1);
331
+ }
332
+ return;
333
+ }
334
+
335
+ // Hybrid mode: some values provided, prompt for the rest
336
+ try {
337
+ console.log(chalk.blue('🚀 Confluence CLI Configuration'));
338
+ console.log('Completing configuration with interactive prompts:\n');
339
+
340
+ const mergedValues = await promptForMissingValues(providedValues);
341
+
342
+ // Normalize auth type
343
+ mergedValues.authType = normalizeAuthType(mergedValues.authType, Boolean(mergedValues.email));
344
+
345
+ saveConfig(mergedValues);
346
+ } catch (error) {
347
+ console.error(chalk.red(`❌ ${error.message}`));
348
+ process.exit(1);
349
+ }
129
350
  }
130
351
 
131
352
  function getConfig() {
@@ -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.15.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');