@aaronshaf/confluence-cli 0.1.15

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 (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +69 -0
  3. package/package.json +73 -0
  4. package/src/cli/commands/attachments.ts +113 -0
  5. package/src/cli/commands/clone.ts +188 -0
  6. package/src/cli/commands/comments.ts +56 -0
  7. package/src/cli/commands/create.ts +58 -0
  8. package/src/cli/commands/delete.ts +46 -0
  9. package/src/cli/commands/doctor.ts +161 -0
  10. package/src/cli/commands/duplicate-check.ts +89 -0
  11. package/src/cli/commands/file-rename.ts +113 -0
  12. package/src/cli/commands/folder-hierarchy.ts +241 -0
  13. package/src/cli/commands/info.ts +56 -0
  14. package/src/cli/commands/labels.ts +53 -0
  15. package/src/cli/commands/move.ts +23 -0
  16. package/src/cli/commands/open.ts +145 -0
  17. package/src/cli/commands/pull.ts +241 -0
  18. package/src/cli/commands/push-errors.ts +40 -0
  19. package/src/cli/commands/push.ts +699 -0
  20. package/src/cli/commands/search.ts +62 -0
  21. package/src/cli/commands/setup.ts +124 -0
  22. package/src/cli/commands/spaces.ts +42 -0
  23. package/src/cli/commands/status.ts +88 -0
  24. package/src/cli/commands/tree.ts +190 -0
  25. package/src/cli/help.ts +425 -0
  26. package/src/cli/index.ts +413 -0
  27. package/src/cli/utils/browser.ts +34 -0
  28. package/src/cli/utils/progress-reporter.ts +49 -0
  29. package/src/cli.ts +6 -0
  30. package/src/lib/config.ts +156 -0
  31. package/src/lib/confluence-client/attachment-operations.ts +221 -0
  32. package/src/lib/confluence-client/client.ts +653 -0
  33. package/src/lib/confluence-client/comment-operations.ts +60 -0
  34. package/src/lib/confluence-client/folder-operations.ts +203 -0
  35. package/src/lib/confluence-client/index.ts +47 -0
  36. package/src/lib/confluence-client/label-operations.ts +102 -0
  37. package/src/lib/confluence-client/page-operations.ts +270 -0
  38. package/src/lib/confluence-client/search-operations.ts +60 -0
  39. package/src/lib/confluence-client/types.ts +329 -0
  40. package/src/lib/confluence-client/user-operations.ts +58 -0
  41. package/src/lib/dependency-sorter.ts +233 -0
  42. package/src/lib/errors.ts +237 -0
  43. package/src/lib/file-scanner.ts +195 -0
  44. package/src/lib/formatters.ts +314 -0
  45. package/src/lib/health-check.ts +204 -0
  46. package/src/lib/markdown/converter.ts +427 -0
  47. package/src/lib/markdown/frontmatter.ts +116 -0
  48. package/src/lib/markdown/html-converter.ts +398 -0
  49. package/src/lib/markdown/index.ts +21 -0
  50. package/src/lib/markdown/link-converter.ts +189 -0
  51. package/src/lib/markdown/reference-updater.ts +251 -0
  52. package/src/lib/markdown/slugify.ts +32 -0
  53. package/src/lib/page-state.ts +195 -0
  54. package/src/lib/resolve-page-target.ts +33 -0
  55. package/src/lib/space-config.ts +264 -0
  56. package/src/lib/sync/cleanup.ts +50 -0
  57. package/src/lib/sync/folder-path.ts +61 -0
  58. package/src/lib/sync/index.ts +2 -0
  59. package/src/lib/sync/link-resolution-pass.ts +139 -0
  60. package/src/lib/sync/sync-engine.ts +681 -0
  61. package/src/lib/sync/sync-specific.ts +221 -0
  62. package/src/lib/sync/types.ts +42 -0
  63. package/src/test/attachments.test.ts +68 -0
  64. package/src/test/clone.test.ts +373 -0
  65. package/src/test/comments.test.ts +53 -0
  66. package/src/test/config.test.ts +209 -0
  67. package/src/test/confluence-client.test.ts +535 -0
  68. package/src/test/delete.test.ts +39 -0
  69. package/src/test/dependency-sorter.test.ts +384 -0
  70. package/src/test/errors.test.ts +199 -0
  71. package/src/test/file-rename.test.ts +305 -0
  72. package/src/test/file-scanner.test.ts +331 -0
  73. package/src/test/folder-hierarchy.test.ts +337 -0
  74. package/src/test/formatters.test.ts +213 -0
  75. package/src/test/html-converter.test.ts +399 -0
  76. package/src/test/info.test.ts +56 -0
  77. package/src/test/labels.test.ts +70 -0
  78. package/src/test/link-conversion-integration.test.ts +189 -0
  79. package/src/test/link-converter.test.ts +413 -0
  80. package/src/test/link-resolution-pass.test.ts +368 -0
  81. package/src/test/markdown.test.ts +443 -0
  82. package/src/test/mocks/handlers.ts +228 -0
  83. package/src/test/move.test.ts +53 -0
  84. package/src/test/msw-schema-validation.ts +151 -0
  85. package/src/test/page-state.test.ts +542 -0
  86. package/src/test/push.test.ts +551 -0
  87. package/src/test/reference-updater.test.ts +293 -0
  88. package/src/test/resolve-page-target.test.ts +55 -0
  89. package/src/test/search.test.ts +64 -0
  90. package/src/test/setup-msw.ts +75 -0
  91. package/src/test/space-config.test.ts +516 -0
  92. package/src/test/spaces.test.ts +53 -0
  93. package/src/test/sync-engine.test.ts +486 -0
  94. package/src/types/turndown-plugin-gfm.d.ts +9 -0
@@ -0,0 +1,653 @@
1
+ import { Effect, pipe, Schedule, Schema } from 'effect';
2
+ import type { Config } from '../config.js';
3
+ import {
4
+ ApiError,
5
+ AuthError,
6
+ type FolderNotFoundError,
7
+ NetworkError,
8
+ type PageNotFoundError,
9
+ RateLimitError,
10
+ SpaceNotFoundError,
11
+ type VersionConflictError,
12
+ } from '../errors.js';
13
+ import {
14
+ createFolderEffect as createFolderEffectFn,
15
+ findFolderByTitle as findFolderByTitleFn,
16
+ getFolderEffect as getFolderEffectFn,
17
+ movePageEffect as movePageEffectFn,
18
+ } from './folder-operations.js';
19
+ import {
20
+ createPageEffect as createPageEffectFn,
21
+ deletePageEffect as deletePageEffectFn,
22
+ setContentPropertyEffect as setContentPropertyEffectFn,
23
+ updatePageEffect as updatePageEffectFn,
24
+ } from './page-operations.js';
25
+ import { addLabelEffect as addLabelEffectFn, removeLabelEffect as removeLabelEffectFn } from './label-operations.js';
26
+ import {
27
+ deleteAttachmentEffect as deleteAttachmentEffectFn,
28
+ downloadAttachment as downloadAttachmentFn,
29
+ getAllAttachments as getAllAttachmentsFn,
30
+ getAttachmentsEffect as getAttachmentsEffectFn,
31
+ uploadAttachmentEffect as uploadAttachmentEffectFn,
32
+ } from './attachment-operations.js';
33
+ import { searchEffect as searchEffectFn } from './search-operations.js';
34
+ import { getAllFooterComments as getAllFooterCommentsFn } from './comment-operations.js';
35
+ import { getUserEffect as getUserEffectFn } from './user-operations.js';
36
+ import {
37
+ CommentsResponseSchema,
38
+ FolderSchema,
39
+ LabelsResponseSchema,
40
+ PageSchema,
41
+ PagesResponseSchema,
42
+ SpaceSchema,
43
+ SpacesResponseSchema,
44
+ type Attachment,
45
+ type AttachmentsResponse,
46
+ type Comment,
47
+ type CommentsResponse,
48
+ type CreateFolderRequest,
49
+ type CreatePageRequest,
50
+ type Folder,
51
+ type Label,
52
+ type LabelsResponse,
53
+ type Page,
54
+ type PagesResponse,
55
+ type SearchResponse,
56
+ type Space,
57
+ type SpacesResponse,
58
+ type UpdatePageRequest,
59
+ type User,
60
+ type VersionConflictResponse,
61
+ } from './types.js';
62
+
63
+ /**
64
+ * Extract cursor from a next-page link returned by the Confluence API.
65
+ * Uses the URL API to safely parse query parameters.
66
+ */
67
+ function extractCursor(nextLink: string | undefined): string | undefined {
68
+ if (!nextLink) return undefined;
69
+ try {
70
+ // nextLink may be relative (e.g. /wiki/api/v2/spaces?cursor=xxx); use a dummy base
71
+ const url = new URL(nextLink, 'https://placeholder.invalid');
72
+ return url.searchParams.get('cursor') ?? undefined;
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Retry configuration for rate limiting
80
+ * Uses exponential backoff with jitter per ADR-0010
81
+ */
82
+ const MAX_RETRIES = 5;
83
+ const BASE_DELAY_MS = 1000;
84
+ /** Shared retry schedule for rate-limited requests */
85
+ const rateLimitRetrySchedule = Schedule.exponential(BASE_DELAY_MS).pipe(
86
+ Schedule.jittered,
87
+ Schedule.whileInput((error: unknown) => error instanceof RateLimitError),
88
+ Schedule.upTo(MAX_RETRIES * BASE_DELAY_MS * 32),
89
+ );
90
+
91
+ /**
92
+ * Confluence API v2 client
93
+ * Only supports Confluence Cloud (*.atlassian.net) per ADR-0012
94
+ */
95
+ export class ConfluenceClient {
96
+ private baseUrl: string;
97
+ private authHeader: string;
98
+ constructor(config: Config) {
99
+ this.baseUrl = config.confluenceUrl;
100
+ this.authHeader = `Basic ${Buffer.from(`${config.email}:${config.apiToken}`).toString('base64')}`;
101
+ }
102
+
103
+ /** Make an authenticated API request with retry logic */
104
+ private fetchWithRetryEffect<T>(
105
+ path: string,
106
+ schema: Schema.Schema<T>,
107
+ options?: RequestInit,
108
+ ): Effect.Effect<T, ApiError | AuthError | NetworkError | RateLimitError> {
109
+ const url = `${this.baseUrl}/wiki/api/v2${path}`;
110
+
111
+ const makeRequest = Effect.tryPromise({
112
+ try: async () => {
113
+ const response = await fetch(url, {
114
+ ...options,
115
+ headers: {
116
+ Authorization: this.authHeader,
117
+ Accept: 'application/json',
118
+ 'Content-Type': 'application/json',
119
+ ...options?.headers,
120
+ },
121
+ });
122
+
123
+ if (response.status === 429) {
124
+ const retryAfter = response.headers.get('Retry-After');
125
+ throw new RateLimitError(
126
+ 'Rate limited by Confluence API',
127
+ retryAfter ? Number.parseInt(retryAfter, 10) : undefined,
128
+ );
129
+ }
130
+
131
+ if (response.status === 401) {
132
+ throw new AuthError('Invalid credentials. Please check your email and API token.', 401);
133
+ }
134
+
135
+ if (response.status === 403) {
136
+ throw new AuthError('Access denied. Please check your permissions.', 403);
137
+ }
138
+
139
+ if (!response.ok) {
140
+ const errorText = await response.text();
141
+ throw new ApiError(`API request failed: ${response.status} ${errorText}`, response.status);
142
+ }
143
+
144
+ return response.json();
145
+ },
146
+ catch: (error) => {
147
+ if (error instanceof RateLimitError || error instanceof AuthError || error instanceof ApiError) {
148
+ return error;
149
+ }
150
+ return new NetworkError(`Network error: ${error}`);
151
+ },
152
+ });
153
+
154
+ return pipe(
155
+ makeRequest,
156
+ Effect.flatMap((data) =>
157
+ Schema.decodeUnknown(schema)(data).pipe(Effect.mapError((e) => new ApiError(`Invalid response: ${e}`, 500))),
158
+ ),
159
+ Effect.retry(rateLimitRetrySchedule),
160
+ );
161
+ }
162
+
163
+ /** Async wrapper for fetch with retry */
164
+ private async fetchWithRetry<T>(path: string, schema: Schema.Schema<T>, options?: RequestInit): Promise<T> {
165
+ return Effect.runPromise(this.fetchWithRetryEffect(path, schema, options));
166
+ }
167
+
168
+ // ================== Spaces API ==================
169
+
170
+ /** Get all spaces (Effect version) */
171
+ getSpacesEffect(limit = 25): Effect.Effect<SpacesResponse, ApiError | AuthError | NetworkError | RateLimitError> {
172
+ return this.fetchWithRetryEffect(`/spaces?limit=${limit}`, SpacesResponseSchema);
173
+ }
174
+
175
+ /** Get all spaces (async version) */
176
+ async getSpaces(limit = 25): Promise<SpacesResponse> {
177
+ return this.fetchWithRetry(`/spaces?limit=${limit}`, SpacesResponseSchema);
178
+ }
179
+
180
+ /** Get all spaces with pagination (async version) */
181
+ async getAllSpaces(): Promise<Space[]> {
182
+ const allSpaces: Space[] = [];
183
+ let cursor: string | undefined;
184
+ do {
185
+ let path = '/spaces?limit=100';
186
+ if (cursor) path += `&cursor=${encodeURIComponent(cursor)}`;
187
+ const response = await this.fetchWithRetry(path, SpacesResponseSchema);
188
+ allSpaces.push(...response.results);
189
+ cursor = extractCursor(response._links?.next);
190
+ } while (cursor);
191
+ return allSpaces;
192
+ }
193
+
194
+ /** Get a space by key (Effect version) */
195
+ getSpaceByKeyEffect(
196
+ key: string,
197
+ ): Effect.Effect<Space, ApiError | AuthError | NetworkError | RateLimitError | SpaceNotFoundError> {
198
+ return pipe(
199
+ this.fetchWithRetryEffect(`/spaces?keys=${key}&limit=1`, SpacesResponseSchema),
200
+ Effect.flatMap((response) => {
201
+ if (response.results.length === 0) {
202
+ return Effect.fail(new SpaceNotFoundError(key));
203
+ }
204
+ return Effect.succeed(response.results[0]);
205
+ }),
206
+ );
207
+ }
208
+
209
+ /** Get a space by key (async version) */
210
+ async getSpaceByKey(key: string): Promise<Space> {
211
+ const response = await this.fetchWithRetry(`/spaces?keys=${key}&limit=1`, SpacesResponseSchema);
212
+ if (response.results.length === 0) {
213
+ throw new SpaceNotFoundError(key);
214
+ }
215
+ return response.results[0];
216
+ }
217
+
218
+ /** Get a space by ID (Effect version) */
219
+ getSpaceByIdEffect(
220
+ id: string,
221
+ ): Effect.Effect<Space, ApiError | AuthError | NetworkError | RateLimitError | SpaceNotFoundError> {
222
+ const baseUrl = this.baseUrl;
223
+ const authHeader = this.authHeader;
224
+ return Effect.tryPromise({
225
+ try: async () => {
226
+ const url = `${baseUrl}/wiki/api/v2/spaces/${id}`;
227
+ const response = await fetch(url, { headers: { Authorization: authHeader, Accept: 'application/json' } });
228
+ if (response.status === 404) throw new SpaceNotFoundError(id);
229
+ if (response.status === 401) throw new AuthError('Invalid credentials', 401);
230
+ if (response.status === 403) throw new AuthError('Access denied', 403);
231
+ if (!response.ok) throw new ApiError(`API error: ${response.status}`, response.status);
232
+ return Schema.decodeUnknownSync(SpaceSchema)(await response.json());
233
+ },
234
+ catch: (error) => {
235
+ if (error instanceof SpaceNotFoundError || error instanceof AuthError || error instanceof ApiError)
236
+ return error;
237
+ return new NetworkError(`Network error: ${error}`);
238
+ },
239
+ });
240
+ }
241
+
242
+ /** Get a space by ID (async version) */
243
+ async getSpaceById(id: string): Promise<Space> {
244
+ return Effect.runPromise(this.getSpaceByIdEffect(id));
245
+ }
246
+
247
+ // ================== Pages API ==================
248
+
249
+ /**
250
+ * Get all pages in a space (Effect version)
251
+ * Note: Returns pages with all statuses. Use getAllPagesInSpace for filtered results (current only).
252
+ */
253
+ getPagesInSpaceEffect(
254
+ spaceId: string,
255
+ limit = 25,
256
+ cursor?: string,
257
+ ): Effect.Effect<PagesResponse, ApiError | AuthError | NetworkError | RateLimitError> {
258
+ let path = `/spaces/${spaceId}/pages?limit=${limit}&body-format=storage`;
259
+ if (cursor) {
260
+ path += `&cursor=${encodeURIComponent(cursor)}`;
261
+ }
262
+ return this.fetchWithRetryEffect(path, PagesResponseSchema);
263
+ }
264
+
265
+ /**
266
+ * Get all pages in a space (async version)
267
+ * Note: Returns pages with all statuses. Use getAllPagesInSpace for filtered results (current only).
268
+ */
269
+ async getPagesInSpace(spaceId: string, limit = 25, cursor?: string): Promise<PagesResponse> {
270
+ return Effect.runPromise(this.getPagesInSpaceEffect(spaceId, limit, cursor));
271
+ }
272
+
273
+ /**
274
+ * Get all pages in a space with pagination (async version)
275
+ * Only returns pages with status='current' (excludes archived and trashed pages)
276
+ */
277
+ async getAllPagesInSpace(spaceId: string): Promise<Page[]> {
278
+ const allPages: Page[] = [];
279
+ let cursor: string | undefined;
280
+
281
+ do {
282
+ const response = await this.getPagesInSpace(spaceId, 100, cursor);
283
+ // Filter out archived and trashed pages - only include current pages
284
+ allPages.push(...response.results.filter((page) => page.status === 'current'));
285
+
286
+ cursor = extractCursor(response._links?.next);
287
+ } while (cursor);
288
+
289
+ return allPages;
290
+ }
291
+
292
+ /** Get a single page by ID (Effect version) */
293
+ getPageEffect(
294
+ pageId: string,
295
+ includeBody = true,
296
+ ): Effect.Effect<Page, ApiError | AuthError | NetworkError | RateLimitError> {
297
+ const bodyFormat = includeBody ? '&body-format=storage' : '';
298
+ return this.fetchWithRetryEffect(`/pages/${pageId}?${bodyFormat}`, PageSchema);
299
+ }
300
+
301
+ /** Get a single page by ID (async version) */
302
+ async getPage(pageId: string, includeBody = true): Promise<Page> {
303
+ return Effect.runPromise(this.getPageEffect(pageId, includeBody));
304
+ }
305
+
306
+ /**
307
+ * Update a page (Effect version)
308
+ * Uses PUT /wiki/api/v2/pages/{id} endpoint
309
+ */
310
+ updatePageEffect(
311
+ request: UpdatePageRequest,
312
+ ): Effect.Effect<
313
+ Page,
314
+ ApiError | AuthError | NetworkError | RateLimitError | PageNotFoundError | VersionConflictError
315
+ > {
316
+ return updatePageEffectFn(this.baseUrl, this.authHeader, request);
317
+ }
318
+
319
+ /** Update a page (async version) */
320
+ async updatePage(request: UpdatePageRequest): Promise<Page> {
321
+ return Effect.runPromise(this.updatePageEffect(request));
322
+ }
323
+
324
+ /** Create a new page (Effect version) */
325
+ createPageEffect(
326
+ request: CreatePageRequest,
327
+ ): Effect.Effect<Page, ApiError | AuthError | NetworkError | RateLimitError> {
328
+ return createPageEffectFn(this.baseUrl, this.authHeader, request);
329
+ }
330
+
331
+ /** Create a new page (async version) */
332
+ async createPage(request: CreatePageRequest): Promise<Page> {
333
+ return Effect.runPromise(this.createPageEffect(request));
334
+ }
335
+
336
+ /** Set a content property on a page (Effect version) */
337
+ setContentPropertyEffect(
338
+ pageId: string,
339
+ key: string,
340
+ value: unknown,
341
+ ): Effect.Effect<void, ApiError | AuthError | NetworkError | RateLimitError> {
342
+ return setContentPropertyEffectFn(this.baseUrl, this.authHeader, pageId, key, value);
343
+ }
344
+
345
+ /** Set a content property on a page (async version) */
346
+ async setContentProperty(pageId: string, key: string, value: unknown): Promise<void> {
347
+ return Effect.runPromise(this.setContentPropertyEffect(pageId, key, value));
348
+ }
349
+
350
+ /** Set editor version to v2 for a page (enables new editor) */
351
+ async setEditorV2(pageId: string): Promise<void> {
352
+ return this.setContentProperty(pageId, 'editor', 'v2');
353
+ }
354
+
355
+ /** Get child pages of a page (Effect version) */
356
+ getChildPagesEffect(
357
+ pageId: string,
358
+ limit = 25,
359
+ cursor?: string,
360
+ ): Effect.Effect<PagesResponse, ApiError | AuthError | NetworkError | RateLimitError> {
361
+ let path = `/pages/${pageId}/children?limit=${limit}`;
362
+ if (cursor) {
363
+ path += `&cursor=${encodeURIComponent(cursor)}`;
364
+ }
365
+ return this.fetchWithRetryEffect(path, PagesResponseSchema);
366
+ }
367
+
368
+ /** Get child pages of a page (async version) */
369
+ async getChildPages(pageId: string, limit = 25, cursor?: string): Promise<PagesResponse> {
370
+ return Effect.runPromise(this.getChildPagesEffect(pageId, limit, cursor));
371
+ }
372
+
373
+ // ================== Labels API ==================
374
+
375
+ /** Get labels for a page (Effect version) */
376
+ getLabelsEffect(
377
+ pageId: string,
378
+ limit = 25,
379
+ ): Effect.Effect<LabelsResponse, ApiError | AuthError | NetworkError | RateLimitError> {
380
+ return this.fetchWithRetryEffect(`/pages/${pageId}/labels?limit=${limit}`, LabelsResponseSchema);
381
+ }
382
+
383
+ /** Get labels for a page (async version) */
384
+ async getLabels(pageId: string, limit = 25): Promise<LabelsResponse> {
385
+ return Effect.runPromise(this.getLabelsEffect(pageId, limit));
386
+ }
387
+
388
+ /** Get all labels for a page (async version) */
389
+ async getAllLabels(pageId: string): Promise<Label[]> {
390
+ const response = await this.getLabels(pageId, 100);
391
+ return [...response.results];
392
+ }
393
+
394
+ // ================== Users API ==================
395
+
396
+ /**
397
+ * Get user information by account ID (Effect version)
398
+ * Uses v1 API as v2 does not have a user endpoint
399
+ */
400
+ getUserEffect(accountId: string): Effect.Effect<User, ApiError | AuthError | NetworkError | RateLimitError> {
401
+ return getUserEffectFn(this.baseUrl, this.authHeader, accountId);
402
+ }
403
+
404
+ /** Get user information by account ID (async version) */
405
+ async getUser(accountId: string): Promise<User> {
406
+ return Effect.runPromise(this.getUserEffect(accountId));
407
+ }
408
+
409
+ // ================== Folders API ==================
410
+
411
+ /**
412
+ * Get a folder by ID (Effect version)
413
+ * Uses v2 /folders/{id} endpoint per ADR-0018
414
+ */
415
+ getFolderEffect(
416
+ folderId: string,
417
+ ): Effect.Effect<Folder, ApiError | AuthError | NetworkError | RateLimitError | FolderNotFoundError> {
418
+ return getFolderEffectFn(this.baseUrl, this.authHeader, folderId);
419
+ }
420
+
421
+ /** Get a folder by ID (async version) */
422
+ async getFolder(folderId: string): Promise<Folder> {
423
+ return Effect.runPromise(this.getFolderEffect(folderId));
424
+ }
425
+
426
+ /**
427
+ * Discover and fetch all folders referenced by pages
428
+ * Finds pages with parentIds that don't match any page and fetches those as folders
429
+ */
430
+ async discoverFolders(pages: Page[]): Promise<Folder[]> {
431
+ const pageIds = new Set(pages.map((p) => p.id));
432
+ const potentialFolderIds = new Set<string>();
433
+
434
+ // Find parentIds that aren't pages
435
+ for (const page of pages) {
436
+ if (page.parentId && !pageIds.has(page.parentId)) {
437
+ potentialFolderIds.add(page.parentId);
438
+ }
439
+ }
440
+
441
+ // Fetch each potential folder
442
+ const folders: Folder[] = [];
443
+ for (const folderId of potentialFolderIds) {
444
+ try {
445
+ const folder = await this.getFolder(folderId);
446
+ folders.push(folder);
447
+
448
+ // Check if this folder's parent is also a folder we need
449
+ if (folder.parentId && !pageIds.has(folder.parentId) && !potentialFolderIds.has(folder.parentId)) {
450
+ potentialFolderIds.add(folder.parentId);
451
+ }
452
+ } catch {
453
+ // Silently skip if folder fetch fails (might be deleted)
454
+ }
455
+ }
456
+
457
+ return folders;
458
+ }
459
+
460
+ /** Get all pages and folders in a space */
461
+ async getAllContentInSpace(spaceId: string): Promise<{ pages: Page[]; folders: Folder[] }> {
462
+ const pages = await this.getAllPagesInSpace(spaceId);
463
+ const folders = await this.discoverFolders(pages);
464
+ return { pages, folders };
465
+ }
466
+
467
+ /**
468
+ * Create a new folder (Effect version)
469
+ * Uses POST /wiki/api/v2/folders endpoint
470
+ */
471
+ createFolderEffect(
472
+ request: CreateFolderRequest,
473
+ ): Effect.Effect<Folder, ApiError | AuthError | NetworkError | RateLimitError> {
474
+ return createFolderEffectFn(this.baseUrl, this.authHeader, request);
475
+ }
476
+
477
+ /** Create a new folder (async version) */
478
+ async createFolder(request: CreateFolderRequest): Promise<Folder> {
479
+ return Effect.runPromise(this.createFolderEffect(request));
480
+ }
481
+
482
+ /**
483
+ * Find a folder by title in a space
484
+ * Uses v1 CQL search API to find folders by title
485
+ * @param spaceKey - Space key to search in
486
+ * @param title - Folder title to find
487
+ * @param parentId - Optional parent folder ID (for nested folders)
488
+ * @returns Folder if found, null otherwise
489
+ */
490
+ async findFolderByTitle(spaceKey: string, title: string, parentId?: string): Promise<Folder | null> {
491
+ return findFolderByTitleFn(this.baseUrl, this.authHeader, spaceKey, title, parentId);
492
+ }
493
+
494
+ /**
495
+ * Move a page to a new parent (Effect version)
496
+ * Uses v1 API: PUT /wiki/rest/api/content/{id}/move/{position}/{targetId}
497
+ */
498
+ movePageEffect(
499
+ pageId: string,
500
+ targetId: string,
501
+ position: 'append' | 'prepend' = 'append',
502
+ ): Effect.Effect<void, ApiError | AuthError | NetworkError | RateLimitError | PageNotFoundError> {
503
+ return movePageEffectFn(this.baseUrl, this.authHeader, pageId, targetId, position);
504
+ }
505
+
506
+ /** Move a page to a new parent (async version) */
507
+ async movePage(pageId: string, targetId: string, position: 'append' | 'prepend' = 'append'): Promise<void> {
508
+ return Effect.runPromise(this.movePageEffect(pageId, targetId, position));
509
+ }
510
+
511
+ // ================== Search API ==================
512
+
513
+ /** Search pages using CQL (Effect version) */
514
+ searchEffect(
515
+ cql: string,
516
+ limit = 10,
517
+ ): Effect.Effect<SearchResponse, ApiError | AuthError | NetworkError | RateLimitError> {
518
+ return searchEffectFn(this.baseUrl, this.authHeader, cql, limit);
519
+ }
520
+
521
+ /** Search pages using CQL (async version) */
522
+ async search(cql: string, limit = 10): Promise<SearchResponse> {
523
+ return Effect.runPromise(this.searchEffect(cql, limit));
524
+ }
525
+
526
+ // ================== Comments API ==================
527
+
528
+ /** Get footer comments for a page (Effect version) */
529
+ getFooterCommentsEffect(
530
+ pageId: string,
531
+ ): Effect.Effect<CommentsResponse, ApiError | AuthError | NetworkError | RateLimitError> {
532
+ return this.fetchWithRetryEffect(`/pages/${pageId}/footer-comments?body-format=storage`, CommentsResponseSchema);
533
+ }
534
+
535
+ /** Get footer comments for a page (async version) */
536
+ async getFooterComments(pageId: string): Promise<CommentsResponse> {
537
+ return Effect.runPromise(this.getFooterCommentsEffect(pageId));
538
+ }
539
+
540
+ /** Get all footer comments for a page with pagination (async version) */
541
+ async getAllFooterComments(pageId: string): Promise<Comment[]> {
542
+ return getAllFooterCommentsFn(this.baseUrl, this.authHeader, pageId);
543
+ }
544
+
545
+ // ================== Attachments API ==================
546
+
547
+ /** Get attachments for a page (Effect version) */
548
+ getAttachmentsEffect(
549
+ pageId: string,
550
+ ): Effect.Effect<AttachmentsResponse, ApiError | AuthError | NetworkError | RateLimitError> {
551
+ return getAttachmentsEffectFn(this.baseUrl, this.authHeader, pageId);
552
+ }
553
+
554
+ /** Get attachments for a page (async version) */
555
+ async getAttachments(pageId: string): Promise<AttachmentsResponse> {
556
+ return Effect.runPromise(this.getAttachmentsEffect(pageId));
557
+ }
558
+
559
+ /** Get all attachments for a page with pagination (async version) */
560
+ async getAllAttachments(pageId: string): Promise<Attachment[]> {
561
+ return getAllAttachmentsFn(this.baseUrl, this.authHeader, pageId);
562
+ }
563
+
564
+ /** Upload an attachment to a page (Effect version) */
565
+ uploadAttachmentEffect(
566
+ pageId: string,
567
+ filename: string,
568
+ data: Buffer,
569
+ mimeType: string,
570
+ ): Effect.Effect<void, ApiError | AuthError | NetworkError | RateLimitError> {
571
+ return uploadAttachmentEffectFn(this.baseUrl, this.authHeader, pageId, filename, data, mimeType);
572
+ }
573
+
574
+ /** Upload an attachment to a page (async version) */
575
+ async uploadAttachment(pageId: string, filename: string, data: Buffer, mimeType: string): Promise<void> {
576
+ return Effect.runPromise(this.uploadAttachmentEffect(pageId, filename, data, mimeType));
577
+ }
578
+
579
+ /** Download an attachment by its download link (async version) */
580
+ async downloadAttachment(downloadLink: string): Promise<Buffer> {
581
+ return downloadAttachmentFn(this.baseUrl, this.authHeader, downloadLink);
582
+ }
583
+
584
+ /** Delete an attachment (Effect version) */
585
+ deleteAttachmentEffect(
586
+ attachmentId: string,
587
+ ): Effect.Effect<void, ApiError | AuthError | NetworkError | RateLimitError> {
588
+ return deleteAttachmentEffectFn(this.baseUrl, this.authHeader, attachmentId);
589
+ }
590
+
591
+ /** Delete an attachment (async version) */
592
+ async deleteAttachment(attachmentId: string): Promise<void> {
593
+ return Effect.runPromise(this.deleteAttachmentEffect(attachmentId));
594
+ }
595
+
596
+ // ================== Label Mutations ==================
597
+
598
+ /** Add a label to a page (Effect version) */
599
+ addLabelEffect(
600
+ pageId: string,
601
+ labelName: string,
602
+ ): Effect.Effect<void, ApiError | AuthError | NetworkError | RateLimitError> {
603
+ return addLabelEffectFn(this.baseUrl, this.authHeader, pageId, labelName);
604
+ }
605
+
606
+ /** Add a label to a page (async version) */
607
+ async addLabel(pageId: string, labelName: string): Promise<void> {
608
+ return Effect.runPromise(this.addLabelEffect(pageId, labelName));
609
+ }
610
+
611
+ /** Remove a label from a page (Effect version) */
612
+ removeLabelEffect(
613
+ pageId: string,
614
+ labelName: string,
615
+ ): Effect.Effect<void, ApiError | AuthError | NetworkError | RateLimitError> {
616
+ return removeLabelEffectFn(this.baseUrl, this.authHeader, pageId, labelName);
617
+ }
618
+
619
+ /** Remove a label from a page (async version) */
620
+ async removeLabel(pageId: string, labelName: string): Promise<void> {
621
+ return Effect.runPromise(this.removeLabelEffect(pageId, labelName));
622
+ }
623
+
624
+ // ================== Page Deletion ==================
625
+
626
+ /** Delete a page (Effect version) */
627
+ deletePageEffect(
628
+ pageId: string,
629
+ ): Effect.Effect<void, ApiError | AuthError | NetworkError | RateLimitError | PageNotFoundError> {
630
+ return deletePageEffectFn(this.baseUrl, this.authHeader, pageId);
631
+ }
632
+
633
+ /** Delete a page (async version) */
634
+ async deletePage(pageId: string): Promise<void> {
635
+ return Effect.runPromise(this.deletePageEffect(pageId));
636
+ }
637
+
638
+ // ================== Verification ==================
639
+
640
+ /** Verify connection by fetching spaces (Effect version) */
641
+ verifyConnectionEffect(): Effect.Effect<boolean, ApiError | AuthError | NetworkError | RateLimitError> {
642
+ return pipe(
643
+ this.getSpacesEffect(1),
644
+ Effect.map(() => true),
645
+ );
646
+ }
647
+
648
+ /** Verify connection by fetching spaces (async version) */
649
+ async verifyConnection(): Promise<boolean> {
650
+ await this.getSpaces(1);
651
+ return true;
652
+ }
653
+ }