confluence-cli 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [1.9.0](https://github.com/pchuri/confluence-cli/compare/v1.8.0...v1.9.0) (2025-12-04)
2
+
3
+
4
+ ### Features
5
+
6
+ * add attachments list and download command ([#17](https://github.com/pchuri/confluence-cli/issues/17)) ([fb3d4f8](https://github.com/pchuri/confluence-cli/commit/fb3d4f81a3926fec832a39c78f4eda3b4a22130a))
7
+
1
8
  # [1.8.0](https://github.com/pchuri/confluence-cli/compare/v1.7.0...v1.8.0) (2025-09-28)
2
9
 
3
10
 
package/README.md CHANGED
@@ -10,6 +10,7 @@ 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
13
14
  - 🛠️ **Edit workflow** - Export page content for editing and re-import
14
15
  - 🔧 **Easy setup** - Simple configuration with environment variables or interactive setup
15
16
 
@@ -107,6 +108,18 @@ confluence search "search term"
107
108
  confluence search "search term" --limit 5
108
109
  ```
109
110
 
111
+ ### List or Download Attachments
112
+ ```bash
113
+ # List all attachments on a page
114
+ confluence attachments 123456789
115
+
116
+ # Filter by filename and limit the number returned
117
+ confluence attachments 123456789 --pattern "*.png" --limit 5
118
+
119
+ # Download matching attachments to a directory
120
+ confluence attachments 123456789 --pattern "*.png" --download --dest ./downloads
121
+ ```
122
+
110
123
  ### List Spaces
111
124
  ```bash
112
125
  confluence spaces
package/bin/confluence.js CHANGED
@@ -337,6 +337,90 @@ 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
+
340
424
  // Copy page tree command
341
425
  program
342
426
  .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.9.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
  });