confluence-cli 1.15.1 → 1.17.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 +52 -2
- package/bin/confluence.js +129 -0
- package/lib/confluence-client.js +113 -0
- package/package.json +2 -1
- package/tests/confluence-client.test.js +257 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [1.17.0](https://github.com/pchuri/confluence-cli/compare/v1.16.0...v1.17.0) (2026-02-13)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* add attachment upload and delete commands ([#36](https://github.com/pchuri/confluence-cli/issues/36)) ([ed62bb4](https://github.com/pchuri/confluence-cli/commit/ed62bb45468566c128f066016615048e31ed1775))
|
|
7
|
+
|
|
8
|
+
# [1.16.0](https://github.com/pchuri/confluence-cli/compare/v1.15.1...v1.16.0) (2026-02-13)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* Add move command to relocate pages ([#32](https://github.com/pchuri/confluence-cli/issues/32)) ([a37f9b8](https://github.com/pchuri/confluence-cli/commit/a37f9b83174ef08a6517fe279dcce1b39fc1fb1a))
|
|
14
|
+
|
|
1
15
|
## [1.15.1](https://github.com/pchuri/confluence-cli/compare/v1.15.0...v1.15.1) (2026-02-12)
|
|
2
16
|
|
|
3
17
|
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ A powerful command-line interface for Atlassian Confluence that allows you to re
|
|
|
11
11
|
- ✏️ **Create pages** - Create new pages with support for Markdown, HTML, or Storage format
|
|
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
|
-
- 📎 **Attachments** - List or
|
|
14
|
+
- 📎 **Attachments** - List, download, upload, or delete page attachments
|
|
15
15
|
- 💬 **Comments** - List, create, and delete page comments (footer or inline)
|
|
16
16
|
- 📦 **Export** - Save a page and its attachments to a local folder
|
|
17
17
|
- 🛠️ **Edit workflow** - Export page content for editing and re-import
|
|
@@ -160,6 +160,27 @@ confluence attachments 123456789 --pattern "*.png" --limit 5
|
|
|
160
160
|
confluence attachments 123456789 --pattern "*.png" --download --dest ./downloads
|
|
161
161
|
```
|
|
162
162
|
|
|
163
|
+
### Upload Attachments
|
|
164
|
+
```bash
|
|
165
|
+
# Upload a single attachment
|
|
166
|
+
confluence attachment-upload 123456789 --file ./report.pdf
|
|
167
|
+
|
|
168
|
+
# Upload multiple files with a comment
|
|
169
|
+
confluence attachment-upload 123456789 --file ./a.pdf --file ./b.png --comment "v2"
|
|
170
|
+
|
|
171
|
+
# Replace an existing attachment by filename
|
|
172
|
+
confluence attachment-upload 123456789 --file ./diagram.png --replace
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Delete Attachments
|
|
176
|
+
```bash
|
|
177
|
+
# Delete an attachment by ID
|
|
178
|
+
confluence attachment-delete 123456789 998877
|
|
179
|
+
|
|
180
|
+
# Skip confirmation
|
|
181
|
+
confluence attachment-delete 123456789 998877 --yes
|
|
182
|
+
```
|
|
183
|
+
|
|
163
184
|
### Comments
|
|
164
185
|
```bash
|
|
165
186
|
# List all comments (footer + inline)
|
|
@@ -296,6 +317,22 @@ confluence update 123456789 --file ./updated-content.md --format markdown
|
|
|
296
317
|
confluence update 123456789 --title "New Title" --content "And new content"
|
|
297
318
|
```
|
|
298
319
|
|
|
320
|
+
### Move a Page to New Parent
|
|
321
|
+
|
|
322
|
+
```bash
|
|
323
|
+
# Move page by ID
|
|
324
|
+
confluence move 123456789 987654321
|
|
325
|
+
|
|
326
|
+
# Move page and rename it
|
|
327
|
+
confluence move 123456789 987654321 --title "Relocated Page"
|
|
328
|
+
|
|
329
|
+
# Move using URLs (for convenience)
|
|
330
|
+
confluence move "https://domain.atlassian.net/wiki/viewpage.action?pageId=123456789" \
|
|
331
|
+
"https://domain.atlassian.net/wiki/viewpage.action?pageId=987654321"
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
**Note:** Pages can only be moved within the same Confluence space. Cross-space moves are not supported.
|
|
335
|
+
|
|
299
336
|
### Delete a Page
|
|
300
337
|
```bash
|
|
301
338
|
# Delete by page ID (prompts for confirmation)
|
|
@@ -341,9 +378,12 @@ confluence stats
|
|
|
341
378
|
| `create-child <title> <parentId>` | Create a child page | `--content <string>`, `--file <path>`, `--format <storage\|html\|markdown>` |
|
|
342
379
|
| `copy-tree <sourcePageId> <targetParentId> [newTitle]` | Copy page tree with all children | `--max-depth <number>`, `--exclude <patterns>`, `--delay-ms <ms>`, `--copy-suffix <text>`, `--dry-run`, `--fail-on-error`, `--quiet` |
|
|
343
380
|
| `update <pageId>` | Update a page's title or content | `--title <string>`, `--content <string>`, `--file <path>`, `--format <storage\|html\|markdown>` |
|
|
381
|
+
| `move <pageId_or_url> <newParentId_or_url>` | Move a page to a new parent location | `--title <string>` |
|
|
344
382
|
| `delete <pageId_or_url>` | Delete a page by ID or URL | `--yes` |
|
|
345
383
|
| `edit <pageId>` | Export page content for editing | `--output <file>` |
|
|
346
384
|
| `attachments <pageId_or_url>` | List or download attachments for a page | `--limit <number>`, `--pattern <glob>`, `--download`, `--dest <directory>` |
|
|
385
|
+
| `attachment-upload <pageId_or_url>` | Upload attachments to a page | `--file <path>`, `--comment <text>`, `--replace`, `--minor-edit` |
|
|
386
|
+
| `attachment-delete <pageId_or_url> <attachmentId>` | Delete an attachment from a page | `--yes` |
|
|
347
387
|
| `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
388
|
| `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
389
|
| `comment-delete <commentId>` | Delete a comment by ID | `--yes` |
|
|
@@ -371,6 +411,16 @@ confluence search "API documentation" --limit 3
|
|
|
371
411
|
# List all spaces
|
|
372
412
|
confluence spaces
|
|
373
413
|
|
|
414
|
+
# Move a page to a new parent
|
|
415
|
+
confluence move 123456789 987654321
|
|
416
|
+
|
|
417
|
+
# Move and rename
|
|
418
|
+
confluence move 123456789 987654321 --title "New Title"
|
|
419
|
+
|
|
420
|
+
# Upload and delete an attachment
|
|
421
|
+
confluence attachment-upload 123456789 --file ./report.pdf
|
|
422
|
+
confluence attachment-delete 123456789 998877 --yes
|
|
423
|
+
|
|
374
424
|
# View usage statistics
|
|
375
425
|
confluence stats
|
|
376
426
|
```
|
|
@@ -414,7 +464,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
|
|
414
464
|
- [ ] Bulk operations
|
|
415
465
|
- [ ] Export pages to different formats
|
|
416
466
|
- [ ] Integration with other Atlassian tools (Jira)
|
|
417
|
-
- [
|
|
467
|
+
- [x] Page attachments management (list, download, upload, delete)
|
|
418
468
|
- [x] Comments
|
|
419
469
|
- [ ] Reviews
|
|
420
470
|
|
package/bin/confluence.js
CHANGED
|
@@ -277,6 +277,33 @@ program
|
|
|
277
277
|
}
|
|
278
278
|
});
|
|
279
279
|
|
|
280
|
+
// Move command
|
|
281
|
+
program
|
|
282
|
+
.command('move <pageId_or_url> <newParentId_or_url>')
|
|
283
|
+
.description('Move a page to a new parent location (within same space)')
|
|
284
|
+
.option('-t, --title <title>', 'New page title (optional)')
|
|
285
|
+
.action(async (pageId, newParentId, options) => {
|
|
286
|
+
const analytics = new Analytics();
|
|
287
|
+
try {
|
|
288
|
+
const config = getConfig();
|
|
289
|
+
const client = new ConfluenceClient(config);
|
|
290
|
+
const result = await client.movePage(pageId, newParentId, options.title);
|
|
291
|
+
|
|
292
|
+
console.log(chalk.green('✅ Page moved successfully!'));
|
|
293
|
+
console.log(`Title: ${chalk.blue(result.title)}`);
|
|
294
|
+
console.log(`ID: ${chalk.blue(result.id)}`);
|
|
295
|
+
console.log(`New Parent: ${chalk.blue(newParentId)}`);
|
|
296
|
+
console.log(`Version: ${chalk.blue(result.version.number)}`);
|
|
297
|
+
console.log(`URL: ${chalk.gray(`https://${config.domain}/wiki${result._links.webui}`)}`);
|
|
298
|
+
|
|
299
|
+
analytics.track('move', true);
|
|
300
|
+
} catch (error) {
|
|
301
|
+
analytics.track('move', false);
|
|
302
|
+
console.error(chalk.red('Error:'), error.message);
|
|
303
|
+
process.exit(1);
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
280
307
|
// Delete command
|
|
281
308
|
program
|
|
282
309
|
.command('delete <pageIdOrUrl>')
|
|
@@ -468,6 +495,108 @@ program
|
|
|
468
495
|
}
|
|
469
496
|
});
|
|
470
497
|
|
|
498
|
+
// Attachment upload command
|
|
499
|
+
program
|
|
500
|
+
.command('attachment-upload <pageId>')
|
|
501
|
+
.description('Upload one or more attachments to a page')
|
|
502
|
+
.option('-f, --file <file>', 'File to upload (repeatable)', (value, previous) => {
|
|
503
|
+
const files = Array.isArray(previous) ? previous : [];
|
|
504
|
+
files.push(value);
|
|
505
|
+
return files;
|
|
506
|
+
}, [])
|
|
507
|
+
.option('--comment <comment>', 'Comment for the attachment(s)')
|
|
508
|
+
.option('--replace', 'Replace an existing attachment with the same filename')
|
|
509
|
+
.option('--minor-edit', 'Mark the upload as a minor edit')
|
|
510
|
+
.action(async (pageId, options) => {
|
|
511
|
+
const analytics = new Analytics();
|
|
512
|
+
try {
|
|
513
|
+
const files = Array.isArray(options.file) ? options.file.filter(Boolean) : [];
|
|
514
|
+
if (files.length === 0) {
|
|
515
|
+
throw new Error('At least one --file option is required.');
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const fs = require('fs');
|
|
519
|
+
const path = require('path');
|
|
520
|
+
const config = getConfig();
|
|
521
|
+
const client = new ConfluenceClient(config);
|
|
522
|
+
|
|
523
|
+
const resolvedFiles = files.map((filePath) => ({
|
|
524
|
+
original: filePath,
|
|
525
|
+
resolved: path.resolve(filePath)
|
|
526
|
+
}));
|
|
527
|
+
|
|
528
|
+
resolvedFiles.forEach((file) => {
|
|
529
|
+
if (!fs.existsSync(file.resolved)) {
|
|
530
|
+
throw new Error(`File not found: ${file.original}`);
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
let uploaded = 0;
|
|
535
|
+
for (const file of resolvedFiles) {
|
|
536
|
+
const result = await client.uploadAttachment(pageId, file.resolved, {
|
|
537
|
+
comment: options.comment,
|
|
538
|
+
replace: options.replace,
|
|
539
|
+
minorEdit: options.minorEdit === true ? true : undefined
|
|
540
|
+
});
|
|
541
|
+
const attachment = result.results[0];
|
|
542
|
+
if (attachment) {
|
|
543
|
+
console.log(`⬆️ ${chalk.green(attachment.title)} (ID: ${attachment.id}, Version: ${attachment.version})`);
|
|
544
|
+
} else {
|
|
545
|
+
console.log(`⬆️ ${chalk.green(path.basename(file.resolved))}`);
|
|
546
|
+
}
|
|
547
|
+
uploaded += 1;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
console.log(chalk.green(`Uploaded ${uploaded} attachment${uploaded === 1 ? '' : 's'} to page ${pageId}`));
|
|
551
|
+
analytics.track('attachment_upload', true);
|
|
552
|
+
} catch (error) {
|
|
553
|
+
analytics.track('attachment_upload', false);
|
|
554
|
+
console.error(chalk.red('Error:'), error.message);
|
|
555
|
+
process.exit(1);
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
// Attachment delete command
|
|
560
|
+
program
|
|
561
|
+
.command('attachment-delete <pageId> <attachmentId>')
|
|
562
|
+
.description('Delete an attachment by ID from a page')
|
|
563
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
564
|
+
.action(async (pageId, attachmentId, options) => {
|
|
565
|
+
const analytics = new Analytics();
|
|
566
|
+
try {
|
|
567
|
+
const config = getConfig();
|
|
568
|
+
const client = new ConfluenceClient(config);
|
|
569
|
+
|
|
570
|
+
if (!options.yes) {
|
|
571
|
+
const { confirmed } = await inquirer.prompt([
|
|
572
|
+
{
|
|
573
|
+
type: 'confirm',
|
|
574
|
+
name: 'confirmed',
|
|
575
|
+
default: false,
|
|
576
|
+
message: `Delete attachment ${attachmentId} from page ${pageId}?`
|
|
577
|
+
}
|
|
578
|
+
]);
|
|
579
|
+
|
|
580
|
+
if (!confirmed) {
|
|
581
|
+
console.log(chalk.yellow('Cancelled.'));
|
|
582
|
+
analytics.track('attachment_delete_cancel', true);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const result = await client.deleteAttachment(pageId, attachmentId);
|
|
588
|
+
|
|
589
|
+
console.log(chalk.green('✅ Attachment deleted successfully!'));
|
|
590
|
+
console.log(`ID: ${chalk.blue(result.id)}`);
|
|
591
|
+
console.log(`Page ID: ${chalk.blue(result.pageId)}`);
|
|
592
|
+
analytics.track('attachment_delete', true);
|
|
593
|
+
} catch (error) {
|
|
594
|
+
analytics.track('attachment_delete', false);
|
|
595
|
+
console.error(chalk.red('Error:'), error.message);
|
|
596
|
+
process.exit(1);
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
|
|
471
600
|
// Comments command
|
|
472
601
|
program
|
|
473
602
|
.command('comments <pageId>')
|
package/lib/confluence-client.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
const axios = require('axios');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const FormData = require('form-data');
|
|
2
5
|
const { convert } = require('html-to-text');
|
|
3
6
|
const MarkdownIt = require('markdown-it');
|
|
4
7
|
|
|
@@ -808,6 +811,67 @@ class ConfluenceClient {
|
|
|
808
811
|
return downloadResponse.data;
|
|
809
812
|
}
|
|
810
813
|
|
|
814
|
+
/**
|
|
815
|
+
* Upload an attachment to a page
|
|
816
|
+
*/
|
|
817
|
+
async uploadAttachment(pageIdOrUrl, filePath, options = {}) {
|
|
818
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
819
|
+
throw new Error('File path is required for attachment upload.');
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const resolvedPath = path.resolve(filePath);
|
|
823
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
824
|
+
throw new Error(`File not found: ${filePath}`);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const pageId = await this.extractPageId(pageIdOrUrl);
|
|
828
|
+
const form = new FormData();
|
|
829
|
+
form.append('file', fs.createReadStream(resolvedPath), { filename: path.basename(resolvedPath) });
|
|
830
|
+
|
|
831
|
+
if (options.comment !== undefined && options.comment !== null) {
|
|
832
|
+
form.append('comment', options.comment, { contentType: 'text/plain; charset=utf-8' });
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
if (typeof options.minorEdit === 'boolean') {
|
|
836
|
+
form.append('minorEdit', options.minorEdit ? 'true' : 'false');
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const method = options.replace ? 'put' : 'post';
|
|
840
|
+
const response = await this.client.request({
|
|
841
|
+
url: `/content/${pageId}/child/attachment`,
|
|
842
|
+
method,
|
|
843
|
+
headers: {
|
|
844
|
+
...form.getHeaders(),
|
|
845
|
+
'X-Atlassian-Token': 'nocheck'
|
|
846
|
+
},
|
|
847
|
+
data: form,
|
|
848
|
+
maxBodyLength: Infinity,
|
|
849
|
+
maxContentLength: Infinity
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
const results = Array.isArray(response.data?.results)
|
|
853
|
+
? response.data.results.map((item) => this.normalizeAttachment(item))
|
|
854
|
+
: [];
|
|
855
|
+
|
|
856
|
+
return {
|
|
857
|
+
results,
|
|
858
|
+
raw: response.data
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Delete an attachment by ID
|
|
864
|
+
*/
|
|
865
|
+
async deleteAttachment(pageIdOrUrl, attachmentId) {
|
|
866
|
+
if (!attachmentId) {
|
|
867
|
+
throw new Error('Attachment ID is required.');
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
const pageId = await this.extractPageId(pageIdOrUrl);
|
|
871
|
+
await this.client.delete(`/content/${pageId}/child/attachment/${attachmentId}`);
|
|
872
|
+
return { id: String(attachmentId), pageId: String(pageId) };
|
|
873
|
+
}
|
|
874
|
+
|
|
811
875
|
/**
|
|
812
876
|
* Convert markdown to Confluence storage format
|
|
813
877
|
*/
|
|
@@ -1449,6 +1513,55 @@ class ConfluenceClient {
|
|
|
1449
1513
|
return response.data;
|
|
1450
1514
|
}
|
|
1451
1515
|
|
|
1516
|
+
/**
|
|
1517
|
+
* Move a page to a new parent location
|
|
1518
|
+
*/
|
|
1519
|
+
async movePage(pageIdOrUrl, newParentIdOrUrl, newTitle = null) {
|
|
1520
|
+
// Resolve both IDs from URLs if needed
|
|
1521
|
+
const pageId = await this.extractPageId(pageIdOrUrl);
|
|
1522
|
+
const newParentId = await this.extractPageId(newParentIdOrUrl);
|
|
1523
|
+
|
|
1524
|
+
// Fetch current page
|
|
1525
|
+
const response = await this.client.get(`/content/${pageId}`, {
|
|
1526
|
+
params: { expand: 'body.storage,version,space' }
|
|
1527
|
+
});
|
|
1528
|
+
const { version, title, body, space } = response.data;
|
|
1529
|
+
|
|
1530
|
+
// Fetch new parent to get its space (for validation)
|
|
1531
|
+
const parentResponse = await this.client.get(`/content/${newParentId}`, {
|
|
1532
|
+
params: { expand: 'space' }
|
|
1533
|
+
});
|
|
1534
|
+
const parentSpace = parentResponse.data.space;
|
|
1535
|
+
|
|
1536
|
+
// Validate same space
|
|
1537
|
+
if (parentSpace.key !== space.key) {
|
|
1538
|
+
throw new Error(
|
|
1539
|
+
`Cannot move page across spaces. Page is in space "${space.key}" ` +
|
|
1540
|
+
`but new parent is in space "${parentSpace.key}". ` +
|
|
1541
|
+
'Pages can only be moved within the same space.'
|
|
1542
|
+
);
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// Proceed with move
|
|
1546
|
+
const pageData = {
|
|
1547
|
+
id: pageId,
|
|
1548
|
+
type: 'page',
|
|
1549
|
+
title: newTitle || title,
|
|
1550
|
+
space: { key: space.key },
|
|
1551
|
+
body: {
|
|
1552
|
+
storage: {
|
|
1553
|
+
value: body.storage.value,
|
|
1554
|
+
representation: 'storage'
|
|
1555
|
+
}
|
|
1556
|
+
},
|
|
1557
|
+
version: { number: version.number + 1 },
|
|
1558
|
+
ancestors: [{ id: newParentId }]
|
|
1559
|
+
};
|
|
1560
|
+
|
|
1561
|
+
const updateResponse = await this.client.put(`/content/${pageId}`, pageData);
|
|
1562
|
+
return updateResponse.data;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1452
1565
|
/**
|
|
1453
1566
|
* Get page content for editing
|
|
1454
1567
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "confluence-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.17.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": {
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"axios": "^1.12.0",
|
|
27
27
|
"chalk": "^4.1.2",
|
|
28
28
|
"commander": "^11.1.0",
|
|
29
|
+
"form-data": "^4.0.5",
|
|
29
30
|
"html-to-text": "^9.0.5",
|
|
30
31
|
"inquirer": "^8.2.6",
|
|
31
32
|
"markdown-it": "^14.1.0",
|
|
@@ -1,6 +1,44 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const FormData = require('form-data');
|
|
1
5
|
const ConfluenceClient = require('../lib/confluence-client');
|
|
2
6
|
const MockAdapter = require('axios-mock-adapter');
|
|
3
7
|
|
|
8
|
+
const removeDirRecursive = (dir) => {
|
|
9
|
+
if (!dir) return;
|
|
10
|
+
try {
|
|
11
|
+
if (fs.rmSync) {
|
|
12
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
} catch (error) {
|
|
16
|
+
void error;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (!fs.existsSync(dir)) return;
|
|
20
|
+
|
|
21
|
+
fs.readdirSync(dir).forEach((entry) => {
|
|
22
|
+
const entryPath = path.join(dir, entry);
|
|
23
|
+
const stats = fs.lstatSync(entryPath);
|
|
24
|
+
if (stats.isDirectory()) {
|
|
25
|
+
removeDirRecursive(entryPath);
|
|
26
|
+
} else {
|
|
27
|
+
try {
|
|
28
|
+
fs.unlinkSync(entryPath);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
void error;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
fs.rmdirSync(dir);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
void error;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
4
42
|
describe('ConfluenceClient', () => {
|
|
5
43
|
let client;
|
|
6
44
|
|
|
@@ -298,6 +336,160 @@ describe('ConfluenceClient', () => {
|
|
|
298
336
|
});
|
|
299
337
|
});
|
|
300
338
|
|
|
339
|
+
describe('movePage', () => {
|
|
340
|
+
test('should move a page by ID', async () => {
|
|
341
|
+
const mock = new MockAdapter(client.client);
|
|
342
|
+
|
|
343
|
+
mock.onGet('/content/123456789').reply(200, {
|
|
344
|
+
id: '123456789',
|
|
345
|
+
title: 'Original Title',
|
|
346
|
+
version: { number: 5 },
|
|
347
|
+
body: { storage: { value: '<p>Original content</p>' } },
|
|
348
|
+
space: { key: 'TEST' }
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
mock.onGet('/content/987654321').reply(200, {
|
|
352
|
+
id: '987654321',
|
|
353
|
+
space: { key: 'TEST' }
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
mock.onPut('/content/123456789').reply(200, {
|
|
357
|
+
id: '123456789',
|
|
358
|
+
title: 'Original Title',
|
|
359
|
+
version: { number: 6 },
|
|
360
|
+
ancestors: [{ id: '987654321' }]
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
const result = await client.movePage('123456789', '987654321');
|
|
364
|
+
|
|
365
|
+
expect(result.id).toBe('123456789');
|
|
366
|
+
expect(result.version.number).toBe(6);
|
|
367
|
+
expect(result.ancestors).toEqual([{ id: '987654321' }]);
|
|
368
|
+
|
|
369
|
+
mock.restore();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test('should move a page with new title', async () => {
|
|
373
|
+
const mock = new MockAdapter(client.client);
|
|
374
|
+
|
|
375
|
+
mock.onGet('/content/555666777').reply(200, {
|
|
376
|
+
id: '555666777',
|
|
377
|
+
title: 'Old Title',
|
|
378
|
+
version: { number: 2 },
|
|
379
|
+
body: { storage: { value: '<p>Page content</p>' } },
|
|
380
|
+
space: { key: 'DOCS' }
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
mock.onGet('/content/888999000').reply(200, {
|
|
384
|
+
id: '888999000',
|
|
385
|
+
space: { key: 'DOCS' }
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
mock.onPut('/content/555666777').reply(200, {
|
|
389
|
+
id: '555666777',
|
|
390
|
+
title: 'New Title',
|
|
391
|
+
version: { number: 3 },
|
|
392
|
+
ancestors: [{ id: '888999000' }]
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
const result = await client.movePage('555666777', '888999000', 'New Title');
|
|
396
|
+
|
|
397
|
+
expect(result.title).toBe('New Title');
|
|
398
|
+
expect(result.version.number).toBe(3);
|
|
399
|
+
expect(result.ancestors).toEqual([{ id: '888999000' }]);
|
|
400
|
+
|
|
401
|
+
mock.restore();
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test('should move a page using URL for pageId', async () => {
|
|
405
|
+
const mock = new MockAdapter(client.client);
|
|
406
|
+
const pageUrl = 'https://test.atlassian.net/wiki/viewpage.action?pageId=111222333';
|
|
407
|
+
|
|
408
|
+
mock.onGet('/content/111222333').reply(200, {
|
|
409
|
+
id: '111222333',
|
|
410
|
+
title: 'Test Page',
|
|
411
|
+
version: { number: 1 },
|
|
412
|
+
body: { storage: { value: '<p>Content</p>' } },
|
|
413
|
+
space: { key: 'TEST' }
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
mock.onGet('/content/444555666').reply(200, {
|
|
417
|
+
id: '444555666',
|
|
418
|
+
space: { key: 'TEST' }
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
mock.onPut('/content/111222333').reply(200, {
|
|
422
|
+
id: '111222333',
|
|
423
|
+
title: 'Test Page',
|
|
424
|
+
version: { number: 2 },
|
|
425
|
+
ancestors: [{ id: '444555666' }]
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
const result = await client.movePage(pageUrl, '444555666');
|
|
429
|
+
|
|
430
|
+
expect(result.id).toBe('111222333');
|
|
431
|
+
expect(result.version.number).toBe(2);
|
|
432
|
+
|
|
433
|
+
mock.restore();
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test('should move a page using URLs for both parameters', async () => {
|
|
437
|
+
const mock = new MockAdapter(client.client);
|
|
438
|
+
const pageUrl = 'https://test.atlassian.net/wiki/viewpage.action?pageId=777888999';
|
|
439
|
+
const parentUrl = 'https://test.atlassian.net/wiki/viewpage.action?pageId=111000111';
|
|
440
|
+
|
|
441
|
+
mock.onGet('/content/777888999').reply(200, {
|
|
442
|
+
id: '777888999',
|
|
443
|
+
title: 'Source Page',
|
|
444
|
+
version: { number: 3 },
|
|
445
|
+
body: { storage: { value: '<p>Page content</p>' } },
|
|
446
|
+
space: { key: 'DOCS' }
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
mock.onGet('/content/111000111').reply(200, {
|
|
450
|
+
id: '111000111',
|
|
451
|
+
space: { key: 'DOCS' }
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
mock.onPut('/content/777888999').reply(200, {
|
|
455
|
+
id: '777888999',
|
|
456
|
+
title: 'Source Page',
|
|
457
|
+
version: { number: 4 },
|
|
458
|
+
ancestors: [{ id: '111000111' }]
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
const result = await client.movePage(pageUrl, parentUrl);
|
|
462
|
+
|
|
463
|
+
expect(result.id).toBe('777888999');
|
|
464
|
+
expect(result.version.number).toBe(4);
|
|
465
|
+
|
|
466
|
+
mock.restore();
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test('should throw error when moving page across spaces', async () => {
|
|
470
|
+
const mock = new MockAdapter(client.client);
|
|
471
|
+
|
|
472
|
+
mock.onGet('/content/123456789').reply(200, {
|
|
473
|
+
id: '123456789',
|
|
474
|
+
title: 'Page in Space A',
|
|
475
|
+
version: { number: 1 },
|
|
476
|
+
body: { storage: { value: '<p>Content</p>' } },
|
|
477
|
+
space: { key: 'SPACEA' }
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
mock.onGet('/content/987654321').reply(200, {
|
|
481
|
+
id: '987654321',
|
|
482
|
+
space: { key: 'SPACEB' }
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
await expect(
|
|
486
|
+
client.movePage('123456789', '987654321')
|
|
487
|
+
).rejects.toThrow('Cannot move page across spaces');
|
|
488
|
+
|
|
489
|
+
mock.restore();
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
301
493
|
describe('page tree operations', () => {
|
|
302
494
|
test('should have required methods for tree operations', () => {
|
|
303
495
|
expect(typeof client.getChildPages).toBe('function');
|
|
@@ -445,6 +637,8 @@ describe('ConfluenceClient', () => {
|
|
|
445
637
|
expect(typeof client.listAttachments).toBe('function');
|
|
446
638
|
expect(typeof client.getAllAttachments).toBe('function');
|
|
447
639
|
expect(typeof client.downloadAttachment).toBe('function');
|
|
640
|
+
expect(typeof client.uploadAttachment).toBe('function');
|
|
641
|
+
expect(typeof client.deleteAttachment).toBe('function');
|
|
448
642
|
});
|
|
449
643
|
|
|
450
644
|
test('matchesPattern should respect glob patterns', () => {
|
|
@@ -460,5 +654,68 @@ describe('ConfluenceClient', () => {
|
|
|
460
654
|
expect(client.parseNextStart('/rest/api/content/1/child/attachment?limit=50')).toBeNull();
|
|
461
655
|
expect(client.parseNextStart(null)).toBeNull();
|
|
462
656
|
});
|
|
657
|
+
|
|
658
|
+
test('uploadAttachment should send multipart request with Atlassian token header', async () => {
|
|
659
|
+
const mock = new MockAdapter(client.client);
|
|
660
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'confluence-cli-'));
|
|
661
|
+
const tempFile = path.join(tempDir, 'upload.txt');
|
|
662
|
+
fs.writeFileSync(tempFile, 'hello');
|
|
663
|
+
|
|
664
|
+
try {
|
|
665
|
+
mock.onPost('/content/123/child/attachment').reply((config) => {
|
|
666
|
+
expect(config.headers['X-Atlassian-Token']).toBe('nocheck');
|
|
667
|
+
const contentType = config.headers['content-type'] || config.headers['Content-Type'];
|
|
668
|
+
expect(contentType).toContain('multipart/form-data');
|
|
669
|
+
expect(config.data).toBeInstanceOf(FormData);
|
|
670
|
+
return [200, {
|
|
671
|
+
results: [{
|
|
672
|
+
id: '1',
|
|
673
|
+
title: 'upload.txt',
|
|
674
|
+
version: { number: 2 },
|
|
675
|
+
_links: { download: '/download' }
|
|
676
|
+
}]
|
|
677
|
+
}];
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
const response = await client.uploadAttachment('123', tempFile, { comment: 'note', minorEdit: true });
|
|
681
|
+
expect(response.results[0].title).toBe('upload.txt');
|
|
682
|
+
} finally {
|
|
683
|
+
mock.restore();
|
|
684
|
+
removeDirRecursive(tempDir);
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
test('uploadAttachment should use PUT when replace is true', async () => {
|
|
689
|
+
const mock = new MockAdapter(client.client);
|
|
690
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'confluence-cli-'));
|
|
691
|
+
const tempFile = path.join(tempDir, 'replace.txt');
|
|
692
|
+
fs.writeFileSync(tempFile, 'replace');
|
|
693
|
+
|
|
694
|
+
try {
|
|
695
|
+
mock.onPut('/content/456/child/attachment').reply(200, {
|
|
696
|
+
results: [{
|
|
697
|
+
id: '2',
|
|
698
|
+
title: 'replace.txt',
|
|
699
|
+
version: { number: 3 },
|
|
700
|
+
_links: { download: '/download' }
|
|
701
|
+
}]
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
const response = await client.uploadAttachment('456', tempFile, { replace: true });
|
|
705
|
+
expect(response.results[0].title).toBe('replace.txt');
|
|
706
|
+
} finally {
|
|
707
|
+
mock.restore();
|
|
708
|
+
removeDirRecursive(tempDir);
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
test('deleteAttachment should call delete endpoint', async () => {
|
|
713
|
+
const mock = new MockAdapter(client.client);
|
|
714
|
+
mock.onDelete('/content/123/child/attachment/999').reply(204);
|
|
715
|
+
|
|
716
|
+
await expect(client.deleteAttachment('123', '999')).resolves.toEqual({ id: '999', pageId: '123' });
|
|
717
|
+
|
|
718
|
+
mock.restore();
|
|
719
|
+
});
|
|
463
720
|
});
|
|
464
721
|
});
|