From c41d19b4c4b6b42b4b8a6bda077bd3265cbf4db7 Mon Sep 17 00:00:00 2001 From: "heecheol.park" Date: Wed, 13 May 2026 00:03:52 +0900 Subject: [PATCH 1/6] refactor(cli): extract profile commands to bin/commands/profile.js Move `profile list/use/add/remove` into a dedicated module and migrate the four actions onto the `withLocal` helper so they share the standard analytics + error pipeline instead of inline try/catch + process.exit(1). --- bin/commands/profile.js | 81 ++++++++++++++++++++++++++++++++++++++++ bin/confluence.js | 83 ++--------------------------------------- 2 files changed, 84 insertions(+), 80 deletions(-) create mode 100644 bin/commands/profile.js diff --git a/bin/commands/profile.js b/bin/commands/profile.js new file mode 100644 index 0000000..3588784 --- /dev/null +++ b/bin/commands/profile.js @@ -0,0 +1,81 @@ +const chalk = require('chalk'); +const inquirer = require('inquirer'); +const { + initConfig, + listProfiles, + setActiveProfile, + deleteProfile, + isValidProfileName, +} = require('../../lib/config'); + +function registerProfileCommands(program, { withLocal }) { + const profileCmd = program + .command('profile') + .description('Manage configuration profiles'); + + profileCmd + .command('list') + .description('List all configuration profiles') + .action(withLocal('profile_list', async () => { + const { profiles } = listProfiles(); + if (profiles.length === 0) { + console.log(chalk.yellow('No profiles configured. Run "confluence init" to create one.')); + return; + } + console.log(chalk.blue('Configuration profiles:\n')); + profiles.forEach(p => { + const marker = p.active ? chalk.green(' (active)') : ''; + const readOnlyBadge = p.readOnly ? chalk.red(' [read-only]') : ''; + console.log(` ${p.active ? chalk.green('*') : ' '} ${chalk.cyan(p.name)}${marker}${readOnlyBadge} - ${chalk.gray(p.domain)}`); + }); + })); + + profileCmd + .command('use ') + .description('Set the active configuration profile') + .action(withLocal('profile_use', async (_ctx, name) => { + setActiveProfile(name); + console.log(chalk.green(`Switched to profile "${name}"`)); + })); + + profileCmd + .command('add ') + .description('Add a new configuration profile interactively') + .option('-d, --domain ', 'Confluence domain') + .option('--protocol ', 'Protocol (http or https)') + .option('-p, --api-path ', 'REST API path') + .option('-a, --auth-type ', 'Authentication type (basic, bearer, mtls, or cookie)') + .option('-e, --email ', 'Email or username for basic auth') + .option('-t, --token ', 'API token') + .option('-c, --cookie ', 'Cookie for Enterprise SSO authentication (e.g., "JSESSIONID=...")') + .option('--tls-ca-cert ', 'CA certificate for mTLS connections') + .option('--tls-client-cert ', 'Client certificate for mTLS connections') + .option('--tls-client-key ', 'Client private key for mTLS connections') + .option('--read-only', 'Set profile to read-only mode (blocks write operations)') + .action(withLocal('profile_add', async (_ctx, name, options) => { + if (!isValidProfileName(name)) { + throw new Error('Invalid profile name. Use only letters, numbers, hyphens, and underscores.'); + } + await initConfig({ ...options, profile: name }); + })); + + profileCmd + .command('remove ') + .description('Remove a configuration profile') + .action(withLocal('profile_remove', async (_ctx, name) => { + const { confirmed } = await inquirer.prompt([{ + type: 'confirm', + name: 'confirmed', + message: `Delete profile "${name}"?`, + default: false, + }]); + if (!confirmed) { + console.log(chalk.yellow('Cancelled.')); + return; + } + deleteProfile(name); + console.log(chalk.green(`Profile "${name}" removed.`)); + })); +} + +module.exports = registerProfileCommands; diff --git a/bin/confluence.js b/bin/confluence.js index 5f154e3..337eca8 100755 --- a/bin/confluence.js +++ b/bin/confluence.js @@ -6,9 +6,10 @@ const { program } = require('commander'); const chalk = require('chalk'); const inquirer = require('inquirer'); const ConfluenceClient = require('../lib/confluence-client'); -const { getConfig, initConfig, listProfiles, setActiveProfile, deleteProfile, isValidProfileName } = require('../lib/config'); +const { getConfig, initConfig } = require('../lib/config'); const Analytics = require('../lib/analytics'); const pkg = require('../package.json'); +const registerProfileCommands = require('./commands/profile'); function assertWritable(config) { if (config.readOnly) { @@ -1885,85 +1886,7 @@ function printTree(nodes, client, config, options, depth = 1) { }); } -// Profile management commands -const profileCmd = program - .command('profile') - .description('Manage configuration profiles'); - -profileCmd - .command('list') - .description('List all configuration profiles') - .action(() => { - const { profiles } = listProfiles(); - if (profiles.length === 0) { - console.log(chalk.yellow('No profiles configured. Run "confluence init" to create one.')); - return; - } - console.log(chalk.blue('Configuration profiles:\n')); - profiles.forEach(p => { - const marker = p.active ? chalk.green(' (active)') : ''; - const readOnlyBadge = p.readOnly ? chalk.red(' [read-only]') : ''; - console.log(` ${p.active ? chalk.green('*') : ' '} ${chalk.cyan(p.name)}${marker}${readOnlyBadge} - ${chalk.gray(p.domain)}`); - }); - }); - -profileCmd - .command('use ') - .description('Set the active configuration profile') - .action((name) => { - try { - setActiveProfile(name); - console.log(chalk.green(`Switched to profile "${name}"`)); - } catch (error) { - console.error(chalk.red('Error:'), error.message); - process.exit(1); - } - }); - -profileCmd - .command('add ') - .description('Add a new configuration profile interactively') - .option('-d, --domain ', 'Confluence domain') - .option('--protocol ', 'Protocol (http or https)') - .option('-p, --api-path ', 'REST API path') - .option('-a, --auth-type ', 'Authentication type (basic, bearer, mtls, or cookie)') - .option('-e, --email ', 'Email or username for basic auth') - .option('-t, --token ', 'API token') - .option('-c, --cookie ', 'Cookie for Enterprise SSO authentication (e.g., "JSESSIONID=...")') - .option('--tls-ca-cert ', 'CA certificate for mTLS connections') - .option('--tls-client-cert ', 'Client certificate for mTLS connections') - .option('--tls-client-key ', 'Client private key for mTLS connections') - .option('--read-only', 'Set profile to read-only mode (blocks write operations)') - .action(async (name, options) => { - if (!isValidProfileName(name)) { - console.error(chalk.red('Invalid profile name. Use only letters, numbers, hyphens, and underscores.')); - process.exit(1); - } - await initConfig({ ...options, profile: name }); - }); - -profileCmd - .command('remove ') - .description('Remove a configuration profile') - .action(async (name) => { - try { - const { confirmed } = await inquirer.prompt([{ - type: 'confirm', - name: 'confirmed', - message: `Delete profile "${name}"?`, - default: false - }]); - if (!confirmed) { - console.log(chalk.yellow('Cancelled.')); - return; - } - deleteProfile(name); - console.log(chalk.green(`Profile "${name}" removed.`)); - } catch (error) { - console.error(chalk.red('Error:'), error.message); - process.exit(1); - } - }); +registerProfileCommands(program, { withLocal }); // Convert command (local format conversion, no server connection required) const VALID_INPUT_FORMATS = ['markdown', 'storage', 'html']; From b86a55fcbf8409f24b38425c3b9091754398a925 Mon Sep 17 00:00:00 2001 From: "heecheol.park" Date: Wed, 13 May 2026 00:04:30 +0900 Subject: [PATCH 2/6] refactor(cli): extract versions commands to bin/commands/versions.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move `versions`, `version-delete`, and `versions-purge` into a dedicated module. No behavior changes — same prompts, exit codes, and analytics keys. --- bin/commands/versions.js | 120 +++++++++++++++++++++++++++++++++++++++ bin/confluence.js | 119 +------------------------------------- 2 files changed, 122 insertions(+), 117 deletions(-) create mode 100644 bin/commands/versions.js diff --git a/bin/commands/versions.js b/bin/commands/versions.js new file mode 100644 index 0000000..50b5b5b --- /dev/null +++ b/bin/commands/versions.js @@ -0,0 +1,120 @@ +const chalk = require('chalk'); +const inquirer = require('inquirer'); + +function registerVersionCommands(program, { withClient }) { + program + .command('versions ') + .description('List historical versions of a Confluence page') + .option('--format ', 'Output format: text or json (default: text)', 'text') + .action(withClient('versions', async ({ client, analytics }, pageId, options) => { + const resolvedId = String(await client.extractPageId(pageId)); + const versions = await client.listVersions(resolvedId); + + if (options.format === 'json') { + console.log(JSON.stringify({ pageId: resolvedId, versions }, null, 2)); + } else { + const max = versions.length ? Math.max(...versions.map(v => v.number)) : 0; + console.log(chalk.blue(`Versions for page ${resolvedId} (${versions.length} total):`)); + if (versions.length === 0) { + console.log(chalk.yellow(' (no versions returned)')); + } + for (const v of versions) { + const tag = v.number === max ? chalk.green(' [current]') : ''; + const author = v.by || 'unknown'; + const note = v.message ? ` — ${v.message}` : ''; + console.log(` v${v.number}${tag} ${v.when} ${author}${note}`); + } + } + analytics.track('versions', true); + })); + + program + .command('version-delete ') + .description('Delete a single historical version of a page (cannot delete the current version)') + .option('-y, --yes', 'Skip confirmation prompt') + .action(withClient('version_delete', async ({ client, analytics }, pageId, versionNumber, options) => { + const resolvedId = String(await client.extractPageId(pageId)); + const n = Number(versionNumber); + + if (!options.yes) { + const { confirmed } = await inquirer.prompt([{ + type: 'confirm', + name: 'confirmed', + default: false, + message: `Delete v${n} of page ${resolvedId}? This cannot be undone.`, + }]); + if (!confirmed) { + console.log(chalk.yellow('Cancelled.')); + analytics.track('version_delete_cancel', true); + return; + } + } + + const result = await client.deleteVersion(resolvedId, n); + const note = result.viaExperimental ? chalk.yellow(' (via experimental endpoint)') : ''; + console.log(chalk.green(`✅ Deleted v${result.versionNumber} of page ${result.id}${note}`)); + analytics.track('version_delete', true); + }, { writable: true })); + + program + .command('versions-purge ') + .description('Delete every non-current historical version of a page (keeps only current)') + .option('-y, --yes', 'Skip confirmation prompt') + .option('--throttle ', 'Sleep between version-delete calls', '0') + .action(withClient('versions_purge', async ({ client, analytics }, pageId, options) => { + const resolvedId = String(await client.extractPageId(pageId)); + const versions = await client.listVersions(resolvedId); + + if (versions.length === 0) { + console.log(chalk.yellow(`No versions returned for page ${resolvedId}.`)); + analytics.track('versions_purge', true); + return; + } + const max = Math.max(...versions.map(v => v.number)); + const historicalCount = versions.filter(v => v.number !== max).length; + if (historicalCount === 0) { + console.log(chalk.yellow(`Only current version v${max} exists for page ${resolvedId}; nothing to purge.`)); + analytics.track('versions_purge', true); + return; + } + + if (!options.yes) { + const { confirmed } = await inquirer.prompt([{ + type: 'confirm', + name: 'confirmed', + default: false, + message: `Delete ${historicalCount} historical version(s) of page ${resolvedId}? Current version (v${max}) will be kept.`, + }]); + if (!confirmed) { + console.log(chalk.yellow('Cancelled.')); + analytics.track('versions_purge_cancel', true); + return; + } + } + + const throttleMs = Math.max(0, parseFloat(options.throttle || '0')) * 1000; + const result = await client.purgeNonCurrentVersions(resolvedId, { + onProgress: async (event) => { + if (event.kind === 'deleted') { + const note = event.viaExperimental ? chalk.yellow(' (experimental)') : ''; + console.log(chalk.green(` ✓ deleted v${event.versionNumber}${note}`)); + } else if (event.kind === 'failed') { + console.log(chalk.red(` ✗ v${event.versionNumber}: ${event.message}`)); + } + if (throttleMs > 0) { + await new Promise(r => setTimeout(r, throttleMs)); + } + }, + }); + + console.log(''); + console.log(chalk.green(`✅ Purge complete for page ${result.id}: ` + + `${result.deleted} deleted, ${result.failed} failed, kept v${result.kept}.`)); + analytics.track('versions_purge', result.failed === 0); + if (result.failed > 0) { + process.exitCode = 1; + } + }, { writable: true })); +} + +module.exports = registerVersionCommands; diff --git a/bin/confluence.js b/bin/confluence.js index 337eca8..32e5209 100755 --- a/bin/confluence.js +++ b/bin/confluence.js @@ -10,6 +10,7 @@ const { getConfig, initConfig } = require('../lib/config'); const Analytics = require('../lib/analytics'); const pkg = require('../package.json'); const registerProfileCommands = require('./commands/profile'); +const registerVersionCommands = require('./commands/versions'); function assertWritable(config) { if (config.readOnly) { @@ -423,123 +424,7 @@ program analytics.track('delete', true); }, { writable: true })); -// List historical versions of a page -program - .command('versions ') - .description('List historical versions of a Confluence page') - .option('--format ', 'Output format: text or json (default: text)', 'text') - .action(withClient('versions', async ({ client, analytics }, pageId, options) => { - const resolvedId = String(await client.extractPageId(pageId)); - const versions = await client.listVersions(resolvedId); - - if (options.format === 'json') { - console.log(JSON.stringify({ pageId: resolvedId, versions }, null, 2)); - } else { - const max = versions.length ? Math.max(...versions.map(v => v.number)) : 0; - console.log(chalk.blue(`Versions for page ${resolvedId} (${versions.length} total):`)); - if (versions.length === 0) { - console.log(chalk.yellow(' (no versions returned)')); - } - for (const v of versions) { - const tag = v.number === max ? chalk.green(' [current]') : ''; - const author = v.by || 'unknown'; - const note = v.message ? ` — ${v.message}` : ''; - console.log(` v${v.number}${tag} ${v.when} ${author}${note}`); - } - } - analytics.track('versions', true); - })); - -// Delete a single historical version of a page -program - .command('version-delete ') - .description('Delete a single historical version of a page (cannot delete the current version)') - .option('-y, --yes', 'Skip confirmation prompt') - .action(withClient('version_delete', async ({ client, analytics }, pageId, versionNumber, options) => { - const resolvedId = String(await client.extractPageId(pageId)); - const n = Number(versionNumber); - - if (!options.yes) { - const { confirmed } = await inquirer.prompt([{ - type: 'confirm', - name: 'confirmed', - default: false, - message: `Delete v${n} of page ${resolvedId}? This cannot be undone.` - }]); - if (!confirmed) { - console.log(chalk.yellow('Cancelled.')); - analytics.track('version_delete_cancel', true); - return; - } - } - - const result = await client.deleteVersion(resolvedId, n); - const note = result.viaExperimental ? chalk.yellow(' (via experimental endpoint)') : ''; - console.log(chalk.green(`✅ Deleted v${result.versionNumber} of page ${result.id}${note}`)); - analytics.track('version_delete', true); - }, { writable: true })); - -// Convenience: delete every non-current historical version of a page, -// keeping only the current one. -program - .command('versions-purge ') - .description('Delete every non-current historical version of a page (keeps only current)') - .option('-y, --yes', 'Skip confirmation prompt') - .option('--throttle ', 'Sleep between version-delete calls', '0') - .action(withClient('versions_purge', async ({ client, analytics }, pageId, options) => { - const resolvedId = String(await client.extractPageId(pageId)); - const versions = await client.listVersions(resolvedId); - - if (versions.length === 0) { - console.log(chalk.yellow(`No versions returned for page ${resolvedId}.`)); - analytics.track('versions_purge', true); - return; - } - const max = Math.max(...versions.map(v => v.number)); - const historicalCount = versions.filter(v => v.number !== max).length; - if (historicalCount === 0) { - console.log(chalk.yellow(`Only current version v${max} exists for page ${resolvedId}; nothing to purge.`)); - analytics.track('versions_purge', true); - return; - } - - if (!options.yes) { - const { confirmed } = await inquirer.prompt([{ - type: 'confirm', - name: 'confirmed', - default: false, - message: `Delete ${historicalCount} historical version(s) of page ${resolvedId}? Current version (v${max}) will be kept.` - }]); - if (!confirmed) { - console.log(chalk.yellow('Cancelled.')); - analytics.track('versions_purge_cancel', true); - return; - } - } - - const throttleMs = Math.max(0, parseFloat(options.throttle || '0')) * 1000; - const result = await client.purgeNonCurrentVersions(resolvedId, { - onProgress: async (event) => { - if (event.kind === 'deleted') { - const note = event.viaExperimental ? chalk.yellow(' (experimental)') : ''; - console.log(chalk.green(` ✓ deleted v${event.versionNumber}${note}`)); - } else if (event.kind === 'failed') { - console.log(chalk.red(` ✗ v${event.versionNumber}: ${event.message}`)); - } - if (throttleMs > 0) { - await new Promise(r => setTimeout(r, throttleMs)); - } - } - }); - - console.log(''); - console.log(chalk.green(`✅ Purge complete for page ${result.id}: ` + - `${result.deleted} deleted, ${result.failed} failed, kept v${result.kept}.`)); - analytics.track('versions_purge', result.failed === 0); - if (result.failed > 0) { - process.exitCode = 1; - } - }, { writable: true })); +registerVersionCommands(program, { withClient }); // Edit command - opens page content for editing program From b4d0015c01cbc0d2766bfe5248623cc27c41db7c Mon Sep 17 00:00:00 2001 From: "heecheol.park" Date: Wed, 13 May 2026 00:05:18 +0900 Subject: [PATCH 3/6] refactor(cli): extract attachment commands to bin/commands/attachments.js Move `attachments`, `attachment-upload`, and `attachment-delete` into a dedicated module. The download path's inline `sanitizeFilename` is duplicated here rather than imported, keeping the module self-contained (the export command keeps its own copy when it moves out next). --- bin/commands/attachments.js | 210 ++++++++++++++++++++++++++++++++++++ bin/confluence.js | 193 +-------------------------------- 2 files changed, 212 insertions(+), 191 deletions(-) create mode 100644 bin/commands/attachments.js diff --git a/bin/commands/attachments.js b/bin/commands/attachments.js new file mode 100644 index 0000000..5bb61fb --- /dev/null +++ b/bin/commands/attachments.js @@ -0,0 +1,210 @@ +const fs = require('fs'); +const path = require('path'); +const chalk = require('chalk'); +const inquirer = require('inquirer'); + +function sanitizeFilename(filename) { + if (!filename || typeof filename !== 'string') { + return 'unnamed'; + } + const stripped = path.basename(filename.replace(/\\/g, '/')); + const cleaned = stripped + // eslint-disable-next-line no-control-regex + .replace(/[\\/:*?"<>|\x00-\x1f]/g, '_') + .replace(/^\.+/, '') + .trim(); + return cleaned || 'unnamed'; +} + +function registerAttachmentCommands(program, { withClient }) { + program + .command('attachments ') + .description('List or download attachments for a page') + .option('-l, --limit ', 'Maximum number of attachments to fetch (default: all)') + .option('-p, --pattern ', 'Filter attachments by filename (e.g., "*.png")') + .option('-d, --download', 'Download matching attachments') + .option('--dest ', 'Directory to save downloads (default: current directory)', '.') + .option('-f, --format ', 'Output format (text, json)', 'text') + .action(withClient('attachments', async ({ client, analytics }, pageId, options) => { + const maxResults = options.limit ? parseInt(options.limit, 10) : null; + const pattern = options.pattern ? options.pattern.trim() : null; + + if (options.limit && (Number.isNaN(maxResults) || maxResults <= 0)) { + throw new Error('Limit must be a positive number.'); + } + + const format = (options.format || 'text').toLowerCase(); + if (!['text', 'json'].includes(format)) { + throw new Error('Format must be one of: text, json'); + } + + const attachments = await client.getAllAttachments(pageId, { maxResults }); + const filtered = pattern ? attachments.filter(att => client.matchesPattern(att.title, pattern)) : attachments; + + if (filtered.length === 0) { + if (format === 'json') { + console.log(JSON.stringify({ attachmentCount: 0, attachments: [] }, null, 2)); + } else { + console.log(chalk.yellow('No attachments found.')); + } + analytics.track('attachments', true); + return; + } + + if (format === 'json' && !options.download) { + const output = { + attachmentCount: filtered.length, + attachments: filtered.map(att => ({ + id: att.id, + title: att.title, + mediaType: att.mediaType || '', + fileSize: att.fileSize, + fileSizeFormatted: att.fileSize ? `${Math.max(1, Math.round(att.fileSize / 1024))} KB` : 'unknown size', + version: att.version, + downloadLink: att.downloadLink, + })), + }; + console.log(JSON.stringify(output, null, 2)); + } else if (!options.download) { + console.log(chalk.blue(`Found ${filtered.length} attachment${filtered.length === 1 ? '' : 's'}:`)); + filtered.forEach((att, index) => { + const sizeKb = att.fileSize ? `${Math.max(1, Math.round(att.fileSize / 1024))} KB` : 'unknown size'; + const typeLabel = att.mediaType || 'unknown'; + console.log(`${index + 1}. ${chalk.green(att.title)} (ID: ${att.id})`); + console.log(` Type: ${chalk.gray(typeLabel)} • Size: ${chalk.gray(sizeKb)} • Version: ${chalk.gray(att.version)}`); + }); + } + + if (options.download) { + const destDir = path.resolve(options.dest || '.'); + fs.mkdirSync(destDir, { recursive: true }); + + const uniquePathFor = (dir, filename) => { + const safeFilename = sanitizeFilename(filename); + const parsed = path.parse(safeFilename); + let attempt = path.join(dir, safeFilename); + let counter = 1; + while (fs.existsSync(attempt)) { + const suffix = ` (${counter})`; + const nextName = `${parsed.name}${suffix}${parsed.ext}`; + attempt = path.join(dir, nextName); + counter += 1; + } + return attempt; + }; + + const writeStream = (stream, targetPath) => new Promise((resolve, reject) => { + const writer = fs.createWriteStream(targetPath); + stream.pipe(writer); + stream.on('error', reject); + writer.on('error', reject); + writer.on('finish', resolve); + }); + + const downloadResults = []; + for (const attachment of filtered) { + const targetPath = uniquePathFor(destDir, attachment.title); + const dataStream = await client.downloadAttachment(pageId, attachment); + await writeStream(dataStream, targetPath); + downloadResults.push({ title: attachment.title, id: attachment.id, savedTo: targetPath }); + if (format !== 'json') { + console.log(`⬇️ ${chalk.green(attachment.title)} -> ${chalk.gray(targetPath)}`); + } + } + + if (format === 'json') { + const output = { + attachmentCount: filtered.length, + downloaded: downloadResults.length, + destination: destDir, + attachments: downloadResults, + }; + console.log(JSON.stringify(output, null, 2)); + } else { + console.log(chalk.green(`Downloaded ${downloadResults.length} attachment${downloadResults.length === 1 ? '' : 's'} to ${destDir}`)); + } + } + + analytics.track('attachments', true); + })); + + program + .command('attachment-upload ') + .description('Upload one or more attachments to a page') + .option('-f, --file ', 'File to upload (repeatable)', (value, previous) => { + const files = Array.isArray(previous) ? previous : []; + files.push(value); + return files; + }, []) + .option('--comment ', 'Comment for the attachment(s)') + .option('--replace', 'Replace an existing attachment with the same filename') + .option('--minor-edit', 'Mark the upload as a minor edit') + .action(withClient('attachment_upload', async ({ client, analytics }, pageId, options) => { + const files = Array.isArray(options.file) ? options.file.filter(Boolean) : []; + if (files.length === 0) { + throw new Error('At least one --file option is required.'); + } + + const resolvedFiles = files.map((filePath) => ({ + original: filePath, + resolved: path.resolve(filePath), + })); + + resolvedFiles.forEach((file) => { + if (!fs.existsSync(file.resolved)) { + throw new Error(`File not found: ${file.original}`); + } + }); + + let uploaded = 0; + for (const file of resolvedFiles) { + const result = await client.uploadAttachment(pageId, file.resolved, { + comment: options.comment, + replace: options.replace, + minorEdit: options.minorEdit === true ? true : undefined, + }); + const attachment = result.results[0]; + if (attachment) { + console.log(`⬆️ ${chalk.green(attachment.title)} (ID: ${attachment.id}, Version: ${attachment.version})`); + } else { + console.log(`⬆️ ${chalk.green(path.basename(file.resolved))}`); + } + uploaded += 1; + } + + console.log(chalk.green(`Uploaded ${uploaded} attachment${uploaded === 1 ? '' : 's'} to page ${pageId}`)); + analytics.track('attachment_upload', true); + }, { writable: true })); + + program + .command('attachment-delete ') + .description('Delete an attachment by ID from a page') + .option('-y, --yes', 'Skip confirmation prompt') + .action(withClient('attachment_delete', async ({ client, analytics }, pageId, attachmentId, options) => { + if (!options.yes) { + const { confirmed } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirmed', + default: false, + message: `Delete attachment ${attachmentId} from page ${pageId}?`, + }, + ]); + + if (!confirmed) { + console.log(chalk.yellow('Cancelled.')); + analytics.track('attachment_delete_cancel', true); + return; + } + } + + const result = await client.deleteAttachment(pageId, attachmentId); + + console.log(chalk.green('✅ Attachment deleted successfully!')); + console.log(`ID: ${chalk.blue(result.id)}`); + console.log(`Page ID: ${chalk.blue(result.pageId)}`); + analytics.track('attachment_delete', true); + }, { writable: true })); +} + +module.exports = registerAttachmentCommands; diff --git a/bin/confluence.js b/bin/confluence.js index 32e5209..f7829e9 100755 --- a/bin/confluence.js +++ b/bin/confluence.js @@ -11,6 +11,7 @@ const Analytics = require('../lib/analytics'); const pkg = require('../package.json'); const registerProfileCommands = require('./commands/profile'); const registerVersionCommands = require('./commands/versions'); +const registerAttachmentCommands = require('./commands/attachments'); function assertWritable(config) { if (config.readOnly) { @@ -470,197 +471,7 @@ program analytics.track('find', true); })); -// Attachments command -program - .command('attachments ') - .description('List or download attachments for a page') - .option('-l, --limit ', 'Maximum number of attachments to fetch (default: all)') - .option('-p, --pattern ', 'Filter attachments by filename (e.g., "*.png")') - .option('-d, --download', 'Download matching attachments') - .option('--dest ', 'Directory to save downloads (default: current directory)', '.') - .option('-f, --format ', 'Output format (text, json)', 'text') - .action(withClient('attachments', async ({ client, analytics }, pageId, options) => { - const maxResults = options.limit ? parseInt(options.limit, 10) : null; - const pattern = options.pattern ? options.pattern.trim() : null; - - if (options.limit && (Number.isNaN(maxResults) || maxResults <= 0)) { - throw new Error('Limit must be a positive number.'); - } - - const format = (options.format || 'text').toLowerCase(); - if (!['text', 'json'].includes(format)) { - throw new Error('Format must be one of: text, json'); - } - - const attachments = await client.getAllAttachments(pageId, { maxResults }); - const filtered = pattern ? attachments.filter(att => client.matchesPattern(att.title, pattern)) : attachments; - - if (filtered.length === 0) { - if (format === 'json') { - console.log(JSON.stringify({ attachmentCount: 0, attachments: [] }, null, 2)); - } else { - console.log(chalk.yellow('No attachments found.')); - } - analytics.track('attachments', true); - return; - } - - if (format === 'json' && !options.download) { - const output = { - attachmentCount: filtered.length, - attachments: filtered.map(att => ({ - id: att.id, - title: att.title, - mediaType: att.mediaType || '', - fileSize: att.fileSize, - fileSizeFormatted: att.fileSize ? `${Math.max(1, Math.round(att.fileSize / 1024))} KB` : 'unknown size', - version: att.version, - downloadLink: att.downloadLink - })) - }; - console.log(JSON.stringify(output, null, 2)); - } else if (!options.download) { - console.log(chalk.blue(`Found ${filtered.length} attachment${filtered.length === 1 ? '' : 's'}:`)); - filtered.forEach((att, index) => { - const sizeKb = att.fileSize ? `${Math.max(1, Math.round(att.fileSize / 1024))} KB` : 'unknown size'; - const typeLabel = att.mediaType || 'unknown'; - console.log(`${index + 1}. ${chalk.green(att.title)} (ID: ${att.id})`); - console.log(` Type: ${chalk.gray(typeLabel)} • Size: ${chalk.gray(sizeKb)} • Version: ${chalk.gray(att.version)}`); - }); - } - - if (options.download) { - const destDir = path.resolve(options.dest || '.'); - fs.mkdirSync(destDir, { recursive: true }); - - const uniquePathFor = (dir, filename) => { - const safeFilename = sanitizeFilename(filename); - const parsed = path.parse(safeFilename); - let attempt = path.join(dir, safeFilename); - let counter = 1; - while (fs.existsSync(attempt)) { - const suffix = ` (${counter})`; - const nextName = `${parsed.name}${suffix}${parsed.ext}`; - attempt = path.join(dir, nextName); - counter += 1; - } - return attempt; - }; - - const writeStream = (stream, targetPath) => new Promise((resolve, reject) => { - const writer = fs.createWriteStream(targetPath); - stream.pipe(writer); - stream.on('error', reject); - writer.on('error', reject); - writer.on('finish', resolve); - }); - - const downloadResults = []; - for (const attachment of filtered) { - const targetPath = uniquePathFor(destDir, attachment.title); - const dataStream = await client.downloadAttachment(pageId, attachment); - await writeStream(dataStream, targetPath); - downloadResults.push({ title: attachment.title, id: attachment.id, savedTo: targetPath }); - if (format !== 'json') { - console.log(`⬇️ ${chalk.green(attachment.title)} -> ${chalk.gray(targetPath)}`); - } - } - - if (format === 'json') { - const output = { - attachmentCount: filtered.length, - downloaded: downloadResults.length, - destination: destDir, - attachments: downloadResults - }; - console.log(JSON.stringify(output, null, 2)); - } else { - console.log(chalk.green(`Downloaded ${downloadResults.length} attachment${downloadResults.length === 1 ? '' : 's'} to ${destDir}`)); - } - } - - analytics.track('attachments', true); - })); - -// Attachment upload command -program - .command('attachment-upload ') - .description('Upload one or more attachments to a page') - .option('-f, --file ', 'File to upload (repeatable)', (value, previous) => { - const files = Array.isArray(previous) ? previous : []; - files.push(value); - return files; - }, []) - .option('--comment ', 'Comment for the attachment(s)') - .option('--replace', 'Replace an existing attachment with the same filename') - .option('--minor-edit', 'Mark the upload as a minor edit') - .action(withClient('attachment_upload', async ({ client, analytics }, pageId, options) => { - const files = Array.isArray(options.file) ? options.file.filter(Boolean) : []; - if (files.length === 0) { - throw new Error('At least one --file option is required.'); - } - - const resolvedFiles = files.map((filePath) => ({ - original: filePath, - resolved: path.resolve(filePath) - })); - - resolvedFiles.forEach((file) => { - if (!fs.existsSync(file.resolved)) { - throw new Error(`File not found: ${file.original}`); - } - }); - - let uploaded = 0; - for (const file of resolvedFiles) { - const result = await client.uploadAttachment(pageId, file.resolved, { - comment: options.comment, - replace: options.replace, - minorEdit: options.minorEdit === true ? true : undefined - }); - const attachment = result.results[0]; - if (attachment) { - console.log(`⬆️ ${chalk.green(attachment.title)} (ID: ${attachment.id}, Version: ${attachment.version})`); - } else { - console.log(`⬆️ ${chalk.green(path.basename(file.resolved))}`); - } - uploaded += 1; - } - - console.log(chalk.green(`Uploaded ${uploaded} attachment${uploaded === 1 ? '' : 's'} to page ${pageId}`)); - analytics.track('attachment_upload', true); - }, { writable: true })); - -// Attachment delete command -program - .command('attachment-delete ') - .description('Delete an attachment by ID from a page') - .option('-y, --yes', 'Skip confirmation prompt') - .action(withClient('attachment_delete', async ({ client, analytics }, pageId, attachmentId, options) => { - if (!options.yes) { - const { confirmed } = await inquirer.prompt([ - { - type: 'confirm', - name: 'confirmed', - default: false, - message: `Delete attachment ${attachmentId} from page ${pageId}?` - } - ]); - - if (!confirmed) { - console.log(chalk.yellow('Cancelled.')); - analytics.track('attachment_delete_cancel', true); - return; - } - } - - const result = await client.deleteAttachment(pageId, attachmentId); - - console.log(chalk.green('✅ Attachment deleted successfully!')); - console.log(`ID: ${chalk.blue(result.id)}`); - console.log(`Page ID: ${chalk.blue(result.pageId)}`); - analytics.track('attachment_delete', true); - }, { writable: true })); +registerAttachmentCommands(program, { withClient }); // Property list command program From 1e1b3ea5b364a49d1e49320aff73a2decd29081d Mon Sep 17 00:00:00 2001 From: "heecheol.park" Date: Wed, 13 May 2026 00:05:56 +0900 Subject: [PATCH 4/6] refactor(cli): extract property commands to bin/commands/properties.js Move `property-list`, `property-get`, `property-set`, and `property-delete` into a dedicated module. --- bin/commands/properties.js | 168 +++++++++++++++++++++++++++++++++++++ bin/confluence.js | 166 +----------------------------------- 2 files changed, 170 insertions(+), 164 deletions(-) create mode 100644 bin/commands/properties.js diff --git a/bin/commands/properties.js b/bin/commands/properties.js new file mode 100644 index 0000000..3f494a7 --- /dev/null +++ b/bin/commands/properties.js @@ -0,0 +1,168 @@ +const fs = require('fs'); +const chalk = require('chalk'); +const inquirer = require('inquirer'); + +function registerPropertyCommands(program, { withClient }) { + program + .command('property-list ') + .description('List all content properties for a page') + .option('-f, --format ', 'Output format (text, json)', 'text') + .option('-l, --limit ', 'Maximum number of properties to fetch (default: 25)') + .option('--start ', 'Start index for results (default: 0)', '0') + .option('--all', 'Fetch all properties (ignores pagination)') + .action(withClient('property_list', async ({ client, analytics }, pageId, options) => { + const format = (options.format || 'text').toLowerCase(); + if (!['text', 'json'].includes(format)) { + throw new Error('Format must be one of: text, json'); + } + + const limit = options.limit ? parseInt(options.limit, 10) : null; + if (options.limit && (Number.isNaN(limit) || limit <= 0)) { + throw new Error('Limit must be a positive number.'); + } + + const start = options.start ? parseInt(options.start, 10) : 0; + if (options.start && (Number.isNaN(start) || start < 0)) { + throw new Error('Start must be a non-negative number.'); + } + + let properties = []; + let nextStart = null; + + if (options.all) { + properties = await client.getAllProperties(pageId, { + maxResults: limit || null, + start, + }); + } else { + const response = await client.listProperties(pageId, { + limit: limit || undefined, + start, + }); + properties = response.results; + nextStart = response.nextStart; + } + + if (format === 'json') { + const output = { properties }; + if (!options.all) { + output.nextStart = nextStart; + } + console.log(JSON.stringify(output, null, 2)); + } else if (properties.length === 0) { + console.log(chalk.yellow('No properties found.')); + } else { + properties.forEach((prop, i) => { + const preview = JSON.stringify(prop.value); + const truncated = preview.length > 80 ? preview.slice(0, 77) + '...' : preview; + console.log(`${chalk.blue(i + 1 + '.')} ${chalk.green(prop.key)} (v${prop.version.number}): ${truncated}`); + }); + + if (!options.all && nextStart !== null && nextStart !== undefined) { + console.log(chalk.gray(`Next start: ${nextStart}`)); + } + } + analytics.track('property_list', true); + })); + + program + .command('property-get ') + .description('Get a content property by key') + .option('-f, --format ', 'Output format (text, json)', 'text') + .action(withClient('property_get', async ({ client, analytics }, pageId, key, options) => { + const format = (options.format || 'text').toLowerCase(); + if (!['text', 'json'].includes(format)) { + throw new Error('Format must be one of: text, json'); + } + + const property = await client.getProperty(pageId, key); + + if (format === 'json') { + console.log(JSON.stringify(property, null, 2)); + } else { + console.log(`${chalk.green('Key:')} ${property.key}`); + console.log(`${chalk.green('Version:')} ${property.version.number}`); + console.log(`${chalk.green('Value:')}`); + console.log(JSON.stringify(property.value, null, 2)); + } + analytics.track('property_get', true); + })); + + program + .command('property-set ') + .description('Set a content property (create or update)') + .option('-v, --value ', 'Property value as JSON') + .option('--file ', 'Read property value from a JSON file') + .option('-f, --format ', 'Output format (text, json)', 'text') + .action(withClient('property_set', async ({ client, analytics }, pageId, key, options) => { + if (!options.value && !options.file) { + throw new Error('Provide a value with --value or --file.'); + } + + let value; + if (options.file) { + const raw = fs.readFileSync(options.file, 'utf-8'); + try { + value = JSON.parse(raw); + } catch { + throw new Error(`Invalid JSON in file ${options.file}`); + } + } else { + try { + value = JSON.parse(options.value); + } catch { + throw new Error('Invalid JSON in --value'); + } + } + + const format = (options.format || 'text').toLowerCase(); + if (!['text', 'json'].includes(format)) { + throw new Error('Format must be one of: text, json'); + } + + const result = await client.setProperty(pageId, key, value); + + if (format === 'json') { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(chalk.green('✅ Property set successfully!')); + console.log(`${chalk.green('Key:')} ${result.key}`); + console.log(`${chalk.green('Version:')} ${result.version.number}`); + console.log(`${chalk.green('Value:')}`); + console.log(JSON.stringify(result.value, null, 2)); + } + analytics.track('property_set', true); + }, { writable: true })); + + program + .command('property-delete ') + .description('Delete a content property by key') + .option('-y, --yes', 'Skip confirmation prompt') + .action(withClient('property_delete', async ({ client, analytics }, pageId, key, options) => { + if (!options.yes) { + const { confirmed } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirmed', + default: false, + message: `Delete property "${key}" from page ${pageId}?`, + }, + ]); + + if (!confirmed) { + console.log(chalk.yellow('Cancelled.')); + analytics.track('property_delete_cancel', true); + return; + } + } + + const result = await client.deleteProperty(pageId, key); + + console.log(chalk.green('✅ Property deleted successfully!')); + console.log(`${chalk.green('Key:')} ${chalk.blue(result.key)}`); + console.log(`${chalk.green('Page ID:')} ${chalk.blue(result.pageId)}`); + analytics.track('property_delete', true); + }, { writable: true })); +} + +module.exports = registerPropertyCommands; diff --git a/bin/confluence.js b/bin/confluence.js index f7829e9..872aa0a 100755 --- a/bin/confluence.js +++ b/bin/confluence.js @@ -12,6 +12,7 @@ const pkg = require('../package.json'); const registerProfileCommands = require('./commands/profile'); const registerVersionCommands = require('./commands/versions'); const registerAttachmentCommands = require('./commands/attachments'); +const registerPropertyCommands = require('./commands/properties'); function assertWritable(config) { if (config.readOnly) { @@ -473,170 +474,7 @@ program registerAttachmentCommands(program, { withClient }); -// Property list command -program - .command('property-list ') - .description('List all content properties for a page') - .option('-f, --format ', 'Output format (text, json)', 'text') - .option('-l, --limit ', 'Maximum number of properties to fetch (default: 25)') - .option('--start ', 'Start index for results (default: 0)', '0') - .option('--all', 'Fetch all properties (ignores pagination)') - .action(withClient('property_list', async ({ client, analytics }, pageId, options) => { - const format = (options.format || 'text').toLowerCase(); - if (!['text', 'json'].includes(format)) { - throw new Error('Format must be one of: text, json'); - } - - const limit = options.limit ? parseInt(options.limit, 10) : null; - if (options.limit && (Number.isNaN(limit) || limit <= 0)) { - throw new Error('Limit must be a positive number.'); - } - - const start = options.start ? parseInt(options.start, 10) : 0; - if (options.start && (Number.isNaN(start) || start < 0)) { - throw new Error('Start must be a non-negative number.'); - } - - let properties = []; - let nextStart = null; - - if (options.all) { - properties = await client.getAllProperties(pageId, { - maxResults: limit || null, - start - }); - } else { - const response = await client.listProperties(pageId, { - limit: limit || undefined, - start - }); - properties = response.results; - nextStart = response.nextStart; - } - - if (format === 'json') { - const output = { properties }; - if (!options.all) { - output.nextStart = nextStart; - } - console.log(JSON.stringify(output, null, 2)); - } else if (properties.length === 0) { - console.log(chalk.yellow('No properties found.')); - } else { - properties.forEach((prop, i) => { - const preview = JSON.stringify(prop.value); - const truncated = preview.length > 80 ? preview.slice(0, 77) + '...' : preview; - console.log(`${chalk.blue(i + 1 + '.')} ${chalk.green(prop.key)} (v${prop.version.number}): ${truncated}`); - }); - - if (!options.all && nextStart !== null && nextStart !== undefined) { - console.log(chalk.gray(`Next start: ${nextStart}`)); - } - } - analytics.track('property_list', true); - })); - -// Property get command -program - .command('property-get ') - .description('Get a content property by key') - .option('-f, --format ', 'Output format (text, json)', 'text') - .action(withClient('property_get', async ({ client, analytics }, pageId, key, options) => { - const format = (options.format || 'text').toLowerCase(); - if (!['text', 'json'].includes(format)) { - throw new Error('Format must be one of: text, json'); - } - - const property = await client.getProperty(pageId, key); - - if (format === 'json') { - console.log(JSON.stringify(property, null, 2)); - } else { - console.log(`${chalk.green('Key:')} ${property.key}`); - console.log(`${chalk.green('Version:')} ${property.version.number}`); - console.log(`${chalk.green('Value:')}`); - console.log(JSON.stringify(property.value, null, 2)); - } - analytics.track('property_get', true); - })); - -// Property set command -program - .command('property-set ') - .description('Set a content property (create or update)') - .option('-v, --value ', 'Property value as JSON') - .option('--file ', 'Read property value from a JSON file') - .option('-f, --format ', 'Output format (text, json)', 'text') - .action(withClient('property_set', async ({ client, analytics }, pageId, key, options) => { - if (!options.value && !options.file) { - throw new Error('Provide a value with --value or --file.'); - } - - let value; - if (options.file) { - const raw = fs.readFileSync(options.file, 'utf-8'); - try { - value = JSON.parse(raw); - } catch { - throw new Error(`Invalid JSON in file ${options.file}`); - } - } else { - try { - value = JSON.parse(options.value); - } catch { - throw new Error('Invalid JSON in --value'); - } - } - - const format = (options.format || 'text').toLowerCase(); - if (!['text', 'json'].includes(format)) { - throw new Error('Format must be one of: text, json'); - } - - const result = await client.setProperty(pageId, key, value); - - if (format === 'json') { - console.log(JSON.stringify(result, null, 2)); - } else { - console.log(chalk.green('✅ Property set successfully!')); - console.log(`${chalk.green('Key:')} ${result.key}`); - console.log(`${chalk.green('Version:')} ${result.version.number}`); - console.log(`${chalk.green('Value:')}`); - console.log(JSON.stringify(result.value, null, 2)); - } - analytics.track('property_set', true); - }, { writable: true })); - -// Property delete command -program - .command('property-delete ') - .description('Delete a content property by key') - .option('-y, --yes', 'Skip confirmation prompt') - .action(withClient('property_delete', async ({ client, analytics }, pageId, key, options) => { - if (!options.yes) { - const { confirmed } = await inquirer.prompt([ - { - type: 'confirm', - name: 'confirmed', - default: false, - message: `Delete property "${key}" from page ${pageId}?` - } - ]); - - if (!confirmed) { - console.log(chalk.yellow('Cancelled.')); - analytics.track('property_delete_cancel', true); - return; - } - } - - const result = await client.deleteProperty(pageId, key); - - console.log(chalk.green('✅ Property deleted successfully!')); - console.log(`${chalk.green('Key:')} ${chalk.blue(result.key)}`); - console.log(`${chalk.green('Page ID:')} ${chalk.blue(result.pageId)}`); - analytics.track('property_delete', true); - }, { writable: true })); +registerPropertyCommands(program, { withClient }); // Comments command program From d49b1a58e09d2bcfb2277ab4d45c739e2eb4c897 Mon Sep 17 00:00:00 2001 From: "heecheol.park" Date: Wed, 13 May 2026 00:06:49 +0900 Subject: [PATCH 5/6] refactor(cli): extract comment commands to bin/commands/comment.js Move `comments`, `comment`, and `comment-delete` into a dedicated module along with their three rendering helpers (`parseLocationOptions`, `formatBodyBlock`, `buildCommentTree`), which are only used here. The `comment_create` onError hint and the `comment_delete_cancel` analytics key are preserved. --- bin/commands/comment.js | 334 ++++++++++++++++++++++++++++++++++++++++ bin/confluence.js | 331 +-------------------------------------- 2 files changed, 336 insertions(+), 329 deletions(-) create mode 100644 bin/commands/comment.js diff --git a/bin/commands/comment.js b/bin/commands/comment.js new file mode 100644 index 0000000..b0b4813 --- /dev/null +++ b/bin/commands/comment.js @@ -0,0 +1,334 @@ +const fs = require('fs'); +const chalk = require('chalk'); +const inquirer = require('inquirer'); + +function parseLocationOptions(raw) { + if (!raw) { + return []; + } + if (Array.isArray(raw)) { + return raw.flatMap(item => String(item).split(',')) + .map(value => value.trim().toLowerCase()) + .filter(Boolean); + } + return String(raw).split(',').map(value => value.trim().toLowerCase()).filter(Boolean); +} + +function formatBodyBlock(text, indent = '') { + return text.split('\n').map(line => `${indent}${chalk.white(line)}`).join('\n'); +} + +function buildCommentTree(comments) { + const nodes = comments.map((comment, index) => ({ + ...comment, + _order: index, + children: [], + })); + const byId = new Map(nodes.map(node => [String(node.id), node])); + const roots = []; + + nodes.forEach((node) => { + const parentId = node.parentId ? String(node.parentId) : null; + if (parentId && byId.has(parentId)) { + byId.get(parentId).children.push(node); + } else { + roots.push(node); + } + }); + + const sortNodes = (list) => { + list.sort((a, b) => a._order - b._order); + list.forEach((child) => sortNodes(child.children)); + }; + + sortNodes(roots); + return roots; +} + +function registerCommentCommands(program, { withClient }) { + program + .command('comments ') + .description('List comments for a page by ID or URL') + .option('-f, --format ', 'Output format (text, markdown, json)', 'text') + .option('-l, --limit ', 'Maximum number of comments to fetch (default: 25)') + .option('--start ', 'Start index for results (default: 0)', '0') + .option('--location ', 'Filter by location (inline, footer, resolved). Comma-separated') + .option('--depth ', 'Comment depth ("" for root only, "all")') + .option('--all', 'Fetch all comments (ignores pagination)') + .action(withClient('comments', async ({ client, analytics }, pageId, options) => { + const format = (options.format || 'text').toLowerCase(); + if (!['text', 'markdown', 'json'].includes(format)) { + throw new Error('Format must be one of: text, markdown, json'); + } + + const limit = options.limit ? parseInt(options.limit, 10) : null; + if (options.limit && (Number.isNaN(limit) || limit <= 0)) { + throw new Error('Limit must be a positive number.'); + } + + const start = options.start ? parseInt(options.start, 10) : 0; + if (options.start && (Number.isNaN(start) || start < 0)) { + throw new Error('Start must be a non-negative number.'); + } + + const locationValues = parseLocationOptions(options.location); + const invalidLocations = locationValues.filter(value => !['inline', 'footer', 'resolved'].includes(value)); + if (invalidLocations.length > 0) { + throw new Error(`Invalid location value(s): ${invalidLocations.join(', ')}`); + } + const locationParam = locationValues.length === 0 + ? null + : (locationValues.length === 1 ? locationValues[0] : locationValues); + + let comments = []; + let nextStart = null; + + if (options.all) { + comments = await client.getAllComments(pageId, { + maxResults: limit || null, + start, + location: locationParam, + depth: options.depth, + }); + } else { + const response = await client.listComments(pageId, { + limit: limit || undefined, + start, + location: locationParam, + depth: options.depth, + }); + comments = response.results; + nextStart = response.nextStart; + } + + if (comments.length === 0) { + console.log(chalk.yellow('No comments found.')); + analytics.track('comments', true); + return; + } + + if (format === 'json') { + const resolvedPageId = await client.extractPageId(pageId); + const output = { + pageId: resolvedPageId, + commentCount: comments.length, + comments: comments.map(comment => ({ + ...comment, + bodyStorage: comment.body, + bodyText: client.formatCommentBody(comment.body, 'text'), + })), + }; + if (!options.all) { + output.nextStart = nextStart; + } + console.log(JSON.stringify(output, null, 2)); + analytics.track('comments', true); + return; + } + + const commentTree = buildCommentTree(comments); + console.log(chalk.blue(`Found ${comments.length} comment${comments.length === 1 ? '' : 's'}:`)); + + const renderComments = (nodes, path = []) => { + nodes.forEach((comment, index) => { + const currentPath = [...path, index + 1]; + const level = currentPath.length - 1; + const indent = ' '.repeat(level); + const branchGlyph = level > 0 ? (index === nodes.length - 1 ? '└─ ' : '├─ ') : ''; + const headerPrefix = `${indent}${chalk.dim(branchGlyph)}`; + const bodyIndent = level === 0 + ? ' ' + : `${indent}${' '.repeat(branchGlyph.length)}`; + + const isReply = Boolean(comment.parentId); + const location = comment.location || 'unknown'; + const author = comment.author?.displayName || 'Unknown'; + const createdAt = comment.createdAt || 'unknown date'; + const metaParts = [`Created: ${createdAt}`]; + if (comment.status) metaParts.push(`Status: ${comment.status}`); + if (comment.version) metaParts.push(`Version: ${comment.version}`); + if (!isReply && comment.resolution) metaParts.push(`Resolution: ${comment.resolution}`); + + const label = isReply ? chalk.gray('[reply]') : chalk.cyan(`[${location}]`); + console.log(`${headerPrefix}${currentPath.join('.')}. ${chalk.green(author)} ${chalk.gray(`(ID: ${comment.id})`)} ${label}`); + console.log(chalk.dim(`${bodyIndent}${metaParts.join(' • ')}`)); + + if (!isReply) { + const inlineProps = comment.inlineProperties || {}; + const selectionText = inlineProps.selection || inlineProps.originalSelection; + if (selectionText) { + const selectionLabel = inlineProps.selection ? 'Highlight' : 'Highlight (original)'; + console.log(chalk.dim(`${bodyIndent}${selectionLabel}: ${selectionText}`)); + } + if (inlineProps.markerRef) { + console.log(chalk.dim(`${bodyIndent}Marker ref: ${inlineProps.markerRef}`)); + } + } + + const body = client.formatCommentBody(comment.body, format); + if (body) { + console.log(`${bodyIndent}${chalk.yellowBright('Body:')}`); + console.log(formatBodyBlock(body, `${bodyIndent} `)); + } + + if (comment.children && comment.children.length > 0) { + renderComments(comment.children, currentPath); + } + }); + }; + + renderComments(commentTree); + + if (!options.all && nextStart !== null && nextStart !== undefined) { + console.log(chalk.gray(`Next start: ${nextStart}`)); + } + + analytics.track('comments', true); + })); + + program + .command('comment ') + .description('Create a comment on a page by ID or URL (footer or inline)') + .option('-f, --file ', 'Read content from file') + .option('-c, --content ', 'Comment content as string') + .option('--format ', 'Content format (storage, html, markdown)', 'storage') + .option('--parent ', 'Reply to a comment by ID') + .option('--location ', 'Comment location (inline or footer)', 'footer') + .option('--inline-selection ', 'Inline selection text') + .option('--inline-original-selection ', 'Original inline selection text') + .option('--inline-marker-ref ', 'Inline marker reference (optional)') + .option('--inline-properties ', 'Inline properties JSON (advanced)') + .action(withClient('comment_create', async ({ client, analytics }, pageId, options) => { + let content = ''; + + if (options.file) { + if (!fs.existsSync(options.file)) { + throw new Error(`File not found: ${options.file}`); + } + content = fs.readFileSync(options.file, 'utf8'); + } else if (options.content) { + content = options.content; + } else { + throw new Error('Either --file or --content option is required'); + } + + const location = (options.location || 'footer').toLowerCase(); + if (!['inline', 'footer'].includes(location)) { + throw new Error('Location must be either "inline" or "footer".'); + } + + let inlineProperties = {}; + if (options.inlineProperties) { + try { + const parsed = JSON.parse(options.inlineProperties); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Inline properties must be a JSON object.'); + } + inlineProperties = { ...parsed }; + } catch (error) { + throw new Error(`Invalid --inline-properties JSON: ${error.message}`); + } + } + + if (options.inlineSelection) { + inlineProperties.selection = options.inlineSelection; + } + if (options.inlineOriginalSelection) { + inlineProperties.originalSelection = options.inlineOriginalSelection; + } + if (options.inlineMarkerRef) { + inlineProperties.markerRef = options.inlineMarkerRef; + } + + if (Object.keys(inlineProperties).length > 0 && location !== 'inline') { + throw new Error('Inline properties can only be used with --location inline.'); + } + + const parentId = options.parent; + + if (location === 'inline') { + const hasSelection = inlineProperties.selection || inlineProperties.originalSelection; + if (!hasSelection && !parentId) { + throw new Error('Inline comments require --inline-selection or --inline-original-selection when starting a new inline thread.'); + } + if (hasSelection) { + if (!inlineProperties.originalSelection && inlineProperties.selection) { + inlineProperties.originalSelection = inlineProperties.selection; + } + if (!inlineProperties.markerRef) { + inlineProperties.markerRef = `comment-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + } + } + } + + const result = await client.createComment(pageId, content, options.format, { + parentId, + location, + inlineProperties: location === 'inline' ? inlineProperties : null, + }); + + console.log(chalk.green('✅ Comment created successfully!')); + console.log(`ID: ${chalk.blue(result.id)}`); + if (result.container?.id) { + console.log(`Page ID: ${chalk.blue(result.container.id)}`); + } + if (result._links?.webui) { + const url = client.toAbsoluteUrl(result._links.webui); + console.log(`URL: ${chalk.gray(url)}`); + } + + analytics.track('comment_create', true); + }, { + writable: true, + onError: (error, _pageId, options) => { + if (error.response?.data) { + const detail = typeof error.response.data === 'string' + ? error.response.data + : JSON.stringify(error.response.data, null, 2); + console.error(chalk.red('API response:'), detail); + } + const apiErrors = error.response?.data?.data?.errors || error.response?.data?.errors || []; + const errorKeys = apiErrors + .map((entry) => entry?.message?.key || entry?.message || entry?.key) + .filter(Boolean); + const needsInlineMeta = ['matchIndex', 'lastFetchTime', 'serializedHighlights'] + .every((key) => errorKeys.includes(key)); + const location = (options?.location || 'footer').toLowerCase(); + if (location === 'inline' && needsInlineMeta) { + console.error(chalk.yellow('Inline comment creation requires editor highlight metadata (matchIndex, lastFetchTime, serializedHighlights).')); + console.error(chalk.yellow('Try replying to an existing inline comment or use footer comments instead.')); + } + }, + })); + + program + .command('comment-delete ') + .description('Delete a comment by ID') + .option('-y, --yes', 'Skip confirmation prompt') + .action(withClient('comment_delete', async ({ client, analytics }, commentId, options) => { + if (!options.yes) { + const { confirmed } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirmed', + default: false, + message: `Delete comment ${commentId}?`, + }, + ]); + + if (!confirmed) { + console.log(chalk.yellow('Cancelled.')); + analytics.track('comment_delete_cancel', true); + return; + } + } + + const result = await client.deleteComment(commentId); + + console.log(chalk.green('✅ Comment deleted successfully!')); + console.log(`ID: ${chalk.blue(result.id)}`); + analytics.track('comment_delete', true); + }, { writable: true })); +} + +module.exports = registerCommentCommands; diff --git a/bin/confluence.js b/bin/confluence.js index 872aa0a..0a1412f 100755 --- a/bin/confluence.js +++ b/bin/confluence.js @@ -13,6 +13,7 @@ const registerProfileCommands = require('./commands/profile'); const registerVersionCommands = require('./commands/versions'); const registerAttachmentCommands = require('./commands/attachments'); const registerPropertyCommands = require('./commands/properties'); +const registerCommentCommands = require('./commands/comment'); function assertWritable(config) { if (config.readOnly) { @@ -476,292 +477,7 @@ registerAttachmentCommands(program, { withClient }); registerPropertyCommands(program, { withClient }); -// Comments command -program - .command('comments ') - .description('List comments for a page by ID or URL') - .option('-f, --format ', 'Output format (text, markdown, json)', 'text') - .option('-l, --limit ', 'Maximum number of comments to fetch (default: 25)') - .option('--start ', 'Start index for results (default: 0)', '0') - .option('--location ', 'Filter by location (inline, footer, resolved). Comma-separated') - .option('--depth ', 'Comment depth ("" for root only, "all")') - .option('--all', 'Fetch all comments (ignores pagination)') - .action(withClient('comments', async ({ client, analytics }, pageId, options) => { - const format = (options.format || 'text').toLowerCase(); - if (!['text', 'markdown', 'json'].includes(format)) { - throw new Error('Format must be one of: text, markdown, json'); - } - - const limit = options.limit ? parseInt(options.limit, 10) : null; - if (options.limit && (Number.isNaN(limit) || limit <= 0)) { - throw new Error('Limit must be a positive number.'); - } - - const start = options.start ? parseInt(options.start, 10) : 0; - if (options.start && (Number.isNaN(start) || start < 0)) { - throw new Error('Start must be a non-negative number.'); - } - - const locationValues = parseLocationOptions(options.location); - const invalidLocations = locationValues.filter(value => !['inline', 'footer', 'resolved'].includes(value)); - if (invalidLocations.length > 0) { - throw new Error(`Invalid location value(s): ${invalidLocations.join(', ')}`); - } - const locationParam = locationValues.length === 0 - ? null - : (locationValues.length === 1 ? locationValues[0] : locationValues); - - let comments = []; - let nextStart = null; - - if (options.all) { - comments = await client.getAllComments(pageId, { - maxResults: limit || null, - start, - location: locationParam, - depth: options.depth - }); - } else { - const response = await client.listComments(pageId, { - limit: limit || undefined, - start, - location: locationParam, - depth: options.depth - }); - comments = response.results; - nextStart = response.nextStart; - } - - if (comments.length === 0) { - console.log(chalk.yellow('No comments found.')); - analytics.track('comments', true); - return; - } - - if (format === 'json') { - const resolvedPageId = await client.extractPageId(pageId); - const output = { - pageId: resolvedPageId, - commentCount: comments.length, - comments: comments.map(comment => ({ - ...comment, - bodyStorage: comment.body, - bodyText: client.formatCommentBody(comment.body, 'text') - })) - }; - if (!options.all) { - output.nextStart = nextStart; - } - console.log(JSON.stringify(output, null, 2)); - analytics.track('comments', true); - return; - } - - const commentTree = buildCommentTree(comments); - console.log(chalk.blue(`Found ${comments.length} comment${comments.length === 1 ? '' : 's'}:`)); - - const renderComments = (nodes, path = []) => { - nodes.forEach((comment, index) => { - const currentPath = [...path, index + 1]; - const level = currentPath.length - 1; - const indent = ' '.repeat(level); - const branchGlyph = level > 0 ? (index === nodes.length - 1 ? '└─ ' : '├─ ') : ''; - const headerPrefix = `${indent}${chalk.dim(branchGlyph)}`; - const bodyIndent = level === 0 - ? ' ' - : `${indent}${' '.repeat(branchGlyph.length)}`; - - const isReply = Boolean(comment.parentId); - const location = comment.location || 'unknown'; - const author = comment.author?.displayName || 'Unknown'; - const createdAt = comment.createdAt || 'unknown date'; - const metaParts = [`Created: ${createdAt}`]; - if (comment.status) metaParts.push(`Status: ${comment.status}`); - if (comment.version) metaParts.push(`Version: ${comment.version}`); - if (!isReply && comment.resolution) metaParts.push(`Resolution: ${comment.resolution}`); - - const label = isReply ? chalk.gray('[reply]') : chalk.cyan(`[${location}]`); - console.log(`${headerPrefix}${currentPath.join('.')}. ${chalk.green(author)} ${chalk.gray(`(ID: ${comment.id})`)} ${label}`); - console.log(chalk.dim(`${bodyIndent}${metaParts.join(' • ')}`)); - - if (!isReply) { - const inlineProps = comment.inlineProperties || {}; - const selectionText = inlineProps.selection || inlineProps.originalSelection; - if (selectionText) { - const selectionLabel = inlineProps.selection ? 'Highlight' : 'Highlight (original)'; - console.log(chalk.dim(`${bodyIndent}${selectionLabel}: ${selectionText}`)); - } - if (inlineProps.markerRef) { - console.log(chalk.dim(`${bodyIndent}Marker ref: ${inlineProps.markerRef}`)); - } - } - - const body = client.formatCommentBody(comment.body, format); - if (body) { - console.log(`${bodyIndent}${chalk.yellowBright('Body:')}`); - console.log(formatBodyBlock(body, `${bodyIndent} `)); - } - - if (comment.children && comment.children.length > 0) { - renderComments(comment.children, currentPath); - } - }); - }; - - renderComments(commentTree); - - if (!options.all && nextStart !== null && nextStart !== undefined) { - console.log(chalk.gray(`Next start: ${nextStart}`)); - } - - analytics.track('comments', true); - })); - -// Comment creation command -program - .command('comment ') - .description('Create a comment on a page by ID or URL (footer or inline)') - .option('-f, --file ', 'Read content from file') - .option('-c, --content ', 'Comment content as string') - .option('--format ', 'Content format (storage, html, markdown)', 'storage') - .option('--parent ', 'Reply to a comment by ID') - .option('--location ', 'Comment location (inline or footer)', 'footer') - .option('--inline-selection ', 'Inline selection text') - .option('--inline-original-selection ', 'Original inline selection text') - .option('--inline-marker-ref ', 'Inline marker reference (optional)') - .option('--inline-properties ', 'Inline properties JSON (advanced)') - .action(withClient('comment_create', async ({ client, analytics }, pageId, options) => { - let content = ''; - - if (options.file) { - if (!fs.existsSync(options.file)) { - throw new Error(`File not found: ${options.file}`); - } - content = fs.readFileSync(options.file, 'utf8'); - } else if (options.content) { - content = options.content; - } else { - throw new Error('Either --file or --content option is required'); - } - - const location = (options.location || 'footer').toLowerCase(); - if (!['inline', 'footer'].includes(location)) { - throw new Error('Location must be either "inline" or "footer".'); - } - - let inlineProperties = {}; - if (options.inlineProperties) { - try { - const parsed = JSON.parse(options.inlineProperties); - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error('Inline properties must be a JSON object.'); - } - inlineProperties = { ...parsed }; - } catch (error) { - throw new Error(`Invalid --inline-properties JSON: ${error.message}`); - } - } - - if (options.inlineSelection) { - inlineProperties.selection = options.inlineSelection; - } - if (options.inlineOriginalSelection) { - inlineProperties.originalSelection = options.inlineOriginalSelection; - } - if (options.inlineMarkerRef) { - inlineProperties.markerRef = options.inlineMarkerRef; - } - - if (Object.keys(inlineProperties).length > 0 && location !== 'inline') { - throw new Error('Inline properties can only be used with --location inline.'); - } - - const parentId = options.parent; - - if (location === 'inline') { - const hasSelection = inlineProperties.selection || inlineProperties.originalSelection; - if (!hasSelection && !parentId) { - throw new Error('Inline comments require --inline-selection or --inline-original-selection when starting a new inline thread.'); - } - if (hasSelection) { - if (!inlineProperties.originalSelection && inlineProperties.selection) { - inlineProperties.originalSelection = inlineProperties.selection; - } - if (!inlineProperties.markerRef) { - inlineProperties.markerRef = `comment-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - } - } - } - - const result = await client.createComment(pageId, content, options.format, { - parentId, - location, - inlineProperties: location === 'inline' ? inlineProperties : null - }); - - console.log(chalk.green('✅ Comment created successfully!')); - console.log(`ID: ${chalk.blue(result.id)}`); - if (result.container?.id) { - console.log(`Page ID: ${chalk.blue(result.container.id)}`); - } - if (result._links?.webui) { - const url = client.toAbsoluteUrl(result._links.webui); - console.log(`URL: ${chalk.gray(url)}`); - } - - analytics.track('comment_create', true); - }, { - writable: true, - onError: (error, _pageId, options) => { - if (error.response?.data) { - const detail = typeof error.response.data === 'string' - ? error.response.data - : JSON.stringify(error.response.data, null, 2); - console.error(chalk.red('API response:'), detail); - } - const apiErrors = error.response?.data?.data?.errors || error.response?.data?.errors || []; - const errorKeys = apiErrors - .map((entry) => entry?.message?.key || entry?.message || entry?.key) - .filter(Boolean); - const needsInlineMeta = ['matchIndex', 'lastFetchTime', 'serializedHighlights'] - .every((key) => errorKeys.includes(key)); - const location = (options?.location || 'footer').toLowerCase(); - if (location === 'inline' && needsInlineMeta) { - console.error(chalk.yellow('Inline comment creation requires editor highlight metadata (matchIndex, lastFetchTime, serializedHighlights).')); - console.error(chalk.yellow('Try replying to an existing inline comment or use footer comments instead.')); - } - } - })); - -// Comment delete command -program - .command('comment-delete ') - .description('Delete a comment by ID') - .option('-y, --yes', 'Skip confirmation prompt') - .action(withClient('comment_delete', async ({ client, analytics }, commentId, options) => { - if (!options.yes) { - const { confirmed } = await inquirer.prompt([ - { - type: 'confirm', - name: 'confirmed', - default: false, - message: `Delete comment ${commentId}?` - } - ]); - - if (!confirmed) { - console.log(chalk.yellow('Cancelled.')); - analytics.track('comment_delete_cancel', true); - return; - } - } - - const result = await client.deleteComment(commentId); - - console.log(chalk.green('✅ Comment deleted successfully!')); - console.log(`ID: ${chalk.blue(result.id)}`); - analytics.track('comment_delete', true); - }, { writable: true })); +registerCommentCommands(program, { withClient }); // Export page content with attachments program @@ -1098,49 +814,6 @@ function sanitizeTitle(value) { return cleaned || fallback; } -function parseLocationOptions(raw) { - if (!raw) { - return []; - } - if (Array.isArray(raw)) { - return raw.flatMap(item => String(item).split(',')) - .map(value => value.trim().toLowerCase()) - .filter(Boolean); - } - return String(raw).split(',').map(value => value.trim().toLowerCase()).filter(Boolean); -} - -function formatBodyBlock(text, indent = '') { - return text.split('\n').map(line => `${indent}${chalk.white(line)}`).join('\n'); -} - -function buildCommentTree(comments) { - const nodes = comments.map((comment, index) => ({ - ...comment, - _order: index, - children: [] - })); - const byId = new Map(nodes.map(node => [String(node.id), node])); - const roots = []; - - nodes.forEach((node) => { - const parentId = node.parentId ? String(node.parentId) : null; - if (parentId && byId.has(parentId)) { - byId.get(parentId).children.push(node); - } else { - roots.push(node); - } - }); - - const sortNodes = (list) => { - list.sort((a, b) => a._order - b._order); - list.forEach((child) => sortNodes(child.children)); - }; - - sortNodes(roots); - return roots; -} - // Copy page tree command program .command('copy-tree [newTitle]') From 91e133234b163c2c2c13ab5404b9bde5c8092903 Mon Sep 17 00:00:00 2001 From: "heecheol.park" Date: Wed, 13 May 2026 00:07:40 +0900 Subject: [PATCH 6/6] refactor(cli): extract export command and helpers to bin/commands/export.js Move the `export` command along with EXPORT_MARKER and its six helpers (writeExportMarker, isExportDirectory, sanitizeFilename, uniquePathFor, writeStream, sanitizeTitle, exportRecursive) into a dedicated module. The helpers are re-exposed from the new module for unit testing; the `_test` block in bin/confluence.js drops them since they no longer live there, and tests/export.test.js imports directly from the new module. --- bin/commands/export.js | 349 +++++++++++++++++++++++++++++++++++++++++ bin/confluence.js | 343 +--------------------------------------- tests/export.test.js | 19 +-- 3 files changed, 359 insertions(+), 352 deletions(-) create mode 100644 bin/commands/export.js diff --git a/bin/commands/export.js b/bin/commands/export.js new file mode 100644 index 0000000..f51f13c --- /dev/null +++ b/bin/commands/export.js @@ -0,0 +1,349 @@ +const fs = require('fs'); +const path = require('path'); +const chalk = require('chalk'); + +const EXPORT_MARKER = '.confluence-export.json'; + +function writeExportMarker(fs, path, exportDir, meta) { + const marker = { + exportedAt: new Date().toISOString(), + pageId: meta.pageId, + title: meta.title, + tool: 'confluence-cli', + }; + fs.writeFileSync(path.join(exportDir, EXPORT_MARKER), JSON.stringify(marker, null, 2)); +} + +function isExportDirectory(fs, path, dir) { + return fs.existsSync(path.join(dir, EXPORT_MARKER)); +} + +function sanitizeFilename(filename) { + if (!filename || typeof filename !== 'string') { + return 'unnamed'; + } + const stripped = path.basename(filename.replace(/\\/g, '/')); + const cleaned = stripped + // eslint-disable-next-line no-control-regex + .replace(/[\\/:*?"<>|\x00-\x1f]/g, '_') + .replace(/^\.+/, '') + .trim(); + return cleaned || 'unnamed'; +} + +function uniquePathFor(fs, path, dir, filename) { + const safeFilename = sanitizeFilename(filename); + const parsed = path.parse(safeFilename); + let attempt = path.join(dir, safeFilename); + let counter = 1; + while (fs.existsSync(attempt)) { + const suffix = ` (${counter})`; + const nextName = `${parsed.name}${suffix}${parsed.ext}`; + attempt = path.join(dir, nextName); + counter += 1; + } + return attempt; +} + +function writeStream(fs, stream, targetPath) { + return new Promise((resolve, reject) => { + const writer = fs.createWriteStream(targetPath); + stream.pipe(writer); + stream.on('error', reject); + writer.on('error', reject); + writer.on('finish', resolve); + }); +} + +function sanitizeTitle(value) { + const fallback = 'page'; + if (!value || typeof value !== 'string') { + return fallback; + } + const cleaned = value + // eslint-disable-next-line no-control-regex + .replace(/[\\/:*?"<>|\x00-\x1f]/g, ' ') + .replace(/^\.+/, '') + .trim(); + return cleaned || fallback; +} + +async function exportRecursive(client, fs, path, pageId, options) { + const maxDepth = options.maxDepth || 10; + const delayMs = options.delayMs != null ? options.delayMs : 100; + const excludePatterns = options.exclude + ? options.exclude.split(',').map(p => p.trim()).filter(Boolean) + : []; + const format = (options.format || 'markdown').toLowerCase(); + const formatExt = { markdown: 'md', html: 'html', text: 'txt' }; + const contentExt = formatExt[format] || 'txt'; + const contentFile = options.file || `page.${contentExt}`; + const baseDir = path.resolve(options.dest || '.'); + + // 1. Fetch root page + const rootPage = await client.getPageInfo(pageId); + console.log(`Fetching descendants of "${chalk.blue(rootPage.title)}"...`); + + // 2. Fetch all descendants + const descendants = await client.getAllDescendantPages(pageId, maxDepth); + + // 3. Filter by exclude patterns + const allPages = [{ id: rootPage.id, title: rootPage.title, parentId: null }]; + for (const page of descendants) { + if (excludePatterns.length && client.shouldExcludePage(page.title, excludePatterns)) { + continue; + } + allPages.push(page); + } + + // 4. Build tree + const tree = client.buildPageTree(allPages.slice(1), pageId); + + const totalPages = allPages.length; + console.log(`Found ${chalk.blue(totalPages)} page${totalPages === 1 ? '' : 's'} to export.`); + + // 5. Dry run — print tree and return + if (options.dryRun) { + const printTree = (nodes, indent = '') => { + for (const node of nodes) { + console.log(`${indent}${chalk.blue(node.title)} (${node.id})`); + if (node.children && node.children.length) { + printTree(node.children, indent + ' '); + } + } + }; + console.log(`\n${chalk.blue(rootPage.title)} (${rootPage.id})`); + printTree(tree, ' '); + console.log(chalk.yellow('\nDry run — no files written.')); + return; + } + + // 6. Overwrite — remove existing root export directory for a clean slate + if (options.overwrite) { + const rootFolderName = sanitizeTitle(rootPage.title); + const rootExportDir = path.join(baseDir, rootFolderName); + if (fs.existsSync(rootExportDir)) { + if (!isExportDirectory(fs, path, rootExportDir)) { + throw new Error(`Refusing to overwrite "${rootExportDir}" - it was not created by confluence-cli (missing ${EXPORT_MARKER}).`); + } + fs.rmSync(rootExportDir, { recursive: true, force: true }); + } + } + + // 7. Walk tree depth-first and export each page + const failures = []; + let exported = 0; + + async function exportPage(page, dir) { + exported += 1; + console.log(`[${exported}/${totalPages}] Exporting: ${chalk.blue(page.title)}`); + + const folderName = sanitizeTitle(page.title); + let exportDir = path.join(dir, folderName); + + // Handle duplicate sibling folder names + if (fs.existsSync(exportDir)) { + let counter = 1; + while (fs.existsSync(`${exportDir} (${counter})`)) { + counter += 1; + } + exportDir = `${exportDir} (${counter})`; + } + fs.mkdirSync(exportDir, { recursive: true }); + + // Fetch content and write + const content = await client.readPage( + page.id, + format, + options.referencedOnly ? { extractReferencedAttachments: true } : {} + ); + const referencedAttachments = options.referencedOnly + ? (client._referencedAttachments || new Set()) + : null; + fs.writeFileSync(path.join(exportDir, contentFile), content); + + // 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; + } + + if (filtered.length > 0) { + const attachmentsDirName = options.attachmentsDir || 'attachments'; + const attachmentsDir = path.join(exportDir, attachmentsDirName); + fs.mkdirSync(attachmentsDir, { recursive: true }); + + for (const attachment of filtered) { + const targetPath = uniquePathFor(fs, path, attachmentsDir, attachment.title); + const dataStream = await client.downloadAttachment(page.id, attachment); + await writeStream(fs, dataStream, targetPath); + } + } + } + + return exportDir; + } + + async function walkTree(nodes, parentDir) { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + try { + const nodeDir = await exportPage(node, parentDir); + if (node.children && node.children.length) { + await walkTree(node.children, nodeDir); + } + } catch (error) { + failures.push({ id: node.id, title: node.title, error: error.message }); + console.error(chalk.red(` Failed: ${node.title} — ${error.message}`)); + } + + // Rate limiting between pages + if (delayMs > 0 && exported < totalPages) { + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + } + } + + // Export root page + let rootDir; + try { + rootDir = await exportPage(rootPage, baseDir); + writeExportMarker(fs, path, rootDir, { pageId, title: rootPage.title }); + } catch (error) { + failures.push({ id: rootPage.id, title: rootPage.title, error: error.message }); + console.error(chalk.red(` Failed: ${rootPage.title} — ${error.message}`)); + // Can't continue without root directory + throw new Error(`Failed to export root page: ${error.message}`); + } + + if (delayMs > 0 && tree.length > 0) { + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + + // Export descendants + await walkTree(tree, rootDir); + + // 8. Summary + const succeeded = exported - failures.length; + console.log(chalk.green(`\n✅ Exported ${succeeded}/${totalPages} page${totalPages === 1 ? '' : 's'} to ${rootDir}`)); + if (failures.length > 0) { + console.log(chalk.red(`\n${failures.length} failure${failures.length === 1 ? '' : 's'}:`)); + for (const f of failures) { + console.log(chalk.red(` - ${f.title} (${f.id}): ${f.error}`)); + } + } +} + +function registerExportCommand(program, { withClient }) { + program + .command('export ') + .description('Export a page to a directory with its attachments') + .option('--format ', 'Content format (html, text, markdown)', 'markdown') + .option('--dest ', 'Base directory to export into', '.') + .option('--file ', 'Content filename (default: page.)') + .option('--attachments-dir ', 'Subdirectory for attachments', 'attachments') + .option('--pattern ', 'Filter attachments by filename (e.g., "*.png")') + .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') + .option('--max-depth ', 'Limit recursion depth (default: 10)', parseInt) + .option('--exclude ', 'Comma-separated title glob patterns to skip') + .option('--delay-ms ', 'Delay between page exports in ms (default: 100)', parseInt) + .option('--dry-run', 'Preview pages without writing files') + .option('--overwrite', 'Overwrite existing export directory (replaces content, removes stale files)') + .action(withClient('export', async ({ client, analytics }, pageId, options) => { + if (options.recursive) { + await exportRecursive(client, fs, path, pageId, options); + analytics.track('export', true); + return; + } + + const format = (options.format || 'markdown').toLowerCase(); + const formatExt = { markdown: 'md', html: 'html', text: 'txt' }; + const contentExt = formatExt[format] || 'txt'; + + const pageInfo = await client.getPageInfo(pageId); + const content = await client.readPage( + pageId, + format, + options.referencedOnly ? { extractReferencedAttachments: true } : {} + ); + const referencedAttachments = options.referencedOnly + ? (client._referencedAttachments || new Set()) + : null; + + const baseDir = path.resolve(options.dest || '.'); + const folderName = sanitizeTitle(pageInfo.title || 'page'); + const exportDir = path.join(baseDir, folderName); + if (options.overwrite && fs.existsSync(exportDir)) { + if (!isExportDirectory(fs, path, exportDir)) { + throw new Error(`Refusing to overwrite "${exportDir}" - it was not created by confluence-cli (missing ${EXPORT_MARKER}).`); + } + fs.rmSync(exportDir, { recursive: true, force: true }); + } + fs.mkdirSync(exportDir, { recursive: true }); + + const contentFile = options.file || `page.${contentExt}`; + const contentPath = path.join(exportDir, contentFile); + fs.writeFileSync(contentPath, content); + writeExportMarker(fs, path, exportDir, { pageId, title: pageInfo.title }); + + console.log(chalk.green('✅ Page exported')); + console.log(`Title: ${chalk.blue(pageInfo.title)}`); + 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; + } + + if (filtered.length === 0) { + console.log(chalk.yellow('No attachments to download.')); + } else { + const attachmentsDirName = options.attachmentsDir || 'attachments'; + const attachmentsDir = path.join(exportDir, attachmentsDirName); + fs.mkdirSync(attachmentsDir, { recursive: true }); + + let downloaded = 0; + for (const attachment of filtered) { + const targetPath = uniquePathFor(fs, path, attachmentsDir, attachment.title); + // Pass the full attachment object so downloadAttachment can use downloadLink directly + const dataStream = await client.downloadAttachment(pageId, attachment); + await writeStream(fs, dataStream, targetPath); + downloaded += 1; + console.log(`⬇️ ${chalk.green(attachment.title)} -> ${chalk.gray(targetPath)}`); + } + + console.log(chalk.green(`Downloaded ${downloaded} attachment${downloaded === 1 ? '' : 's'} to ${attachmentsDir}`)); + } + } + + analytics.track('export', true); + })); +} + +module.exports = registerExportCommand; +module.exports.EXPORT_MARKER = EXPORT_MARKER; +module.exports.writeExportMarker = writeExportMarker; +module.exports.isExportDirectory = isExportDirectory; +module.exports.sanitizeFilename = sanitizeFilename; +module.exports.uniquePathFor = uniquePathFor; +module.exports.writeStream = writeStream; +module.exports.sanitizeTitle = sanitizeTitle; +module.exports.exportRecursive = exportRecursive; diff --git a/bin/confluence.js b/bin/confluence.js index 0a1412f..5c2d178 100755 --- a/bin/confluence.js +++ b/bin/confluence.js @@ -14,6 +14,7 @@ const registerVersionCommands = require('./commands/versions'); const registerAttachmentCommands = require('./commands/attachments'); const registerPropertyCommands = require('./commands/properties'); const registerCommentCommands = require('./commands/comment'); +const registerExportCommand = require('./commands/export'); function assertWritable(config) { if (config.readOnly) { @@ -479,340 +480,7 @@ registerPropertyCommands(program, { withClient }); registerCommentCommands(program, { withClient }); -// Export page content with attachments -program - .command('export ') - .description('Export a page to a directory with its attachments') - .option('--format ', 'Content format (html, text, markdown)', 'markdown') - .option('--dest ', 'Base directory to export into', '.') - .option('--file ', 'Content filename (default: page.)') - .option('--attachments-dir ', 'Subdirectory for attachments', 'attachments') - .option('--pattern ', 'Filter attachments by filename (e.g., "*.png")') - .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') - .option('--max-depth ', 'Limit recursion depth (default: 10)', parseInt) - .option('--exclude ', 'Comma-separated title glob patterns to skip') - .option('--delay-ms ', 'Delay between page exports in ms (default: 100)', parseInt) - .option('--dry-run', 'Preview pages without writing files') - .option('--overwrite', 'Overwrite existing export directory (replaces content, removes stale files)') - .action(withClient('export', async ({ client, analytics }, pageId, options) => { - if (options.recursive) { - await exportRecursive(client, fs, path, pageId, options); - analytics.track('export', true); - return; - } - - const format = (options.format || 'markdown').toLowerCase(); - const formatExt = { markdown: 'md', html: 'html', text: 'txt' }; - const contentExt = formatExt[format] || 'txt'; - - const pageInfo = await client.getPageInfo(pageId); - const content = await client.readPage( - pageId, - format, - options.referencedOnly ? { extractReferencedAttachments: true } : {} - ); - const referencedAttachments = options.referencedOnly - ? (client._referencedAttachments || new Set()) - : null; - - const baseDir = path.resolve(options.dest || '.'); - const folderName = sanitizeTitle(pageInfo.title || 'page'); - const exportDir = path.join(baseDir, folderName); - if (options.overwrite && fs.existsSync(exportDir)) { - if (!isExportDirectory(fs, path, exportDir)) { - throw new Error(`Refusing to overwrite "${exportDir}" - it was not created by confluence-cli (missing ${EXPORT_MARKER}).`); - } - fs.rmSync(exportDir, { recursive: true, force: true }); - } - fs.mkdirSync(exportDir, { recursive: true }); - - const contentFile = options.file || `page.${contentExt}`; - const contentPath = path.join(exportDir, contentFile); - fs.writeFileSync(contentPath, content); - writeExportMarker(fs, path, exportDir, { pageId, title: pageInfo.title }); - - console.log(chalk.green('✅ Page exported')); - console.log(`Title: ${chalk.blue(pageInfo.title)}`); - 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; - } - - if (filtered.length === 0) { - console.log(chalk.yellow('No attachments to download.')); - } else { - const attachmentsDirName = options.attachmentsDir || 'attachments'; - const attachmentsDir = path.join(exportDir, attachmentsDirName); - fs.mkdirSync(attachmentsDir, { recursive: true }); - - let downloaded = 0; - for (const attachment of filtered) { - const targetPath = uniquePathFor(fs, path, attachmentsDir, attachment.title); - // Pass the full attachment object so downloadAttachment can use downloadLink directly - const dataStream = await client.downloadAttachment(pageId, attachment); - await writeStream(fs, dataStream, targetPath); - downloaded += 1; - console.log(`⬇️ ${chalk.green(attachment.title)} -> ${chalk.gray(targetPath)}`); - } - - console.log(chalk.green(`Downloaded ${downloaded} attachment${downloaded === 1 ? '' : 's'} to ${attachmentsDir}`)); - } - } - - analytics.track('export', true); - })); - -const EXPORT_MARKER = '.confluence-export.json'; - -function writeExportMarker(fs, path, exportDir, meta) { - const marker = { - exportedAt: new Date().toISOString(), - pageId: meta.pageId, - title: meta.title, - tool: 'confluence-cli', - }; - fs.writeFileSync(path.join(exportDir, EXPORT_MARKER), JSON.stringify(marker, null, 2)); -} - -function isExportDirectory(fs, path, dir) { - return fs.existsSync(path.join(dir, EXPORT_MARKER)); -} - -function sanitizeFilename(filename) { - if (!filename || typeof filename !== 'string') { - return 'unnamed'; - } - const stripped = path.basename(filename.replace(/\\/g, '/')); - const cleaned = stripped - // eslint-disable-next-line no-control-regex - .replace(/[\\/:*?"<>|\x00-\x1f]/g, '_') - .replace(/^\.+/, '') - .trim(); - return cleaned || 'unnamed'; -} - -function uniquePathFor(fs, path, dir, filename) { - const safeFilename = sanitizeFilename(filename); - const parsed = path.parse(safeFilename); - let attempt = path.join(dir, safeFilename); - let counter = 1; - while (fs.existsSync(attempt)) { - const suffix = ` (${counter})`; - const nextName = `${parsed.name}${suffix}${parsed.ext}`; - attempt = path.join(dir, nextName); - counter += 1; - } - return attempt; -} - -function writeStream(fs, stream, targetPath) { - return new Promise((resolve, reject) => { - const writer = fs.createWriteStream(targetPath); - stream.pipe(writer); - stream.on('error', reject); - writer.on('error', reject); - writer.on('finish', resolve); - }); -} - -async function exportRecursive(client, fs, path, pageId, options) { - const maxDepth = options.maxDepth || 10; - const delayMs = options.delayMs != null ? options.delayMs : 100; - const excludePatterns = options.exclude - ? options.exclude.split(',').map(p => p.trim()).filter(Boolean) - : []; - const format = (options.format || 'markdown').toLowerCase(); - const formatExt = { markdown: 'md', html: 'html', text: 'txt' }; - const contentExt = formatExt[format] || 'txt'; - const contentFile = options.file || `page.${contentExt}`; - const baseDir = path.resolve(options.dest || '.'); - - // 1. Fetch root page - const rootPage = await client.getPageInfo(pageId); - console.log(`Fetching descendants of "${chalk.blue(rootPage.title)}"...`); - - // 2. Fetch all descendants - const descendants = await client.getAllDescendantPages(pageId, maxDepth); - - // 3. Filter by exclude patterns - const allPages = [{ id: rootPage.id, title: rootPage.title, parentId: null }]; - for (const page of descendants) { - if (excludePatterns.length && client.shouldExcludePage(page.title, excludePatterns)) { - continue; - } - allPages.push(page); - } - - // 4. Build tree - const tree = client.buildPageTree(allPages.slice(1), pageId); - - const totalPages = allPages.length; - console.log(`Found ${chalk.blue(totalPages)} page${totalPages === 1 ? '' : 's'} to export.`); - - // 5. Dry run — print tree and return - if (options.dryRun) { - const printTree = (nodes, indent = '') => { - for (const node of nodes) { - console.log(`${indent}${chalk.blue(node.title)} (${node.id})`); - if (node.children && node.children.length) { - printTree(node.children, indent + ' '); - } - } - }; - console.log(`\n${chalk.blue(rootPage.title)} (${rootPage.id})`); - printTree(tree, ' '); - console.log(chalk.yellow('\nDry run — no files written.')); - return; - } - - // 6. Overwrite — remove existing root export directory for a clean slate - if (options.overwrite) { - const rootFolderName = sanitizeTitle(rootPage.title); - const rootExportDir = path.join(baseDir, rootFolderName); - if (fs.existsSync(rootExportDir)) { - if (!isExportDirectory(fs, path, rootExportDir)) { - throw new Error(`Refusing to overwrite "${rootExportDir}" - it was not created by confluence-cli (missing ${EXPORT_MARKER}).`); - } - fs.rmSync(rootExportDir, { recursive: true, force: true }); - } - } - - // 7. Walk tree depth-first and export each page - const failures = []; - let exported = 0; - - async function exportPage(page, dir) { - exported += 1; - console.log(`[${exported}/${totalPages}] Exporting: ${chalk.blue(page.title)}`); - - const folderName = sanitizeTitle(page.title); - let exportDir = path.join(dir, folderName); - - // Handle duplicate sibling folder names - if (fs.existsSync(exportDir)) { - let counter = 1; - while (fs.existsSync(`${exportDir} (${counter})`)) { - counter += 1; - } - exportDir = `${exportDir} (${counter})`; - } - fs.mkdirSync(exportDir, { recursive: true }); - - // Fetch content and write - const content = await client.readPage( - page.id, - format, - options.referencedOnly ? { extractReferencedAttachments: true } : {} - ); - const referencedAttachments = options.referencedOnly - ? (client._referencedAttachments || new Set()) - : null; - fs.writeFileSync(path.join(exportDir, contentFile), content); - - // 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; - } - - if (filtered.length > 0) { - const attachmentsDirName = options.attachmentsDir || 'attachments'; - const attachmentsDir = path.join(exportDir, attachmentsDirName); - fs.mkdirSync(attachmentsDir, { recursive: true }); - - for (const attachment of filtered) { - const targetPath = uniquePathFor(fs, path, attachmentsDir, attachment.title); - const dataStream = await client.downloadAttachment(page.id, attachment); - await writeStream(fs, dataStream, targetPath); - } - } - } - - return exportDir; - } - - async function walkTree(nodes, parentDir) { - for (let i = 0; i < nodes.length; i++) { - const node = nodes[i]; - try { - const nodeDir = await exportPage(node, parentDir); - if (node.children && node.children.length) { - await walkTree(node.children, nodeDir); - } - } catch (error) { - failures.push({ id: node.id, title: node.title, error: error.message }); - console.error(chalk.red(` Failed: ${node.title} — ${error.message}`)); - } - - // Rate limiting between pages - if (delayMs > 0 && exported < totalPages) { - await new Promise(resolve => setTimeout(resolve, delayMs)); - } - } - } - - // Export root page - let rootDir; - try { - rootDir = await exportPage(rootPage, baseDir); - writeExportMarker(fs, path, rootDir, { pageId, title: rootPage.title }); - } catch (error) { - failures.push({ id: rootPage.id, title: rootPage.title, error: error.message }); - console.error(chalk.red(` Failed: ${rootPage.title} — ${error.message}`)); - // Can't continue without root directory - throw new Error(`Failed to export root page: ${error.message}`); - } - - if (delayMs > 0 && tree.length > 0) { - await new Promise(resolve => setTimeout(resolve, delayMs)); - } - - // Export descendants - await walkTree(tree, rootDir); - - // 8. Summary - const succeeded = exported - failures.length; - console.log(chalk.green(`\n✅ Exported ${succeeded}/${totalPages} page${totalPages === 1 ? '' : 's'} to ${rootDir}`)); - if (failures.length > 0) { - console.log(chalk.red(`\n${failures.length} failure${failures.length === 1 ? '' : 's'}:`)); - for (const f of failures) { - console.log(chalk.red(` - ${f.title} (${f.id}): ${f.error}`)); - } - } -} - -function sanitizeTitle(value) { - const fallback = 'page'; - if (!value || typeof value !== 'string') { - return fallback; - } - const cleaned = value - // eslint-disable-next-line no-control-regex - .replace(/[\\/:*?"<>|\x00-\x1f]/g, ' ') - .replace(/^\.+/, '') - .trim(); - return cleaned || fallback; -} +registerExportCommand(program, { withClient }); // Copy page tree command program @@ -1182,13 +850,6 @@ program module.exports = { program, _test: { - EXPORT_MARKER, - writeExportMarker, - isExportDirectory, - uniquePathFor, - exportRecursive, - sanitizeTitle, - sanitizeFilename, assertWritable, assertNonEmpty, assertValidType, diff --git a/tests/export.test.js b/tests/export.test.js index 8c9e311..189eee5 100644 --- a/tests/export.test.js +++ b/tests/export.test.js @@ -1,17 +1,14 @@ const path = require('path'); -// Require the CLI module (guarded by require.main check, won't parse argv) const { - _test: { - EXPORT_MARKER, - writeExportMarker, - isExportDirectory, - uniquePathFor, - exportRecursive, - sanitizeTitle, - sanitizeFilename, - }, -} = require('../bin/confluence.js'); + EXPORT_MARKER, + writeExportMarker, + isExportDirectory, + uniquePathFor, + exportRecursive, + sanitizeTitle, + sanitizeFilename, +} = require('../bin/commands/export.js'); // --------------------------------------------------------------------------- // Helpers: in-memory fs mock