confluence-cli 1.24.1 → 1.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/confluence.js CHANGED
@@ -1216,6 +1216,12 @@ program
1216
1216
  .option('--pattern <glob>', 'Filter attachments by filename (e.g., "*.png")')
1217
1217
  .option('--referenced-only', 'Download only attachments referenced in the page content')
1218
1218
  .option('--skip-attachments', 'Do not download attachments')
1219
+ .option('-r, --recursive', 'Export page and all descendants')
1220
+ .option('--max-depth <depth>', 'Limit recursion depth (default: 10)', parseInt)
1221
+ .option('--exclude <patterns>', 'Comma-separated title glob patterns to skip')
1222
+ .option('--delay-ms <ms>', 'Delay between page exports in ms (default: 100)', parseInt)
1223
+ .option('--dry-run', 'Preview pages without writing files')
1224
+ .option('--overwrite', 'Overwrite existing export directory (replaces content, removes stale files)')
1219
1225
  .action(async (pageId, options) => {
1220
1226
  const analytics = new Analytics();
1221
1227
  try {
@@ -1224,6 +1230,12 @@ program
1224
1230
  const fs = require('fs');
1225
1231
  const path = require('path');
1226
1232
 
1233
+ if (options.recursive) {
1234
+ await exportRecursive(client, fs, path, pageId, options);
1235
+ analytics.track('export', true);
1236
+ return;
1237
+ }
1238
+
1227
1239
  const format = (options.format || 'markdown').toLowerCase();
1228
1240
  const formatExt = { markdown: 'md', html: 'html', text: 'txt' };
1229
1241
  const contentExt = formatExt[format] || 'txt';
@@ -1241,11 +1253,18 @@ program
1241
1253
  const baseDir = path.resolve(options.dest || '.');
1242
1254
  const folderName = sanitizeTitle(pageInfo.title || 'page');
1243
1255
  const exportDir = path.join(baseDir, folderName);
1256
+ if (options.overwrite && fs.existsSync(exportDir)) {
1257
+ if (!isExportDirectory(fs, path, exportDir)) {
1258
+ throw new Error(`Refusing to overwrite "${exportDir}" - it was not created by confluence-cli (missing ${EXPORT_MARKER}).`);
1259
+ }
1260
+ fs.rmSync(exportDir, { recursive: true, force: true });
1261
+ }
1244
1262
  fs.mkdirSync(exportDir, { recursive: true });
1245
1263
 
1246
1264
  const contentFile = options.file || `page.${contentExt}`;
1247
1265
  const contentPath = path.join(exportDir, contentFile);
1248
1266
  fs.writeFileSync(contentPath, content);
1267
+ writeExportMarker(fs, path, exportDir, { pageId, title: pageInfo.title });
1249
1268
 
1250
1269
  console.log(chalk.green('✅ Page exported'));
1251
1270
  console.log(`Title: ${chalk.blue(pageInfo.title)}`);
@@ -1271,33 +1290,12 @@ program
1271
1290
  const attachmentsDir = path.join(exportDir, attachmentsDirName);
1272
1291
  fs.mkdirSync(attachmentsDir, { recursive: true });
1273
1292
 
1274
- const uniquePathFor = (dir, filename) => {
1275
- const parsed = path.parse(filename);
1276
- let attempt = path.join(dir, filename);
1277
- let counter = 1;
1278
- while (fs.existsSync(attempt)) {
1279
- const suffix = ` (${counter})`;
1280
- const nextName = `${parsed.name}${suffix}${parsed.ext}`;
1281
- attempt = path.join(dir, nextName);
1282
- counter += 1;
1283
- }
1284
- return attempt;
1285
- };
1286
-
1287
- const writeStream = (stream, targetPath) => new Promise((resolve, reject) => {
1288
- const writer = fs.createWriteStream(targetPath);
1289
- stream.pipe(writer);
1290
- stream.on('error', reject);
1291
- writer.on('error', reject);
1292
- writer.on('finish', resolve);
1293
- });
1294
-
1295
1293
  let downloaded = 0;
1296
1294
  for (const attachment of filtered) {
1297
- const targetPath = uniquePathFor(attachmentsDir, attachment.title);
1295
+ const targetPath = uniquePathFor(fs, path, attachmentsDir, attachment.title);
1298
1296
  // Pass the full attachment object so downloadAttachment can use downloadLink directly
1299
1297
  const dataStream = await client.downloadAttachment(pageId, attachment);
1300
- await writeStream(dataStream, targetPath);
1298
+ await writeStream(fs, dataStream, targetPath);
1301
1299
  downloaded += 1;
1302
1300
  console.log(`⬇️ ${chalk.green(attachment.title)} -> ${chalk.gray(targetPath)}`);
1303
1301
  }
@@ -1314,6 +1312,219 @@ program
1314
1312
  }
1315
1313
  });
