diff --git a/README.md b/README.md index 30f5a8d..25fcf88 100644 --- a/README.md +++ b/README.md @@ -631,6 +631,11 @@ confluence export 123456789 --dest ./exports # Custom content format/filename and attachment filtering confluence export 123456789 --format html --file content.html --pattern "*.png" +# Exclude matching attachments (comma-separated globs) +confluence export 123456789 --exclude-attachments "*.mp4,*.mov" + +# Exclusions are applied after --pattern or --referenced-only when combined + # Skip attachments if you only need the content file confluence export 123456789 --skip-attachments ``` @@ -978,7 +983,7 @@ confluence stats | `property-get ` | Get a content property by key | `--json` | | `property-set ` | Set a content property (create or update) | `--value `, `--file `, `--json` | | `property-delete ` | Delete a content property by key | `--yes`, `--json` | -| `export ` | Export a page to a directory with its attachments | `--format `, `--dest `, `--file `, `--attachments-dir `, `--pattern `, `--referenced-only`, `--skip-attachments`, `-r, --recursive`, `--max-depth `, `--exclude `, `--delay-ms `, `--dry-run`, `--overwrite` | +| `export ` | Export a page to a directory with its attachments | `--format `, `--dest `, `--file `, `--attachments-dir `, `--pattern `, `--exclude-attachments `, `--referenced-only`, `--skip-attachments`, `-r, --recursive`, `--max-depth `, `--exclude `, `--delay-ms `, `--dry-run`, `--overwrite` | | `profile list` | List all configuration profiles | | | `profile use ` | Set the active configuration profile | | | `profile add ` | Add a new configuration profile | `-d, --domain`, `-p, --api-path`, `-a, --auth-type`, `-e, --email`, `-t, --token`, `--protocol`, `--read-only` | diff --git a/bin/commands/export.js b/bin/commands/export.js index f07b2de..79fdf5d 100644 --- a/bin/commands/export.js +++ b/bin/commands/export.js @@ -56,6 +56,39 @@ function sanitizeTitle(value) { return cleaned || fallback; } +/** + * Filters attachments according to the export options. + * + * @param {ConfluenceClient} client - Confluence client instance. + * @param {Array} attachments - Attachments to filter. + * @param {Object} options - Export options. + * @param {Set} referencedAttachments - Referenced attachment filenames. + * @returns {Array} Filtered attachments. + */ +function filterAttachments(client, attachments, options, referencedAttachments = null) { + const pattern = options.pattern ? options.pattern.trim() : null; + const excludeAttachmentPatterns = options.excludeAttachments + ? options.excludeAttachments.split(',').map(p => p.trim()).filter(Boolean) + : []; + + let filtered; + if (pattern) { + filtered = attachments.filter(att => client.matchesPattern(att.title, pattern)); + } else if (options.referencedOnly) { + filtered = attachments.filter(att => referencedAttachments?.has(att.title)); + } else { + filtered = attachments; + } + + if (excludeAttachmentPatterns.length > 0) { + filtered = filtered.filter( + att => !client.matchesPattern(att.title, excludeAttachmentPatterns) + ); + } + + return filtered; +} + async function exportRecursive(client, fs, path, pageId, options) { const maxDepth = options.maxDepth || 10; const delayMs = options.delayMs != null ? options.delayMs : 100; @@ -152,17 +185,8 @@ async function exportRecursive(client, fs, path, pageId, options) { // Download attachments if (!options.skipAttachments) { - const pattern = options.pattern ? options.pattern.trim() : null; const allAttachments = await client.getAllAttachments(page.id); - - let filtered; - if (pattern) { - filtered = allAttachments.filter(att => client.matchesPattern(att.title, pattern)); - } else if (options.referencedOnly) { - filtered = allAttachments.filter(att => referencedAttachments?.has(att.title)); - } else { - filtered = allAttachments; - } + const filtered = filterAttachments(client, allAttachments, options, referencedAttachments); if (filtered.length > 0) { const attachmentsDirName = options.attachmentsDir || 'attachments'; @@ -239,6 +263,7 @@ function registerExportCommand(program, { withClient }) { .option('--file ', 'Content filename (default: page.)') .option('--attachments-dir ', 'Subdirectory for attachments', 'attachments') .option('--pattern ', 'Filter attachments by filename (e.g., "*.png")') + .option('--exclude-attachments ', 'Comma-separated attachment filename glob patterns to skip') .option('--referenced-only', 'Download only attachments referenced in the page content') .option('--skip-attachments', 'Do not download attachments') .option('-r, --recursive', 'Export page and all descendants') @@ -297,17 +322,8 @@ function registerExportCommand(program, { withClient }) { console.log(`Content: ${chalk.gray(contentPath)}`); if (!options.skipAttachments) { - const pattern = options.pattern ? options.pattern.trim() : null; const allAttachments = await client.getAllAttachments(pageId); - - let filtered; - if (pattern) { - filtered = allAttachments.filter(att => client.matchesPattern(att.title, pattern)); - } else if (options.referencedOnly) { - filtered = allAttachments.filter(att => referencedAttachments?.has(att.title)); - } else { - filtered = allAttachments; - } + const filtered = filterAttachments(client, allAttachments, options, referencedAttachments); if (filtered.length === 0) { console.log(chalk.yellow('No attachments to download.')); @@ -341,4 +357,5 @@ module.exports.isExportDirectory = isExportDirectory; module.exports.uniquePathFor = uniquePathFor; module.exports.writeStream = writeStream; module.exports.sanitizeTitle = sanitizeTitle; +module.exports.filterAttachments = filterAttachments; module.exports.exportRecursive = exportRecursive; diff --git a/plugins/confluence/skills/confluence/SKILL.md b/plugins/confluence/skills/confluence/SKILL.md index 9fa40bb..2ebe5cf 100644 --- a/plugins/confluence/skills/confluence/SKILL.md +++ b/plugins/confluence/skills/confluence/SKILL.md @@ -439,7 +439,7 @@ Only content with a storage body can be exported for editing. Folders and other Export a page and its attachments to a local directory. ```sh -confluence export [--format html|text|markdown] [--dest ] [--file ] [--attachments-dir ] [--pattern ] [--referenced-only] [--skip-attachments] +confluence export [--format html|text|markdown] [--dest ] [--file ] [--attachments-dir ] [--pattern ] [--exclude-attachments ] [--referenced-only] [--skip-attachments] ``` | Option | Default | Description | @@ -449,13 +449,17 @@ confluence export [--format html|text|markdown] [--dest ] [- | `--file` | `page.` | Filename for the content file | | `--attachments-dir` | `attachments` | Subdirectory name for attachments | | `--pattern` | — | Glob filter for attachments (e.g. `*.png`) | +| `--exclude-attachments` | — | Comma-separated attachment filename globs to skip (e.g. `*.mp4,*.mov`) | | `--referenced-only` | false | Only download attachments referenced in the page content | | `--skip-attachments` | false | Do not download attachments | +When combined with `--pattern` or `--referenced-only`, `--exclude-attachments` is applied last. + ```sh confluence export 123456789 --format markdown --dest ./docs confluence export 123456789 --format markdown --dest ./docs --skip-attachments confluence export 123456789 --pattern "*.png" --dest ./output +confluence export 123456789 --exclude-attachments "*.mp4,*.mov" --dest ./output ``` Creates a subdirectory named after the page title under `--dest`. diff --git a/tests/export.test.js b/tests/export.test.js index 2667d7e..96cf836 100644 --- a/tests/export.test.js +++ b/tests/export.test.js @@ -11,6 +11,7 @@ const { uniquePathFor, exportRecursive, sanitizeTitle, + filterAttachments, } = require('../bin/commands/export.js'); const { sanitizeFilename } = require('../lib/file-utils'); @@ -218,6 +219,85 @@ describe('sanitizeTitle', () => { }); }); +// --------------------------------------------------------------------------- +// Attachment filtering +// --------------------------------------------------------------------------- +describe('filterAttachments', () => { + const attachments = [ + { id: '1', title: 'video.mp4' }, + { id: '2', title: 'diagram.png' }, + { id: '3', title: 'archive.zip' }, + ]; + + function matchesPattern(value, patterns) { + const list = Array.isArray(patterns) ? patterns : [patterns]; + return list.some((pattern) => { + if (pattern === '*') return true; + if (pattern === '*.mp4') return value.toLowerCase().endsWith('.mp4'); + if (pattern === '*.png') return value.toLowerCase().endsWith('.png'); + if (pattern === '*.zip') return value.toLowerCase().endsWith('.zip'); + return value.toLowerCase() === pattern.toLowerCase(); + }); + } + + test('preserves existing behavior when no attachment exclusions are provided', () => { + const client = { matchesPattern: jest.fn(matchesPattern) }; + + expect(filterAttachments(client, attachments, {})).toEqual(attachments); + }); + + test('ignores empty attachment exclusion patterns', () => { + const client = { matchesPattern: jest.fn(matchesPattern) }; + + expect(filterAttachments(client, attachments, { + excludeAttachments: ' , , ', + })).toEqual(attachments); + }); + + test('applies attachment exclusions after an include pattern', () => { + const client = { matchesPattern: jest.fn(matchesPattern) }; + + const filtered = filterAttachments(client, attachments, { + pattern: '*', + excludeAttachments: '*.mp4, *.zip', + }); + + expect(filtered).toEqual([{ id: '2', title: 'diagram.png' }]); + }); + + test('applies attachment exclusions after referenced-only filtering', () => { + const client = { matchesPattern: jest.fn(matchesPattern) }; + const referenced = new Set(['video.mp4', 'diagram.png']); + + const filtered = filterAttachments(client, attachments, { + referencedOnly: true, + excludeAttachments: '*.mp4', + }, referenced); + + expect(filtered).toEqual([{ id: '2', title: 'diagram.png' }]); + }); + + test('supports multiple comma-separated exclusion patterns', () => { + const client = { matchesPattern: jest.fn(matchesPattern) }; + + const filtered = filterAttachments(client, attachments, { + excludeAttachments: '*.mp4, *.zip', + }); + + expect(filtered).toEqual([{ id: '2', title: 'diagram.png' }]); + }); + + test('can exclude every attachment', () => { + const client = { matchesPattern: jest.fn(matchesPattern) }; + + const filtered = filterAttachments(client, attachments, { + excludeAttachments: '*', + }); + + expect(filtered).toEqual([]); + }); +}); + describe('registered non-recursive export command', () => { test('dry-run avoids reading content, downloading attachments, and creating export artifacts', async () => { const client = { @@ -254,6 +334,95 @@ describe('registered non-recursive export command', () => { fs.rmSync(temporaryRoot, { recursive: true, force: true }); } }); + + test('exclude-attachments skips matching downloads without affecting other attachments', async () => { + const attachments = [ + { id: 'attachment-1', title: 'video.mp4' }, + { id: 'attachment-2', title: 'diagram.png' }, + { id: 'attachment-3', title: 'recording.MOV' }, + ]; + const client = { + getPageInfo: jest.fn(async () => ({ id: '123', title: 'Export Page' })), + readPage: jest.fn(async () => '# content'), + getAllAttachments: jest.fn(async () => attachments), + downloadAttachment: jest.fn(async () => { + const stream = new PassThrough(); + stream.end('attachment'); + return stream; + }), + matchesPattern: jest.fn((value, patterns) => { + const list = Array.isArray(patterns) ? patterns : [patterns]; + return list.some((pattern) => { + if (pattern === '*.mp4') return value.toLowerCase().endsWith('.mp4'); + if (pattern === '*.mov') return value.toLowerCase().endsWith('.mov'); + return false; + }); + }), + _referencedAttachments: new Set(), + }; + const analytics = { track: jest.fn() }; + const program = new Command(); + const registerExportCommand = require('../bin/commands/export.js'); + registerExportCommand(program, { + withClient: (_command, handler) => async (...args) => handler({ client, analytics }, ...args), + }); + const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'confluence-export-exclude-')); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + + try { + await program.parseAsync([ + 'export', + '123', + '--dest', temporaryRoot, + '--exclude-attachments', '*.mp4, *.mov', + ], { from: 'user' }); + + expect(client.getAllAttachments).toHaveBeenCalledWith('123'); + expect(client.downloadAttachment).toHaveBeenCalledTimes(1); + expect(client.downloadAttachment).toHaveBeenCalledWith('123', attachments[1]); + expect(fs.existsSync(path.join(temporaryRoot, 'Export Page', 'attachments', 'diagram.png'))).toBe(true); + expect(fs.existsSync(path.join(temporaryRoot, 'Export Page', 'attachments', 'video.mp4'))).toBe(false); + expect(fs.existsSync(path.join(temporaryRoot, 'Export Page', 'attachments', 'recording.MOV'))).toBe(false); + } finally { + logSpy.mockRestore(); + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + } + }); + + test('skip-attachments still bypasses attachment listing when exclusions are also provided', async () => { + const client = { + getPageInfo: jest.fn(async () => ({ id: '123', title: 'Export Page' })), + readPage: jest.fn(async () => '# content'), + getAllAttachments: jest.fn(async () => []), + downloadAttachment: jest.fn(), + matchesPattern: jest.fn(), + _referencedAttachments: new Set(), + }; + const analytics = { track: jest.fn() }; + const program = new Command(); + const registerExportCommand = require('../bin/commands/export.js'); + registerExportCommand(program, { + withClient: (_command, handler) => async (...args) => handler({ client, analytics }, ...args), + }); + const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'confluence-export-skip-')); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + + try { + await program.parseAsync([ + 'export', + '123', + '--dest', temporaryRoot, + '--skip-attachments', + '--exclude-attachments', '*.mp4', + ], { from: 'user' }); + + expect(client.getAllAttachments).not.toHaveBeenCalled(); + expect(client.downloadAttachment).not.toHaveBeenCalled(); + } finally { + logSpy.mockRestore(); + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + } + }); }); // --------------------------------------------------------------------------- @@ -406,4 +575,32 @@ describe('exportRecursive', () => { consoleSpy.mockRestore(); }); + + test('exclude-attachments applies during recursive export', async () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + client.getAllDescendantPages.mockResolvedValue([]); + client.buildPageTree.mockReturnValue([]); + client.getAllAttachments.mockResolvedValue([ + { id: 'attachment-1', title: 'video.mp4' }, + { id: 'attachment-2', title: 'diagram.png' }, + ]); + client.matchesPattern.mockImplementation((value, patterns) => { + const list = Array.isArray(patterns) ? patterns : [patterns]; + return list.some(pattern => pattern === '*.mp4' && value.toLowerCase().endsWith('.mp4')); + }); + + await exportRecursive(client, fs, path, '1', { + dest: '/tmp/out', + delayMs: 0, + excludeAttachments: '*.mp4', + }); + + expect(client.downloadAttachment).toHaveBeenCalledTimes(1); + expect(client.downloadAttachment).toHaveBeenCalledWith( + '1', + expect.objectContaining({ title: 'diagram.png' }) + ); + + consoleSpy.mockRestore(); + }); });