@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,264 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { Schema } from 'effect';
4
+
5
+ /**
6
+ * Legacy page sync info schema for migration detection
7
+ * Per ADR-0024: This format is deprecated in favor of frontmatter as source of truth
8
+ * @deprecated Use page mappings (pageId -> localPath) instead
9
+ */
10
+ const LegacyPageSyncInfoSchema = Schema.Struct({
11
+ pageId: Schema.String,
12
+ version: Schema.Number,
13
+ lastModified: Schema.optional(Schema.String),
14
+ localPath: Schema.String,
15
+ title: Schema.optional(Schema.String),
16
+ });
17
+
18
+ /**
19
+ * Legacy page sync information - kept for migration support
20
+ * Per ADR-0024: Version, title, lastModified now read from frontmatter
21
+ * @deprecated Use page mappings and read frontmatter for full info
22
+ */
23
+ export type PageSyncInfo = Schema.Schema.Type<typeof LegacyPageSyncInfoSchema>;
24
+
25
+ /**
26
+ * Folder sync info schema for tracking created folders
27
+ * Per ADR-0023: Folder push workflow support
28
+ */
29
+ const FolderSyncInfoSchema = Schema.Struct({
30
+ folderId: Schema.String,
31
+ title: Schema.String,
32
+ parentId: Schema.optional(Schema.String),
33
+ localPath: Schema.String, // Directory path, e.g., "docs/api"
34
+ });
35
+
36
+ /**
37
+ * Folder sync information stored in .confluence.json
38
+ * Per ADR-0023: Folder push workflow support
39
+ */
40
+ export type FolderSyncInfo = Schema.Schema.Type<typeof FolderSyncInfoSchema>;
41
+
42
+ /**
43
+ * Space configuration schema
44
+ */
45
+ const SpaceConfigSchema = Schema.Struct({
46
+ spaceKey: Schema.String,
47
+ spaceId: Schema.String,
48
+ spaceName: Schema.String,
49
+ lastSync: Schema.optional(Schema.String),
50
+ syncState: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Any })),
51
+ });
52
+
53
+ export type SpaceConfig = Schema.Schema.Type<typeof SpaceConfigSchema>;
54
+
55
+ /**
56
+ * Full space configuration schema including sync state
57
+ * Per ADR-0024: pages is now a simple mapping (pageId -> localPath)
58
+ * Version, title, and timestamps are read from frontmatter when needed
59
+ */
60
+ const SpaceConfigWithStateSchema = Schema.Struct({
61
+ spaceKey: Schema.String,
62
+ spaceId: Schema.String,
63
+ spaceName: Schema.String,
64
+ lastSync: Schema.optional(Schema.String),
65
+ syncState: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Any })),
66
+ pages: Schema.Record({ key: Schema.String, value: Schema.String }), // pageId -> localPath
67
+ folders: Schema.optional(Schema.Record({ key: Schema.String, value: FolderSyncInfoSchema })),
68
+ });
69
+
70
+ /**
71
+ * Full space configuration including sync state
72
+ * Per ADR-0024: pages is now Record<string, string> (pageId -> localPath)
73
+ */
74
+ export type SpaceConfigWithState = Schema.Schema.Type<typeof SpaceConfigWithStateSchema>;
75
+
76
+ /**
77
+ * Detect if pages object is in legacy format (object values vs string values)
78
+ */
79
+ function isLegacyFormat(pages: Record<string, unknown>): boolean {
80
+ const firstValue = Object.values(pages)[0];
81
+ return typeof firstValue === 'object' && firstValue !== null;
82
+ }
83
+
84
+ /**
85
+ * Migrate legacy pages format to new format
86
+ * Legacy: { pageId: { pageId, version, localPath, ... } }
87
+ * New: { pageId: localPath }
88
+ */
89
+ function migrateLegacyPages(pages: Record<string, unknown>): Record<string, string> {
90
+ const migrated: Record<string, string> = {};
91
+ for (const [id, page] of Object.entries(pages)) {
92
+ if (typeof page === 'object' && page !== null && 'localPath' in page) {
93
+ migrated[id] = (page as { localPath: string }).localPath;
94
+ }
95
+ }
96
+ return migrated;
97
+ }
98
+
99
+ const CONFIG_FILENAME = '.confluence.json';
100
+
101
+ /**
102
+ * Read space configuration from the current directory
103
+ * Validates the config against the schema for security
104
+ * Per ADR-0024: Auto-migrates legacy format to new format
105
+ */
106
+ export function readSpaceConfig(directory: string): SpaceConfigWithState | null {
107
+ const configPath = join(directory, CONFIG_FILENAME);
108
+
109
+ if (!existsSync(configPath)) {
110
+ return null;
111
+ }
112
+
113
+ try {
114
+ const content = readFileSync(configPath, 'utf-8');
115
+ const parsed = JSON.parse(content);
116
+
117
+ // Check for legacy format and migrate if needed (ADR-0024)
118
+ // Note: If multiple processes read the config simultaneously during migration,
119
+ // both may attempt to write. This is acceptable for a CLI tool since:
120
+ // 1. Migration is idempotent (same result regardless of which write wins)
121
+ // 2. Concurrent CLI invocations on the same space are rare in practice
122
+ if (parsed.pages && Object.keys(parsed.pages).length > 0 && isLegacyFormat(parsed.pages)) {
123
+ const migratedPages = migrateLegacyPages(parsed.pages);
124
+ const migrated = { ...parsed, pages: migratedPages };
125
+
126
+ // Write migrated config back to disk
127
+ writeFileSync(configPath, JSON.stringify(migrated, null, 2), 'utf-8');
128
+
129
+ // Validate and return migrated config
130
+ return Schema.decodeUnknownSync(SpaceConfigWithStateSchema)(migrated);
131
+ }
132
+
133
+ // Validate against schema to prevent malformed config attacks
134
+ return Schema.decodeUnknownSync(SpaceConfigWithStateSchema)(parsed);
135
+ } catch {
136
+ // Invalid config file - return null to indicate no valid config
137
+ return null;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Write space configuration to the current directory
143
+ */
144
+ export function writeSpaceConfig(directory: string, config: SpaceConfigWithState): void {
145
+ const configPath = join(directory, CONFIG_FILENAME);
146
+ writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
147
+ }
148
+
149
+ /**
150
+ * Check if a directory has space configuration
151
+ */
152
+ export function hasSpaceConfig(directory: string): boolean {
153
+ return existsSync(join(directory, CONFIG_FILENAME));
154
+ }
155
+
156
+ /**
157
+ * Create initial space configuration
158
+ */
159
+ export function createSpaceConfig(spaceKey: string, spaceId: string, spaceName: string): SpaceConfigWithState {
160
+ return {
161
+ spaceKey,
162
+ spaceId,
163
+ spaceName,
164
+ pages: {},
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Update the last sync time in the configuration
170
+ */
171
+ export function updateLastSync(config: SpaceConfigWithState): SpaceConfigWithState {
172
+ return {
173
+ ...config,
174
+ lastSync: new Date().toISOString(),
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Add or update a page in the sync state
180
+ * Per ADR-0024: Only stores the mapping (pageId -> localPath)
181
+ * Version, title, timestamps are read from frontmatter when needed
182
+ *
183
+ * @param config - Current space configuration
184
+ * @param pageInfo - Page info (only pageId and localPath are stored)
185
+ */
186
+ export function updatePageSyncInfo(
187
+ config: SpaceConfigWithState,
188
+ pageInfo: { pageId: string; localPath: string },
189
+ ): SpaceConfigWithState {
190
+ return {
191
+ ...config,
192
+ pages: {
193
+ ...config.pages,
194
+ [pageInfo.pageId]: pageInfo.localPath,
195
+ },
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Remove a page from the sync state
201
+ */
202
+ export function removePageSyncInfo(config: SpaceConfigWithState, pageId: string): SpaceConfigWithState {
203
+ const { [pageId]: _, ...remainingPages } = config.pages;
204
+ return {
205
+ ...config,
206
+ pages: remainingPages,
207
+ };
208
+ }
209
+
210
+ /**
211
+ * Get all tracked page IDs
212
+ */
213
+ export function getTrackedPageIds(config: SpaceConfigWithState): string[] {
214
+ return Object.keys(config.pages);
215
+ }
216
+
217
+ /**
218
+ * Add or update a folder in the sync state
219
+ * Per ADR-0023: Folder push workflow support
220
+ */
221
+ export function updateFolderSyncInfo(config: SpaceConfigWithState, folderInfo: FolderSyncInfo): SpaceConfigWithState {
222
+ return {
223
+ ...config,
224
+ folders: {
225
+ ...(config.folders || {}),
226
+ [folderInfo.folderId]: folderInfo,
227
+ },
228
+ };
229
+ }
230
+
231
+ /**
232
+ * Get a folder by its local path
233
+ * Per ADR-0023: Folder push workflow support
234
+ */
235
+ export function getFolderByPath(config: SpaceConfigWithState, localPath: string): FolderSyncInfo | undefined {
236
+ if (!config.folders) return undefined;
237
+ for (const folder of Object.values(config.folders)) {
238
+ if (folder.localPath === localPath) {
239
+ return folder;
240
+ }
241
+ }
242
+ return undefined;
243
+ }
244
+
245
+ /**
246
+ * Get a folder by its ID
247
+ * Per ADR-0023: Folder push workflow support
248
+ */
249
+ export function getFolderById(config: SpaceConfigWithState, folderId: string): FolderSyncInfo | undefined {
250
+ return config.folders?.[folderId];
251
+ }
252
+
253
+ /**
254
+ * Remove a folder from the sync state
255
+ * Per ADR-0023: Folder push workflow support
256
+ */
257
+ export function removeFolderSyncInfo(config: SpaceConfigWithState, folderId: string): SpaceConfigWithState {
258
+ if (!config.folders) return config;
259
+ const { [folderId]: _, ...remainingFolders } = config.folders;
260
+ return {
261
+ ...config,
262
+ folders: remainingFolders,
263
+ };
264
+ }
@@ -0,0 +1,50 @@
1
+ import { existsSync, readdirSync, rmSync, unlinkSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { assertPathWithinDirectory } from './folder-path.js';
4
+
5
+ /**
6
+ * Clean up old files that weren't re-downloaded during force sync
7
+ * Per ADR-0024: previouslyTrackedPages is Record<string, string> (pageId -> localPath)
8
+ *
9
+ * @param directory - Space directory
10
+ * @param previouslyTrackedPages - Pages tracked before force sync
11
+ * @param currentPages - Current page mappings after sync
12
+ * @returns Array of warning messages for failed cleanups
13
+ */
14
+ export function cleanupOldFiles(
15
+ directory: string,
16
+ previouslyTrackedPages: Record<string, string>,
17
+ currentPages: Record<string, string>,
18
+ ): string[] {
19
+ const warnings: string[] = [];
20
+ const newTrackedPaths = new Set(Object.values(currentPages));
21
+
22
+ for (const [pageId, localPath] of Object.entries(previouslyTrackedPages)) {
23
+ // Skip if this path was re-used by a new page
24
+ if (newTrackedPaths.has(localPath)) continue;
25
+ // Skip if page was re-downloaded (exists in new config)
26
+ if (currentPages[pageId]) continue;
27
+
28
+ try {
29
+ assertPathWithinDirectory(directory, localPath);
30
+ const fullPath = join(directory, localPath);
31
+ if (existsSync(fullPath)) {
32
+ unlinkSync(fullPath);
33
+ // Clean up empty parent directories
34
+ let parentDir = dirname(fullPath);
35
+ while (parentDir !== directory) {
36
+ if (existsSync(parentDir) && readdirSync(parentDir).length === 0) {
37
+ rmSync(parentDir, { recursive: true });
38
+ parentDir = dirname(parentDir);
39
+ } else {
40
+ break;
41
+ }
42
+ }
43
+ }
44
+ } catch (err) {
45
+ warnings.push(`Failed to clean up old file ${localPath}: ${err}`);
46
+ }
47
+ }
48
+
49
+ return warnings;
50
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Path and folder utilities for sync operations
3
+ * Per ADR-0023: Folder push workflow support
4
+ */
5
+
6
+ import { resolve } from 'node:path';
7
+ import type { ContentItem, Folder } from '../confluence-client/index.js';
8
+ import { SyncError } from '../errors.js';
9
+ import { RESERVED_FILENAMES } from '../file-scanner.js';
10
+ import { slugify } from '../markdown/index.js';
11
+
12
+ /**
13
+ * Validate that a path stays within a base directory (prevents path traversal)
14
+ * @throws SyncError if path escapes the base directory
15
+ */
16
+ export function assertPathWithinDirectory(baseDir: string, targetPath: string): void {
17
+ const resolvedBase = resolve(baseDir);
18
+ const resolvedTarget = resolve(baseDir, targetPath);
19
+ if (!resolvedTarget.startsWith(`${resolvedBase}/`) && resolvedTarget !== resolvedBase) {
20
+ throw new SyncError(`Path traversal detected: "${targetPath}" escapes base directory`);
21
+ }
22
+ }
23
+
24
+ /** Check if page title would generate a reserved filename (CLAUDE.md, AGENTS.md) */
25
+ export const wouldGenerateReservedFilename = (title: string): boolean => RESERVED_FILENAMES.has(`${slugify(title)}.md`);
26
+
27
+ /**
28
+ * Generate the local path for a folder based on hierarchy
29
+ * Per ADR-0023: Folder push workflow support
30
+ */
31
+ export function generateFolderPath(folder: Folder, contentMap: Map<string, ContentItem>): string {
32
+ const parentChain: string[] = [];
33
+ let currentId: string | undefined | null = folder.parentId;
34
+
35
+ // Track visited IDs to prevent infinite loops from circular references
36
+ const visited = new Set<string>();
37
+
38
+ // Build parent chain
39
+ while (currentId && !visited.has(currentId)) {
40
+ visited.add(currentId);
41
+ const parent = contentMap.get(currentId);
42
+ if (parent) {
43
+ parentChain.unshift(slugify(parent.title));
44
+ currentId = parent.parentId;
45
+ } else {
46
+ break;
47
+ }
48
+ }
49
+
50
+ // Warn if we detected a circular reference (loop exited because of visited check)
51
+ if (currentId && visited.has(currentId)) {
52
+ console.warn(
53
+ `Warning: Circular reference detected in folder hierarchy for "${folder.title}" (id: ${folder.id}). Path may be truncated.`,
54
+ );
55
+ }
56
+
57
+ // Add this folder's slug
58
+ parentChain.push(slugify(folder.title));
59
+
60
+ return parentChain.join('/');
61
+ }
@@ -0,0 +1,2 @@
1
+ export { SyncEngine } from './sync-engine.js';
2
+ export type { SyncChange, SyncDiff, SyncOptions, SyncProgressReporter, SyncResult } from './types.js';
@@ -0,0 +1,139 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { buildPageLookupMapFromCache, confluenceLinkToRelativePath } from '../markdown/link-converter.js';
4
+ import { buildPageStateFromFiles, type PageStateCache } from '../page-state.js';
5
+ import type { SpaceConfigWithState } from '../space-config.js';
6
+
7
+ /**
8
+ * Decode common HTML entities in a string
9
+ * Handles the most common entities: &amp; &lt; &gt; &quot; &#39;
10
+ */
11
+ function decodeHtmlEntities(text: string): string {
12
+ return text
13
+ .replace(/&amp;/g, '&')
14
+ .replace(/&lt;/g, '<')
15
+ .replace(/&gt;/g, '>')
16
+ .replace(/&quot;/g, '"')
17
+ .replace(/&#39;/g, "'")
18
+ .replace(/&#x27;/g, "'");
19
+ }
20
+
21
+ /**
22
+ * Result of second pass link resolution
23
+ */
24
+ export interface LinkResolutionResult {
25
+ filesUpdated: number;
26
+ linksResolved: number;
27
+ warnings: string[];
28
+ }
29
+
30
+ /**
31
+ * Extract unresolved Confluence links from markdown content
32
+ * These are links that couldn't be resolved in the first pass and remain as:
33
+ * - Raw HTML: <ac:link><ri:page ri:content-title="..."/></ac:link>
34
+ * - Confluence URLs: [text](https://site.atlassian.net/wiki/...)
35
+ *
36
+ * @param content - Markdown content to scan
37
+ * @returns Array of {title, fullMatch} for each unresolved link
38
+ */
39
+ function extractUnresolvedLinks(content: string): Array<{ title: string; fullMatch: string; linkText: string }> {
40
+ const unresolvedLinks: Array<{ title: string; fullMatch: string; linkText: string }> = [];
41
+
42
+ // Match <ac:link> elements with ri:page
43
+ // Pattern: <ac:link>...<ri:page ri:content-title="Title" .../>...</ac:link>
44
+ const acLinkPattern =
45
+ /<ac:link>\s*<ri:page[^>]*ri:content-title=["']([^"']+)["'][^>]*\/>\s*(?:<ac:plain-text-link-body><!\[CDATA\[([^\]]*)\]\]><\/ac:plain-text-link-body>)?\s*<\/ac:link>/g;
46
+
47
+ let match: RegExpExecArray | null;
48
+ while ((match = acLinkPattern.exec(content)) !== null) {
49
+ // Decode HTML entities in title (e.g., "Page &amp; Info" -> "Page & Info")
50
+ const decodedTitle = decodeHtmlEntities(match[1]);
51
+ const decodedLinkText = match[2] ? decodeHtmlEntities(match[2]) : decodedTitle;
52
+
53
+ unresolvedLinks.push({
54
+ title: decodedTitle,
55
+ fullMatch: match[0],
56
+ linkText: decodedLinkText,
57
+ });
58
+ }
59
+
60
+ return unresolvedLinks;
61
+ }
62
+
63
+ /**
64
+ * Perform second pass link resolution on all markdown files
65
+ *
66
+ * After the initial pull, some links couldn't be resolved because their target pages
67
+ * hadn't been pulled yet. This function rebuilds the page lookup map and resolves
68
+ * those links.
69
+ *
70
+ * Per ADR-0022: Converts Confluence page links to relative markdown paths
71
+ *
72
+ * @param directory - Space directory containing markdown files
73
+ * @param config - Space configuration with page mappings
74
+ * @returns Result with count of files and links updated
75
+ */
76
+ export function resolveLinksSecondPass(directory: string, config: SpaceConfigWithState): LinkResolutionResult {
77
+ const result: LinkResolutionResult = {
78
+ filesUpdated: 0,
79
+ linksResolved: 0,
80
+ warnings: [],
81
+ };
82
+
83
+ // Rebuild page state cache from all files
84
+ const pageStateBuildResult = buildPageStateFromFiles(directory, config.pages);
85
+ const pageState: PageStateCache = pageStateBuildResult;
86
+ result.warnings.push(...pageStateBuildResult.warnings);
87
+
88
+ // Rebuild page lookup map with complete set of pages
89
+ const pageLookupMap = buildPageLookupMapFromCache(pageState, false);
90
+
91
+ // Scan all tracked pages for unresolved links
92
+ for (const [_pageId, localPath] of Object.entries(config.pages)) {
93
+ const fullPath = join(directory, localPath);
94
+
95
+ let content: string;
96
+ try {
97
+ content = readFileSync(fullPath, 'utf-8');
98
+ } catch (error) {
99
+ result.warnings.push(`Could not read ${localPath}: ${error}`);
100
+ continue;
101
+ }
102
+
103
+ // Find unresolved links in this file
104
+ const unresolvedLinks = extractUnresolvedLinks(content);
105
+ if (unresolvedLinks.length === 0) {
106
+ continue; // No unresolved links, skip this file
107
+ }
108
+
109
+ // Try to resolve each link
110
+ let updatedContent = content;
111
+ let linksResolvedInFile = 0;
112
+
113
+ for (const link of unresolvedLinks) {
114
+ // Try to convert to relative path
115
+ const relativePath = confluenceLinkToRelativePath(link.title, localPath, pageLookupMap);
116
+
117
+ if (relativePath) {
118
+ // Replace the unresolved link with markdown link
119
+ const markdownLink = `[${link.linkText}](${relativePath})`;
120
+ updatedContent = updatedContent.replace(link.fullMatch, markdownLink);
121
+ linksResolvedInFile++;
122
+ }
123
+ // If still can't resolve, leave it as-is (page might be in different space or deleted)
124
+ }
125
+
126
+ // Write updated content if any links were resolved
127
+ if (linksResolvedInFile > 0) {
128
+ try {
129
+ writeFileSync(fullPath, updatedContent, 'utf-8');
130
+ result.filesUpdated++;
131
+ result.linksResolved += linksResolvedInFile;
132
+ } catch (error) {
133
+ result.warnings.push(`Failed to update ${localPath}: ${error}`);
134
+ }
135
+ }
136
+ }
137
+
138
+ return result;
139
+ }