1316
1314
 
1315
+ const EXPORT_MARKER = '.confluence-export.json';
1316
+
1317
+ function writeExportMarker(fs, path, exportDir, meta) {
1318
+ const marker = {
1319
+ exportedAt: new Date().toISOString(),
1320
+ pageId: meta.pageId,
1321
+ title: meta.title,
1322
+ tool: 'confluence-cli',
1323
+ };
1324
+ fs.writeFileSync(path.join(exportDir, EXPORT_MARKER), JSON.stringify(marker, null, 2));
1325
+ }
1326
+
1327
+ function isExportDirectory(fs, path, dir) {
1328
+ return fs.existsSync(path.join(dir, EXPORT_MARKER));
1329
+ }
1330
+
1331
+ function uniquePathFor(fs, path, dir, filename) {
1332
+ const parsed = path.parse(filename);
1333
+ let attempt = path.join(dir, filename);
1334
+ let counter = 1;
1335
+ while (fs.existsSync(attempt)) {
1336
+ const suffix = ` (${counter})`;
1337
+ const nextName = `${parsed.name}${suffix}${parsed.ext}`;
1338
+ attempt = path.join(dir, nextName);
1339
+ counter += 1;
1340
+ }
1341
+ return attempt;
1342
+ }
1343
+
1344
+ function writeStream(fs, stream, targetPath) {
1345
+ return new Promise((resolve, reject) => {
1346
+ const writer = fs.createWriteStream(targetPath);
1347
+ stream.pipe(writer);
1348
+ stream.on('error', reject);
1349
+ writer.on('error', reject);
1350
+ writer.on('finish', resolve);
1351
+ });
1352
+ }
1353
+
1354
+ async function exportRecursive(client, fs, path, pageId, options) {
1355
+ const maxDepth = options.maxDepth || 10;
1356
+ const delayMs = options.delayMs != null ? options.delayMs : 100;
1357
+ const excludePatterns = options.exclude
1358
+ ? options.exclude.split(',').map(p => p.trim()).filter(Boolean)
1359
+ : [];
1360
+ const format = (options.format || 'markdown').toLowerCase();
1361
+ const formatExt = { markdown: 'md', html: 'html', text: 'txt' };
1362
+ const contentExt = formatExt[format] || 'txt';
1363
+ const contentFile = options.file || `page.${contentExt}`;
1364
+ const baseDir = path.resolve(options.dest || '.');
1365
+
1366
+ // 1. Fetch root page
1367
+ const rootPage = await client.getPageInfo(pageId);
1368
+ console.log(`Fetching descendants of "${chalk.blue(rootPage.title)}"...`);
1369
+
1370
+ // 2. Fetch all descendants
1371
+ const descendants = await client.getAllDescendantPages(pageId, maxDepth);
1372
+
1373
+ // 3. Filter by exclude patterns
1374
+ const allPages = [{ id: rootPage.id, title: rootPage.title, parentId: null }];
1375
+ for (const page of descendants) {
1376
+ if (excludePatterns.length && client.shouldExcludePage(page.title, excludePatterns)) {
1377
+ continue;
1378
+ }
1379
+ allPages.push(page);
1380
+ }
1381
+
1382
+ // 4. Build tree
1383
+ const tree = client.buildPageTree(allPages.slice(1), pageId);
1384
+
1385
+ const totalPages = allPages.length;
1386
+ console.log(`Found ${chalk.blue(totalPages)} page${totalPages === 1 ? '' : 's'} to export.`);
1387
+
1388
+ // 5. Dry run — print tree and return
1389
+ if (options.dryRun) {
1390
+ const printTree = (nodes, indent = '') => {
1391
+ for (const node of nodes) {
1392
+ console.log(`${indent}${chalk.blue(node.title)} (${node.id})`);
1393
+ if (node.children && node.children.length) {
1394
+ printTree(node.children, indent + ' ');
1395
+ }
1396
+ }
1397
+ };
1398
+ console.log(`\n${chalk.blue(rootPage.title)} (${rootPage.id})`);
1399
+ printTree(tree, ' ');
1400
+ console.log(chalk.yellow('\nDry run — no files written.'));
1401
+ return;
1402
+ }
1403
+
1404
+ // 6. Overwrite — remove existing root export directory for a clean slate
1405
+ if (options.overwrite) {
1406
+ const rootFolderName = sanitizeTitle(rootPage.title);
1407
+ const rootExportDir = path.join(baseDir, rootFolderName);
1408
+ if (fs.existsSync(rootExportDir)) {
1409
+ if (!isExportDirectory(fs, path, rootExportDir)) {
1410
+ throw new Error(`Refusing to overwrite "${rootExportDir}" - it was not created by confluence-cli (missing ${EXPORT_MARKER}).`);
1411
+ }
1412
+ fs.rmSync(rootExportDir, { recursive: true, force: true });
1413
+ }
1414
+ }
1415
+
1416
+ // 7. Walk tree depth-first and export each page
1417
+ const failures = [];
1418
+ let exported = 0;
1419
+
1420
+ async function exportPage(page, dir) {
1421
+ exported += 1;
1422
+ console.log(`[${exported}/${totalPages}] Exporting: ${chalk.blue(page.title)}`);
1423
+
1424
+ const folderName = sanitizeTitle(page.title);
1425
+ let exportDir = path.join(dir, folderName);
1426
+
1427
+ // Handle duplicate sibling folder names
1428
+ if (fs.existsSync(exportDir)) {
1429
+ let counter = 1;
1430
+ while (fs.existsSync(`${exportDir} (${counter})`)) {
1431
+ counter += 1;
1432
+ }
1433
+ exportDir = `${exportDir} (${counter})`;
1434
+ }
1435
+ fs.mkdirSync(exportDir, { recursive: true });
1436
+
1437
+ // Fetch content and write
1438
+ const content = await client.readPage(
1439
+ page.id,
1440
+ format,
1441
+ options.referencedOnly ? { extractReferencedAttachments: true } : {}
1442
+ );
1443
+ const referencedAttachments = options.referencedOnly
1444
+ ? (client._referencedAttachments || new Set())
1445
+ : null;
1446
+ fs.writeFileSync(path.join(exportDir, contentFile), content);
1447
+
1448
+ // Download attachments
1449
+ if (!options.skipAttachments) {
1450
+ const pattern = options.pattern ? options.pattern.trim() : null;
1451
+ const allAttachments = await client.getAllAttachments(page.id);
1452
+
1453
+ let filtered;
1454
+ if (pattern) {
1455
+ filtered = allAttachments.filter(att => client.matchesPattern(att.title, pattern));
1456
+ } else if (options.referencedOnly) {
1457
+ filtered = allAttachments.filter(att => referencedAttachments?.has(att.title));
1458
+ } else {
1459
+ filtered = allAttachments;
1460
+ }
1461
+
1462
+ if (filtered.length > 0) {
1463
+ const attachmentsDirName = options.attachmentsDir || 'attachments';
1464
+ const attachmentsDir = path.join(exportDir, attachmentsDirName);
1465
+ fs.mkdirSync(attachmentsDir, { recursive: true });
1466
+
1467
+ for (const attachment of filtered) {
1468
+ const targetPath = uniquePathFor(fs, path, attachmentsDir, attachment.title);
1469
+ const dataStream = await client.downloadAttachment(page.id, attachment);
1470
+ await writeStream(fs, dataStream, targetPath);
1471
+ }
1472
+ }
1473
+ }
1474
+
1475
+ return exportDir;
1476
+ }
1477
+
1478
+ async function walkTree(nodes, parentDir) {
1479
+ for (let i = 0; i < nodes.length; i++) {
1480
+ const node = nodes[i];
1481
+ try {
1482
+ const nodeDir = await exportPage(node, parentDir);
1483
+ if (node.children && node.children.length) {
1484
+ await walkTree(node.children, nodeDir);
1485
+ }
1486
+ } catch (error) {
1487
+ failures.push({ id: node.id, title: node.title, error: error.message });
1488
+ console.error(chalk.red(` Failed: ${node.title} — ${error.message}`));
1489
+ }
1490
+
1491
+ // Rate limiting between pages
1492
+ if (delayMs > 0 && exported < totalPages) {
1493
+ await new Promise(resolve => setTimeout(resolve, delayMs));
1494
+ }
1495
+ }
1496
+ }
1497
+
1498
+ // Export root page
1499
+ let rootDir;
1500
+ try {
1501
+ rootDir = await exportPage(rootPage, baseDir);
1502
+ writeExportMarker(fs, path, rootDir, { pageId, title: rootPage.title });
1503
+ } catch (error) {
1504
+ failures.push({ id: rootPage.id, title: rootPage.title, error: error.message });
1505
+ console.error(chalk.red(` Failed: ${rootPage.title} — ${error.message}`));
1506
+ // Can't continue without root directory
1507
+ throw new Error(`Failed to export root page: ${error.message}`);
1508
+ }
1509
+
1510
+ if (delayMs > 0 && tree.length > 0) {
1511
+ await new Promise(resolve => setTimeout(resolve, delayMs));
1512
+ }
1513
+
1514
+ // Export descendants
1515
+ await walkTree(tree, rootDir);
1516
+
1517
+ // 8. Summary
1518
+ const succeeded = exported - failures.length;
1519
+ console.log(chalk.green(`\n✅ Exported ${succeeded}/${totalPages} page${totalPages === 1 ? '' : 's'} to ${rootDir}`));
1520
+ if (failures.length > 0) {
1521
+ console.log(chalk.red(`\n${failures.length} failure${failures.length === 1 ? '' : 's'}:`));
1522
+ for (const f of failures) {
1523
+ console.log(chalk.red(` - ${f.title} (${f.id}): ${f.error}`));
1524
+ }
1525
+ }
1526
+ }
1527
+
1317
1528
  function sanitizeTitle(value) {
1318
1529
  const fallback = 'page';
1319
1530
  if (!value || typeof value !== 'string') {
@@ -1641,8 +1852,22 @@ function printTree(nodes, config, options, depth = 1) {
1641
1852
  });
1642
1853
  }
