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 +14 -0
- package/README.md +68 -4
- package/bin/confluence.js +367 -8
- package/eslint.config.js +33 -0
- package/lib/config.js +252 -31
- 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,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
|
|
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:
|
|
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
|
|
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
|
-
- [
|
|
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
|
-
.
|
|
21
|
-
|
|
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
|
|
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
|
|
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
|
|
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]')
|
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/config.js
CHANGED
|
@@ -55,22 +55,90 @@ const normalizeApiPath = (rawValue, domain) => {
|
|
|
55
55
|
return withoutTrailing || inferApiPath(domain);
|
|
56
56
|
};
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
// Helper function to validate CLI-provided options
|
|
59
|
+
const validateCliOptions = (options) => {
|
|
60
|
+
const errors = [];
|
|
61
61
|
|
|
62
|
-
|
|
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
|
-
|
|
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) =>
|
|
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 (
|
|
113
|
-
|
|
197
|
+
if (questions.length === 0) {
|
|
198
|
+
return providedValues;
|
|
114
199
|
}
|
|
115
200
|
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
215
|
+
// Check if any CLI options were provided
|
|
216
|
+
const hasCliOptions = Object.values(providedValues).some(v => v);
|
|
125
217
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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() {
|
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.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": "^
|
|
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');
|