@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,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
|
+
}
|