@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,681 @@
1
+ import { existsSync, mkdirSync, readdirSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import {
4
+ ConfluenceClient,
5
+ type ContentItem,
6
+ type Page,
7
+ type PageTreeNode,
8
+ type User,
9
+ } from '../confluence-client/index.js';
10
+ import type { Config } from '../config.js';
11
+ import { SyncError } from '../errors.js';
12
+ import {
13
+ buildPageLookupMapFromCache,
14
+ MarkdownConverter,
15
+ slugify,
16
+ updateReferencesAfterRename,
17
+ } from '../markdown/index.js';
18
+ import { buildPageStateFromFiles, type PageStateCache } from '../page-state.js';
19
+ import {
20
+ createSpaceConfig,
21
+ readSpaceConfig,
22
+ updateFolderSyncInfo,
23
+ updateLastSync,
24
+ updatePageSyncInfo,
25
+ writeSpaceConfig,
26
+ type FolderSyncInfo,
27
+ type SpaceConfigWithState,
28
+ } from '../space-config.js';
29
+ import { assertPathWithinDirectory, generateFolderPath, wouldGenerateReservedFilename } from './folder-path.js';
30
+ import { cleanupOldFiles } from './cleanup.js';
31
+ import { resolveLinksSecondPass } from './link-resolution-pass.js';
32
+ import { syncSpecificPages } from './sync-specific.js';
33
+ import type { SyncChange, SyncDiff, SyncOptions, SyncResult } from './types.js';
34
+
35
+ export type { SyncChange, SyncDiff, SyncOptions, SyncProgressReporter, SyncResult } from './types.js';
36
+
37
+ /**
38
+ * SyncEngine handles syncing Confluence spaces to local directories
39
+ * Per ADR-0007: One-way sync from Confluence to local only
40
+ */
41
+ export class SyncEngine {
42
+ private client: ConfluenceClient;
43
+ private converter: MarkdownConverter;
44
+ private baseUrl: string;
45
+ private userCache = new Map<string, User | undefined>();
46
+
47
+ constructor(config: Config) {
48
+ this.client = new ConfluenceClient(config);
49
+ this.converter = new MarkdownConverter();
50
+ this.baseUrl = config.confluenceUrl;
51
+ }
52
+
53
+ /**
54
+ * Initialize sync for a space in the given directory
55
+ */
56
+ async initSync(directory: string, spaceKey: string): Promise<SpaceConfigWithState> {
57
+ // Get space info
58
+ const space = await this.client.getSpaceByKey(spaceKey);
59
+
60
+ // Create space config
61
+ const config = createSpaceConfig(space.key, space.id, space.name);
62
+
63
+ // Create directory if it doesn't exist
64
+ if (!existsSync(directory)) {
65
+ mkdirSync(directory, { recursive: true });
66
+ }
67
+
68
+ // Write config
69
+ writeSpaceConfig(directory, config);
70
+
71
+ return config;
72
+ }
73
+
74
+ /**
75
+ * Fetch the full page tree for a space
76
+ */
77
+ async fetchPageTree(spaceId: string): Promise<Page[]> {
78
+ return this.client.getAllPagesInSpace(spaceId);
79
+ }
80
+
81
+ /**
82
+ * Build a tree structure from flat page list
83
+ */
84
+ buildPageTree(pages: Page[]): PageTreeNode[] {
85
+ const pageMap = new Map<string, PageTreeNode>();
86
+ const roots: PageTreeNode[] = [];
87
+
88
+ // Create nodes for all pages
89
+ for (const page of pages) {
90
+ pageMap.set(page.id, { page, children: [] });
91
+ }
92
+
93
+ // Build tree structure
94
+ for (const page of pages) {
95
+ const node = pageMap.get(page.id);
96
+ if (!node) continue;
97
+ if (page.parentId && pageMap.has(page.parentId)) {
98
+ pageMap.get(page.parentId)?.children.push(node);
99
+ } else {
100
+ roots.push(node);
101
+ }
102
+ }
103
+
104
+ return roots;
105
+ }
106
+
107
+ /**
108
+ * Compute the diff between remote and local state
109
+ * Per ADR-0024: Uses PageStateCache for version comparison (frontmatter as source of truth)
110
+ * @param remotePages - Pages from Confluence API
111
+ * @param localConfig - Local space configuration with page mappings
112
+ * @param pageState - Optional PageStateCache with version info from frontmatter
113
+ * @param forcePageIds - Page IDs to force resync regardless of version
114
+ */
115
+ computeDiff(
116
+ remotePages: Page[],
117
+ localConfig: SpaceConfigWithState | null,
118
+ pageState?: PageStateCache,
119
+ forcePageIds?: Set<string>,
120
+ ): SyncDiff {
121
+ // Filter out archived pages - they should be treated as deleted locally
122
+ const currentPages = remotePages.filter((page) => page.status === 'current');
123
+
124
+ const diff: SyncDiff = {
125
+ added: [],
126
+ modified: [],
127
+ deleted: [],
128
+ };
129
+
130
+ const localPageMappings = localConfig?.pages || {};
131
+ const remotePageIds = new Set(currentPages.map((p) => p.id));
132
+
133
+ // Find added and modified pages
134
+ for (const page of currentPages) {
135
+ const localPath = localPageMappings[page.id];
136
+ const isForced = forcePageIds?.has(page.id);
137
+
138
+ if (!localPath) {
139
+ diff.added.push({
140
+ type: 'added',
141
+ pageId: page.id,
142
+ title: page.title,
143
+ });
144
+ } else {
145
+ const remoteVersion = page.version?.number || 0;
146
+ // Get local version from PageStateCache (read from frontmatter)
147
+ const localPageInfo = pageState?.pages.get(page.id);
148
+ const localVersion = localPageInfo?.version || 0;
149
+
150
+ // Include in modified if version changed OR if page is in forcePageIds
151
+ if (remoteVersion > localVersion || isForced) {
152
+ diff.modified.push({
153
+ type: 'modified',
154
+ pageId: page.id,
155
+ title: page.title,
156
+ localPath,
157
+ });
158
+ }
159
+ }
160
+ }
161
+
162
+ // Find deleted pages
163
+ for (const [pageId, localPath] of Object.entries(localPageMappings)) {
164
+ if (!remotePageIds.has(pageId)) {
165
+ diff.deleted.push({
166
+ type: 'deleted',
167
+ pageId,
168
+ title: localPath.split('/').pop()?.replace('.md', '') || pageId,
169
+ localPath,
170
+ });
171
+ }
172
+ }
173
+
174
+ return diff;
175
+ }
176
+
177
+ /**
178
+ * Safely fetch user information by account ID with caching
179
+ * Returns undefined if user cannot be fetched (e.g., user deleted, permissions, etc.)
180
+ */
181
+ private async fetchUser(accountId: string | undefined): Promise<User | undefined> {
182
+ if (!accountId) return undefined;
183
+ if (this.userCache.has(accountId)) {
184
+ return this.userCache.get(accountId);
185
+ }
186
+ try {
187
+ const user = await this.client.getUser(accountId);
188
+ this.userCache.set(accountId, user);
189
+ return user;
190
+ } catch {
191
+ // Silently fail if user cannot be fetched, but cache the failure
192
+ this.userCache.set(accountId, undefined);
193
+ return undefined;
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Generate the local path for a page based on hierarchy
199
+ * Per ADR-0005: Directory structure mirrors page tree
200
+ * Per ADR-0018: Handles folders as parents in the hierarchy
201
+ *
202
+ * Space homepage (root page) becomes README.md at root, its children are at root level
203
+ */
204
+ private generateLocalPath(
205
+ page: Page,
206
+ pages: Page[],
207
+ contentMap: Map<string, ContentItem>,
208
+ existingPaths: Set<string>,
209
+ homepageId?: string,
210
+ ): string {
211
+ // Space homepage becomes README.md at root
212
+ if (page.id === homepageId) {
213
+ const basePath = 'README.md';
214
+ existingPaths.add(basePath);
215
+ return basePath;
216
+ }
217
+
218
+ const parentChain: string[] = [];
219
+ let currentId: string | undefined | null = page.parentId;
220
+
221
+ // Build parent chain (can include both pages and folders)
222
+ // Skip the homepage - its children should be at root level
223
+ while (currentId && currentId !== homepageId) {
224
+ const parent = contentMap.get(currentId);
225
+ if (parent) {
226
+ parentChain.unshift(slugify(parent.title));
227
+ currentId = parent.parentId;
228
+ } else {
229
+ break;
230
+ }
231
+ }
232
+
233
+ // Check if page has children (pages or folders can be parents)
234
+ const hasChildren = pages.some((p) => p.parentId === page.id);
235
+ const slug = slugify(page.title);
236
+
237
+ let basePath: string;
238
+ if (hasChildren) {
239
+ // Pages with children use folder with README.md
240
+ basePath = [...parentChain, slug, 'README.md'].join('/');
241
+ } else {
242
+ // Leaf pages are single .md files
243
+ basePath = [...parentChain, `${slug}.md`].join('/');
244
+ }
245
+
246
+ // Handle conflicts by appending counter
247
+ if (existingPaths.has(basePath)) {
248
+ let counter = 2;
249
+ const ext = hasChildren ? '/README.md' : '.md';
250
+ const baseWithoutExt = hasChildren ? basePath.replace('/README.md', '') : basePath.replace('.md', '');
251
+
252
+ while (existingPaths.has(`${baseWithoutExt}-${counter}${ext}`)) {
253
+ counter++;
254
+ }
255
+ basePath = `${baseWithoutExt}-${counter}${ext}`;
256
+ }
257
+
258
+ existingPaths.add(basePath);
259
+ return basePath;
260
+ }
261
+
262
+ /**
263
+ * Sync a space to a local directory
264
+ */
265
+ async sync(directory: string, options: SyncOptions = {}): Promise<SyncResult> {
266
+ // Fast path: if only specific pages requested (not full force), use optimized method
267
+ if (options.forcePages && options.forcePages.length > 0 && !options.force) {
268
+ return syncSpecificPages(this.client, this.converter, this.baseUrl, directory, options.forcePages, options);
269
+ }
270
+
271
+ const result: SyncResult = {
272
+ success: true,
273
+ changes: { added: [], modified: [], deleted: [] },
274
+ warnings: [],
275
+ errors: [],
276
+ cancelled: false,
277
+ };
278
+ const progress = options.progress;
279
+ const signal = options.signal;
280
+
281
+ try {
282
+ // Read existing config
283
+ let config = readSpaceConfig(directory);
284
+ if (!config) {
285
+ throw new SyncError('No space configuration found. Run "cn sync --init <SPACE_KEY>" first.');
286
+ }
287
+
288
+ // Fetch all pages and folders (per ADR-0018)
289
+ progress?.onFetchStart?.();
290
+ const { pages: remotePages, folders } = await this.client.getAllContentInSpace(config.spaceId);
291
+ progress?.onFetchComplete?.(remotePages.length, folders.length);
292
+
293
+ // Build combined content map for parent lookup (includes both pages and folders)
294
+ const contentMap = new Map<string, ContentItem>();
295
+ for (const page of remotePages) {
296
+ contentMap.set(page.id, page);
297
+ }
298
+ for (const folder of folders) {
299
+ contentMap.set(folder.id, folder);
300
+ }
301
+
302
+ // Track discovered folders in config (ADR-0023)
303
+ // This enables push to reuse folder IDs for the same paths
304
+ for (const folder of folders) {
305
+ const folderPath = generateFolderPath(folder, contentMap);
306
+ const folderInfo: FolderSyncInfo = {
307
+ folderId: folder.id,
308
+ title: folder.title,
309
+ parentId: folder.parentId ?? undefined,
310
+ localPath: folderPath,
311
+ };
312
+ config = updateFolderSyncInfo(config, folderInfo);
313
+ }
314
+ // Save folder tracking immediately
315
+ if (folders.length > 0 && !options.dryRun) {
316
+ writeSpaceConfig(directory, config);
317
+ }
318
+
319
+ // Find the space homepage (root page with no parent)
320
+ // Homepage content goes to README.md, its children are at root level
321
+ const homepage = remotePages.find((p) => !p.parentId);
322
+ const homepageId = homepage?.id;
323
+
324
+ // For force sync, save old tracked pages for cleanup after successful download
325
+ // This ensures we don't delete files until new content is confirmed
326
+ const previouslyTrackedPages = options.force ? { ...config.pages } : {};
327
+ if (options.force && !options.dryRun) {
328
+ // Clear tracked pages so everything is treated as "added"
329
+ config = { ...config, pages: {} };
330
+ writeSpaceConfig(directory, config);
331
+ }
332
+
333
+ // Build PageStateCache for version comparison (ADR-0024)
334
+ // This reads frontmatter from all tracked files
335
+ const pageStateBuildResult = buildPageStateFromFiles(directory, config.pages);
336
+ const pageState = pageStateBuildResult;
337
+ // Add warnings from page state building (e.g., missing or malformed files)
338
+ result.warnings.push(...pageStateBuildResult.warnings);
339
+
340
+ // Resolve forcePages to page IDs (can be page IDs or local paths)
341
+ let forcePageIds: Set<string> | undefined;
342
+ if (options.forcePages && options.forcePages.length > 0) {
343
+ forcePageIds = new Set<string>();
344
+ // Build reverse lookup from localPath to pageId (pages is now Record<string, string>)
345
+ const pathToPageId = new Map<string, string>();
346
+ for (const [pageId, localPath] of Object.entries(config.pages)) {
347
+ pathToPageId.set(localPath, pageId);
348
+ }
349
+
350
+ for (const pageRef of options.forcePages) {
351
+ // Check if it's a page ID (exists in remote pages)
352
+ if (remotePages.some((p) => p.id === pageRef)) {
353
+ forcePageIds.add(pageRef);
354
+ }
355
+ // Check if it's a local path
356
+ else {
357
+ const pageId = pathToPageId.get(pageRef);
358
+ if (pageId !== undefined) {
359
+ forcePageIds.add(pageId);
360
+ }
361
+ // Try normalizing the path (remove leading ./)
362
+ else {
363
+ const normalizedPath = pageRef.replace(/^\.\//, '');
364
+ const normalizedPageId = pathToPageId.get(normalizedPath);
365
+ if (normalizedPageId !== undefined) {
366
+ forcePageIds.add(normalizedPageId);
367
+ } else {
368
+ result.warnings.push(`Could not find page for: ${pageRef}`);
369
+ }
370
+ }
371
+ }
372
+ }
373
+ }
374
+
375
+ // Compute diff (pass pageState for version comparison from frontmatter)
376
+ const diff = options.force
377
+ ? {
378
+ added: remotePages.map((p) => ({ type: 'added' as const, pageId: p.id, title: p.title })),
379
+ modified: [],
380
+ deleted: [],
381
+ }
382
+ : this.computeDiff(remotePages, config, pageState, forcePageIds);
383
+
384
+ result.changes = diff;
385
+ progress?.onDiffComplete?.(diff.added.length, diff.modified.length, diff.deleted.length);
386
+
387
+ // If dry run, return without applying changes
388
+ if (options.dryRun) {
389
+ return result;
390
+ }
391
+
392
+ // Track existing paths for conflict resolution (pages is now Record<string, string>)
393
+ const existingPaths = new Set<string>();
394
+ for (const localPath of Object.values(config.pages)) {
395
+ existingPaths.add(localPath);
396
+ }
397
+
398
+ // Build page lookup map for link conversion (ADR-0022)
399
+ // Reuse the already-built pageState to avoid duplicate file I/O
400
+ // Enable duplicate title warnings during sync
401
+ const pageLookupMap = buildPageLookupMapFromCache(pageState, true);
402
+
403
+ // Pre-compute child counts for all pages (O(n) instead of O(n^2))
404
+ const childCountMap = new Map<string, number>();
405
+ for (const p of remotePages) {
406
+ if (p.parentId) {
407
+ childCountMap.set(p.parentId, (childCountMap.get(p.parentId) || 0) + 1);
408
+ }
409
+ }
410
+
411
+ // Calculate total changes for progress
412
+ const totalChanges = diff.added.length + diff.modified.length + diff.deleted.length;
413
+ let currentChange = 0;
414
+
415
+ // Process added pages
416
+ for (const change of diff.added) {
417
+ // Yield to event loop to allow signal handlers to run (Bun native)
418
+ await Bun.sleep(0);
419
+
420
+ // Check for cancellation signal
421
+ if (signal?.cancelled) {
422
+ result.cancelled = true;
423
+ break;
424
+ }
425
+ currentChange++;
426
+ progress?.onPageStart?.(currentChange, totalChanges, change.title, 'added');
427
+ try {
428
+ const page = remotePages.find((p) => p.id === change.pageId);
429
+ if (!page) continue;
430
+
431
+ // Skip reserved filenames BEFORE generating path (avoids polluting existingPaths)
432
+ // Check early to avoid unnecessary API calls
433
+ if (wouldGenerateReservedFilename(page.title)) {
434
+ result.warnings.push(`Skipping page "${page.title}": would generate reserved filename`);
435
+ continue;
436
+ }
437
+
438
+ // Get full page content
439
+ const fullPage = await this.client.getPage(page.id, true);
440
+
441
+ // Get labels
442
+ const labels = await this.client.getAllLabels(page.id);
443
+
444
+ // Get parent title (can be page or folder)
445
+ const parentTitle = page.parentId ? contentMap.get(page.parentId)?.title : undefined;
446
+
447
+ // Get author and last modifier user information
448
+ const author = await this.fetchUser(fullPage.authorId);
449
+ const lastModifier = await this.fetchUser(fullPage.version?.authorId);
450
+
451
+ // Generate local path first (needed for link conversion)
452
+ const localPath = this.generateLocalPath(page, remotePages, contentMap, existingPaths, homepageId);
453
+ (change as SyncChange).localPath = localPath;
454
+
455
+ // Get child count from pre-computed map (defaults to 0 if no children)
456
+ const childCount = childCountMap.get(page.id) ?? 0;
457
+
458
+ // Convert to markdown with link conversion (ADR-0022)
459
+ const { markdown, warnings } = this.converter.convertPage(
460
+ fullPage,
461
+ labels,
462
+ parentTitle,
463
+ this.baseUrl,
464
+ author,
465
+ lastModifier,
466
+ localPath,
467
+ pageLookupMap,
468
+ childCount,
469
+ );
470
+ result.warnings.push(...warnings.map((w) => `${page.title}: ${w}`));
471
+
472
+ // Validate path stays within directory (prevents path traversal)
473
+ assertPathWithinDirectory(directory, localPath);
474
+
475
+ // Write file
476
+ const fullPath = join(directory, localPath);
477
+ mkdirSync(dirname(fullPath), { recursive: true });
478
+ writeFileSync(fullPath, markdown, 'utf-8');
479
+
480
+ // Update sync state and save immediately (for resume support)
481
+ // Per ADR-0024: Only store pageId -> localPath mapping
482
+ // Version, title, timestamps are in frontmatter
483
+ config = updatePageSyncInfo(config, { pageId: page.id, localPath });
484
+ writeSpaceConfig(directory, config);
485
+ progress?.onPageComplete?.(currentChange, totalChanges, change.title, localPath);
486
+ } catch (error) {
487
+ const errorMsg = `Failed to sync page "${change.title}": ${error}`;
488
+ result.errors.push(errorMsg);
489
+ result.success = false;
490
+ progress?.onPageError?.(change.title, String(error));
491
+ }
492
+ }
493
+
494
+ // Process modified pages
495
+ for (const change of diff.modified) {
496
+ // Yield to event loop to allow signal handlers to run (Bun native)
497
+ await Bun.sleep(0);
498
+
499
+ // Check for cancellation signal
500
+ if (signal?.cancelled) {
501
+ result.cancelled = true;
502
+ break;
503
+ }
504
+ currentChange++;
505
+ progress?.onPageStart?.(currentChange, totalChanges, change.title, 'modified');
506
+ try {
507
+ const page = remotePages.find((p) => p.id === change.pageId);
508
+ if (!page) continue;
509
+
510
+ // Skip reserved filenames BEFORE generating path (avoids polluting existingPaths)
511
+ // Check early to avoid unnecessary API calls
512
+ if (wouldGenerateReservedFilename(page.title)) {
513
+ result.warnings.push(`Skipping page "${page.title}": would generate reserved filename`);
514
+ continue;
515
+ }
516
+
517
+ // Get full page content
518
+ const fullPage = await this.client.getPage(page.id, true);
519
+
520
+ // Get labels
521
+ const labels = await this.client.getAllLabels(page.id);
522
+
523
+ // Get parent title (can be page or folder)
524
+ const parentTitle = page.parentId ? contentMap.get(page.parentId)?.title : undefined;
525
+
526
+ // Get author and last modifier user information
527
+ const author = await this.fetchUser(fullPage.authorId);
528
+ const lastModifier = await this.fetchUser(fullPage.version?.authorId);
529
+
530
+ // Always generate path based on current title/hierarchy
531
+ // This handles title changes by moving files to new locations
532
+ const newPath = this.generateLocalPath(page, remotePages, contentMap, existingPaths, homepageId);
533
+ const oldPath = change.localPath;
534
+
535
+ // Get child count from pre-computed map (defaults to 0 if no children)
536
+ const childCount = childCountMap.get(page.id) ?? 0;
537
+
538
+ // Convert to markdown with link conversion (ADR-0022)
539
+ // Use newPath for link conversion since that's where the file will be
540
+ const { markdown, warnings } = this.converter.convertPage(
541
+ fullPage,
542
+ labels,
543
+ parentTitle,
544
+ this.baseUrl,
545
+ author,
546
+ lastModifier,
547
+ newPath,
548
+ pageLookupMap,
549
+ childCount,
550
+ );
551
+ result.warnings.push(...warnings.map((w) => `${page.title}: ${w}`));
552
+
553
+ // If path changed (title or parent changed), update references and delete old file
554
+ if (oldPath && oldPath !== newPath) {
555
+ assertPathWithinDirectory(directory, oldPath);
556
+ const oldFullPath = join(directory, oldPath);
557
+ if (existsSync(oldFullPath)) {
558
+ // Update references in other files (ADR-0022)
559
+ const referenceUpdates = updateReferencesAfterRename(directory, oldPath, newPath);
560
+ if (referenceUpdates.length > 0) {
561
+ const details = referenceUpdates
562
+ .map((u) => `${u.filePath} (${u.updatedCount} ref${u.updatedCount !== 1 ? 's' : ''})`)
563
+ .join(', ');
564
+ result.warnings.push(`Updated references to renamed page "${page.title}":\n ${details}`);
565
+ }
566
+
567
+ unlinkSync(oldFullPath);
568
+ // Clean up empty parent directories
569
+ let parentDir = dirname(oldFullPath);
570
+ while (parentDir !== directory) {
571
+ if (existsSync(parentDir) && readdirSync(parentDir).length === 0) {
572
+ rmSync(parentDir, { recursive: true });
573
+ parentDir = dirname(parentDir);
574
+ } else {
575
+ break;
576
+ }
577
+ }
578
+ }
579
+ }
580
+
581
+ // Validate new path stays within directory
582
+ assertPathWithinDirectory(directory, newPath);
583
+
584
+ // Write file at new location
585
+ const fullPath = join(directory, newPath);
586
+ mkdirSync(dirname(fullPath), { recursive: true });
587
+ writeFileSync(fullPath, markdown, 'utf-8');
588
+
589
+ // Update sync state and save immediately (for resume support)
590
+ // Per ADR-0024: Only store pageId -> localPath mapping
591
+ // Version, title, timestamps are in frontmatter
592
+ config = updatePageSyncInfo(config, { pageId: page.id, localPath: newPath });
593
+ writeSpaceConfig(directory, config);
594
+ progress?.onPageComplete?.(currentChange, totalChanges, change.title, newPath);
595
+ } catch (error) {
596
+ const errorMsg = `Failed to update page "${change.title}": ${error}`;
597
+ result.errors.push(errorMsg);
598
+ result.success = false;
599
+ progress?.onPageError?.(change.title, String(error));
600
+ }
601
+ }
602
+
603
+ // Process deleted pages
604
+ for (const change of diff.deleted) {
605
+ // Yield to event loop to allow signal handlers to run (Bun native)
606
+ await Bun.sleep(0);
607
+
608
+ // Check for cancellation signal
609
+ if (signal?.cancelled) {
610
+ result.cancelled = true;
611
+ break;
612
+ }
613
+ currentChange++;
614
+ progress?.onPageStart?.(currentChange, totalChanges, change.title, 'deleted');
615
+ try {
616
+ if (change.localPath) {
617
+ // Validate path stays within directory (prevents path traversal)
618
+ assertPathWithinDirectory(directory, change.localPath);
619
+
620
+ const fullPath = join(directory, change.localPath);
621
+ if (existsSync(fullPath)) {
622
+ unlinkSync(fullPath);
623
+
624
+ // Clean up empty parent directories
625
+ let parentDir = dirname(fullPath);
626
+ while (parentDir !== directory) {
627
+ if (existsSync(parentDir) && readdirSync(parentDir).length === 0) {
628
+ rmSync(parentDir, { recursive: true });
629
+ parentDir = dirname(parentDir);
630
+ } else {
631
+ break;
632
+ }
633
+ }
634
+ }
635
+
636
+ // Remove from sync state and save immediately (for resume support)
637
+ const { [change.pageId]: _, ...remainingPages } = config.pages;
638
+ config = { ...config, pages: remainingPages };
639
+ writeSpaceConfig(directory, config);
640
+ }
641
+ progress?.onPageComplete?.(currentChange, totalChanges, change.title, change.localPath || '');
642
+ } catch (error) {
643
+ const errorMsg = `Failed to delete page "${change.title}": ${error}`;
644
+ result.errors.push(errorMsg);
645
+ result.success = false;
646
+ progress?.onPageError?.(change.title, String(error));
647
+ }
648
+ }
649
+
650
+ // For force sync: clean up old files that weren't re-downloaded
651
+ if (options.force && !result.cancelled && Object.keys(previouslyTrackedPages).length > 0) {
652
+ const cleanupWarnings = cleanupOldFiles(directory, previouslyTrackedPages, config.pages);
653
+ result.warnings.push(...cleanupWarnings);
654
+ }
655
+
656
+ // Second pass: resolve links that couldn't be resolved in first pass
657
+ // Per ADR-0022: Links may fail in first pass if target pages haven't been pulled yet
658
+ // Only run if pages were added or modified and not cancelled
659
+ if (!result.cancelled && (diff.added.length > 0 || diff.modified.length > 0)) {
660
+ const linkResolution = resolveLinksSecondPass(directory, config);
661
+ result.warnings.push(...linkResolution.warnings);
662
+
663
+ // Add info message about resolved links (shown as a "warning" for visibility)
664
+ if (linkResolution.filesUpdated > 0) {
665
+ result.warnings.push(
666
+ `Second pass: Resolved ${linkResolution.linksResolved} link${linkResolution.linksResolved !== 1 ? 's' : ''} in ${linkResolution.filesUpdated} file${linkResolution.filesUpdated !== 1 ? 's' : ''}`,
667
+ );
668
+ }
669
+ }
670
+
671
+ // Update last sync time and save config
672
+ config = updateLastSync(config);
673
+ writeSpaceConfig(directory, config);
674
+ } catch (error) {
675
+ result.errors.push(`Sync failed: ${error}`);
676
+ result.success = false;
677
+ }
678
+
679
+ return result;
680
+ }
681
+ }