confluence-cli 1.24.0 → 1.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/confluence.js +251 -27
- package/package.json +1 -1
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,21 @@ function printTree(nodes, config, options, depth = 1) {
|
|
|
1641
1852
|
});
|
|
1642
1853
|
}
|
|
1643
1854
|
|
|
1644
|
-
|
|
1645
|
-
|
|
1855
|
+
// Exported for testing
|
|
1856
|
+
module.exports = {
|
|
1857
|
+
_test: {
|
|
1858
|
+
EXPORT_MARKER,
|
|
1859
|
+
writeExportMarker,
|
|
1860
|
+
isExportDirectory,
|
|
1861
|
+
uniquePathFor,
|
|
1862
|
+
exportRecursive,
|
|
1863
|
+
sanitizeTitle,
|
|
1864
|
+
},
|
|
1865
|
+
};
|
|
1866
|
+
|
|
1867
|
+
if (require.main === module) {
|
|
1868
|
+
if (process.argv.length <= 2) {
|
|
1869
|
+
program.help({ error: false });
|
|
1870
|
+
}
|
|
1871
|
+
program.parse(process.argv);
|
|
1646
1872
|
}
|
|
1647
|
-
|
|
1648
|
-
program.parse(process.argv);
|