confluence-exporter 1.0.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.
Files changed (91) hide show
  1. package/.eslintrc.cjs +18 -0
  2. package/.github/copilot-instructions.md +3 -0
  3. package/.github/prompts/analyze.prompt.md +101 -0
  4. package/.github/prompts/clarify.prompt.md +158 -0
  5. package/.github/prompts/constitution.prompt.md +73 -0
  6. package/.github/prompts/implement.prompt.md +56 -0
  7. package/.github/prompts/plan.prompt.md +50 -0
  8. package/.github/prompts/specify.prompt.md +21 -0
  9. package/.github/prompts/tasks.prompt.md +69 -0
  10. package/LICENSE +21 -0
  11. package/README.md +332 -0
  12. package/agents.md +1174 -0
  13. package/dist/api.d.ts +73 -0
  14. package/dist/api.js +387 -0
  15. package/dist/api.js.map +1 -0
  16. package/dist/commands/download.command.d.ts +18 -0
  17. package/dist/commands/download.command.js +257 -0
  18. package/dist/commands/download.command.js.map +1 -0
  19. package/dist/commands/executor.d.ts +22 -0
  20. package/dist/commands/executor.js +52 -0
  21. package/dist/commands/executor.js.map +1 -0
  22. package/dist/commands/help.command.d.ts +8 -0
  23. package/dist/commands/help.command.js +68 -0
  24. package/dist/commands/help.command.js.map +1 -0
  25. package/dist/commands/index.command.d.ts +14 -0
  26. package/dist/commands/index.command.js +95 -0
  27. package/dist/commands/index.command.js.map +1 -0
  28. package/dist/commands/index.d.ts +13 -0
  29. package/dist/commands/index.js +13 -0
  30. package/dist/commands/index.js.map +1 -0
  31. package/dist/commands/plan.command.d.ts +54 -0
  32. package/dist/commands/plan.command.js +272 -0
  33. package/dist/commands/plan.command.js.map +1 -0
  34. package/dist/commands/registry.d.ts +12 -0
  35. package/dist/commands/registry.js +32 -0
  36. package/dist/commands/registry.js.map +1 -0
  37. package/dist/commands/transform.command.d.ts +69 -0
  38. package/dist/commands/transform.command.js +951 -0
  39. package/dist/commands/transform.command.js.map +1 -0
  40. package/dist/commands/types.d.ts +12 -0
  41. package/dist/commands/types.js +5 -0
  42. package/dist/commands/types.js.map +1 -0
  43. package/dist/commands/update.command.d.ts +10 -0
  44. package/dist/commands/update.command.js +201 -0
  45. package/dist/commands/update.command.js.map +1 -0
  46. package/dist/constants.d.ts +1 -0
  47. package/dist/constants.js +2 -0
  48. package/dist/constants.js.map +1 -0
  49. package/dist/index.d.ts +5 -0
  50. package/dist/index.js +110 -0
  51. package/dist/index.js.map +1 -0
  52. package/dist/logger.d.ts +15 -0
  53. package/dist/logger.js +52 -0
  54. package/dist/logger.js.map +1 -0
  55. package/dist/types.d.ts +167 -0
  56. package/dist/types.js +5 -0
  57. package/dist/types.js.map +1 -0
  58. package/dist/utils.d.ts +56 -0
  59. package/dist/utils.js +178 -0
  60. package/dist/utils.js.map +1 -0
  61. package/eslint.config.js +29 -0
  62. package/jest.config.cjs +25 -0
  63. package/migrate-meta.js +132 -0
  64. package/package.json +53 -0
  65. package/src/api.ts +469 -0
  66. package/src/commands/download.command.ts +324 -0
  67. package/src/commands/executor.ts +62 -0
  68. package/src/commands/help.command.ts +72 -0
  69. package/src/commands/index.command.ts +111 -0
  70. package/src/commands/index.ts +14 -0
  71. package/src/commands/plan.command.ts +318 -0
  72. package/src/commands/registry.ts +39 -0
  73. package/src/commands/transform.command.ts +1103 -0
  74. package/src/commands/types.ts +16 -0
  75. package/src/commands/update.command.ts +229 -0
  76. package/src/constants.ts +0 -0
  77. package/src/index.ts +120 -0
  78. package/src/logger.ts +60 -0
  79. package/src/test.sh +66 -0
  80. package/src/types.ts +176 -0
  81. package/src/utils.ts +204 -0
  82. package/tests/commands/README.md +123 -0
  83. package/tests/commands/download.command.test.ts +8 -0
  84. package/tests/commands/help.command.test.ts +8 -0
  85. package/tests/commands/index.command.test.ts +8 -0
  86. package/tests/commands/plan.command.test.ts +15 -0
  87. package/tests/commands/transform.command.test.ts +8 -0
  88. package/tests/fixtures/_index.yaml +38 -0
  89. package/tests/fixtures/mock-pages.ts +62 -0
  90. package/tsconfig.json +25 -0
  91. package/vite.config.ts +45 -0
