@iwonz/ywtc 0.0.0-stage → 2.0.0

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/.env.example +4 -0
  2. package/CHANGELOG.md +39 -0
  3. package/LICENSE +21 -0
  4. package/README.md +259 -2
  5. package/dist/adapters/confluence.d.ts +46 -0
  6. package/dist/adapters/confluence.js +187 -0
  7. package/dist/adapters/confluence.js.map +1 -0
  8. package/dist/adapters/factory.d.ts +3 -0
  9. package/dist/adapters/factory.js +11 -0
  10. package/dist/adapters/factory.js.map +1 -0
  11. package/dist/adapters/yandex.d.ts +45 -0
  12. package/dist/adapters/yandex.js +233 -0
  13. package/dist/adapters/yandex.js.map +1 -0
  14. package/dist/cli-arguments.d.ts +41 -0
  15. package/dist/cli-arguments.js +149 -0
  16. package/dist/cli-arguments.js.map +1 -0
  17. package/dist/cli.d.ts +2 -0
  18. package/dist/cli.js +180 -0
  19. package/dist/cli.js.map +1 -0
  20. package/dist/clients/confluence-cloud.d.ts +45 -0
  21. package/dist/clients/confluence-cloud.js +261 -0
  22. package/dist/clients/confluence-cloud.js.map +1 -0
  23. package/dist/clients/confluence-factory.d.ts +3 -0
  24. package/dist/clients/confluence-factory.js +8 -0
  25. package/dist/clients/confluence-factory.js.map +1 -0
  26. package/dist/clients/confluence.d.ts +42 -0
  27. package/dist/clients/confluence.js +286 -0
  28. package/dist/clients/confluence.js.map +1 -0
  29. package/dist/clients/yandex-wiki.d.ts +35 -0
  30. package/dist/clients/yandex-wiki.js +255 -0
  31. package/dist/clients/yandex-wiki.js.map +1 -0
  32. package/dist/config.d.ts +52 -0
  33. package/dist/config.js +177 -0
  34. package/dist/config.js.map +1 -0
  35. package/dist/content-model.d.ts +12 -0
  36. package/dist/content-model.js +416 -0
  37. package/dist/content-model.js.map +1 -0
  38. package/dist/conversion.d.ts +43 -0
  39. package/dist/conversion.js +450 -0
  40. package/dist/conversion.js.map +1 -0
  41. package/dist/doctor.d.ts +24 -0
  42. package/dist/doctor.js +45 -0
  43. package/dist/doctor.js.map +1 -0
  44. package/dist/domain.d.ts +202 -0
  45. package/dist/domain.js +18 -0
  46. package/dist/domain.js.map +1 -0
  47. package/dist/filesystem-export.d.ts +53 -0
  48. package/dist/filesystem-export.js +243 -0
  49. package/dist/filesystem-export.js.map +1 -0
  50. package/dist/graph.d.ts +13 -0
  51. package/dist/graph.js +121 -0
  52. package/dist/graph.js.map +1 -0
  53. package/dist/http.d.ts +26 -0
  54. package/dist/http.js +100 -0
  55. package/dist/http.js.map +1 -0
  56. package/dist/index.d.ts +24 -0
  57. package/dist/index.js +25 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/input.d.ts +4 -0
  60. package/dist/input.js +56 -0
  61. package/dist/input.js.map +1 -0
  62. package/dist/locators.d.ts +24 -0
  63. package/dist/locators.js +84 -0
  64. package/dist/locators.js.map +1 -0
  65. package/dist/logger.d.ts +7 -0
  66. package/dist/logger.js +28 -0
  67. package/dist/logger.js.map +1 -0
  68. package/dist/migrator.d.ts +53 -0
  69. package/dist/migrator.js +669 -0
  70. package/dist/migrator.js.map +1 -0
  71. package/dist/rate-limiter.d.ts +12 -0
  72. package/dist/rate-limiter.js +31 -0
  73. package/dist/rate-limiter.js.map +1 -0
  74. package/dist/report.d.ts +101 -0
  75. package/dist/report.js +106 -0
  76. package/dist/report.js.map +1 -0
  77. package/dist/state.d.ts +119 -0
  78. package/dist/state.js +201 -0
  79. package/dist/state.js.map +1 -0
  80. package/dist/types.d.ts +197 -0
  81. package/dist/types.js +2 -0
  82. package/dist/types.js.map +1 -0
  83. package/dist/updater.d.ts +24 -0
  84. package/dist/updater.js +92 -0
  85. package/dist/updater.js.map +1 -0
  86. package/dist/version.d.ts +1 -0
  87. package/dist/version.js +8 -0
  88. package/dist/version.js.map +1 -0
  89. package/dist/workspace.d.ts +29 -0
  90. package/dist/workspace.js +157 -0
  91. package/dist/workspace.js.map +1 -0
  92. package/docs/assets/readme-header.png +0 -0
  93. package/package.json +110 -4
  94. package/ywtc.config.example.yaml +52 -0
