@bestend/confluence-cli 1.15.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/.eslintrc.js +23 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +34 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +26 -0
- package/.github/ISSUE_TEMPLATE/feedback.md +37 -0
- package/.github/pull_request_template.md +31 -0
- package/.github/workflows/ci.yml +67 -0
- package/.github/workflows/publish.yml +26 -0
- package/.releaserc +17 -0
- package/CHANGELOG.md +232 -0
- package/CONTRIBUTING.md +246 -0
- package/LICENSE +21 -0
- package/README.md +454 -0
- package/bin/confluence.js +1225 -0
- package/bin/index.js +24 -0
- package/docs/PROMOTION.md +63 -0
- package/eslint.config.js +33 -0
- package/examples/copy-tree-example.sh +117 -0
- package/examples/create-child-page-example.sh +67 -0
- package/examples/demo-page-management.sh +68 -0
- package/examples/demo.sh +43 -0
- package/examples/sample-page.md +30 -0
- package/jest.config.js +13 -0
- package/lib/analytics.js +87 -0
- package/lib/config.js +437 -0
- package/lib/confluence-client.js +1810 -0
- package/llms.txt +46 -0
- package/package.json +57 -0
- package/tests/confluence-client.test.js +459 -0
|
@@ -0,0 +1,1225 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { program } = require('commander');
|
|
4
|
+
const chalk = require('chalk');
|
|
5
|
+
const inquirer = require('inquirer');
|
|
6
|
+
const ConfluenceClient = require('../lib/confluence-client');
|
|
7
|
+
const { getConfig, initConfig } = require('../lib/config');
|
|
8
|
+
const Analytics = require('../lib/analytics');
|
|
9
|
+
const pkg = require('../package.json');
|
|
10
|
+
|
|
11
|
+
program
|
|
12
|
+
.name('confluence')
|
|
13
|
+
.description('CLI tool for Atlassian Confluence')
|
|
14
|
+
.version(pkg.version);
|
|
15
|
+
|
|
16
|
+
// Init command
|
|
17
|
+
program
|
|
18
|
+
.command('init')
|
|
19
|
+
.description('Initialize Confluence CLI configuration')
|
|
20
|
+
.option('-d, --domain <domain>', 'Confluence domain')
|
|
21
|
+
.option('-p, --api-path <path>', 'REST API path')
|
|
22
|
+
.option('-a, --auth-type <type>', 'Authentication type (basic or bearer)')
|
|
23
|
+
.option('-e, --email <email>', 'Email for basic auth')
|
|
24
|
+
.option('-t, --token <token>', 'API token')
|
|
25
|
+
.action(async (options) => {
|
|
26
|
+
await initConfig(options);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Read command
|
|
30
|
+
program
|
|
31
|
+
.command('read <pageId>')
|
|
32
|
+
.description('Read a Confluence page by ID or URL')
|
|
33
|
+
.option('-f, --format <format>', 'Output format (html, text, markdown)', 'text')
|
|
34
|
+
.action(async (pageId, options) => {
|
|
35
|
+
const analytics = new Analytics();
|
|
36
|
+
try {
|
|
37
|
+
const client = new ConfluenceClient(getConfig());
|
|
38
|
+
const content = await client.readPage(pageId, options.format);
|
|
39
|
+
console.log(content);
|
|
40
|
+
analytics.track('read', true);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
analytics.track('read', false);
|
|
43
|
+
console.error(chalk.red('Error:'), error.message);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Info command
|
|
49
|
+
program
|
|
50
|
+
.command('info <pageId>')
|
|
51
|
+
.description('Get information about a Confluence page')
|
|
52
|
+
.action(async (pageId) => {
|
|
53
|
+
const analytics = new Analytics();
|
|
54
|
+
try {
|
|
55
|
+
const client = new ConfluenceClient(getConfig());
|
|
56
|
+
const info = await client.getPageInfo(pageId);
|
|
57
|
+
console.log(chalk.blue('Page Information:'));
|
|
58
|
+
console.log(`Title: ${chalk.green(info.title)}`);
|
|
59
|
+
console.log(`ID: ${chalk.green(info.id)}`);
|
|
60
|
+
console.log(`Type: ${chalk.green(info.type)}`);
|
|
61
|
+
console.log(`Status: ${chalk.green(info.status)}`);
|
|
62
|
+
if (info.space) {
|
|
63
|
+
console.log(`Space: ${chalk.green(info.space.name)} (${info.space.key})`);
|
|
64
|
+
}
|
|
65
|
+
analytics.track('info', true);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
analytics.track('info', false);
|
|
68
|
+
console.error(chalk.red('Error:'), error.message);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Search command
|
|
74
|
+
program
|
|
75
|
+
.command('search <query>')
|
|
76
|
+
.description('Search for Confluence pages')
|
|
77
|
+
.option('-l, --limit <limit>', 'Limit number of results', '10')
|
|
78
|
+
.action(async (query, options) => {
|
|
79
|
+
const analytics = new Analytics();
|
|
80
|
+
try {
|
|
81
|
+
const client = new ConfluenceClient(getConfig());
|
|
82
|
+
const results = await client.search(query, parseInt(options.limit));
|
|
83
|
+
|
|
84
|
+
if (results.length === 0) {
|
|
85
|
+
console.log(chalk.yellow('No results found.'));
|
|
86
|
+
analytics.track('search', true);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
console.log(chalk.blue(`Found ${results.length} results:`));
|
|
91
|
+
results.forEach((result, index) => {
|
|
92
|
+
console.log(`${index + 1}. ${chalk.green(result.title)} (ID: ${result.id})`);
|
|
93
|
+
if (result.excerpt) {
|
|
94
|
+
console.log(` ${chalk.gray(result.excerpt)}`);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
analytics.track('search', true);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
analytics.track('search', false);
|
|
100
|
+
console.error(chalk.red('Error:'), error.message);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// List spaces command
|
|
106
|
+
program
|
|
107
|
+
.command('spaces')
|
|
108
|
+
.description('List all Confluence spaces')
|
|
109
|
+
.action(async () => {
|
|
110
|
+
const analytics = new Analytics();
|
|
111
|
+
try {
|
|
112
|
+
const config = getConfig();
|
|
113
|
+
const client = new ConfluenceClient(config);
|
|
114
|
+
const spaces = await client.getSpaces();
|
|
115
|
+
|
|
116
|
+
console.log(chalk.blue('Available spaces:'));
|
|
117
|
+
spaces.forEach(space => {
|
|
118
|
+
console.log(`${chalk.green(space.key)} - ${space.name}`);
|
|
119
|
+
});
|
|
120
|
+
analytics.track('spaces', true);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
analytics.track('spaces', false);
|
|
123
|
+
console.error(chalk.red('Error:'), error.message);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Stats command
|
|
129
|
+
program
|
|
130
|
+
.command('stats')
|
|
131
|
+
.description('Show usage statistics')
|
|
132
|
+
.action(async () => {
|
|
133
|
+
try {
|
|
134
|
+
const analytics = new Analytics();
|
|
135
|
+
analytics.showStats();
|
|
136
|
+
} catch (error) {
|
|
137
|
+
console.error(chalk.red('Error:'), error.message);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Create command
|
|
143
|
+
program
|
|
144
|
+
.command('create <title> <spaceKey>')
|
|
145
|
+
.description('Create a new Confluence page')
|
|
146
|
+
.option('-f, --file <file>', 'Read content from file')
|
|
147
|
+
.option('-c, --content <content>', 'Page content as string')
|
|
148
|
+
.option('--format <format>', 'Content format (storage, html, markdown)', 'storage')
|
|
149
|
+
.action(async (title, spaceKey, options) => {
|
|
150
|
+
const analytics = new Analytics();
|
|
151
|
+
try {
|
|
152
|
+
const config = getConfig();
|
|
153
|
+
const client = new ConfluenceClient(config);
|
|
154
|
+
|
|
155
|
+
let content = '';
|
|
156
|
+
|
|
157
|
+
if (options.file) {
|
|
158
|
+
const fs = require('fs');
|
|
159
|
+
if (!fs.existsSync(options.file)) {
|
|
160
|
+
throw new Error(`File not found: ${options.file}`);
|
|
161
|
+
}
|
|
162
|
+
content = fs.readFileSync(options.file, 'utf8');
|
|
163
|
+
} else if (options.content) {
|
|
164
|
+
content = options.content;
|
|
165
|
+
} else {
|
|
166
|
+
throw new Error('Either --file or --content option is required');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const result = await client.createPage(title, spaceKey, content, options.format);
|
|
170
|
+
|
|
171
|
+
console.log(chalk.green('✅ Page created successfully!'));
|
|
172
|
+
console.log(`Title: ${chalk.blue(result.title)}`);
|
|
173
|
+
console.log(`ID: ${chalk.blue(result.id)}`);
|
|
174
|
+
console.log(`Space: ${chalk.blue(result.space.name)} (${result.space.key})`);
|
|
175
|
+
console.log(`URL: ${chalk.gray(`https://${config.domain}/wiki${result._links.webui}`)}`);
|
|
176
|
+
|
|
177
|
+
analytics.track('create', true);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
analytics.track('create', false);
|
|
180
|
+
console.error(chalk.red('Error:'), error.message);
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Create child page command
|
|
186
|
+
program
|
|
187
|
+
.command('create-child <title> <parentId>')
|
|
188
|
+
.description('Create a new Confluence page as a child of another page')
|
|
189
|
+
.option('-f, --file <file>', 'Read content from file')
|
|
190
|
+
.option('-c, --content <content>', 'Page content as string')
|
|
191
|
+
.option('--format <format>', 'Content format (storage, html, markdown)', 'storage')
|
|
192
|
+
.action(async (title, parentId, options) => {
|
|
193
|
+
const analytics = new Analytics();
|
|
194
|
+
try {
|
|
195
|
+
const config = getConfig();
|
|
196
|
+
const client = new ConfluenceClient(config);
|
|
197
|
+
|
|
198
|
+
// Get parent page info to get space key
|
|
199
|
+
const parentInfo = await client.getPageInfo(parentId);
|
|
200
|
+
const spaceKey = parentInfo.space.key;
|
|
201
|
+
|
|
202
|
+
let content = '';
|
|
203
|
+
|
|
204
|
+
if (options.file) {
|
|
205
|
+
const fs = require('fs');
|
|
206
|
+
if (!fs.existsSync(options.file)) {
|
|
207
|
+
throw new Error(`File not found: ${options.file}`);
|
|
208
|
+
}
|
|
209
|
+
content = fs.readFileSync(options.file, 'utf8');
|
|
210
|
+
} else if (options.content) {
|
|
211
|
+
content = options.content;
|
|
212
|
+
} else {
|
|
213
|
+
throw new Error('Either --file or --content option is required');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const result = await client.createChildPage(title, spaceKey, parentId, content, options.format);
|
|
217
|
+
|
|
218
|
+
console.log(chalk.green('✅ Child page created successfully!'));
|
|
219
|
+
console.log(`Title: ${chalk.blue(result.title)}`);
|
|
220
|
+
console.log(`ID: ${chalk.blue(result.id)}`);
|
|
221
|
+
console.log(`Parent: ${chalk.blue(parentInfo.title)} (${parentId})`);
|
|
222
|
+
console.log(`Space: ${chalk.blue(result.space.name)} (${result.space.key})`);
|
|
223
|
+
console.log(`URL: ${chalk.gray(`https://${config.domain}/wiki${result._links.webui}`)}`);
|
|
224
|
+
|
|
225
|
+
analytics.track('create_child', true);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
analytics.track('create_child', false);
|
|
228
|
+
console.error(chalk.red('Error:'), error.message);
|
|
229
|
+
process.exit(1);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// Update command
|
|
234
|
+
program
|
|
235
|
+
.command('update <pageId>')
|
|
236
|
+
.description('Update an existing Confluence page')
|
|
237
|
+
.option('-t, --title <title>', 'New page title (optional)')
|
|
238
|
+
.option('-f, --file <file>', 'Read content from file')
|
|
239
|
+
.option('-c, --content <content>', 'Page content as string')
|
|
240
|
+
.option('--format <format>', 'Content format (storage, html, markdown)', 'storage')
|
|
241
|
+
.action(async (pageId, options) => {
|
|
242
|
+
const analytics = new Analytics();
|
|
243
|
+
try {
|
|
244
|
+
// Check if at least one option is provided
|
|
245
|
+
if (!options.title && !options.file && !options.content) {
|
|
246
|
+
throw new Error('At least one of --title, --file, or --content must be provided.');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const config = getConfig();
|
|
250
|
+
const client = new ConfluenceClient(config);
|
|
251
|
+
|
|
252
|
+
let content = null; // Use null to indicate no content change
|
|
253
|
+
|
|
254
|
+
if (options.file) {
|
|
255
|
+
const fs = require('fs');
|
|
256
|
+
if (!fs.existsSync(options.file)) {
|
|
257
|
+
throw new Error(`File not found: ${options.file}`);
|
|
258
|
+
}
|
|
259
|
+
content = fs.readFileSync(options.file, 'utf8');
|
|
260
|
+
} else if (options.content) {
|
|
261
|
+
content = options.content;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const result = await client.updatePage(pageId, options.title, content, options.format);
|
|
265
|
+
|
|
266
|
+
console.log(chalk.green('✅ Page updated successfully!'));
|
|
267
|
+
console.log(`Title: ${chalk.blue(result.title)}`);
|
|
268
|
+
console.log(`ID: ${chalk.blue(result.id)}`);
|
|
269
|
+
console.log(`Version: ${chalk.blue(result.version.number)}`);
|
|
270
|
+
console.log(`URL: ${chalk.gray(`https://${config.domain}/wiki${result._links.webui}`)}`);
|
|
271
|
+
|
|
272
|
+
analytics.track('update', true);
|
|
273
|
+
} catch (error) {
|
|
274
|
+
analytics.track('update', false);
|
|
275
|
+
console.error(chalk.red('Error:'), error.message);
|
|
276
|
+
process.exit(1);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// Delete command
|
|
281
|
+
program
|
|
282
|
+
.command('delete <pageIdOrUrl>')
|
|
283
|
+
.description('Delete a Confluence page by ID or URL')
|
|
284
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
285
|
+
.action(async (pageIdOrUrl, options) => {
|
|
286
|
+
const analytics = new Analytics();
|
|
287
|
+
try {
|
|
288
|
+
const config = getConfig();
|
|
289
|
+
const client = new ConfluenceClient(config);
|
|
290
|
+
const pageInfo = await client.getPageInfo(pageIdOrUrl);
|
|
291
|
+
|
|
292
|
+
if (!options.yes) {
|
|
293
|
+
const spaceLabel = pageInfo.space?.key ? ` (${pageInfo.space.key})` : '';
|
|
294
|
+
const { confirmed } = await inquirer.prompt([
|
|
295
|
+
{
|
|
296
|
+
type: 'confirm',
|
|
297
|
+
name: 'confirmed',
|
|
298
|
+
default: false,
|
|
299
|
+
message: `Delete "${pageInfo.title}" (ID: ${pageInfo.id})${spaceLabel}?`
|
|
300
|
+
}
|
|
301
|
+
]);
|
|
302
|
+
|
|
303
|
+
if (!confirmed) {
|
|
304
|
+
console.log(chalk.yellow('Cancelled.'));
|
|
305
|
+
analytics.track('delete_cancel', true);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const result = await client.deletePage(pageInfo.id);
|
|
311
|
+
|
|
312
|
+
console.log(chalk.green('✅ Page deleted successfully!'));
|
|
313
|
+
console.log(`Title: ${chalk.blue(pageInfo.title)}`);
|
|
314
|
+
console.log(`ID: ${chalk.blue(result.id)}`);
|
|
315
|
+
analytics.track('delete', true);
|
|
316
|
+
} catch (error) {
|
|
317
|
+
analytics.track('delete', false);
|
|
318
|
+
console.error(chalk.red('Error:'), error.message);
|
|
319
|
+
process.exit(1);
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// Edit command - opens page content for editing
|
|
324
|
+
program
|
|
325
|
+
.command('edit <pageId>')
|
|
326
|
+
.description('Get page content for editing')
|
|
327
|
+
.option('-o, --output <file>', 'Save content to file')
|
|
328
|
+
.action(async (pageId, options) => {
|
|
329
|
+
const analytics = new Analytics();
|
|
330
|
+
try {
|
|
331
|
+
const config = getConfig();
|
|
332
|
+
const client = new ConfluenceClient(config);
|
|
333
|
+
const pageData = await client.getPageForEdit(pageId);
|
|
334
|
+
|
|
335
|
+
console.log(chalk.blue('Page Information:'));
|
|
336
|
+
console.log(`Title: ${chalk.green(pageData.title)}`);
|
|
337
|
+
console.log(`ID: ${chalk.green(pageData.id)}`);
|
|
338
|
+
console.log(`Version: ${chalk.green(pageData.version)}`);
|
|
339
|
+
console.log(`Space: ${chalk.green(pageData.space.name)} (${pageData.space.key})`);
|
|
340
|
+
console.log('');
|
|
341
|
+
|
|
342
|
+
if (options.output) {
|
|
343
|
+
const fs = require('fs');
|
|
344
|
+
fs.writeFileSync(options.output, pageData.content);
|
|
345
|
+
console.log(chalk.green(`✅ Content saved to: ${options.output}`));
|
|
346
|
+
console.log(chalk.yellow('💡 Edit the file and use "confluence update" to save changes'));
|
|
347
|
+
} else {
|
|
348
|
+
console.log(chalk.blue('Page Content:'));
|
|
349
|
+
console.log(pageData.content);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
analytics.track('edit', true);
|
|
353
|
+
} catch (error) {
|
|
354
|
+
analytics.track('edit', false);
|
|
355
|
+
console.error(chalk.red('Error:'), error.message);
|
|
356
|
+
process.exit(1);
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// Find page by title command
|
|
361
|
+
program
|
|
362
|
+
.command('find <title>')
|
|
363
|
+
.description('Find a page by title')
|
|
364
|
+
.option('-s, --space <spaceKey>', 'Limit search to specific space')
|
|
365
|
+
.action(async (title, options) => {
|
|
366
|
+
const analytics = new Analytics();
|
|
367
|
+
try {
|
|
368
|
+
const config = getConfig();
|
|
369
|
+
const client = new ConfluenceClient(config);
|
|
370
|
+
const pageInfo = await client.findPageByTitle(title, options.space);
|
|
371
|
+
|
|
372
|
+
console.log(chalk.blue('Page found:'));
|
|
373
|
+
console.log(`Title: ${chalk.green(pageInfo.title)}`);
|
|
374
|
+
console.log(`ID: ${chalk.green(pageInfo.id)}`);
|
|
375
|
+
console.log(`Space: ${chalk.green(pageInfo.space.name)} (${pageInfo.space.key})`);
|
|
376
|
+
console.log(`URL: ${chalk.gray(`https://${config.domain}/wiki${pageInfo.url}`)}`);
|
|
377
|
+
|
|
378
|
+
analytics.track('find', true);
|
|
379
|
+
} catch (error) {
|
|
380
|
+
analytics.track('find', false);
|
|
381
|
+
console.error(chalk.red('Error:'), error.message);
|
|
382
|
+
process.exit(1);
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// Attachments command
|
|
387
|
+
program
|
|
388
|
+
.command('attachments <pageId>')
|
|
389
|
+
.description('List or download attachments for a page')
|
|
390
|
+
.option('-l, --limit <limit>', 'Maximum number of attachments to fetch (default: all)')
|
|
391
|
+
.option('-p, --pattern <glob>', 'Filter attachments by filename (e.g., "*.png")')
|
|
392
|
+
.option('-d, --download', 'Download matching attachments')
|
|
393
|
+
.option('--dest <directory>', 'Directory to save downloads (default: current directory)', '.')
|
|
394
|
+
.action(async (pageId, options) => {
|
|
395
|
+
const analytics = new Analytics();
|
|
396
|
+
try {
|
|
397
|
+
const config = getConfig();
|
|
398
|
+
const client = new ConfluenceClient(config);
|
|
399
|
+
const maxResults = options.limit ? parseInt(options.limit, 10) : null;
|
|
400
|
+
const pattern = options.pattern ? options.pattern.trim() : null;
|
|
401
|
+
|
|
402
|
+
if (options.limit && (Number.isNaN(maxResults) || maxResults <= 0)) {
|
|
403
|
+
throw new Error('Limit must be a positive number.');
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const attachments = await client.getAllAttachments(pageId, { maxResults });
|
|
407
|
+
const filtered = pattern ? attachments.filter(att => client.matchesPattern(att.title, pattern)) : attachments;
|
|
408
|
+
|
|
409
|
+
if (filtered.length === 0) {
|
|
410
|
+
console.log(chalk.yellow('No attachments found.'));
|
|
411
|
+
analytics.track('attachments', true);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
console.log(chalk.blue(`Found ${filtered.length} attachment${filtered.length === 1 ? '' : 's'}:`));
|
|
416
|
+
filtered.forEach((att, index) => {
|
|
417
|
+
const sizeKb = att.fileSize ? `${Math.max(1, Math.round(att.fileSize / 1024))} KB` : 'unknown size';
|
|
418
|
+
const typeLabel = att.mediaType || 'unknown';
|
|
419
|
+
console.log(`${index + 1}. ${chalk.green(att.title)} (ID: ${att.id})`);
|
|
420
|
+
console.log(` Type: ${chalk.gray(typeLabel)} • Size: ${chalk.gray(sizeKb)} • Version: ${chalk.gray(att.version)}`);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
if (options.download) {
|
|
424
|
+
const fs = require('fs');
|
|
425
|
+
const path = require('path');
|
|
426
|
+
const destDir = path.resolve(options.dest || '.');
|
|
427
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
428
|
+
|
|
429
|
+
const uniquePathFor = (dir, filename) => {
|
|
430
|
+
const parsed = path.parse(filename);
|
|
431
|
+
let attempt = path.join(dir, filename);
|
|
432
|
+
let counter = 1;
|
|
433
|
+
while (fs.existsSync(attempt)) {
|
|
434
|
+
const suffix = ` (${counter})`;
|
|
435
|
+
const nextName = `${parsed.name}${suffix}${parsed.ext}`;
|
|
436
|
+
attempt = path.join(dir, nextName);
|
|
437
|
+
counter += 1;
|
|
438
|
+
}
|
|
439
|
+
return attempt;
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
const writeStream = (stream, targetPath) => new Promise((resolve, reject) => {
|
|
443
|
+
const writer = fs.createWriteStream(targetPath);
|
|
444
|
+
stream.pipe(writer);
|
|
445
|
+
stream.on('error', reject);
|
|
446
|
+
writer.on('error', reject);
|
|
447
|
+
writer.on('finish', resolve);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
let downloaded = 0;
|
|
451
|
+
for (const attachment of filtered) {
|
|
452
|
+
const targetPath = uniquePathFor(destDir, attachment.title);
|
|
453
|
+
// Pass the full attachment object so downloadAttachment can use downloadLink directly
|
|
454
|
+
const dataStream = await client.downloadAttachment(pageId, attachment);
|
|
455
|
+
await writeStream(dataStream, targetPath);
|
|
456
|
+
downloaded += 1;
|
|
457
|
+
console.log(`⬇️ ${chalk.green(attachment.title)} -> ${chalk.gray(targetPath)}`);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
console.log(chalk.green(`Downloaded ${downloaded} attachment${downloaded === 1 ? '' : 's'} to ${destDir}`));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
analytics.track('attachments', true);
|
|
464
|
+
} catch (error) {
|
|
465
|
+
analytics.track('attachments', false);
|
|
466
|
+
console.error(chalk.red('Error:'), error.message);
|
|
467
|
+
process.exit(1);
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// Comments command
|
|
472
|
+
program
|
|
473
|
+
.command('comments <pageId>')
|
|
474
|
+
.description('List comments for a page by ID or URL')
|
|
475
|
+
.option('-f, --format <format>', 'Output format (text, markdown, json)', 'text')
|
|
476
|
+
.option('-l, --limit <limit>', 'Maximum number of comments to fetch (default: 25)')
|
|
477
|
+
.option('--start <start>', 'Start index for results (default: 0)', '0')
|
|
478
|
+
.option('--location <location>', 'Filter by location (inline, footer, resolved). Comma-separated')
|
|
479
|
+
.option('--depth <depth>', 'Comment depth ("" for root only, "all")')
|
|
480
|
+
.option('--all', 'Fetch all comments (ignores pagination)')
|
|
481
|
+
.action(async (pageId, options) => {
|
|
482
|
+
const analytics = new Analytics();
|
|
483
|
+
try {
|
|
484
|
+
const config = getConfig();
|
|
485
|
+
const client = new ConfluenceClient(config);
|
|
486
|
+
|
|
487
|
+
const format = (options.format || 'text').toLowerCase();
|
|
488
|
+
if (!['text', 'markdown', 'json'].includes(format)) {
|
|
489
|
+
throw new Error('Format must be one of: text, markdown, json');
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const limit = options.limit ? parseInt(options.limit, 10) : null;
|
|
493
|
+
if (options.limit && (Number.isNaN(limit) || limit <= 0)) {
|
|
494
|
+
throw new Error('Limit must be a positive number.');
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const start = options.start ? parseInt(options.start, 10) : 0;
|
|
498
|
+
if (options.start && (Number.isNaN(start) || start < 0)) {
|
|
499
|
+
throw new Error('Start must be a non-negative number.');
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const locationValues = parseLocationOptions(options.location);
|
|
503
|
+
const invalidLocations = locationValues.filter(value => !['inline', 'footer', 'resolved'].includes(value));
|
|
504
|
+
if (invalidLocations.length > 0) {
|
|
505
|
+
throw new Error(`Invalid location value(s): ${invalidLocations.join(', ')}`);
|
|
506
|
+
}
|
|
507
|
+
const locationParam = locationValues.length === 0
|
|
508
|
+
? null
|
|
509
|
+
: (locationValues.length === 1 ? locationValues[0] : locationValues);
|
|
510
|
+
|
|
511
|
+
let comments = [];
|
|
512
|
+
let nextStart = null;
|
|
513
|
+
|
|
514
|
+
if (options.all) {
|
|
515
|
+
comments = await client.getAllComments(pageId, {
|
|
516
|
+
maxResults: limit || null,
|
|
517
|
+
start,
|
|
518
|
+
location: locationParam,
|
|
519
|
+
depth: options.depth
|
|
520
|
+
});
|
|
521
|
+
} else {
|
|
522
|
+
const response = await client.listComments(pageId, {
|
|
523
|
+
limit: limit || undefined,
|
|
524
|
+
start,
|
|
525
|
+
location: locationParam,
|
|
526
|
+
depth: options.depth
|
|
527
|
+
});
|
|
528
|
+
comments = response.results;
|
|
529
|
+
nextStart = response.nextStart;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (comments.length === 0) {
|
|
533
|
+
console.log(chalk.yellow('No comments found.'));
|
|
534
|
+
analytics.track('comments', true);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (format === 'json') {
|
|
539
|
+
const resolvedPageId = await client.extractPageId(pageId);
|
|
540
|
+
const output = {
|
|
541
|
+
pageId: resolvedPageId,
|
|
542
|
+
commentCount: comments.length,
|
|
543
|
+
comments: comments.map(comment => ({
|
|
544
|
+
...comment,
|
|
545
|
+
bodyStorage: comment.body,
|
|
546
|
+
bodyText: client.formatCommentBody(comment.body, 'text')
|
|
547
|
+
}))
|
|
548
|
+
};
|
|
549
|
+
if (!options.all) {
|
|
550
|
+
output.nextStart = nextStart;
|
|
551
|
+
}
|
|
552
|
+
console.log(JSON.stringify(output, null, 2));
|
|
553
|
+
analytics.track('comments', true);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const commentTree = buildCommentTree(comments);
|
|
558
|
+
console.log(chalk.blue(`Found ${comments.length} comment${comments.length === 1 ? '' : 's'}:`));
|
|
559
|
+
|
|
560
|
+
const renderComments = (nodes, path = []) => {
|
|
561
|
+
nodes.forEach((comment, index) => {
|
|
562
|
+
const currentPath = [...path, index + 1];
|
|
563
|
+
const level = currentPath.length - 1;
|
|
564
|
+
const indent = ' '.repeat(level);
|
|
565
|
+
const branchGlyph = level > 0 ? (index === nodes.length - 1 ? '└─ ' : '├─ ') : '';
|
|
566
|
+
const headerPrefix = `${indent}${chalk.dim(branchGlyph)}`;
|
|
567
|
+
const bodyIndent = level === 0
|
|
568
|
+
? ' '
|
|
569
|
+
: `${indent}${' '.repeat(branchGlyph.length)}`;
|
|
570
|
+
|
|
571
|
+
const isReply = Boolean(comment.parentId);
|
|
572
|
+
const location = comment.location || 'unknown';
|
|
573
|
+
const author = comment.author?.displayName || 'Unknown';
|
|
574
|
+
const createdAt = comment.createdAt || 'unknown date';
|
|
575
|
+
const metaParts = [`Created: ${createdAt}`];
|
|
576
|
+
if (comment.status) metaParts.push(`Status: ${comment.status}`);
|
|
577
|
+
if (comment.version) metaParts.push(`Version: ${comment.version}`);
|
|
578
|
+
if (!isReply && comment.resolution) metaParts.push(`Resolution: ${comment.resolution}`);
|
|
579
|
+
|
|
580
|
+
const label = isReply ? chalk.gray('[reply]') : chalk.cyan(`[${location}]`);
|
|
581
|
+
console.log(`${headerPrefix}${currentPath.join('.')}. ${chalk.green(author)} ${chalk.gray(`(ID: ${comment.id})`)} ${label}`);
|
|
582
|
+
console.log(chalk.dim(`${bodyIndent}${metaParts.join(' • ')}`));
|
|
583
|
+
|
|
584
|
+
if (!isReply) {
|
|
585
|
+
const inlineProps = comment.inlineProperties || {};
|
|
586
|
+
const selectionText = inlineProps.selection || inlineProps.originalSelection;
|
|
587
|
+
if (selectionText) {
|
|
588
|
+
const selectionLabel = inlineProps.selection ? 'Highlight' : 'Highlight (original)';
|
|
589
|
+
console.log(chalk.dim(`${bodyIndent}${selectionLabel}: ${selectionText}`));
|
|
590
|
+
}
|
|
591
|
+
if (inlineProps.markerRef) {
|
|
592
|
+
console.log(chalk.dim(`${bodyIndent}Marker ref: ${inlineProps.markerRef}`));
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const body = client.formatCommentBody(comment.body, format);
|
|
597
|
+
if (body) {
|
|
598
|
+
console.log(`${bodyIndent}${chalk.yellowBright('Body:')}`);
|
|
599
|
+
console.log(formatBodyBlock(body, `${bodyIndent} `));
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (comment.children && comment.children.length > 0) {
|
|
603
|
+
renderComments(comment.children, currentPath);
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
renderComments(commentTree);
|
|
609
|
+
|
|
610
|
+
if (!options.all && nextStart !== null && nextStart !== undefined) {
|
|
611
|
+
console.log(chalk.gray(`Next start: ${nextStart}`));
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
analytics.track('comments', true);
|
|
615
|
+
} catch (error) {
|
|
616
|
+
analytics.track('comments', false);
|
|
617
|
+
console.error(chalk.red('Error:'), error.message);
|
|
618
|
+
process.exit(1);
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
// Comment creation command
|
|
623
|
+
program
|
|
624
|
+
.command('comment <pageId>')
|
|
625
|
+
.description('Create a comment on a page by ID or URL (footer or inline)')
|
|
626
|
+
.option('-f, --file <file>', 'Read content from file')
|
|
627
|
+
.option('-c, --content <content>', 'Comment content as string')
|
|
628
|
+
.option('--format <format>', 'Content format (storage, html, markdown)', 'storage')
|
|
629
|
+
.option('--parent <commentId>', 'Reply to a comment by ID')
|
|
630
|
+
.option('--location <location>', 'Comment location (inline or footer)', 'footer')
|
|
631
|
+
.option('--inline-selection <text>', 'Inline selection text')
|
|
632
|
+
.option('--inline-original-selection <text>', 'Original inline selection text')
|
|
633
|
+
.option('--inline-marker-ref <ref>', 'Inline marker reference (optional)')
|
|
634
|
+
.option('--inline-properties <json>', 'Inline properties JSON (advanced)')
|
|
635
|
+
.action(async (pageId, options) => {
|
|
636
|
+
const analytics = new Analytics();
|
|
637
|
+
let location = null;
|
|
638
|
+
try {
|
|
639
|
+
const config = getConfig();
|
|
640
|
+
const client = new ConfluenceClient(config);
|
|
641
|
+
|
|
642
|
+
let content = '';
|
|
643
|
+
|
|
644
|
+
if (options.file) {
|
|
645
|
+
const fs = require('fs');
|
|
646
|
+
if (!fs.existsSync(options.file)) {
|
|
647
|
+
throw new Error(`File not found: ${options.file}`);
|
|
648
|
+
}
|
|
649
|
+
content = fs.readFileSync(options.file, 'utf8');
|
|
650
|
+
} else if (options.content) {
|
|
651
|
+
content = options.content;
|
|
652
|
+
} else {
|
|
653
|
+
throw new Error('Either --file or --content option is required');
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
location = (options.location || 'footer').toLowerCase();
|
|
657
|
+
if (!['inline', 'footer'].includes(location)) {
|
|
658
|
+
throw new Error('Location must be either "inline" or "footer".');
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
let inlineProperties = {};
|
|
662
|
+
if (options.inlineProperties) {
|
|
663
|
+
try {
|
|
664
|
+
const parsed = JSON.parse(options.inlineProperties);
|
|
665
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
666
|
+
throw new Error('Inline properties must be a JSON object.');
|
|
667
|
+
}
|
|
668
|
+
inlineProperties = { ...parsed };
|
|
669
|
+
} catch (error) {
|
|
670
|
+
throw new Error(`Invalid --inline-properties JSON: ${error.message}`);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (options.inlineSelection) {
|
|
675
|
+
inlineProperties.selection = options.inlineSelection;
|
|
676
|
+
}
|
|
677
|
+
if (options.inlineOriginalSelection) {
|
|
678
|
+
inlineProperties.originalSelection = options.inlineOriginalSelection;
|
|
679
|
+
}
|
|
680
|
+
if (options.inlineMarkerRef) {
|
|
681
|
+
inlineProperties.markerRef = options.inlineMarkerRef;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if (Object.keys(inlineProperties).length > 0 && location !== 'inline') {
|
|
685
|
+
throw new Error('Inline properties can only be used with --location inline.');
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const parentId = options.parent;
|
|
689
|
+
|
|
690
|
+
if (location === 'inline') {
|
|
691
|
+
const hasSelection = inlineProperties.selection || inlineProperties.originalSelection;
|
|
692
|
+
if (!hasSelection && !parentId) {
|
|
693
|
+
throw new Error('Inline comments require --inline-selection or --inline-original-selection when starting a new inline thread.');
|
|
694
|
+
}
|
|
695
|
+
if (hasSelection) {
|
|
696
|
+
if (!inlineProperties.originalSelection && inlineProperties.selection) {
|
|
697
|
+
inlineProperties.originalSelection = inlineProperties.selection;
|
|
698
|
+
}
|
|
699
|
+
if (!inlineProperties.markerRef) {
|
|
700
|
+
inlineProperties.markerRef = `comment-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const result = await client.createComment(pageId, content, options.format, {
|
|
706
|
+
parentId,
|
|
707
|
+
location,
|
|
708
|
+
inlineProperties: location === 'inline' ? inlineProperties : null
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
console.log(chalk.green('✅ Comment created successfully!'));
|
|
712
|
+
console.log(`ID: ${chalk.blue(result.id)}`);
|
|
713
|
+
if (result.container?.id) {
|
|
714
|
+
console.log(`Page ID: ${chalk.blue(result.container.id)}`);
|
|
715
|
+
}
|
|
716
|
+
if (result._links?.webui) {
|
|
717
|
+
const url = client.toAbsoluteUrl(result._links.webui);
|
|
718
|
+
console.log(`URL: ${chalk.gray(url)}`);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
analytics.track('comment_create', true);
|
|
722
|
+
} catch (error) {
|
|
723
|
+
analytics.track('comment_create', false);
|
|
724
|
+
console.error(chalk.red('Error:'), error.message);
|
|
725
|
+
if (error.response?.data) {
|
|
726
|
+
const detail = typeof error.response.data === 'string'
|
|
727
|
+
? error.response.data
|
|
728
|
+
: JSON.stringify(error.response.data, null, 2);
|
|
729
|
+
console.error(chalk.red('API response:'), detail);
|
|
730
|
+
}
|
|
731
|
+
const apiErrors = error.response?.data?.data?.errors || error.response?.data?.errors || [];
|
|
732
|
+
const errorKeys = apiErrors
|
|
733
|
+
.map((entry) => entry?.message?.key || entry?.message || entry?.key)
|
|
734
|
+
.filter(Boolean);
|
|
735
|
+
const needsInlineMeta = ['matchIndex', 'lastFetchTime', 'serializedHighlights']
|
|
736
|
+
.every((key) => errorKeys.includes(key));
|
|
737
|
+
if (location === 'inline' && needsInlineMeta) {
|
|
738
|
+
console.error(chalk.yellow('Inline comment creation requires editor highlight metadata (matchIndex, lastFetchTime, serializedHighlights).'));
|
|
739
|
+
console.error(chalk.yellow('Try replying to an existing inline comment or use footer comments instead.'));
|
|
740
|
+
}
|
|
741
|
+
process.exit(1);
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
// Comment delete command
|
|
746
|
+
program
|
|
747
|
+
.command('comment-delete <commentId>')
|
|
748
|
+
.description('Delete a comment by ID')
|
|
749
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
750
|
+
.action(async (commentId, options) => {
|
|
751
|
+
const analytics = new Analytics();
|
|
752
|
+
try {
|
|
753
|
+
const config = getConfig();
|
|
754
|
+
const client = new ConfluenceClient(config);
|
|
755
|
+
|
|
756
|
+
if (!options.yes) {
|
|
757
|
+
const { confirmed } = await inquirer.prompt([
|
|
758
|
+
{
|
|
759
|
+
type: 'confirm',
|
|
760
|
+
name: 'confirmed',
|
|
761
|
+
default: false,
|
|
762
|
+
message: `Delete comment ${commentId}?`
|
|
763
|
+
}
|
|
764
|
+
]);
|
|
765
|
+
|
|
766
|
+
if (!confirmed) {
|
|
767
|
+
console.log(chalk.yellow('Cancelled.'));
|
|
768
|
+
analytics.track('comment_delete_cancel', true);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const result = await client.deleteComment(commentId);
|
|
774
|
+
|
|
775
|
+
console.log(chalk.green('✅ Comment deleted successfully!'));
|
|
776
|
+
console.log(`ID: ${chalk.blue(result.id)}`);
|
|
777
|
+
analytics.track('comment_delete', true);
|
|
778
|
+
} catch (error) {
|
|
779
|
+
analytics.track('comment_delete', false);
|
|
780
|
+
console.error(chalk.red('Error:'), error.message);
|
|
781
|
+
process.exit(1);
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
// Export page content with attachments
|
|
786
|
+
program
|
|
787
|
+
.command('export <pageId>')
|
|
788
|
+
.description('Export a page to a directory with its attachments')
|
|
789
|
+
.option('--format <format>', 'Content format (html, text, markdown)', 'markdown')
|
|
790
|
+
.option('--dest <directory>', 'Base directory to export into', '.')
|
|
791
|
+
.option('--file <filename>', 'Content filename (default: page.<ext>)')
|
|
792
|
+
.option('--attachments-dir <name>', 'Subdirectory for attachments', 'attachments')
|
|
793
|
+
.option('--pattern <glob>', 'Filter attachments by filename (e.g., "*.png")')
|
|
794
|
+
.option('--referenced-only', 'Download only attachments referenced in the page content')
|
|
795
|
+
.option('--skip-attachments', 'Do not download attachments')
|
|
796
|
+
.action(async (pageId, options) => {
|
|
797
|
+
const analytics = new Analytics();
|
|
798
|
+
try {
|
|
799
|
+
const config = getConfig();
|
|
800
|
+
const client = new ConfluenceClient(config);
|
|
801
|
+
const fs = require('fs');
|
|
802
|
+
const path = require('path');
|
|
803
|
+
|
|
804
|
+
const format = (options.format || 'markdown').toLowerCase();
|
|
805
|
+
const formatExt = { markdown: 'md', html: 'html', text: 'txt' };
|
|
806
|
+
const contentExt = formatExt[format] || 'txt';
|
|
807
|
+
|
|
808
|
+
const pageInfo = await client.getPageInfo(pageId);
|
|
809
|
+
const content = await client.readPage(
|
|
810
|
+
pageId,
|
|
811
|
+
format,
|
|
812
|
+
options.referencedOnly ? { extractReferencedAttachments: true } : {}
|
|
813
|
+
);
|
|
814
|
+
const referencedAttachments = options.referencedOnly
|
|
815
|
+
? (client._referencedAttachments || new Set())
|
|
816
|
+
: null;
|
|
817
|
+
|
|
818
|
+
const baseDir = path.resolve(options.dest || '.');
|
|
819
|
+
const folderName = sanitizeTitle(pageInfo.title || 'page');
|
|
820
|
+
const exportDir = path.join(baseDir, folderName);
|
|
821
|
+
fs.mkdirSync(exportDir, { recursive: true });
|
|
822
|
+
|
|
823
|
+
const contentFile = options.file || `page.${contentExt}`;
|
|
824
|
+
const contentPath = path.join(exportDir, contentFile);
|
|
825
|
+
fs.writeFileSync(contentPath, content);
|
|
826
|
+
|
|
827
|
+
console.log(chalk.green('✅ Page exported'));
|
|
828
|
+
console.log(`Title: ${chalk.blue(pageInfo.title)}`);
|
|
829
|
+
console.log(`Content: ${chalk.gray(contentPath)}`);
|
|
830
|
+
|
|
831
|
+
if (!options.skipAttachments) {
|
|
832
|
+
const pattern = options.pattern ? options.pattern.trim() : null;
|
|
833
|
+
const allAttachments = await client.getAllAttachments(pageId);
|
|
834
|
+
|
|
835
|
+
let filtered;
|
|
836
|
+
if (pattern) {
|
|
837
|
+
filtered = allAttachments.filter(att => client.matchesPattern(att.title, pattern));
|
|
838
|
+
} else if (options.referencedOnly) {
|
|
839
|
+
filtered = allAttachments.filter(att => referencedAttachments?.has(att.title));
|
|
840
|
+
} else {
|
|
841
|
+
filtered = allAttachments;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
if (filtered.length === 0) {
|
|
845
|
+
console.log(chalk.yellow('No attachments to download.'));
|
|
846
|
+
} else {
|
|
847
|
+
const attachmentsDirName = options.attachmentsDir || 'attachments';
|
|
848
|
+
const attachmentsDir = path.join(exportDir, attachmentsDirName);
|
|
849
|
+
fs.mkdirSync(attachmentsDir, { recursive: true });
|
|
850
|
+
|
|
851
|
+
const uniquePathFor = (dir, filename) => {
|
|
852
|
+
const parsed = path.parse(filename);
|
|
853
|
+
let attempt = path.join(dir, filename);
|
|
854
|
+
let counter = 1;
|
|
855
|
+
while (fs.existsSync(attempt)) {
|
|
856
|
+
const suffix = ` (${counter})`;
|
|
857
|
+
const nextName = `${parsed.name}${suffix}${parsed.ext}`;
|
|
858
|
+
attempt = path.join(dir, nextName);
|
|
859
|
+
counter += 1;
|
|
860
|
+
}
|
|
861
|
+
return attempt;
|
|
862
|
+
};
|
|
863
|
+
|
|
864
|
+
const writeStream = (stream, targetPath) => new Promise((resolve, reject) => {
|
|
865
|
+
const writer = fs.createWriteStream(targetPath);
|
|
866
|
+
stream.pipe(writer);
|
|
867
|
+
stream.on('error', reject);
|
|
868
|
+
writer.on('error', reject);
|
|
869
|
+
writer.on('finish', resolve);
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
let downloaded = 0;
|
|
873
|
+
for (const attachment of filtered) {
|
|
874
|
+
const targetPath = uniquePathFor(attachmentsDir, attachment.title);
|
|
875
|
+
// Pass the full attachment object so downloadAttachment can use downloadLink directly
|
|
876
|
+
const dataStream = await client.downloadAttachment(pageId, attachment);
|
|
877
|
+
await writeStream(dataStream, targetPath);
|
|
878
|
+
downloaded += 1;
|
|
879
|
+
console.log(`⬇️ ${chalk.green(attachment.title)} -> ${chalk.gray(targetPath)}`);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
console.log(chalk.green(`Downloaded ${downloaded} attachment${downloaded === 1 ? '' : 's'} to ${attachmentsDir}`));
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
analytics.track('export', true);
|
|
887
|
+
} catch (error) {
|
|
888
|
+
analytics.track('export', false);
|
|
889
|
+
console.error(chalk.red('Error:'), error.message);
|
|
890
|
+
process.exit(1);
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
function sanitizeTitle(value) {
|
|
895
|
+
const fallback = 'page';
|
|
896
|
+
if (!value || typeof value !== 'string') {
|
|
897
|
+
return fallback;
|
|
898
|
+
}
|
|
899
|
+
const cleaned = value.replace(/[\\/:*?"<>|]/g, ' ').trim();
|
|
900
|
+
return cleaned || fallback;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function parseLocationOptions(raw) {
|
|
904
|
+
if (!raw) {
|
|
905
|
+
return [];
|
|
906
|
+
}
|
|
907
|
+
if (Array.isArray(raw)) {
|
|
908
|
+
return raw.flatMap(item => String(item).split(','))
|
|
909
|
+
.map(value => value.trim().toLowerCase())
|
|
910
|
+
.filter(Boolean);
|
|
911
|
+
}
|
|
912
|
+
return String(raw).split(',').map(value => value.trim().toLowerCase()).filter(Boolean);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function formatBodyBlock(text, indent = '') {
|
|
916
|
+
return text.split('\n').map(line => `${indent}${chalk.white(line)}`).join('\n');
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function buildCommentTree(comments) {
|
|
920
|
+
const nodes = comments.map((comment, index) => ({
|
|
921
|
+
...comment,
|
|
922
|
+
_order: index,
|
|
923
|
+
children: []
|
|
924
|
+
}));
|
|
925
|
+
const byId = new Map(nodes.map(node => [String(node.id), node]));
|
|
926
|
+
const roots = [];
|
|
927
|
+
|
|
928
|
+
nodes.forEach((node) => {
|
|
929
|
+
const parentId = node.parentId ? String(node.parentId) : null;
|
|
930
|
+
if (parentId && byId.has(parentId)) {
|
|
931
|
+
byId.get(parentId).children.push(node);
|
|
932
|
+
} else {
|
|
933
|
+
roots.push(node);
|
|
934
|
+
}
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
const sortNodes = (list) => {
|
|
938
|
+
list.sort((a, b) => a._order - b._order);
|
|
939
|
+
list.forEach((child) => sortNodes(child.children));
|
|
940
|
+
};
|
|
941
|
+
|
|
942
|
+
sortNodes(roots);
|
|
943
|
+
return roots;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// Copy page tree command
|
|
947
|
+
program
|
|
948
|
+
.command('copy-tree <sourcePageId> <targetParentId> [newTitle]')
|
|
949
|
+
.description('Copy a page and all its children to a new location')
|
|
950
|
+
.option('--max-depth <depth>', 'Maximum depth to copy (default: 10)', '10')
|
|
951
|
+
.option('--exclude <patterns>', 'Comma-separated patterns to exclude (supports wildcards)')
|
|
952
|
+
.option('--delay-ms <ms>', 'Delay between sibling creations in ms (default: 100)', '100')
|
|
953
|
+
.option('--copy-suffix <suffix>', 'Suffix for new root title (default: " (Copy)")', ' (Copy)')
|
|
954
|
+
.option('-n, --dry-run', 'Preview operations without creating pages')
|
|
955
|
+
.option('--fail-on-error', 'Exit with non-zero code if any page fails')
|
|
956
|
+
.option('-q, --quiet', 'Suppress progress output')
|
|
957
|
+
.action(async (sourcePageId, targetParentId, newTitle, options) => {
|
|
958
|
+
const analytics = new Analytics();
|
|
959
|
+
try {
|
|
960
|
+
const config = getConfig();
|
|
961
|
+
const client = new ConfluenceClient(config);
|
|
962
|
+
|
|
963
|
+
// Parse numeric flags with safe fallbacks
|
|
964
|
+
const parsedDepth = parseInt(options.maxDepth, 10);
|
|
965
|
+
const maxDepth = Number.isNaN(parsedDepth) ? 10 : parsedDepth;
|
|
966
|
+
const parsedDelay = parseInt(options.delayMs, 10);
|
|
967
|
+
const delayMs = Number.isNaN(parsedDelay) ? 100 : parsedDelay;
|
|
968
|
+
const copySuffix = options.copySuffix ?? ' (Copy)';
|
|
969
|
+
|
|
970
|
+
console.log(chalk.blue('🚀 Starting page tree copy...'));
|
|
971
|
+
console.log(`Source: ${sourcePageId}`);
|
|
972
|
+
console.log(`Target parent: ${targetParentId}`);
|
|
973
|
+
if (newTitle) console.log(`New root title: ${newTitle}`);
|
|
974
|
+
console.log(`Max depth: ${maxDepth}`);
|
|
975
|
+
console.log(`Delay: ${delayMs} ms`);
|
|
976
|
+
if (copySuffix) console.log(`Root suffix: ${copySuffix}`);
|
|
977
|
+
console.log('');
|
|
978
|
+
|
|
979
|
+
// Parse exclude patterns
|
|
980
|
+
let excludePatterns = [];
|
|
981
|
+
if (options.exclude) {
|
|
982
|
+
excludePatterns = options.exclude.split(',').map(p => p.trim()).filter(Boolean);
|
|
983
|
+
if (excludePatterns.length > 0) {
|
|
984
|
+
console.log(chalk.yellow(`Exclude patterns: ${excludePatterns.join(', ')}`));
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// Progress callback
|
|
989
|
+
const onProgress = (message) => {
|
|
990
|
+
console.log(message);
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
// Dry-run: compute plan without creating anything
|
|
994
|
+
if (options.dryRun) {
|
|
995
|
+
const info = await client.getPageInfo(sourcePageId);
|
|
996
|
+
const rootTitle = newTitle || `${info.title}${copySuffix}`;
|
|
997
|
+
const descendants = await client.getAllDescendantPages(sourcePageId, maxDepth);
|
|
998
|
+
const filtered = descendants.filter(p => !client.shouldExcludePage(p.title, excludePatterns));
|
|
999
|
+
console.log(chalk.yellow('Dry run: no changes will be made.'));
|
|
1000
|
+
console.log(`Would create root: ${chalk.blue(rootTitle)} (under parent ${targetParentId})`);
|
|
1001
|
+
console.log(`Would create ${filtered.length} child page(s)`);
|
|
1002
|
+
// Show a preview list (first 50)
|
|
1003
|
+
const tree = client.buildPageTree(filtered, sourcePageId);
|
|
1004
|
+
const lines = [];
|
|
1005
|
+
const walk = (nodes, depth = 0) => {
|
|
1006
|
+
for (const n of nodes) {
|
|
1007
|
+
if (lines.length >= 50) return; // limit output
|
|
1008
|
+
lines.push(`${' '.repeat(depth)}- ${n.title}`);
|
|
1009
|
+
if (n.children && n.children.length) walk(n.children, depth + 1);
|
|
1010
|
+
}
|
|
1011
|
+
};
|
|
1012
|
+
walk(tree);
|
|
1013
|
+
if (lines.length) {
|
|
1014
|
+
console.log('Planned children:');
|
|
1015
|
+
lines.forEach(l => console.log(l));
|
|
1016
|
+
if (filtered.length > lines.length) {
|
|
1017
|
+
console.log(`...and ${filtered.length - lines.length} more`);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
analytics.track('copy_tree_dry_run', true);
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// Copy the page tree
|
|
1025
|
+
const result = await client.copyPageTree(
|
|
1026
|
+
sourcePageId,
|
|
1027
|
+
targetParentId,
|
|
1028
|
+
newTitle,
|
|
1029
|
+
{
|
|
1030
|
+
maxDepth,
|
|
1031
|
+
excludePatterns,
|
|
1032
|
+
onProgress: options.quiet ? null : onProgress,
|
|
1033
|
+
quiet: options.quiet,
|
|
1034
|
+
delayMs,
|
|
1035
|
+
copySuffix
|
|
1036
|
+
}
|
|
1037
|
+
);
|
|
1038
|
+
|
|
1039
|
+
console.log('');
|
|
1040
|
+
console.log(chalk.green('✅ Page tree copy completed'));
|
|
1041
|
+
console.log(`Root page: ${chalk.blue(result.rootPage.title)} (ID: ${result.rootPage.id})`);
|
|
1042
|
+
console.log(`Total copied pages: ${chalk.blue(result.totalCopied)}`);
|
|
1043
|
+
if (result.failures?.length) {
|
|
1044
|
+
console.log(chalk.yellow(`Failures: ${result.failures.length}`));
|
|
1045
|
+
result.failures.slice(0, 10).forEach(f => {
|
|
1046
|
+
const reason = f.status ? `${f.status}` : '';
|
|
1047
|
+
console.log(` - ${f.title} (ID: ${f.id})${reason ? `: ${reason}` : ''}`);
|
|
1048
|
+
});
|
|
1049
|
+
if (result.failures.length > 10) {
|
|
1050
|
+
console.log(` - ...and ${result.failures.length - 10} more`);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
console.log(`URL: ${chalk.gray(`https://${config.domain}/wiki${result.rootPage._links.webui}`)}`);
|
|
1054
|
+
if (options.failOnError && result.failures?.length) {
|
|
1055
|
+
analytics.track('copy_tree', false);
|
|
1056
|
+
console.error(chalk.red('Completed with failures and --fail-on-error is set.'));
|
|
1057
|
+
process.exit(1);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
analytics.track('copy_tree', true);
|
|
1061
|
+
} catch (error) {
|
|
1062
|
+
analytics.track('copy_tree', false);
|
|
1063
|
+
console.error(chalk.red('Error:'), error.message);
|
|
1064
|
+
process.exit(1);
|
|
1065
|
+
}
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
// List children command
|
|
1069
|
+
program
|
|
1070
|
+
.command('children <pageId>')
|
|
1071
|
+
.description('List child pages of a Confluence page')
|
|
1072
|
+
.option('-r, --recursive', 'List all descendants recursively', false)
|
|
1073
|
+
.option('--max-depth <number>', 'Maximum depth for recursive listing', '10')
|
|
1074
|
+
.option('--format <format>', 'Output format (list, tree, json)', 'list')
|
|
1075
|
+
.option('--show-url', 'Show page URLs', false)
|
|
1076
|
+
.option('--show-id', 'Show page IDs', false)
|
|
1077
|
+
.action(async (pageId, options) => {
|
|
1078
|
+
const analytics = new Analytics();
|
|
1079
|
+
try {
|
|
1080
|
+
const config = getConfig();
|
|
1081
|
+
const client = new ConfluenceClient(config);
|
|
1082
|
+
|
|
1083
|
+
// Extract page ID from URL if needed
|
|
1084
|
+
const resolvedPageId = await client.extractPageId(pageId);
|
|
1085
|
+
|
|
1086
|
+
// Get children
|
|
1087
|
+
let children;
|
|
1088
|
+
if (options.recursive) {
|
|
1089
|
+
const maxDepth = parseInt(options.maxDepth) || 10;
|
|
1090
|
+
children = await client.getAllDescendantPages(resolvedPageId, maxDepth);
|
|
1091
|
+
} else {
|
|
1092
|
+
children = await client.getChildPages(resolvedPageId);
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
if (children.length === 0) {
|
|
1096
|
+
console.log(chalk.yellow('No child pages found.'));
|
|
1097
|
+
analytics.track('children', true);
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// Format output
|
|
1102
|
+
const format = options.format.toLowerCase();
|
|
1103
|
+
|
|
1104
|
+
if (format === 'json') {
|
|
1105
|
+
// JSON output
|
|
1106
|
+
const output = {
|
|
1107
|
+
pageId: resolvedPageId,
|
|
1108
|
+
childCount: children.length,
|
|
1109
|
+
children: children.map(page => ({
|
|
1110
|
+
id: page.id,
|
|
1111
|
+
title: page.title,
|
|
1112
|
+
type: page.type,
|
|
1113
|
+
status: page.status,
|
|
1114
|
+
spaceKey: page.space?.key,
|
|
1115
|
+
url: `https://${config.domain}/wiki/spaces/${page.space?.key}/pages/${page.id}`,
|
|
1116
|
+
parentId: page.parentId || resolvedPageId
|
|
1117
|
+
}))
|
|
1118
|
+
};
|
|
1119
|
+
console.log(JSON.stringify(output, null, 2));
|
|
1120
|
+
} else if (format === 'tree' && options.recursive) {
|
|
1121
|
+
// Tree format (only for recursive mode)
|
|
1122
|
+
const pageInfo = await client.getPageInfo(resolvedPageId);
|
|
1123
|
+
console.log(chalk.blue(`📁 ${pageInfo.title}`));
|
|
1124
|
+
|
|
1125
|
+
// Build tree structure
|
|
1126
|
+
const tree = buildTree(children, resolvedPageId);
|
|
1127
|
+
printTree(tree, config, options, 1);
|
|
1128
|
+
|
|
1129
|
+
console.log('');
|
|
1130
|
+
console.log(chalk.gray(`Total: ${children.length} child page${children.length === 1 ? '' : 's'}`));
|
|
1131
|
+
} else {
|
|
1132
|
+
// List format (default)
|
|
1133
|
+
console.log(chalk.blue('Child pages:'));
|
|
1134
|
+
console.log('');
|
|
1135
|
+
|
|
1136
|
+
children.forEach((page, index) => {
|
|
1137
|
+
let output = `${index + 1}. ${chalk.green(page.title)}`;
|
|
1138
|
+
|
|
1139
|
+
if (options.showId) {
|
|
1140
|
+
output += ` ${chalk.gray(`(ID: ${page.id})`)}`;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
if (options.showUrl) {
|
|
1144
|
+
const url = `https://${config.domain}/wiki/spaces/${page.space?.key}/pages/${page.id}`;
|
|
1145
|
+
output += `\n ${chalk.gray(url)}`;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
if (options.recursive && page.parentId && page.parentId !== resolvedPageId) {
|
|
1149
|
+
output += ` ${chalk.dim('(nested)')}`;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
console.log(output);
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
console.log('');
|
|
1156
|
+
console.log(chalk.gray(`Total: ${children.length} child page${children.length === 1 ? '' : 's'}`));
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
analytics.track('children', true);
|
|
1160
|
+
} catch (error) {
|
|
1161
|
+
analytics.track('children', false);
|
|
1162
|
+
console.error(chalk.red('Error:'), error.message);
|
|
1163
|
+
process.exit(1);
|
|
1164
|
+
}
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
// Helper function to build tree structure
|
|
1168
|
+
function buildTree(pages, rootId) {
|
|
1169
|
+
const tree = [];
|
|
1170
|
+
const pageMap = new Map();
|
|
1171
|
+
|
|
1172
|
+
// Create a map of all pages
|
|
1173
|
+
pages.forEach(page => {
|
|
1174
|
+
pageMap.set(page.id, { ...page, children: [] });
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
// Build tree structure
|
|
1178
|
+
pages.forEach(page => {
|
|
1179
|
+
const node = pageMap.get(page.id);
|
|
1180
|
+
const parentId = page.parentId || rootId;
|
|
1181
|
+
|
|
1182
|
+
if (parentId === rootId) {
|
|
1183
|
+
tree.push(node);
|
|
1184
|
+
} else {
|
|
1185
|
+
const parent = pageMap.get(parentId);
|
|
1186
|
+
if (parent) {
|
|
1187
|
+
parent.children.push(node);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
return tree;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// Helper function to print tree
|
|
1196
|
+
function printTree(nodes, config, options, depth = 1) {
|
|
1197
|
+
nodes.forEach((node, index) => {
|
|
1198
|
+
const isLast = index === nodes.length - 1;
|
|
1199
|
+
const indent = ' '.repeat(depth - 1);
|
|
1200
|
+
const prefix = isLast ? '└── ' : '├── ';
|
|
1201
|
+
|
|
1202
|
+
let output = `${indent}${prefix}📄 ${chalk.green(node.title)}`;
|
|
1203
|
+
|
|
1204
|
+
if (options.showId) {
|
|
1205
|
+
output += ` ${chalk.gray(`(ID: ${node.id})`)}`;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
if (options.showUrl) {
|
|
1209
|
+
const url = `https://${config.domain}/wiki/spaces/${node.space?.key}/pages/${node.id}`;
|
|
1210
|
+
output += `\n${indent}${isLast ? ' ' : '│ '}${chalk.gray(url)}`;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
console.log(output);
|
|
1214
|
+
|
|
1215
|
+
if (node.children && node.children.length > 0) {
|
|
1216
|
+
printTree(node.children, config, options, depth + 1);
|
|
1217
|
+
}
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
if (process.argv.length <= 2) {
|
|
1222
|
+
program.help({ error: false });
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
program.parse(process.argv);
|