confluence-cli 1.8.0 → 1.10.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.10.0](https://github.com/pchuri/confluence-cli/compare/v1.9.0...v1.10.0) (2025-12-05)
2
+
3
+
4
+ ### Features
5
+
6
+ * export page with attachments ([#18](https://github.com/pchuri/confluence-cli/issues/18)) ([bdd9da4](https://github.com/pchuri/confluence-cli/commit/bdd9da474f13a8b6f96e64836443f65f846257a2))
7
+
8
+ # [1.9.0](https://github.com/pchuri/confluence-cli/compare/v1.8.0...v1.9.0) (2025-12-04)
9
+
10
+
11
+ ### Features
12
+
13
+ * add attachments list and download command ([#17](https://github.com/pchuri/confluence-cli/issues/17)) ([fb3d4f8](https://github.com/pchuri/confluence-cli/commit/fb3d4f81a3926fec832a39c78f4eda3b4a22130a))
14
+
1
15
  # [1.8.0](https://github.com/pchuri/confluence-cli/compare/v1.7.0...v1.8.0) (2025-09-28)
2
16
 
3
17
 
package/README.md CHANGED
@@ -10,6 +10,8 @@ A powerful command-line interface for Atlassian Confluence that allows you to re
10
10
  - 🏠 **List spaces** - View all available Confluence spaces
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
+ - 📎 **Attachments** - List or download page attachments
14
+ - 📦 **Export** - Save a page and its attachments to a local folder
13
15
  - 🛠️ **Edit workflow** - Export page content for editing and re-import
14
16
  - 🔧 **Easy setup** - Simple configuration with environment variables or interactive setup
15
17
 
@@ -107,6 +109,30 @@ confluence search "search term"
107
109
  confluence search "search term" --limit 5
108
110
  ```
109
111
 
112
+ ### List or Download Attachments
113
+ ```bash
114
+ # List all attachments on a page
115
+ confluence attachments 123456789
116
+
117
+ # Filter by filename and limit the number returned
118
+ confluence attachments 123456789 --pattern "*.png" --limit 5
119
+
120
+ # Download matching attachments to a directory
121
+ confluence attachments 123456789 --pattern "*.png" --download --dest ./downloads
122
+ ```
123
+
124
+ ### Export a Page with Attachments
125
+ ```bash
126
+ # Export page content (markdown by default) and all attachments
127
+ confluence export 123456789 --dest ./exports
128
+
129
+ # Custom content format/filename and attachment filtering
130
+ confluence export 123456789 --format html --file content.html --pattern "*.png"
131
+
132
+ # Skip attachments if you only need the content file
133
+ confluence export 123456789 --skip-attachments
134
+ ```
135
+
110
136
  ### List Spaces
111
137
  ```bash
112
138
  confluence spaces
package/bin/confluence.js CHANGED
@@ -337,6 +337,191 @@ program
337
337
  }
338
338
  });
339
339
 
340
+ // Attachments command
341
+ program
342
+ .command('attachments <pageId>')
343
+ .description('List or download attachments for a page')
344
+ .option('-l, --limit <limit>', 'Maximum number of attachments to fetch (default: all)')
345
+ .option('-p, --pattern <glob>', 'Filter attachments by filename (e.g., "*.png")')
346
+ .option('-d, --download', 'Download matching attachments')
347
+ .option('--dest <directory>', 'Directory to save downloads (default: current directory)', '.')
348
+ .action(async (pageId, options) => {
349
+ const analytics = new Analytics();
350
+ try {
351
+ const config = getConfig();
352
+ const client = new ConfluenceClient(config);
353
+ const maxResults = options.limit ? parseInt(options.limit, 10) : null;
354
+ const pattern = options.pattern ? options.pattern.trim() : null;
355
+
356
+ if (options.limit && (Number.isNaN(maxResults) || maxResults <= 0)) {
357
+ throw new Error('Limit must be a positive number.');
358
+ }
359
+
360
+ const attachments = await client.getAllAttachments(pageId, { maxResults });
361
+ const filtered = pattern ? attachments.filter(att => client.matchesPattern(att.title, pattern)) : attachments;
362
+
363
+ if (filtered.length === 0) {
364
+ console.log(chalk.yellow('No attachments found.'));
365
+ analytics.track('attachments', true);
366
+ return;
367
+ }
368
+
369
+ console.log(chalk.blue(`Found ${filtered.length} attachment${filtered.length === 1 ? '' : 's'}:`));
370
+ filtered.forEach((att, index) => {
371
+ const sizeKb = att.fileSize ? `${Math.max(1, Math.round(att.fileSize / 1024))} KB` : 'unknown size';
372
+ const typeLabel = att.mediaType || 'unknown';
373
+ console.log(`${index + 1}. ${chalk.green(att.title)} (ID: ${att.id})`);
374
+ console.log(` Type: ${chalk.gray(typeLabel)} • Size: ${chalk.gray(sizeKb)} • Version: ${chalk.gray(att.version)}`);
375
+ });
376
+
377
+ if (options.download) {
378
+ const fs = require('fs');
379
+ const path = require('path');
380
+ const destDir = path.resolve(options.dest || '.');
381
+ fs.mkdirSync(destDir, { recursive: true });
382
+
383
+ const uniquePathFor = (dir, filename) => {
384
+ const parsed = path.parse(filename);
385
+ let attempt = path.join(dir, filename);
386
+ let counter = 1;
387
+ while (fs.existsSync(attempt)) {
388
+ const suffix = ` (${counter})`;
389
+ const nextName = `${parsed.name}${suffix}${parsed.ext}`;
390
+ attempt = path.join(dir, nextName);
391
+ counter += 1;
392
+ }
393
+ return attempt;
394
+ };
395
+
396
+ const writeStream = (stream, targetPath) => new Promise((resolve, reject) => {
397
+ const writer = fs.createWriteStream(targetPath);
398
+ stream.pipe(writer);
399
+ stream.on('error', reject);
400
+ writer.on('error', reject);
401
+ writer.on('finish', resolve);
402
+ });
403
+
404
+ let downloaded = 0;
405
+ for (const attachment of filtered) {
406
+ const targetPath = uniquePathFor(destDir, attachment.title);
407
+ const dataStream = await client.downloadAttachment(pageId, attachment.id);
408
+ await writeStream(dataStream, targetPath);
409
+ downloaded += 1;
410
+ console.log(`⬇️ ${chalk.green(attachment.title)} -> ${chalk.gray(targetPath)}`);
411
+ }
412
+
413
+ console.log(chalk.green(`Downloaded ${downloaded} attachment${downloaded === 1 ? '' : 's'} to ${destDir}`));
414
+ }
415
+
416
+ analytics.track('attachments', true);
417
+ } catch (error) {
418
+ analytics.track('attachments', false);
419
+ console.error(chalk.red('Error:'), error.message);
420
+ process.exit(1);
421
+ }
422
+ });
423
+
424
+ // Export page content with attachments
425
+ program
426
+ .command('export <pageId>')
427
+ .description('Export a page to a directory with its attachments')
428
+ .option('--format <format>', 'Content format (html, text, markdown)', 'markdown')
429
+ .option('--dest <directory>', 'Base directory to export into', '.')
430
+ .option('--file <filename>', 'Content filename (default: page.<ext>)')
431
+ .option('--attachments-dir <name>', 'Subdirectory for attachments', 'attachments')
432
+ .option('--pattern <glob>', 'Filter attachments by filename (e.g., "*.png")')
433
+ .option('--skip-attachments', 'Do not download attachments')
434
+ .action(async (pageId, options) => {
435
+ const analytics = new Analytics();
436
+ try {
437
+ const config = getConfig();
438
+ const client = new ConfluenceClient(config);
439
+ const fs = require('fs');
440
+ const path = require('path');
441
+
442
+ const format = (options.format || 'markdown').toLowerCase();
443
+ const formatExt = { markdown: 'md', html: 'html', text: 'txt' };
444
+ const contentExt = formatExt[format] || 'txt';
445
+
446
+ const pageInfo = await client.getPageInfo(pageId);
447
+ const content = await client.readPage(pageId, format);
448
+
449
+ const baseDir = path.resolve(options.dest || '.');
450
+ const folderName = sanitizeTitle(pageInfo.title || 'page');
451
+ const exportDir = path.join(baseDir, folderName);
452
+ fs.mkdirSync(exportDir, { recursive: true });
453
+
454
+ const contentFile = options.file || `page.${contentExt}`;
455
+ const contentPath = path.join(exportDir, contentFile);
456
+ fs.writeFileSync(contentPath, content);
457
+
458
+ console.log(chalk.green('✅ Page exported'));
459
+ console.log(`Title: ${chalk.blue(pageInfo.title)}`);
460
+ console.log(`Content: ${chalk.gray(contentPath)}`);
461
+
462
+ if (!options.skipAttachments) {
463
+ const pattern = options.pattern ? options.pattern.trim() : null;
464
+ const attachments = await client.getAllAttachments(pageId);
465
+ const filtered = pattern ? attachments.filter(att => client.matchesPattern(att.title, pattern)) : attachments;
466
+
467
+ if (filtered.length === 0) {
468
+ console.log(chalk.yellow('No attachments to download.'));
469
+ } else {
470
+ const attachmentsDirName = options.attachmentsDir || 'attachments';
471
+ const attachmentsDir = path.join(exportDir, attachmentsDirName);
472
+ fs.mkdirSync(attachmentsDir, { recursive: true });
473
+
474
+ const uniquePathFor = (dir, filename) => {
475
+ const parsed = path.parse(filename);
476
+ let attempt = path.join(dir, filename);
477
+ let counter = 1;
478
+ while (fs.existsSync(attempt)) {
479
+ const suffix = ` (${counter})`;
480
+ const nextName = `${parsed.name}${suffix}${parsed.ext}`;
481
+ attempt = path.join(dir, nextName);
482
+ counter += 1;
483
+ }
484
+ return attempt;
485
+ };
486
+
487
+ const writeStream = (stream, targetPath) => new Promise((resolve, reject) => {
488
+ const writer = fs.createWriteStream(targetPath);
489
+ stream.pipe(writer);
490
+ stream.on('error', reject);
491
+ writer.on('error', reject);
492
+ writer.on('finish', resolve);
493
+ });
494
+
495
+ let downloaded = 0;
496
+ for (const attachment of filtered) {
497
+ const targetPath = uniquePathFor(attachmentsDir, attachment.title);
498
+ const dataStream = await client.downloadAttachment(pageId, attachment.id);
499
+ await writeStream(dataStream, targetPath);
500
+ downloaded += 1;
501
+ console.log(`⬇️ ${chalk.green(attachment.title)} -> ${chalk.gray(targetPath)}`);
502
+ }
503
+
504
+ console.log(chalk.green(`Downloaded ${downloaded} attachment${downloaded === 1 ? '' : 's'} to ${attachmentsDir}`));
505
+ }
506
+ }
507
+
508
+ analytics.track('export', true);
509
+ } catch (error) {
510
+ analytics.track('export', false);
511
+ console.error(chalk.red('Error:'), error.message);
512
+ process.exit(1);
513
+ }
514
+ });
515
+
516
+ function sanitizeTitle(value) {
517
+ const fallback = 'page';
518
+ if (!value || typeof value !== 'string') {
519
+ return fallback;
520
+ }
521
+ const cleaned = value.replace(/[\\/:*?"<>|]/g, ' ').trim();
522
+ return cleaned || fallback;
523
+ }
524
+
340
525
  // Copy page tree command
341
526
  program
342
527
  .command('copy-tree <sourcePageId> <targetParentId> [newTitle]')
@@ -167,6 +167,76 @@ class ConfluenceClient {
167
167
  }));
168
168
  }
169
169
 
170
+ /**
171
+ * List attachments for a page with pagination support
172
+ */
173
+ async listAttachments(pageIdOrUrl, options = {}) {
174
+ const pageId = this.extractPageId(pageIdOrUrl);
175
+ const limit = this.parsePositiveInt(options.limit, 50);
176
+ const start = this.parsePositiveInt(options.start, 0);
177
+ const params = {
178
+ limit,
179
+ start
180
+ };
181
+
182
+ if (options.filename) {
183
+ params.filename = options.filename;
184
+ }
185
+
186
+ const response = await this.client.get(`/content/${pageId}/child/attachment`, { params });
187
+ const results = Array.isArray(response.data.results)
188
+ ? response.data.results.map((item) => this.normalizeAttachment(item))
189
+ : [];
190
+
191
+ return {
192
+ results,
193
+ nextStart: this.parseNextStart(response.data?._links?.next)
194
+ };
195
+ }
196
+
197
+ /**
198
+ * Fetch all attachments for a page, honoring an optional maxResults cap
199
+ */
200
+ async getAllAttachments(pageIdOrUrl, options = {}) {
201
+ const pageSize = this.parsePositiveInt(options.pageSize || options.limit, 50);
202
+ const maxResults = this.parsePositiveInt(options.maxResults, null);
203
+ const filename = options.filename;
204
+ let start = this.parsePositiveInt(options.start, 0);
205
+ const attachments = [];
206
+
207
+ let hasNext = true;
208
+ while (hasNext) {
209
+ const page = await this.listAttachments(pageIdOrUrl, {
210
+ limit: pageSize,
211
+ start,
212
+ filename
213
+ });
214
+ attachments.push(...page.results);
215
+
216
+ if (maxResults && attachments.length >= maxResults) {
217
+ return attachments.slice(0, maxResults);
218
+ }
219
+
220
+ hasNext = page.nextStart !== null && page.nextStart !== undefined;
221
+ if (hasNext) {
222
+ start = page.nextStart;
223
+ }
224
+ }
225
+
226
+ return attachments;
227
+ }
228
+
229
+ /**
230
+ * Download an attachment's data stream
231
+ */
232
+ async downloadAttachment(pageIdOrUrl, attachmentId, options = {}) {
233
+ const pageId = this.extractPageId(pageIdOrUrl);
234
+ const response = await this.client.get(`/content/${pageId}/child/attachment/${attachmentId}/data`, {
235
+ responseType: options.responseType || 'stream'
236
+ });
237
+ return response.data;
238
+ }
239
+
170
240
  /**
171
241
  * Convert markdown to Confluence storage format
172
242
  */
@@ -915,6 +985,65 @@ class ConfluenceClient {
915
985
  return this.globToRegExp(pattern).test(title);
916
986
  });
