@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.
- package/LICENSE +21 -0
- package/README.md +69 -0
- package/package.json +73 -0
- package/src/cli/commands/attachments.ts +113 -0
- package/src/cli/commands/clone.ts +188 -0
- package/src/cli/commands/comments.ts +56 -0
- package/src/cli/commands/create.ts +58 -0
- package/src/cli/commands/delete.ts +46 -0
- package/src/cli/commands/doctor.ts +161 -0
- package/src/cli/commands/duplicate-check.ts +89 -0
- package/src/cli/commands/file-rename.ts +113 -0
- package/src/cli/commands/folder-hierarchy.ts +241 -0
- package/src/cli/commands/info.ts +56 -0
- package/src/cli/commands/labels.ts +53 -0
- package/src/cli/commands/move.ts +23 -0
- package/src/cli/commands/open.ts +145 -0
- package/src/cli/commands/pull.ts +241 -0
- package/src/cli/commands/push-errors.ts +40 -0
- package/src/cli/commands/push.ts +699 -0
- package/src/cli/commands/search.ts +62 -0
- package/src/cli/commands/setup.ts +124 -0
- package/src/cli/commands/spaces.ts +42 -0
- package/src/cli/commands/status.ts +88 -0
- package/src/cli/commands/tree.ts +190 -0
- package/src/cli/help.ts +425 -0
- package/src/cli/index.ts +413 -0
- package/src/cli/utils/browser.ts +34 -0
- package/src/cli/utils/progress-reporter.ts +49 -0
- package/src/cli.ts +6 -0
- package/src/lib/config.ts +156 -0
- package/src/lib/confluence-client/attachment-operations.ts +221 -0
- package/src/lib/confluence-client/client.ts +653 -0
- package/src/lib/confluence-client/comment-operations.ts +60 -0
- package/src/lib/confluence-client/folder-operations.ts +203 -0
- package/src/lib/confluence-client/index.ts +47 -0
- package/src/lib/confluence-client/label-operations.ts +102 -0
- package/src/lib/confluence-client/page-operations.ts +270 -0
- package/src/lib/confluence-client/search-operations.ts +60 -0
- package/src/lib/confluence-client/types.ts +329 -0
- package/src/lib/confluence-client/user-operations.ts +58 -0
- package/src/lib/dependency-sorter.ts +233 -0
- package/src/lib/errors.ts +237 -0
- package/src/lib/file-scanner.ts +195 -0
- package/src/lib/formatters.ts +314 -0
- package/src/lib/health-check.ts +204 -0
- package/src/lib/markdown/converter.ts +427 -0
- package/src/lib/markdown/frontmatter.ts +116 -0
- package/src/lib/markdown/html-converter.ts +398 -0
- package/src/lib/markdown/index.ts +21 -0
- package/src/lib/markdown/link-converter.ts +189 -0
- package/src/lib/markdown/reference-updater.ts +251 -0
- package/src/lib/markdown/slugify.ts +32 -0
- package/src/lib/page-state.ts +195 -0
- package/src/lib/resolve-page-target.ts +33 -0
- package/src/lib/space-config.ts +264 -0
- package/src/lib/sync/cleanup.ts +50 -0
- package/src/lib/sync/folder-path.ts +61 -0
- package/src/lib/sync/index.ts +2 -0
- package/src/lib/sync/link-resolution-pass.ts +139 -0
- package/src/lib/sync/sync-engine.ts +681 -0
- package/src/lib/sync/sync-specific.ts +221 -0
- package/src/lib/sync/types.ts +42 -0
- package/src/test/attachments.test.ts +68 -0
- package/src/test/clone.test.ts +373 -0
- package/src/test/comments.test.ts +53 -0
- package/src/test/config.test.ts +209 -0
- package/src/test/confluence-client.test.ts +535 -0
- package/src/test/delete.test.ts +39 -0
- package/src/test/dependency-sorter.test.ts +384 -0
- package/src/test/errors.test.ts +199 -0
- package/src/test/file-rename.test.ts +305 -0
- package/src/test/file-scanner.test.ts +331 -0
- package/src/test/folder-hierarchy.test.ts +337 -0
- package/src/test/formatters.test.ts +213 -0
- package/src/test/html-converter.test.ts +399 -0
- package/src/test/info.test.ts +56 -0
- package/src/test/labels.test.ts +70 -0
- package/src/test/link-conversion-integration.test.ts +189 -0
- package/src/test/link-converter.test.ts +413 -0
- package/src/test/link-resolution-pass.test.ts +368 -0
- package/src/test/markdown.test.ts +443 -0
- package/src/test/mocks/handlers.ts +228 -0
- package/src/test/move.test.ts +53 -0
- package/src/test/msw-schema-validation.ts +151 -0
- package/src/test/page-state.test.ts +542 -0
- package/src/test/push.test.ts +551 -0
- package/src/test/reference-updater.test.ts +293 -0
- package/src/test/resolve-page-target.test.ts +55 -0
- package/src/test/search.test.ts +64 -0
- package/src/test/setup-msw.ts +75 -0
- package/src/test/space-config.test.ts +516 -0
- package/src/test/spaces.test.ts +53 -0
- package/src/test/sync-engine.test.ts +486 -0
- 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,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: & < > " '
|
|
10
|
+
*/
|
|
11
|
+
function decodeHtmlEntities(text: string): string {
|
|
12
|
+
return text
|
|
13
|
+
.replace(/&/g, '&')
|
|
14
|
+
.replace(/</g, '<')
|
|
15
|
+
.replace(/>/g, '>')
|
|
16
|
+
.replace(/"/g, '"')
|
|
17
|
+
.replace(/'/g, "'")
|
|
18
|
+
.replace(/'/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 & 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
|
+
}
|