@@ -0,0 +1,669 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import PQueue from 'p-queue';
5
+ import { createProviderAdapter } from './adapters/factory.js';
6
+ import { appendGridSnapshots, normalizePageContent, normalizedPreviewHtml, renderedPreviewHtml, renderConfluenceStorage, renderYfm, } from './content-model.js';
7
+ import { assertTransferCapabilities } from './domain.js';
8
+ import { FilesystemExporter } from './filesystem-export.js';
9
+ import { discoverPageGraph } from './graph.js';
10
+ import { safeFilename } from './input.js';
11
+ import { profileBaseUrl, sameRemoteInstance } from './locators.js';
12
+ import { TransferReport } from './report.js';
13
+ import { resolveStateLocation, StateStore, } from './state.js';
14
+ function sourceIdentity(locator, root) {
15
+ return {
16
+ profile: locator.profile.name,
17
+ provider: locator.profile.type,
18
+ instance: profileBaseUrl(locator.profile),
19
+ objectId: root.id,
20
+ ...(root.space ? { containerId: root.space } : {}),
21
+ };
22
+ }
23
+ function destinationIdentity(locator, container) {
24
+ return {
25
+ profile: locator.profile.name,
26
+ provider: locator.profile.type,
27
+ instance: profileBaseUrl(locator.profile),
28
+ objectId: container.id,
29
+ ...(container.space ? { containerId: container.space } : {}),
30
+ };
31
+ }
32
+ function sha256(value) {
33
+ return createHash('sha256').update(value).digest('hex');
34
+ }
35
+ function sourceFingerprint(page) {
36
+ return sha256(`${page.title}\0${page.rawContent}\0${page.version ?? ''}`);
37
+ }
38
+ function cachedPagePath(cacheDirectory, canonicalId) {
39
+ return path.join(cacheDirectory, 'pages', `${sha256(canonicalId)}.json`);
40
+ }
41
+ function cachedDocumentPath(cacheDirectory, canonicalId) {
42
+ return path.join(cacheDirectory, 'documents', `${sha256(canonicalId)}.json`);
43
+ }
44
+ async function cacheDiscoveredPages(cacheDirectory, graph) {
45
+ await mkdir(path.join(cacheDirectory, 'pages'), { recursive: true, mode: 0o700 });
46
+ const fingerprints = new Map();
47
+ await Promise.all(graph.pages.map(async (page) => {
48
+ fingerprints.set(page.canonicalId, sourceFingerprint(page));
49
+ await writeFile(cachedPagePath(cacheDirectory, page.canonicalId), `${JSON.stringify(page)}\n`, { mode: 0o600 });
50
+ page.rawContent = '';
51
+ }));
52
+ return fingerprints;
53
+ }
54
+ async function loadCachedPage(cacheDirectory, canonicalId) {
55
+ return JSON.parse(await readFile(cachedPagePath(cacheDirectory, canonicalId), 'utf8'));
56
+ }
57
+ async function cacheDocument(cacheDirectory, canonicalId, document) {
58
+ const filename = cachedDocumentPath(cacheDirectory, canonicalId);
59
+ await mkdir(path.dirname(filename), { recursive: true, mode: 0o700 });
60
+ await writeFile(filename, `${JSON.stringify(document)}\n`, { mode: 0o600 });
61
+ }
62
+ async function loadCachedDocument(cacheDirectory, canonicalId) {
63
+ return JSON.parse(await readFile(cachedDocumentPath(cacheDirectory, canonicalId), 'utf8'));
64
+ }
65
+ function placeholder(adapter, page) {
66
+ return adapter.kind === 'confluence'
67
+ ? `<p><em>ywtc transfer in progress from <a href="${page.url.replaceAll('&', '&amp;').replaceAll('"', '&quot;')}">${page.provider}</a>.</em></p>`
68
+ : `_ywtc transfer in progress from [${page.provider}](${page.url})._`;
69
+ }
70
+ function collisionTitle(page) {
71
+ const suffix = page.locator.split('/').filter(Boolean).at(-1) ?? page.id;
72
+ return `${page.title} [${suffix}]`;
73
+ }
74
+ function cachePath(cacheDirectory, page, resource) {
75
+ const pageKey = sha256(page.canonicalId).slice(0, 12);
76
+ const resourceKey = sha256(`${resource.id}:${resource.name}`).slice(0, 12);
77
+ return path.join(cacheDirectory, pageKey, `${resourceKey}-${safeFilename(resource.name)}`);
78
+ }
79
+ function destinationFromState(state, adapter) {
80
+ if (!state.destinationId || !state.destinationCanonicalId || !state.destinationLocator) {
81
+ throw new Error(`Incomplete mapped destination for ${state.sourceTitle}`);
82
+ }
83
+ return {
84
+ provider: adapter.kind,
85
+ profile: adapter.profile.name,
86
+ id: state.destinationId,
87
+ canonicalId: state.destinationCanonicalId,
88
+ title: state.destinationTitle,
89
+ url: state.destinationLocator,
90
+ locator: state.destinationId,
91
+ ...(state.destinationFingerprint ? { fingerprint: state.destinationFingerprint } : {}),
92
+ };
93
+ }
94
+ function reportPage(input) {
95
+ return {
96
+ sourceCanonicalId: input.page.canonicalId,
97
+ sourceId: input.page.id,
98
+ sourceTitle: input.page.title,
99
+ sourceUrl: input.page.url,
100
+ ...(input.destination ? {
101
+ destinationId: input.destination.id,
102
+ destinationTitle: input.destination.title,
103
+ destinationUrl: input.destination.url,
104
+ } : {}),
105
+ ...(input.page.projectedParentId ? { parentId: input.page.projectedParentId } : {}),
106
+ depth: input.page.depth,
107
+ status: input.status,
108
+ resources: input.resources.filter((resource) => resource.kind !== 'grid').length,
109
+ bytes: input.resources.reduce((total, resource) => total + (resource.size ?? 0), 0),
110
+ links: input.document.metrics.links,
111
+ images: input.document.metrics.images,
112
+ tables: input.document.metrics.tables,
113
+ codeBlocks: input.document.metrics.codeBlocks,
114
+ degraded: input.document.degraded,
115
+ warnings: [...input.document.warnings, ...(input.warnings ?? [])],
116
+ ...(input.error ? { error: input.error } : {}),
117
+ };
118
+ }
119
+ function runStatus(errors, warnings) {
120
+ if (errors > 0)
121
+ return 'partial';
122
+ return warnings > 0 ? 'completed-with-warnings' : 'completed';
123
+ }
124
+ export class Migrator {
125
+ config;
126
+ logger;
127
+ adapterFactory;
128
+ constructor(config, logger, adapterFactory = createProviderAdapter) {
129
+ this.config = config;
130
+ this.logger = logger;
131
+ this.adapterFactory = adapterFactory;
132
+ }
133
+ async run(options) {
134
+ const source = this.adapterFactory(options.source.profile, this.config);
135
+ const destination = options.destination.type === 'remote'
136
+ ? this.adapterFactory(options.destination.profile, this.config)
137
+ : undefined;
138
+ assertTransferCapabilities(source, destination, options.scope);
139
+ const status = options.onStatus ?? (() => undefined);
140
+ status('Checking source and destination capabilities');
141
+ const [sourcePlatform, destinationPlatform, root, container] = await Promise.all([
142
+ source.preflight(),
143
+ destination?.preflight(),
144
+ source.resolvePage(options.source.value),
145
+ options.destination.type === 'remote' && destination ? destination.resolveContainer(options.destination.value) : undefined,
146
+ ]);
147
+ status(`Connected to ${sourcePlatform.product}${destinationPlatform ? ` and ${destinationPlatform.product}` : ''}`);
148
+ const identity = {
149
+ source: sourceIdentity(options.source, root),
150
+ destination: options.destination.type === 'directory'
151
+ ? { provider: 'filesystem', path: options.destination.path, format: options.destination.format }
152
+ : destinationIdentity(options.destination, container),
153
+ };
154
+ const stateLocation = resolveStateLocation(identity, {
155
+ runtimeDirectory: options.runtimeDirectory,
156
+ ...(options.stateFilename ? { stateFilename: options.stateFilename } : {}),
157
+ });
158
+ const store = new StateStore(stateLocation.filename);
159
+ await store.load(identity, { fresh: options.fresh ?? false, dryRun: options.dryRun });
160
+ if (!options.dryRun)
161
+ await store.save();
162
+ const run = await store.beginRun(options.scope, options.dryRun);
163
+ const report = new TransferReport({
164
+ dryRun: options.dryRun,
165
+ scope: options.scope,
166
+ direction: `${source.kind} → ${options.destination.type === 'directory' ? options.destination.format : destination.kind}`,
167
+ source: { provider: source.kind, profile: source.profile.name, rootId: root.id, title: root.title, url: root.url },
168
+ destination: options.destination.type === 'directory'
169
+ ? { provider: 'filesystem', id: options.destination.path, format: options.destination.format }
170
+ : { provider: destination.kind, profile: destination.profile.name, id: container.id, url: container.url },
171
+ state: {
172
+ id: stateLocation.id,
173
+ path: stateLocation.filename,
174
+ mode: stateLocation.mode,
175
+ source: store.loadInfo.source,
176
+ ...(store.loadInfo.backupFilename ? { backupPath: store.loadInfo.backupFilename } : {}),
177
+ },
178
+ });
179
+ let graph;
180
+ let sourceFingerprints;
181
+ try {
182
+ status(`Discovering ${options.scope} scope from ${root.title}`);
183
+ graph = await discoverPageGraph({
184
+ adapter: source,
185
+ root,
186
+ scope: options.scope,
187
+ ...(options.signal ? { signal: options.signal } : {}),
188
+ onProgress: (progress) => status(`Discovery: ${progress.pages} pages, ${progress.edges} links${progress.active ? ` · ${progress.active}` : ''}`),
189
+ });
190
+ if (container
191
+ && options.destination.type === 'remote'
192
+ && sameRemoteInstance(options.source.profile, options.destination.profile)
193
+ && graph.pages.some((page) => page.id === container.id)) {
194
+ throw new Error('The destination page belongs to the selected source graph. Choose a destination outside the transferred graph.');
195
+ }
196
+ status(`Caching source content for ${graph.pages.length} pages`);
197
+ sourceFingerprints = await cacheDiscoveredPages(options.cacheDirectory, graph);
198
+ await store.setGraph(graph.rootId, graph.edges);
199
+ report.setEdges(graph.edges);
200
+ for (const warning of graph.warnings)
201
+ report.warning(warning);
202
+ }
203
+ catch (error) {
204
+ const finalStatus = options.signal?.aborted ? 'cancelled' : 'failed';
205
+ const message = error instanceof Error ? error.message : String(error);
206
+ report.error(message);
207
+ report.finish(finalStatus);
208
+ const reportPaths = await report.write(options.reportsDirectory);
209
+ if (!options.dryRun)
210
+ await store.finishRun(run.id, finalStatus, { pages: 0, warnings: 0, errors: 1 });
211
+ throw new Error(`${message}. State: ${stateLocation.filename}. Report: ${reportPaths.html}`, { cause: error });
212
+ }
213
+ const resourcesByPage = new Map();
214
+ const inventoryQueue = new PQueue({ concurrency: options.concurrency });
215
+ const inventory = { resources: 0, bytes: 0, grids: 0, links: 0, images: 0, tables: 0, codeBlocks: 0 };
216
+ status(`Inspecting content and resources for ${graph.pages.length} pages`);
217
+ const inventoryErrors = [];
218
+ await Promise.all(graph.pages.map((page) => inventoryQueue.add(async () => {
219
+ try {
220
+ if (options.signal?.aborted)
221
+ throw options.signal.reason ?? new Error('Transfer cancelled');
222
+ const cachedPage = await loadCachedPage(options.cacheDirectory, page.canonicalId);
223
+ const resources = await source.listResources(page);
224
+ resourcesByPage.set(page.canonicalId, resources);
225
+ const document = appendGridSnapshots(normalizePageContent(cachedPage), resources);
226
+ await cacheDocument(options.cacheDirectory, page.canonicalId, document);
227
+ inventory.resources += resources.filter((resource) => resource.kind !== 'grid').length;
228
+ inventory.bytes += resources.reduce((total, resource) => total + (resource.size ?? 0), 0);
229
+ inventory.grids += resources.filter((resource) => resource.kind === 'grid').length;
230
+ inventory.links += document.metrics.links;
231
+ inventory.images += document.metrics.images;
232
+ inventory.tables += document.metrics.tables;
233
+ inventory.codeBlocks += document.metrics.codeBlocks;
234
+ status(`Inspected ${page.title} · ${resources.length} resources`);
235
+ }
236
+ catch (error) {
237
+ const message = `${page.title}: ${error instanceof Error ? error.message : String(error)}`;
238
+ inventoryErrors.push(message);
239
+ report.error(message);
240
+ }
241
+ })));
242
+ if (inventoryErrors.length > 0) {
243
+ const finalStatus = options.signal?.aborted ? 'cancelled' : 'failed';
244
+ report.finish(finalStatus);
245
+ const reportPaths = await report.write(options.reportsDirectory);
246
+ if (!options.dryRun)
247
+ await store.finishRun(run.id, finalStatus, { pages: graph.pages.length, warnings: graph.warnings.length, errors: inventoryErrors.length });
248
+ throw new Error(`Resource preflight failed before destination mutation. State: ${stateLocation.filename}. Report: ${reportPaths.html}`);
249
+ }
250
+ const planSummary = `Plan ready: ${graph.pages.length} pages · ${inventory.resources} files (${inventory.bytes} bytes) · ${inventory.links} links · ${inventory.images} images · ${inventory.tables} tables (${inventory.grids} grids) · ${inventory.codeBlocks} code blocks`;
251
+ status(planSummary);
252
+ this.logger.info(planSummary);
253
+ let output;
254
+ let archive;
255
+ let transferErrors = 0;
256
+ let transferWarnings = graph.warnings.length;
257
+ try {
258
+ if (options.destination.type === 'directory') {
259
+ const result = await this.exportFilesystem(source, graph, options.destination, options, stateLocation.id, store, report, sourceFingerprints, resourcesByPage);
260
+ output = result.directory;
261
+ archive = result.archive;
262
+ transferErrors += result.errors.length;
263
+ transferWarnings += result.warnings.length;
264
+ report.output('directory', result.directory);
265
+ if (result.archive)
266
+ report.output('archive', result.archive);
267
+ if (result.backup)
268
+ report.output('backup', result.backup);
269
+ }
270
+ else {
271
+ const result = await this.transferRemote({
272
+ source,
273
+ destination: destination,
274
+ container: container,
275
+ graph,
276
+ options,
277
+ store,
278
+ report,
279
+ sourceFingerprints,
280
+ resourcesByPage,
281
+ });
282
+ transferErrors += result.errors;
283
+ transferWarnings += result.warnings;
284
+ output = container.url;
285
+ report.output('destination', container.url);
286
+ }
287
+ const finalStatus = runStatus(transferErrors, transferWarnings);
288
+ report.finish(finalStatus);
289
+ const reportPaths = await report.write(options.reportsDirectory);
290
+ if (!options.dryRun)
291
+ await store.finishRun(run.id, finalStatus, { pages: graph.pages.length, warnings: transferWarnings, errors: transferErrors });
292
+ status(`${finalStatus}: ${graph.pages.length} pages · ${transferErrors} errors · ${transferWarnings} warnings`);
293
+ return {
294
+ status: finalStatus,
295
+ hasErrors: transferErrors > 0,
296
+ reportPaths,
297
+ stateId: stateLocation.id,
298
+ stateFilename: stateLocation.filename,
299
+ ...(store.loadInfo.backupFilename ? { stateBackupFilename: store.loadInfo.backupFilename } : {}),
300
+ ...(output ? { output } : {}),
301
+ ...(archive ? { archive } : {}),
302
+ summary: { ...report.data.summary, warnings: transferWarnings, errors: transferErrors },
303
+ };
304
+ }
305
+ catch (error) {
306
+ const cancelled = options.signal?.aborted;
307
+ const finalStatus = cancelled ? 'cancelled' : 'failed';
308
+ const message = error instanceof Error ? error.message : String(error);
309
+ report.error(message);
310
+ report.finish(finalStatus);
311
+ const reportPaths = await report.write(options.reportsDirectory);
312
+ if (!options.dryRun)
313
+ await store.finishRun(run.id, finalStatus, { pages: graph.pages.length, warnings: transferWarnings, errors: transferErrors + 1 });
314
+ this.logger.error(`${message}. Report: ${reportPaths.html}`);
315
+ throw error;
316
+ }
317
+ }
318
+ async exportFilesystem(source, graph, destination, options, identity, store, report, sourceFingerprints, resourcesByPage) {
319
+ const exporter = new FilesystemExporter(source, options.concurrency);
320
+ const totalResources = [...resourcesByPage.values()].reduce((total, resources) => total + resources.filter((resource) => resource.kind !== 'grid').length, 0);
321
+ const total = Math.max(1, graph.pages.length + totalResources);
322
+ let processed = 0;
323
+ const result = await exporter.export({
324
+ graph,
325
+ directory: destination.path,
326
+ format: destination.format,
327
+ identity,
328
+ archive: options.archive,
329
+ dryRun: options.dryRun,
330
+ cacheDirectory: options.cacheDirectory,
331
+ ...(options.signal ? { signal: options.signal } : {}),
332
+ ...(options.onStatus ? { onItem: (message) => {
333
+ processed += 1;
334
+ options.onStatus?.(`Progress ${Math.min(100, Math.floor((processed / total) * 100))}% · ${processed}/${total} · ${message}`);
335
+ } } : {}),
336
+ resourcesByPage,
337
+ loadPage: (page) => loadCachedPage(options.cacheDirectory, page.canonicalId),
338
+ loadDocument: (page) => loadCachedDocument(options.cacheDirectory, page.canonicalId),
339
+ });
340
+ for (const page of graph.pages) {
341
+ const document = await loadCachedDocument(options.cacheDirectory, page.canonicalId);
342
+ const manifestPage = result.manifest.pages.find((item) => item.sourceCanonicalId === page.canonicalId);
343
+ const previous = store.value.pages[page.canonicalId];
344
+ const resources = Object.fromEntries((manifestPage?.resources ?? []).map((resource, index) => [String(index), {
345
+ sourceId: String(index),
346
+ sourceName: resource.name,
347
+ destinationId: resource.path,
348
+ destinationName: resource.name,
349
+ ...(resource.sha256 ? { sha256: resource.sha256 } : {}),
350
+ ...(resource.size !== undefined ? { size: resource.size } : {}),
351
+ status: 'complete',
352
+ updatedAt: new Date().toISOString(),
353
+ }]));
354
+ await store.setPage({
355
+ sourceId: page.id,
356
+ sourceCanonicalId: page.canonicalId,
357
+ sourceLocator: page.locator,
358
+ sourceTitle: page.title,
359
+ ...(page.projectedParentId ? { projectedParentId: page.projectedParentId } : {}),
360
+ ...(manifestPage?.path || previous?.destinationId ? { destinationId: manifestPage?.path ?? previous.destinationId } : {}),
361
+ ...(manifestPage?.path || previous?.destinationCanonicalId ? { destinationCanonicalId: manifestPage?.path ? `filesystem:${manifestPage.path}` : previous.destinationCanonicalId } : {}),
362
+ ...(manifestPage || previous?.destinationLocator ? { destinationLocator: manifestPage ? path.join(destination.path, manifestPage.path) : previous.destinationLocator } : {}),
363
+ destinationTitle: page.title,
364
+ sourceFingerprint: sourceFingerprints.get(page.canonicalId),
365
+ ...(manifestPage || previous?.destinationFingerprint ? { destinationFingerprint: manifestPage ? sha256(JSON.stringify(manifestPage)) : previous.destinationFingerprint } : {}),
366
+ status: options.dryRun ? 'planned' : manifestPage?.degraded ? 'partial' : 'complete',
367
+ resources,
368
+ degradations: manifestPage?.warnings ?? [],
369
+ updatedAt: new Date().toISOString(),
370
+ });
371
+ report.addPage(reportPage({
372
+ page,
373
+ status: options.dryRun ? 'planned' : result.errors.length ? 'partial' : 'complete',
374
+ resources: resourcesByPage.get(page.canonicalId) ?? [],
375
+ document,
376
+ ...(manifestPage?.warnings ? { warnings: manifestPage.warnings } : {}),
377
+ }), { source: normalizedPreviewHtml(document), destination: normalizedPreviewHtml(document) });
378
+ }
379
+ for (const warning of result.warnings)
380
+ report.warning(warning);
381
+ for (const error of result.errors)
382
+ report.error(error);
383
+ return result;
384
+ }
385
+ async transferRemote(input) {
386
+ const { source, destination, container, graph, options, store, report, sourceFingerprints, resourcesByPage } = input;
387
+ const destinationPages = new Map();
388
+ let errors = 0;
389
+ let warnings = 0;
390
+ const totalResources = [...resourcesByPage.values()].reduce((total, resources) => total + resources.filter((resource) => resource.kind !== 'grid').length, 0);
391
+ const progress = { skeletons: 0, pages: 0, resources: 0 };
392
+ const totalUnits = Math.max(1, graph.pages.length * 2 + totalResources);
393
+ const updateProgress = (active) => {
394
+ const completed = progress.skeletons + progress.pages + progress.resources;
395
+ options.onStatus?.(`Progress ${Math.min(100, Math.floor((completed / totalUnits) * 100))}% · pages ${progress.pages}/${graph.pages.length} · files ${progress.resources}/${totalResources} · ${active}`);
396
+ };
397
+ for (const page of graph.pages) {
398
+ if (options.signal?.aborted)
399
+ throw options.signal.reason ?? new Error('Transfer cancelled');
400
+ const saved = store.value.pages[page.canonicalId];
401
+ const parent = page.projectedParentId ? destinationPages.get(page.projectedParentId) : container;
402
+ if (!parent) {
403
+ const message = `Destination parent is unavailable for ${page.title}`;
404
+ errors += 1;
405
+ report.error(message);
406
+ progress.skeletons += 1;
407
+ updateProgress(message);
408
+ continue;
409
+ }
410
+ try {
411
+ let created;
412
+ let title = saved?.destinationTitle ?? page.title;
413
+ if (saved?.destinationId) {
414
+ created = await destination.verifyPage(destinationFromState(saved, destination));
415
+ if (['complete', 'partial'].includes(saved.status) && saved.destinationFingerprint && created.fingerprint !== saved.destinationFingerprint) {
416
+ throw new Error(`Destination conflict for ${page.title}: content changed since the last verified run`);
417
+ }
418
+ }
419
+ else if (options.dryRun) {
420
+ created = {
421
+ provider: destination.kind,
422
+ profile: destination.profile.name,
423
+ id: `dry-run:${page.id}`,
424
+ canonicalId: `dry-run:${page.canonicalId}`,
425
+ title,
426
+ url: container.url,
427
+ locator: `dry-run:${page.id}`,
428
+ ...(container.space ? { space: container.space } : {}),
429
+ };
430
+ }
431
+ else {
432
+ if (await destination.findChild(parent, title)) {
433
+ const base = collisionTitle(page);
434
+ const hash = sha256(page.canonicalId).slice(0, 8);
435
+ title = base;
436
+ let sequence = 0;
437
+ while (await destination.findChild(parent, title)) {
438
+ sequence += 1;
439
+ title = `${base} [${hash}${sequence > 1 ? `-${sequence}` : ''}]`;
440
+ }
441
+ }
442
+ created = await destination.createPage({ container: parent, title, body: placeholder(destination, page) });
443
+ }
444
+ destinationPages.set(page.canonicalId, created);
445
+ const next = {
446
+ sourceId: page.id,
447
+ sourceCanonicalId: page.canonicalId,
448
+ sourceLocator: page.locator,
449
+ sourceTitle: page.title,
450
+ ...(page.projectedParentId ? { projectedParentId: page.projectedParentId } : {}),
451
+ destinationId: created.id,
452
+ destinationCanonicalId: created.canonicalId,
453
+ destinationLocator: created.url,
454
+ destinationTitle: created.title,
455
+ sourceFingerprint: sourceFingerprints.get(page.canonicalId),
456
+ ...(created.fingerprint ? { destinationFingerprint: created.fingerprint } : {}),
457
+ status: options.dryRun ? 'planned' : saved?.status ?? 'skeleton-created',
458
+ resources: saved?.resources ?? {},
459
+ degradations: saved?.degradations ?? [],
460
+ updatedAt: new Date().toISOString(),
461
+ };
462
+ await store.setPage(next);
463
+ options.onStatus?.(`${options.dryRun ? 'Planned' : 'Created'} page ${page.title}`);
464
+ }
465
+ catch (error) {
466
+ errors += 1;
467
+ const message = `${page.title}: ${error instanceof Error ? error.message : String(error)}`;
468
+ report.error(message);
469
+ const savedState = store.value.pages[page.canonicalId];
470
+ if (savedState)
471
+ await store.setPage({ ...savedState, status: /conflict/i.test(message) ? 'conflict' : 'failed', lastError: message, updatedAt: new Date().toISOString() });
472
+ }
473
+ finally {
474
+ progress.skeletons += 1;
475
+ updateProgress(`structure ${page.title}`);
476
+ }
477
+ }
478
+ const contentQueue = new PQueue({ concurrency: options.concurrency });
479
+ const contentTasks = graph.pages.map((page) => contentQueue.add(async () => {
480
+ if (options.signal?.aborted)
481
+ throw options.signal.reason ?? new Error('Transfer cancelled');
482
+ const destinationPage = destinationPages.get(page.canonicalId);
483
+ const document = await loadCachedDocument(options.cacheDirectory, page.canonicalId);
484
+ const cachedPage = await loadCachedPage(options.cacheDirectory, page.canonicalId);
485
+ const resources = resourcesByPage.get(page.canonicalId) ?? [];
486
+ if (!destinationPage) {
487
+ const failedState = store.value.pages[page.canonicalId];
488
+ report.addPage(reportPage({
489
+ page,
490
+ status: failedState?.status ?? 'blocked',
491
+ resources,
492
+ document,
493
+ error: failedState?.lastError ?? 'Destination skeleton was not created',
494
+ }));
495
+ progress.pages += 1;
496
+ updateProgress(`blocked ${page.title}`);
497
+ return;
498
+ }
499
+ const state = store.value.pages[page.canonicalId];
500
+ const uploaded = new Map();
501
+ try {
502
+ for (const resource of resources.filter((item) => item.kind !== 'grid')) {
503
+ const key = resource.name.toLocaleLowerCase();
504
+ const existing = state.resources[resource.id];
505
+ if (existing?.status === 'complete' && existing.destinationId) {
506
+ uploaded.set(key, {
507
+ id: existing.destinationId,
508
+ name: existing.destinationName,
509
+ ...(existing.size !== undefined ? { size: existing.size } : {}),
510
+ ...(existing.sha256 ? { fingerprint: existing.sha256 } : {}),
511
+ });
512
+ progress.resources += 1;
513
+ updateProgress(`reused ${resource.name}`);
514
+ continue;
515
+ }
516
+ if (options.dryRun) {
517
+ uploaded.set(key, {
518
+ id: `dry-run:${resource.id}`,
519
+ name: safeFilename(resource.name),
520
+ ...(resource.size !== undefined ? { size: resource.size } : {}),
521
+ });
522
+ progress.resources += 1;
523
+ updateProgress(`planned ${resource.name}`);
524
+ continue;
525
+ }
526
+ const filename = cachePath(options.cacheDirectory, page, resource);
527
+ const downloaded = await source.downloadResource(resource, filename);
528
+ const result = await destination.uploadResource({
529
+ page: destinationPage,
530
+ name: safeFilename(resource.name),
531
+ filePath: downloaded.path,
532
+ size: downloaded.size,
533
+ ...(downloaded.mimeType ?? resource.mimeType ? { mimeType: downloaded.mimeType ?? resource.mimeType } : {}),
534
+ ...(existing?.destinationId ? { existingId: existing.destinationId } : {}),
535
+ });
536
+ uploaded.set(key, result);
537
+ state.resources[resource.id] = {
538
+ sourceId: resource.id,
539
+ sourceName: resource.name,
540
+ destinationId: result.id,
541
+ destinationName: result.name,
542
+ sha256: downloaded.sha256,
543
+ size: downloaded.size,
544
+ status: 'complete',
545
+ updatedAt: new Date().toISOString(),
546
+ };
547
+ await store.setPage({ ...state, updatedAt: new Date().toISOString() });
548
+ progress.resources += 1;
549
+ updateProgress(`uploaded ${resource.name} → ${page.title}`);
550
+ }
551
+ const context = {
552
+ pages: destinationPages,
553
+ resources: uploaded,
554
+ sourcePages: new Map(graph.pages.map((item) => [item.canonicalId, item])),
555
+ currentPageId: page.canonicalId,
556
+ };
557
+ const rendered = destination.kind === 'confluence'
558
+ ? await renderConfluenceStorage(document, cachedPage, context, container.space ?? '')
559
+ : await renderYfm(document, context);
560
+ warnings += rendered.warnings.length;
561
+ if (rendered.degraded && !options.dryRun) {
562
+ const extension = page.rawFormat === 'yfm' ? 'yfm' : 'storage.xml';
563
+ const snapshotName = `ywtc-source-${sha256(page.canonicalId).slice(0, 8)}.${extension}`;
564
+ const snapshotPath = path.join(options.cacheDirectory, 'snapshots', snapshotName);
565
+ await mkdir(path.dirname(snapshotPath), { recursive: true });
566
+ await writeFile(snapshotPath, cachedPage.rawContent, { mode: 0o600 });
567
+ const snapshotKey = `snapshot:${page.canonicalId}`;
568
+ const existingSnapshot = state.resources[snapshotKey];
569
+ const snapshot = existingSnapshot?.status === 'complete' && existingSnapshot.destinationId
570
+ ? {
571
+ id: existingSnapshot.destinationId,
572
+ name: existingSnapshot.destinationName,
573
+ ...(existingSnapshot.size !== undefined ? { size: existingSnapshot.size } : {}),
574
+ }
575
+ : await destination.uploadResource({
576
+ page: destinationPage,
577
+ name: snapshotName,
578
+ filePath: snapshotPath,
579
+ size: Buffer.byteLength(cachedPage.rawContent),
580
+ mimeType: page.rawFormat === 'yfm' ? 'text/markdown' : 'application/xml',
581
+ ...(existingSnapshot?.destinationId ? { existingId: existingSnapshot.destinationId } : {}),
582
+ });
583
+ uploaded.set(snapshotName.toLocaleLowerCase(), snapshot);
584
+ state.resources[snapshotKey] = {
585
+ sourceId: snapshotKey,
586
+ sourceName: snapshotName,
587
+ destinationId: snapshot.id,
588
+ destinationName: snapshot.name,
589
+ ...(snapshot.size !== undefined ? { size: snapshot.size } : {}),
590
+ sha256: sha256(cachedPage.rawContent),
591
+ status: 'complete',
592
+ updatedAt: new Date().toISOString(),
593
+ };
594
+ state.degradations = [...new Set([...state.degradations, ...rendered.warnings])];
595
+ }
596
+ let verified = destinationPage;
597
+ if (!options.dryRun) {
598
+ verified = await destination.updatePage({
599
+ page: destinationPage,
600
+ title: destinationPage.title,
601
+ body: rendered.body,
602
+ ...(['complete', 'partial'].includes(state.status) && state.destinationFingerprint ? { expectedFingerprint: state.destinationFingerprint } : {}),
603
+ });
604
+ if (verified.fingerprint) {
605
+ state.destinationFingerprint = verified.fingerprint;
606
+ await store.setPage({ ...state, updatedAt: new Date().toISOString() });
607
+ }
608
+ verified = await destination.verifyPage(verified);
609
+ if ((verified.contentLength ?? 0) === 0) {
610
+ throw new Error(`Verification found empty destination content for ${page.title}`);
611
+ }
612
+ const remoteResources = await destination.listUploadedResources(verified);
613
+ for (const resource of resources.filter((item) => item.kind !== 'grid')) {
614
+ const transferred = state.resources[resource.id];
615
+ const remote = transferred && remoteResources.find((item) => (item.id === transferred.destinationId || item.name === transferred.destinationName));
616
+ if (!transferred || !remote) {
617
+ throw new Error(`Verification did not find destination resource for ${resource.name}`);
618
+ }
619
+ if (destination.kind === 'confluence'
620
+ && transferred.size !== undefined
621
+ && remote.size !== undefined
622
+ && transferred.size !== remote.size) {
623
+ throw new Error(`Verification size mismatch for ${resource.name}: ${transferred.size} source bytes, ${remote.size} destination bytes`);
624
+ }
625
+ }
626
+ }
627
+ const finalState = {
628
+ ...state,
629
+ destinationId: verified.id,
630
+ destinationCanonicalId: verified.canonicalId,
631
+ destinationLocator: verified.url,
632
+ destinationTitle: verified.title,
633
+ ...(verified.fingerprint ? { destinationFingerprint: verified.fingerprint } : {}),
634
+ status: options.dryRun ? 'planned' : rendered.degraded ? 'partial' : 'complete',
635
+ degradations: [...new Set([...state.degradations, ...rendered.warnings])],
636
+ updatedAt: new Date().toISOString(),
637
+ };
638
+ await store.setPage(finalState);
639
+ report.addPage(reportPage({
640
+ page,
641
+ destination: verified,
642
+ status: finalState.status,
643
+ resources,
644
+ document,
645
+ warnings: rendered.warnings,
646
+ }), { source: normalizedPreviewHtml(document), destination: renderedPreviewHtml(rendered.body, destination.kind) });
647
+ }
648
+ catch (error) {
649
+ errors += 1;
650
+ const message = `${page.title}: ${error instanceof Error ? error.message : String(error)}`;
651
+ const failureStatus = /conflict/i.test(message) ? 'conflict' : 'partial';
652
+ report.error(message);
653
+ await store.setPage({ ...state, status: failureStatus, lastError: message, updatedAt: new Date().toISOString() });
654
+ report.addPage(reportPage({ page, destination: destinationPage, status: failureStatus, resources, document, error: message }));
655
+ }
656
+ finally {
657
+ progress.pages += 1;
658
+ updateProgress(`${options.dryRun ? 'planned' : 'completed'} ${page.title}`);
659
+ }
660
+ }, options.signal ? { signal: options.signal } : undefined));
661
+ const settled = await Promise.allSettled(contentTasks);
662
+ await contentQueue.onIdle();
663
+ const rejected = settled.find((result) => result.status === 'rejected');
664
+ if (rejected)
665
+ throw rejected.reason;
666
+ return { errors, warnings };
667
+ }
668
+ }
669
+ //# sourceMappingURL=migrator.js.map