917
987
  }
988
+
989
+ matchesPattern(value, patterns) {
990
+ if (!patterns) {
991
+ return true;
992
+ }
993
+
994
+ const list = Array.isArray(patterns) ? patterns.filter(Boolean) : [patterns];
995
+ if (list.length === 0) {
996
+ return true;
997
+ }
998
+
999
+ return list.some((pattern) => this.globToRegExp(pattern).test(value));
1000
+ }
1001
+
1002
+ normalizeAttachment(raw) {
1003
+ return {
1004
+ id: raw.id,
1005
+ title: raw.title,
1006
+ mediaType: raw.metadata?.mediaType || raw.type || '',
1007
+ fileSize: raw.extensions?.fileSize || 0,
1008
+ version: raw.version?.number || 1,
1009
+ downloadLink: this.toAbsoluteUrl(raw._links?.download)
1010
+ };
1011
+ }
1012
+
1013
+ toAbsoluteUrl(pathOrUrl) {
1014
+ if (!pathOrUrl) {
1015
+ return null;
1016
+ }
1017
+
1018
+ if (pathOrUrl.startsWith('http://') || pathOrUrl.startsWith('https://')) {
1019
+ return pathOrUrl;
1020
+ }
1021
+
1022
+ const normalized = pathOrUrl.startsWith('/') ? pathOrUrl : `/${pathOrUrl}`;
1023
+ return `https://${this.domain}${normalized}`;
1024
+ }
1025
+
1026
+ parseNextStart(nextLink) {
1027
+ if (!nextLink) {
1028
+ return null;
1029
+ }
1030
+
1031
+ const match = nextLink.match(/[?&]start=(\d+)/);
1032
+ if (!match) {
1033
+ return null;
1034
+ }
1035
+
1036
+ const value = parseInt(match[1], 10);
1037
+ return Number.isNaN(value) ? null : value;
1038
+ }
1039
+
1040
+ parsePositiveInt(value, fallback) {
1041
+ const parsed = parseInt(value, 10);
1042
+ if (Number.isNaN(parsed) || parsed < 0) {
1043
+ return fallback;
1044
+ }
1045
+ return parsed;
1046
+ }
918
1047
  }