1643
1854
 
1644
- if (process.argv.length <= 2) {
1645
- program.help({ error: false });
1855
+ // Exported for testing
1856
+ module.exports = {
1857
+ program,
1858
+ _test: {
1859
+ EXPORT_MARKER,
1860
+ writeExportMarker,
1861
+ isExportDirectory,
1862
+ uniquePathFor,
1863
+ exportRecursive,
1864
+ sanitizeTitle,
1865
+ },
1866
+ };
1867
+
1868
+ if (require.main === module) {
1869
+ if (process.argv.length <= 2) {
1870
+ program.help({ error: false });
1871
+ }
1872
+ program.parse(process.argv);
1646
1873
  }
1647
-
1648
- program.parse(process.argv);
package/bin/index.js CHANGED
@@ -21,4 +21,9 @@ if (!nodeVersion.startsWith('v') ||
21
21
  }
22
22
 
23
23
  // Load the main CLI application
24
- require('./confluence.js');
24
+ const { program } = require('./confluence.js');
25
+
26
+ if (process.argv.length <= 2) {
27
+ program.help({ error: false });
28
+ }
29
+ program.parse(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "confluence-cli",
3
- "version": "1.24.1",
3
+ "version": "1.25.1",
4
4
  "description": "A command-line interface for Atlassian Confluence with page creation and editing capabilities",
5
5
  "main": "index.js",
6
6
  "bin": {