package/src/api.ts ADDED
@@ -0,0 +1,469 @@
1
+ /**
2
+ * Minimal Confluence API client
3
+ */
4
+
5
+ import type {
6
+ Page,
7
+ PageMetadata,
8
+ PaginatedResponse,
9
+ ConfluenceConfig,
10
+ User,
11
+ PageResponse,
12
+ RawPage,
13
+ ListPagesResponse,
14
+ ChildPageResponse,
15
+ ChildPagesResponse,
16
+ AttachmentResult,
17
+ AttachmentResponse
18
+ } from './types.js';
19
+ import { logger } from './logger.js';
20
+ import fs from 'fs/promises';
21
+ import path from 'path';
22
+ import { readFileSync, writeFile } from 'fs';
23
+
24
+ export class ConfluenceApi {
25
+ private baseUrl: string;
26
+ private authHeader: string;
27
+ private userCache: Map<string, User> = new Map();
28
+ private cacheFile: string;
29
+
30
+ // Default network timeout for fetch requests (ms)
31
+ private DEFAULT_TIMEOUT = 15000;
32
+
33
+ constructor(config: ConfluenceConfig) {
34
+ this.baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
35
+ const credentials = Buffer.from(`${config.username}:${config.password}`).toString('base64');
36
+ this.authHeader = `Basic ${credentials}`;
37
+
38
+ // Set up user cache file
39
+ this.cacheFile = path.join(config.outputDir, '_user_cache.json');
40
+ this.loadCache();
41
+ }
42
+
43
+ /**
44
+ * Load user cache from filesystem
45
+ */
46
+ private loadCache(): void {
47
+ try {
48
+ const data = readFileSync(this.cacheFile, 'utf8');
49
+ const parsed = JSON.parse(data);
50
+ this.userCache = new Map(Object.entries(parsed));
51
+ logger.debug(`Loaded ${this.userCache.size} users from cache`);
52
+ } catch (err) {
53
+ // Ignore if file doesn't exist or is invalid
54
+ logger.debug('No existing user cache found, starting fresh');
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Save user cache to filesystem
60
+ */
61
+ private async saveCache(): Promise<void> {
62
+ try {
63
+ const obj = Object.fromEntries(this.userCache);
64
+ await fs.writeFile(this.cacheFile, JSON.stringify(obj, null, 2));
65
+ logger.debug(`Saved ${this.userCache.size} users to cache`);
66
+ } catch (err) {
67
+ logger.warn('Failed to save user cache:', err);
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Helper to perform fetch with a timeout using AbortController
73
+ */
74
+ private async fetchWithTimeout(input: RequestInfo | URL, init?: RequestInit, timeoutMs?: number): Promise<Response> {
75
+ const timeout = typeof timeoutMs === 'number' ? timeoutMs : this.DEFAULT_TIMEOUT;
76
+ const controller = new AbortController();
77
+ const id = setTimeout(() => controller.abort(), timeout);
78
+ try {
79
+ logger.debug('fetchWithTimeout ->', input, 'timeoutMs=', timeout);
80
+ const res = await fetch(input, { ...(init || {}), signal: controller.signal } as RequestInit);
81
+ logger.debug('fetchWithTimeout <-', input, 'status=', res.status);
82
+ return res;
83
+ } finally {
84
+ clearTimeout(id);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Fetch a page with its content
90
+ */
91
+ async getPage(pageId: string): Promise<Page> {
92
+ const url = `${this.baseUrl}/rest/api/content/${pageId}?expand=body.storage,version,history.lastUpdated`;
93
+
94
+ const response = await this.fetchWithTimeout(url, {
95
+ headers: {
96
+ 'Authorization': this.authHeader,
97
+ 'Accept': 'application/json'
98
+ }
99
+ });
100
+
101
+ if (!response.ok) {
102
+ throw new Error(`Failed to fetch page ${pageId}: ${response.status} ${response.statusText}`);
103
+ }
104
+
105
+ const data = await response.json() as PageResponse;
106
+
107
+ return {
108
+ id: data.id,
109
+ title: data.title,
110
+ body: data.body?.storage?.value || '',
111
+ version: data.version?.number,
112
+ parentId: data.ancestors?.[data.ancestors.length - 1]?.id,
113
+ modifiedDate: data.version?.when || data.history?.lastUpdated?.when
114
+ };
115
+ }
116
+
117
+ /**
118
+ * List all pages in a space
119
+ */
120
+ async listPages(spaceKey: string, start: number = 0, limit: number = 100): Promise<PaginatedResponse<Page>> {
121
+ const url = `${this.baseUrl}/rest/api/content?spaceKey=${spaceKey}&type=page&expand=body.storage,version,history.lastUpdated,ancestors&start=${start}&limit=${limit}`;
122
+
123
+ const response = await this.fetchWithTimeout(url, {
124
+ headers: {
125
+ 'Authorization': this.authHeader,
126
+ 'Accept': 'application/json'
127
+ }
128
+ });
129
+
130
+ if (!response.ok) {
131
+ throw new Error(`Failed to list pages (${url}): ${response.status} ${response.statusText}`);
132
+ }
133
+
134
+ const data = await response.json() as ListPagesResponse;
135
+
136
+ return {
137
+ results: data.results.map((item: RawPage) => ({
138
+ id: item.id,
139
+ title: item.title,
140
+ body: item.body?.storage?.value || '',
141
+ version: item.version?.number,
142
+ parentId: item.ancestors?.[item.ancestors.length - 1]?.id,
143
+ modifiedDate: item.version?.when || item.history?.lastUpdated?.when
144
+ })),
145
+ start: data.start,
146
+ limit: data.limit,
147
+ size: data.size,
148
+ _links: data._links
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Search pages using CQL query (metadata only, no body content)
154
+ * Useful for finding pages modified after a specific date
155
+ */
156
+ async searchPages(cql: string, pageSize: number = 100): Promise<PageMetadata[]> {
157
+ const results: PageMetadata[] = [];
158
+ let start = 0;
159
+
160
+ while (true) {
161
+ const url = `${this.baseUrl}/rest/api/content/search?cql=${encodeURIComponent(cql)}&expand=version,history.lastUpdated,ancestors&start=${start}&limit=${pageSize}`;
162
+
163
+ logger.debug('downloadAttachment: metadata URL ->', url);
164
+ const response = await this.fetchWithTimeout(url, {
165
+ headers: {
166
+ 'Authorization': this.authHeader,
167
+ 'Accept': 'application/json'
168
+ }
169
+ });
170
+
171
+ if (!response.ok) {
172
+ throw new Error(`Failed to search pages (${url}): ${response.status} ${response.statusText}`);
173
+ }
174
+
175
+ const data = await response.json() as ListPagesResponse;
176
+
177
+ for (const item of data.results) {
178
+ results.push({
179
+ id: item.id,
180
+ title: item.title,
181
+ version: item.version?.number,
182
+ parentId: item.ancestors?.[item.ancestors.length - 1]?.id,
183
+ modifiedDate: item.version?.when || item.history?.lastUpdated?.when
184
+ });
185
+ }
186
+
187
+ // Check if there are more pages
188
+ if (data.results.length < pageSize || !data._links?.next) {
189
+ break;
190
+ }
191
+
192
+ start += pageSize;
193
+ }
194
+
195
+ return results;
196
+ }
197
+
198
+ /**
199
+ * List pages metadata only (no body content) - more efficient for checking updates
200
+ */
201
+ async listPagesMetadata(spaceKey: string, start: number = 0, limit: number = 100): Promise<PaginatedResponse<PageMetadata>> {
202
+ const url = `${this.baseUrl}/rest/api/content?spaceKey=${spaceKey}&type=page&expand=version,history.lastUpdated,ancestors&start=${start}&limit=${limit}`;
203
+
204
+ const response = await this.fetchWithTimeout(url, {
205
+ headers: {
206
+ 'Authorization': this.authHeader,
207
+ 'Accept': 'application/json'
208
+ }
209
+ });
210
+
211
+ if (!response.ok) {
212
+ throw new Error(`Failed to list pages metadata (${url}): ${response.status} ${response.statusText}`);
213
+ }
214
+
215
+ const data = await response.json() as ListPagesResponse;
216
+
217
+ return {
218
+ results: data.results.map((item: RawPage) => ({
219
+ id: item.id,
220
+ title: item.title,
221
+ version: item.version?.number,
222
+ parentId: item.ancestors?.[item.ancestors.length - 1]?.id,
223
+ modifiedDate: item.version?.when || item.history?.lastUpdated?.when
224
+ })),
225
+ start: data.start,
226
+ limit: data.limit,
227
+ size: data.size,
228
+ _links: data._links
229
+ };
230
+ }
231
+
232
+ /**
233
+ * Fetch all pages metadata from a space (handles pagination) - no body content
234
+ */
235
+ async *getAllPagesMetadata(spaceKey: string, pageSize: number = 100): AsyncGenerator<PageMetadata & { apiPageNumber: number }> {
236
+ let start = 0;
237
+ const limit = pageSize;
238
+ let apiPageNumber = 1;
239
+
240
+ while (true) {
241
+ const response = await this.listPagesMetadata(spaceKey, start, limit);
242
+
243
+ for (const page of response.results) {
244
+ yield { ...page, apiPageNumber };
245
+ }
246
+
247
+ // Check if there are more pages
248
+ if (response.results.length < limit || !response._links?.next) {
249
+ break;
250
+ }
251
+
252
+ start += limit;
253
+ apiPageNumber++;
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Fetch all pages from a space (handles pagination)
259
+ */
260
+ async *getAllPages(spaceKey: string, pageSize: number = 25, startFrom: number = 0): AsyncGenerator<Page & { apiPageNumber: number }> {
261
+ let start = startFrom;
262
+ const limit = pageSize;
263
+ let apiPageNumber = Math.floor(startFrom / pageSize) + 1;
264
+
265
+ while (true) {
266
+ const response = await this.listPages(spaceKey, start, limit);
267
+
268
+ for (const page of response.results) {
269
+ yield { ...page, apiPageNumber };
270
+ }
271
+
272
+ // Check if there are more pages
273
+ if (response.results.length < limit || !response._links?.next) {
274
+ break;
275
+ }
276
+
277
+ start += limit;
278
+ apiPageNumber++;
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Get a page by its title in a space
284
+ */
285
+ async getPageByTitle(spaceKey: string, title: string): Promise<Page | null> {
286
+ const url = `${this.baseUrl}/rest/api/content?spaceKey=${spaceKey}&title=${encodeURIComponent(title)}&expand=body.storage,version`;
287
+
288
+ const response = await this.fetchWithTimeout(url, {
289
+ headers: {
290
+ 'Authorization': this.authHeader,
291
+ 'Accept': 'application/json'
292
+ }
293
+ });
294
+
295
+ if (!response.ok) {
296
+ logger.warn(`Failed to fetch page by title "${title}": ${response.status}`);
297
+ return null;
298
+ }
299
+
300
+ const data = await response.json() as ListPagesResponse;
301
+
302
+ if (data.results.length === 0) {
303
+ return null;
304
+ }
305
+
306
+ const page = data.results[0];
307
+ return {
308
+ id: page.id,
309
+ title: page.title,
310
+ body: page.body?.storage?.value || '',
311
+ version: page.version?.number,
312
+ };
313
+ }
314
+
315
+ /**
316
+ * Get child pages of a parent page
317
+ */
318
+ async getChildPages(pageId: string): Promise<Page[]> {
319
+ const url = `${this.baseUrl}/rest/api/content/${pageId}/child/page?expand=version`;
320
+
321
+ const response = await fetch(url, {
322
+ headers: {
323
+ 'Authorization': this.authHeader,
324
+ 'Accept': 'application/json'
325
+ }
326
+ });
327
+
328
+ if (!response.ok) {
329
+ logger.warn(`Failed to fetch child pages for ${pageId}: ${response.status}`);
330
+ return [];
331
+ }
332
+
333
+ const data = await response.json() as ChildPagesResponse;
334
+
335
+ return data.results.map(child => ({
336
+ id: child.id,
337
+ title: child.title,
338
+ body: '', // Don't fetch body for child page lists
339
+ version: child.version?.number,
340
+ }));
341
+ }
342
+
343
+ /**
344
+ * Download an attachment from a page
345
+ */
346
+ async downloadAttachment(pageId: string, filename: string): Promise<Buffer | null> {
347
+ try {
348
+ // First, get the attachment metadata to get the download URL
349
+ const url = `${this.baseUrl}/rest/api/content/${pageId}/child/attachment?filename=${encodeURIComponent(filename)}`;
350
+
351
+ const response = await this.fetchWithTimeout(url, {
352
+ headers: {
353
+ 'Authorization': this.authHeader,
354
+ 'Accept': 'application/json'
355
+ }
356
+ });
357
+
358
+ if (!response.ok) {
359
+ logger.warn(`Failed to fetch attachment metadata for ${filename}: ${response.status}`);
360
+ return null;
361
+ }
362
+
363
+ const data = await response.json() as AttachmentResponse;
364
+ logger.debug('downloadAttachment: metadata results length ->', data.results.length);
365
+
366
+ if (data.results.length === 0) {
367
+ logger.warn(`Attachment not found: ${filename}`);
368
+ return null;
369
+ }
370
+
371
+ // Download the actual file
372
+ const downloadUrl = `${this.baseUrl}${data.results[0]._links.download}`;
373
+ logger.debug('downloadAttachment: download URL ->', downloadUrl);
374
+ const downloadResponse = await this.fetchWithTimeout(downloadUrl, {
375
+ headers: {
376
+ 'Authorization': this.authHeader
377
+ }
378
+ });
379
+
380
+ if (!downloadResponse.ok) {
381
+ logger.warn(`Failed to download attachment ${filename}: ${downloadResponse.status}`);
382
+ return null;
383
+ }
384
+
385
+ const arrayBuffer = await downloadResponse.arrayBuffer();
386
+ return Buffer.from(arrayBuffer);
387
+ } catch (error) {
388
+ logger.warn(`Error downloading attachment ${filename}:`, error);
389
+ return null;
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Get user information by username (with caching)
395
+ */
396
+ async getUserByUsername(username: string): Promise<User | null> {
397
+ // Check cache first
398
+ const cached = this.userCache.get(username);
399
+ if (cached) {
400
+ return cached;
401
+ }
402
+
403
+ try {
404
+ const url = `${this.baseUrl}/rest/api/user?username=${encodeURIComponent(username)}&expand=details.personal`;
405
+
406
+ const response = await this.fetchWithTimeout(url, {
407
+ headers: {
408
+ 'Authorization': this.authHeader,
409
+ 'Accept': 'application/json'
410
+ }
411
+ });
412
+
413
+ if (!response.ok) {
414
+ logger.warn(`Failed to fetch user ${username}: ${response.status}`);
415
+ return null;
416
+ }
417
+
418
+ const data = await response.json() as User;
419
+
420
+ // Cache the result
421
+ this.userCache.set(username, data);
422
+ await this.saveCache();
423
+
424
+ return data;
425
+ } catch (error) {
426
+ logger.warn(`Error fetching user ${username}:`, error);
427
+ return null;
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Get user information by user key (with caching)
433
+ */
434
+ async getUserByKey(userKey: string): Promise<User | null> {
435
+ // Check cache first
436
+ const cacheKey = `key:${userKey}`;
437
+ const cached = this.userCache.get(cacheKey);
438
+ if (cached) {
439
+ return cached;
440
+ }
441
+
442
+ try {
443
+ const url = `${this.baseUrl}/rest/api/user?key=${encodeURIComponent(userKey)}&expand=details.personal`;
444
+
445
+ const response = await this.fetchWithTimeout(url, {
446
+ headers: {
447
+ 'Authorization': this.authHeader,
448
+ 'Accept': 'application/json'
449
+ }
450
+ });
451
+
452
+ if (!response.ok) {
453
+ logger.warn(`Failed to fetch user by key ${userKey}: ${response.status}`);
454
+ return null;
455
+ }
456
+
457
+ const data = await response.json() as User;
458
+
459
+ // Cache the result
460
+ this.userCache.set(cacheKey, data);
461
+ await this.saveCache();
462
+
463
+ return data;
464
+ } catch (error) {
465
+ logger.warn(`Error fetching user by key ${userKey}:`, error);
466
+ return null;
467
+ }
468
+ }
469
+ }