919
1048
 
920
1049
  module.exports = ConfluenceClient;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "confluence-cli",
3
- "version": "1.8.0",
3
+ "version": "1.10.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": {
@@ -35,6 +35,12 @@
35
35
  "eslint": "^8.55.0",
36
36
  "jest": "^29.7.0"
37
37
  },
38
+ "overrides": {
39
+ "js-yaml": "^4.1.1",
40
+ "@istanbuljs/load-nyc-config": {
41
+ "js-yaml": "^3.14.2"
42
+ }
43
+ },
38
44
  "engines": {
39
45
  "node": ">=14.0.0"
40
46
  },
@@ -274,4 +274,26 @@ describe('ConfluenceClient', () => {
274
274
  expect(client.shouldExcludePage('production', patterns)).toBe(false);
275
275
  });
276
276
  });
277
+
278
+ describe('attachments', () => {
279
+ test('should have required methods for attachment handling', () => {
280
+ expect(typeof client.listAttachments).toBe('function');
281
+ expect(typeof client.getAllAttachments).toBe('function');
282
+ expect(typeof client.downloadAttachment).toBe('function');
283
+ });
284
+
285
+ test('matchesPattern should respect glob patterns', () => {
286
+ expect(client.matchesPattern('report.png', '*.png')).toBe(true);
287
+ expect(client.matchesPattern('report.png', '*.jpg')).toBe(false);
288
+ expect(client.matchesPattern('report.png', ['*.jpg', 'report.*'])).toBe(true);
289
+ expect(client.matchesPattern('report.png', null)).toBe(true);
290
+ expect(client.matchesPattern('report.png', [])).toBe(true);
291
+ });
292
+
293
+ test('parseNextStart should read start query param when present', () => {
294
+ expect(client.parseNextStart('/rest/api/content/1/child/attachment?start=25')).toBe(25);
295
+ expect(client.parseNextStart('/rest/api/content/1/child/attachment?limit=50')).toBeNull();
296
+ expect(client.parseNextStart(null)).toBeNull();
297
+ });
298
+ });
277
299
  });