@iwonz/ywtc 3.2.1 → 3.3.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.
- package/CHANGELOG.md +35 -0
- package/README.md +15 -7
- package/dist/adapters/confluence.d.ts +23 -2
- package/dist/adapters/confluence.js +64 -9
- package/dist/adapters/confluence.js.map +1 -1
- package/dist/adapters/yandex.d.ts +16 -2
- package/dist/adapters/yandex.js +49 -4
- package/dist/adapters/yandex.js.map +1 -1
- package/dist/cli-arguments.d.ts +3 -1
- package/dist/cli-arguments.js +6 -2
- package/dist/cli-arguments.js.map +1 -1
- package/dist/cli.js +31 -19
- package/dist/cli.js.map +1 -1
- package/dist/clients/confluence-cloud.d.ts +1 -0
- package/dist/clients/confluence-cloud.js +7 -0
- package/dist/clients/confluence-cloud.js.map +1 -1
- package/dist/clients/confluence.d.ts +2 -0
- package/dist/clients/confluence.js +21 -0
- package/dist/clients/confluence.js.map +1 -1
- package/dist/clients/yandex-wiki.d.ts +3 -1
- package/dist/clients/yandex-wiki.js +23 -5
- package/dist/clients/yandex-wiki.js.map +1 -1
- package/dist/content-model.js +3 -2
- package/dist/content-model.js.map +1 -1
- package/dist/doctor.d.ts +7 -2
- package/dist/doctor.js +8 -3
- package/dist/doctor.js.map +1 -1
- package/dist/domain.d.ts +48 -2
- package/dist/domain.js +5 -1
- package/dist/domain.js.map +1 -1
- package/dist/graph.d.ts +2 -1
- package/dist/graph.js +105 -15
- package/dist/graph.js.map +1 -1
- package/dist/migrator.d.ts +4 -14
- package/dist/migrator.js +349 -68
- package/dist/migrator.js.map +1 -1
- package/dist/report-catalog.d.ts +47 -2
- package/dist/report-catalog.js +71 -9
- package/dist/report-catalog.js.map +1 -1
- package/dist/report.d.ts +96 -24
- package/dist/report.js +303 -36
- package/dist/report.js.map +1 -1
- package/dist/state.d.ts +8 -2
- package/dist/state.js +25 -1
- package/dist/state.js.map +1 -1
- package/dist/types.d.ts +5 -1
- package/package.json +1 -1
package/dist/migrator.js
CHANGED
|
@@ -9,7 +9,7 @@ import { assertTransferCapabilities } from './domain.js';
|
|
|
9
9
|
import { FilesystemExporter } from './filesystem-export.js';
|
|
10
10
|
import { discoverPageGraph } from './graph.js';
|
|
11
11
|
import { profileBaseUrl, sameRemoteInstance } from './locators.js';
|
|
12
|
-
import { TransferReport } from './report.js';
|
|
12
|
+
import { TransferReport, } from './report.js';
|
|
13
13
|
import { DestinationNameAllocator, preferredDestinationName, verifiedCacheFile } from './resource-reconciliation.js';
|
|
14
14
|
import { resolveStateLocation, StateStore, } from './state.js';
|
|
15
15
|
function sourceIdentity(locator, root) {
|
|
@@ -119,8 +119,24 @@ function destinationFromState(state, adapter) {
|
|
|
119
119
|
}
|
|
120
120
|
function reportPage(input) {
|
|
121
121
|
const transferable = input.resources.filter((resource) => resource.kind !== 'grid');
|
|
122
|
-
const
|
|
123
|
-
|
|
122
|
+
const resourceItems = transferable.map((resource) => {
|
|
123
|
+
const state = input.resourceStates?.[resource.id];
|
|
124
|
+
const sourceBytes = state?.size ?? resource.size;
|
|
125
|
+
const status = input.resourceStatusOverride ?? state?.status ?? 'planned';
|
|
126
|
+
return {
|
|
127
|
+
sourceId: resource.id,
|
|
128
|
+
sourceName: resource.name,
|
|
129
|
+
kind: resource.kind,
|
|
130
|
+
status,
|
|
131
|
+
...(sourceBytes !== undefined ? { sourceBytes } : {}),
|
|
132
|
+
...(status === 'complete' && sourceBytes !== undefined ? { transferredBytes: sourceBytes } : {}),
|
|
133
|
+
...(state?.destinationId ? { destinationId: state.destinationId } : {}),
|
|
134
|
+
...(state?.destinationName ? { destinationName: state.destinationName } : {}),
|
|
135
|
+
...(state?.lastError ? { error: state.lastError } : {}),
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
const knownBytes = resourceItems.reduce((total, resource) => total + (resource.sourceBytes ?? 0), 0);
|
|
139
|
+
const transferredBytes = resourceItems.reduce((total, resource) => total + (resource.transferredBytes ?? 0), 0);
|
|
124
140
|
return {
|
|
125
141
|
sourceCanonicalId: input.page.canonicalId,
|
|
126
142
|
sourceId: input.page.id,
|
|
@@ -134,12 +150,16 @@ function reportPage(input) {
|
|
|
134
150
|
...(input.page.projectedParentId ? { parentId: input.page.projectedParentId } : {}),
|
|
135
151
|
depth: input.page.depth,
|
|
136
152
|
status: input.status,
|
|
153
|
+
inventoryStatus: input.inventoryStatus ?? 'complete',
|
|
137
154
|
resources: transferable.length,
|
|
138
|
-
|
|
139
|
-
|
|
155
|
+
resourceItems,
|
|
156
|
+
...(transferable.length === 0 || resourceItems.some((resource) => resource.sourceBytes !== undefined) ? { sourceBytes: knownBytes } : {}),
|
|
157
|
+
transferredBytes,
|
|
158
|
+
bytesUnknown: resourceItems.filter((resource) => resource.sourceBytes === undefined).length,
|
|
140
159
|
links: input.document.metrics.links,
|
|
141
160
|
images: input.document.metrics.images,
|
|
142
161
|
tables: input.document.metrics.tables,
|
|
162
|
+
grids: input.resources.filter((resource) => resource.kind === 'grid').length,
|
|
143
163
|
codeBlocks: input.document.metrics.codeBlocks,
|
|
144
164
|
degraded: input.document.degraded || (input.resourceFallbacks?.length ?? 0) > 0,
|
|
145
165
|
resourceFallbacks: input.resourceFallbacks ?? [],
|
|
@@ -148,10 +168,13 @@ function reportPage(input) {
|
|
|
148
168
|
...(input.error ? { error: input.error } : {}),
|
|
149
169
|
};
|
|
150
170
|
}
|
|
151
|
-
function runStatus(errors, warnings) {
|
|
171
|
+
function runStatus(errors, warnings, degradations) {
|
|
152
172
|
if (errors > 0)
|
|
153
173
|
return 'partial';
|
|
154
|
-
return warnings > 0 ? 'completed-with-warnings' : 'completed';
|
|
174
|
+
return warnings + degradations > 0 ? 'completed-with-warnings' : 'completed';
|
|
175
|
+
}
|
|
176
|
+
function attentionCount(summary) {
|
|
177
|
+
return summary.warnings + summary.degradations;
|
|
155
178
|
}
|
|
156
179
|
export class MigrationRunError extends Error {
|
|
157
180
|
reportPaths;
|
|
@@ -188,11 +211,12 @@ export class Migrator {
|
|
|
188
211
|
this.consistencyOptions = consistencyOptions;
|
|
189
212
|
}
|
|
190
213
|
async run(options) {
|
|
214
|
+
const sourceStatus = options.sourceStatus ?? 'all';
|
|
191
215
|
const source = this.adapterFactory(options.source.profile, this.config);
|
|
192
216
|
const destination = options.destination.type === 'remote'
|
|
193
217
|
? this.adapterFactory(options.destination.profile, this.config)
|
|
194
218
|
: undefined;
|
|
195
|
-
assertTransferCapabilities(source, destination, options.scope);
|
|
219
|
+
assertTransferCapabilities(source, destination, options.scope, sourceStatus);
|
|
196
220
|
const status = options.onStatus ?? (() => undefined);
|
|
197
221
|
status('Checking source and destination capabilities');
|
|
198
222
|
const [sourcePlatform, destinationPlatform, root, container] = await Promise.all([
|
|
@@ -216,10 +240,11 @@ export class Migrator {
|
|
|
216
240
|
await store.load(identity, { fresh: options.fresh ?? false, dryRun: options.dryRun });
|
|
217
241
|
if (!options.dryRun)
|
|
218
242
|
await store.save();
|
|
219
|
-
const run = await store.beginRun(options.scope, options.dryRun);
|
|
243
|
+
const run = await store.beginRun(options.scope, options.dryRun, sourceStatus);
|
|
220
244
|
const report = new TransferReport({
|
|
221
245
|
dryRun: options.dryRun,
|
|
222
246
|
scope: options.scope,
|
|
247
|
+
sourceStatus,
|
|
223
248
|
direction: `${source.kind} → ${options.destination.type === 'directory' ? options.destination.format : destination.kind}`,
|
|
224
249
|
source: { provider: source.kind, profile: source.profile.name, rootId: root.id, title: root.title, url: root.url },
|
|
225
250
|
destination: options.destination.type === 'directory'
|
|
@@ -233,6 +258,7 @@ export class Migrator {
|
|
|
233
258
|
...(store.loadInfo.backupFilename ? { backupPath: store.loadInfo.backupFilename } : {}),
|
|
234
259
|
},
|
|
235
260
|
});
|
|
261
|
+
report.registerPages([{ ...root, depth: 0 }]);
|
|
236
262
|
let graph;
|
|
237
263
|
let sourceFingerprints;
|
|
238
264
|
try {
|
|
@@ -241,32 +267,45 @@ export class Migrator {
|
|
|
241
267
|
adapter: source,
|
|
242
268
|
root,
|
|
243
269
|
scope: options.scope,
|
|
270
|
+
sourceStatus,
|
|
244
271
|
...(options.signal ? { signal: options.signal } : {}),
|
|
245
272
|
onProgress: (progress) => status(`Discovery: ${progress.pages} pages, ${progress.edges} links${progress.active ? ` · ${progress.active}` : ''}`),
|
|
246
273
|
});
|
|
274
|
+
report.registerPages(graph.pages);
|
|
275
|
+
report.setEdges(graph.edges);
|
|
276
|
+
report.setSelection(graph.selection);
|
|
277
|
+
await store.setRunSelection(run.id, graph.selection);
|
|
278
|
+
for (const warning of graph.warnings)
|
|
279
|
+
report.warning(warning);
|
|
247
280
|
if (container
|
|
248
281
|
&& options.destination.type === 'remote'
|
|
249
282
|
&& sameRemoteInstance(options.source.profile, options.destination.profile)
|
|
250
283
|
&& graph.pages.some((page) => page.id === container.id)) {
|
|
251
284
|
throw new Error('The destination page belongs to the selected source graph. Choose a destination outside the transferred graph.');
|
|
252
285
|
}
|
|
286
|
+
status(`Source tree: root ${root.title} · ${graph.selection.returnedDescendants} descendants returned · ${graph.selection.selectedDescendants} selected · ${graph.selection.excluded.length} excluded · depth ${graph.selection.maximumDepth}`);
|
|
253
287
|
status(`Caching source content for ${graph.pages.length} pages`);
|
|
254
288
|
sourceFingerprints = await cacheDiscoveredPages(options.cacheDirectory, graph);
|
|
255
289
|
await store.setGraph(graph.rootId, graph.edges);
|
|
256
|
-
report.setEdges(graph.edges);
|
|
257
|
-
for (const warning of graph.warnings)
|
|
258
|
-
report.warning(warning);
|
|
259
290
|
}
|
|
260
291
|
catch (error) {
|
|
261
292
|
const finalStatus = options.signal?.aborted ? 'cancelled' : 'failed';
|
|
262
293
|
const message = error instanceof Error ? error.message : String(error);
|
|
263
|
-
report.error
|
|
294
|
+
report.updatePage(root.canonicalId, { status: 'failed', inventoryStatus: 'failed', error: message });
|
|
295
|
+
report.issue({
|
|
296
|
+
code: 'source-discovery-failed',
|
|
297
|
+
severity: 'error',
|
|
298
|
+
phase: 'discovery',
|
|
299
|
+
pageId: root.canonicalId,
|
|
300
|
+
retryable: true,
|
|
301
|
+
message,
|
|
302
|
+
});
|
|
264
303
|
report.finish(finalStatus);
|
|
265
304
|
const reportPaths = await report.write(options.reportsDirectory);
|
|
266
305
|
if (!options.dryRun)
|
|
267
306
|
await store.finishRun(run.id, finalStatus, {
|
|
268
307
|
pages: 0,
|
|
269
|
-
warnings: report.data.summary
|
|
308
|
+
warnings: attentionCount(report.data.summary),
|
|
270
309
|
errors: report.data.summary.errors,
|
|
271
310
|
});
|
|
272
311
|
throw new MigrationRunError(message, reportPaths, stateLocation.filename, { cause: error });
|
|
@@ -293,12 +332,26 @@ export class Migrator {
|
|
|
293
332
|
inventory.images += document.metrics.images;
|
|
294
333
|
inventory.tables += document.metrics.tables;
|
|
295
334
|
inventory.codeBlocks += document.metrics.codeBlocks;
|
|
335
|
+
report.addPage(reportPage({
|
|
336
|
+
page,
|
|
337
|
+
status: 'planned',
|
|
338
|
+
resources,
|
|
339
|
+
document,
|
|
340
|
+
}), undefined, false);
|
|
296
341
|
status(`Inspected ${page.title} · ${resources.length} resources`);
|
|
297
342
|
}
|
|
298
343
|
catch (error) {
|
|
299
344
|
const message = `${page.title}: ${error instanceof Error ? error.message : String(error)}`;
|
|
300
345
|
inventoryErrors.push(message);
|
|
301
|
-
report.error
|
|
346
|
+
report.updatePage(page.canonicalId, { status: 'failed', inventoryStatus: 'failed', error: message });
|
|
347
|
+
report.issue({
|
|
348
|
+
code: 'source-inventory-failed',
|
|
349
|
+
severity: 'error',
|
|
350
|
+
phase: 'inventory',
|
|
351
|
+
pageId: page.canonicalId,
|
|
352
|
+
retryable: true,
|
|
353
|
+
message,
|
|
354
|
+
});
|
|
302
355
|
}
|
|
303
356
|
})));
|
|
304
357
|
if (inventoryErrors.length > 0) {
|
|
@@ -308,12 +361,12 @@ export class Migrator {
|
|
|
308
361
|
if (!options.dryRun)
|
|
309
362
|
await store.finishRun(run.id, finalStatus, {
|
|
310
363
|
pages: graph.pages.length,
|
|
311
|
-
warnings: report.data.summary
|
|
364
|
+
warnings: attentionCount(report.data.summary),
|
|
312
365
|
errors: report.data.summary.errors,
|
|
313
366
|
});
|
|
314
367
|
throw new MigrationRunError('Resource preflight failed before destination mutation', reportPaths, stateLocation.filename);
|
|
315
368
|
}
|
|
316
|
-
const planSummary = `Plan ready: ${graph.pages.length} pages · ${inventory.resources} files (${inventory.bytes} known bytes${inventory.unknownSizes > 0 ? ` · ${inventory.unknownSizes} sizes unknown` : ''}) · ${inventory.links} links · ${inventory.images} images · ${inventory.tables} tables (${inventory.grids} grids) · ${inventory.codeBlocks} code blocks`;
|
|
369
|
+
const planSummary = `Plan ready: root + ${graph.selection.selectedDescendants}/${graph.selection.returnedDescendants} descendants (${graph.selection.excluded.length} excluded, depth ${graph.selection.maximumDepth}) · ${graph.pages.length} pages · ${inventory.resources} files (${inventory.bytes} known bytes${inventory.unknownSizes > 0 ? ` · ${inventory.unknownSizes} sizes unknown` : ''}) · ${inventory.links} links · ${inventory.images} images · ${inventory.tables} tables (${inventory.grids} grids) · ${inventory.codeBlocks} code blocks`;
|
|
317
370
|
status(planSummary);
|
|
318
371
|
this.logger.info(planSummary);
|
|
319
372
|
let output;
|
|
@@ -345,16 +398,16 @@ export class Migrator {
|
|
|
345
398
|
report.output('destination', container.url);
|
|
346
399
|
}
|
|
347
400
|
report.finish('completed');
|
|
348
|
-
const finalStatus = runStatus(report.data.summary.errors, report.data.summary.warnings);
|
|
401
|
+
const finalStatus = runStatus(report.data.summary.errors, report.data.summary.warnings, report.data.summary.degradations);
|
|
349
402
|
report.finish(finalStatus);
|
|
350
403
|
const reportPaths = await report.write(options.reportsDirectory);
|
|
351
404
|
if (!options.dryRun)
|
|
352
405
|
await store.finishRun(run.id, finalStatus, {
|
|
353
406
|
pages: graph.pages.length,
|
|
354
|
-
warnings: report.data.summary
|
|
407
|
+
warnings: attentionCount(report.data.summary),
|
|
355
408
|
errors: report.data.summary.errors,
|
|
356
409
|
});
|
|
357
|
-
status(`${finalStatus}: ${
|
|
410
|
+
status(`${finalStatus}: source ${report.data.summary.pages} pages · ${report.data.summary.complete} complete · ${report.data.summary.partial} partial · ${report.data.summary.failed + report.data.summary.blocked} failed/blocked · files ${report.data.summary.resourcesComplete}/${report.data.summary.resources} complete · ${report.data.summary.errors} errors · ${report.data.summary.warnings} warnings · ${report.data.summary.degradations} degradations`);
|
|
358
411
|
return {
|
|
359
412
|
status: finalStatus,
|
|
360
413
|
hasErrors: report.data.summary.errors > 0,
|
|
@@ -377,7 +430,7 @@ export class Migrator {
|
|
|
377
430
|
if (!options.dryRun)
|
|
378
431
|
await store.finishRun(run.id, finalStatus, {
|
|
379
432
|
pages: graph.pages.length,
|
|
380
|
-
warnings: report.data.summary
|
|
433
|
+
warnings: attentionCount(report.data.summary),
|
|
381
434
|
errors: report.data.summary.errors,
|
|
382
435
|
});
|
|
383
436
|
this.logger.error(`${message}. Report: ${reportPaths.html}`);
|
|
@@ -422,10 +475,11 @@ export class Migrator {
|
|
|
422
475
|
destinationName: resource.name,
|
|
423
476
|
...(resource.sha256 ? { sha256: resource.sha256 } : {}),
|
|
424
477
|
...(resource.size !== undefined ? { size: resource.size } : {}),
|
|
425
|
-
status: 'complete',
|
|
478
|
+
status: options.dryRun ? 'planned' : 'complete',
|
|
426
479
|
updatedAt: new Date().toISOString(),
|
|
427
480
|
}];
|
|
428
481
|
}));
|
|
482
|
+
const pageStatus = options.dryRun ? 'planned' : manifestPage?.degraded ? 'partial' : 'complete';
|
|
429
483
|
await store.setPage({
|
|
430
484
|
sourceId: page.id,
|
|
431
485
|
sourceCanonicalId: page.canonicalId,
|
|
@@ -438,17 +492,18 @@ export class Migrator {
|
|
|
438
492
|
destinationTitle: page.title,
|
|
439
493
|
sourceFingerprint: sourceFingerprints.get(page.canonicalId),
|
|
440
494
|
...(manifestPage || previous?.destinationFingerprint ? { destinationFingerprint: manifestPage ? sha256(JSON.stringify(manifestPage)) : previous.destinationFingerprint } : {}),
|
|
441
|
-
status:
|
|
495
|
+
status: pageStatus,
|
|
442
496
|
resources,
|
|
443
497
|
degradations: manifestPage?.warnings ?? [],
|
|
444
498
|
updatedAt: new Date().toISOString(),
|
|
445
499
|
});
|
|
446
500
|
report.addPage(reportPage({
|
|
447
501
|
page,
|
|
448
|
-
status:
|
|
502
|
+
status: pageStatus,
|
|
449
503
|
resources: sourceResources,
|
|
450
504
|
document,
|
|
451
|
-
|
|
505
|
+
resourceStates: resources,
|
|
506
|
+
...(options.dryRun ? { resourceStatusOverride: 'planned' } : {}),
|
|
452
507
|
...(manifestPage?.warnings ? { warnings: manifestPage.warnings } : {}),
|
|
453
508
|
}), { source: normalizedPreviewHtml(document), destination: normalizedPreviewHtml(document) });
|
|
454
509
|
}
|
|
@@ -461,6 +516,7 @@ export class Migrator {
|
|
|
461
516
|
async transferRemote(input) {
|
|
462
517
|
const { source, destination, container, graph, options, store, report, sourceFingerprints, resourcesByPage } = input;
|
|
463
518
|
const destinationPages = new Map();
|
|
519
|
+
const stagingFailures = new Map();
|
|
464
520
|
const sourcePages = new Map(graph.pages.map((page) => [page.canonicalId, page]));
|
|
465
521
|
const bootstrapBodies = new Map();
|
|
466
522
|
const totalResources = [...resourcesByPage.values()].reduce((total, resources) => total + resources.filter((resource) => resource.kind !== 'grid').length, 0);
|
|
@@ -496,7 +552,16 @@ export class Migrator {
|
|
|
496
552
|
const parent = page.projectedParentId ? destinationPages.get(page.projectedParentId) : container;
|
|
497
553
|
if (!parent) {
|
|
498
554
|
const message = `Destination parent is unavailable for ${page.title}`;
|
|
499
|
-
|
|
555
|
+
stagingFailures.set(page.canonicalId, { status: 'blocked', message });
|
|
556
|
+
report.updatePage(page.canonicalId, { status: 'blocked', error: message });
|
|
557
|
+
report.issue({
|
|
558
|
+
code: 'destination-parent-unavailable',
|
|
559
|
+
severity: 'error',
|
|
560
|
+
phase: 'page',
|
|
561
|
+
pageId: page.canonicalId,
|
|
562
|
+
retryable: true,
|
|
563
|
+
message,
|
|
564
|
+
});
|
|
500
565
|
progress.staged += 1;
|
|
501
566
|
updateProgress(message);
|
|
502
567
|
continue;
|
|
@@ -509,6 +574,9 @@ export class Migrator {
|
|
|
509
574
|
let resourcesForState = saved?.resources ?? {};
|
|
510
575
|
let previousDestinationIds = saved?.previousDestinationIds ?? [];
|
|
511
576
|
let mappingRecovered = false;
|
|
577
|
+
const findTitleConflict = (candidateTitle) => destination.findTitleConflict
|
|
578
|
+
? destination.findTitleConflict(parent, candidateTitle)
|
|
579
|
+
: destination.findChild(parent, candidateTitle);
|
|
512
580
|
if (saved?.destinationId) {
|
|
513
581
|
const mapped = destinationFromState(saved, destination);
|
|
514
582
|
try {
|
|
@@ -530,21 +598,71 @@ export class Migrator {
|
|
|
530
598
|
if (page.projectedParentId) {
|
|
531
599
|
await verifyConfirmedDestinationPage(destination, parent, this.consistencyOptions);
|
|
532
600
|
}
|
|
533
|
-
const
|
|
601
|
+
const findRecoveryChild = (candidateTitle) => destination.findRecoverablePage
|
|
602
|
+
? destination.findRecoverablePage(parent, candidateTitle)
|
|
603
|
+
: destination.findChild(parent, candidateTitle);
|
|
604
|
+
let candidate;
|
|
605
|
+
let recoveryLookupFailed = false;
|
|
606
|
+
try {
|
|
607
|
+
candidate = await findRecoveryChild(title);
|
|
608
|
+
}
|
|
609
|
+
catch (lookupError) {
|
|
610
|
+
recoveryLookupFailed = true;
|
|
611
|
+
report.issue({
|
|
612
|
+
code: 'destination-recovery-lookup-failed',
|
|
613
|
+
severity: 'warning',
|
|
614
|
+
phase: 'page',
|
|
615
|
+
pageId: page.canonicalId,
|
|
616
|
+
retryable: true,
|
|
617
|
+
message: `${page.title}: recovery lookup failed; bootstrap recreation will be attempted: ${lookupError instanceof Error ? lookupError.message : String(lookupError)}`,
|
|
618
|
+
});
|
|
619
|
+
}
|
|
534
620
|
if (candidate && sourceDerivedTextMatches(bootstrap.body, candidate.content, destination.kind, cachedPage)) {
|
|
535
621
|
created = candidate;
|
|
536
622
|
}
|
|
537
623
|
else {
|
|
538
624
|
let recoveryTitle = title;
|
|
539
|
-
|
|
540
|
-
if (
|
|
625
|
+
let titleConflict = candidate;
|
|
626
|
+
if (!titleConflict && !recoveryLookupFailed) {
|
|
627
|
+
try {
|
|
628
|
+
titleConflict = await findTitleConflict(recoveryTitle);
|
|
629
|
+
}
|
|
630
|
+
catch (lookupError) {
|
|
631
|
+
recoveryLookupFailed = true;
|
|
632
|
+
report.issue({
|
|
633
|
+
code: 'destination-title-lookup-failed',
|
|
634
|
+
severity: 'warning',
|
|
635
|
+
phase: 'page',
|
|
636
|
+
pageId: page.canonicalId,
|
|
637
|
+
retryable: true,
|
|
638
|
+
message: `${page.title}: destination title lookup failed; bootstrap recreation will be attempted with a stable suffix: ${lookupError instanceof Error ? lookupError.message : String(lookupError)}`,
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
if (titleConflict || recoveryLookupFailed) {
|
|
541
643
|
const base = collisionTitle(page);
|
|
542
644
|
const hash = sha256(page.canonicalId).slice(0, 8);
|
|
543
645
|
recoveryTitle = base;
|
|
544
646
|
let sequence = 0;
|
|
545
|
-
while (
|
|
546
|
-
|
|
547
|
-
|
|
647
|
+
while (!recoveryLookupFailed) {
|
|
648
|
+
try {
|
|
649
|
+
if (!await findTitleConflict(recoveryTitle))
|
|
650
|
+
break;
|
|
651
|
+
sequence += 1;
|
|
652
|
+
recoveryTitle = `${base} [${hash}${sequence > 1 ? `-${sequence}` : ''}]`;
|
|
653
|
+
}
|
|
654
|
+
catch (lookupError) {
|
|
655
|
+
recoveryLookupFailed = true;
|
|
656
|
+
recoveryTitle = `${base} [${hash}]`;
|
|
657
|
+
report.issue({
|
|
658
|
+
code: 'destination-recovery-lookup-failed',
|
|
659
|
+
severity: 'warning',
|
|
660
|
+
phase: 'page',
|
|
661
|
+
pageId: page.canonicalId,
|
|
662
|
+
retryable: true,
|
|
663
|
+
message: `${page.title}: suffixed-title lookup failed; bootstrap recreation will be attempted: ${lookupError instanceof Error ? lookupError.message : String(lookupError)}`,
|
|
664
|
+
});
|
|
665
|
+
}
|
|
548
666
|
}
|
|
549
667
|
}
|
|
550
668
|
created = await destination.createPage({ container: parent, title: recoveryTitle, body: bootstrap.body });
|
|
@@ -578,6 +696,91 @@ export class Migrator {
|
|
|
578
696
|
created = await verifyConfirmedDestinationPage(destination, created, this.consistencyOptions);
|
|
579
697
|
assertDestinationContent(bootstrap.body, created.content, destination.kind, cachedPage);
|
|
580
698
|
}
|
|
699
|
+
if (!mappingRecovered && created.parentId !== parent.id) {
|
|
700
|
+
const oldPage = created;
|
|
701
|
+
if (options.dryRun) {
|
|
702
|
+
report.addHierarchyRepair({
|
|
703
|
+
sourceCanonicalId: page.canonicalId,
|
|
704
|
+
mode: destination.capabilities.reparentPages && destination.reparentPage ? 'moved' : 'recreated',
|
|
705
|
+
...(created.parentId ? { oldParentId: created.parentId } : {}),
|
|
706
|
+
newParentId: parent.id,
|
|
707
|
+
oldDestinationId: created.id,
|
|
708
|
+
newDestinationId: created.id,
|
|
709
|
+
verified: false,
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
else if (destination.capabilities.reparentPages && destination.reparentPage) {
|
|
713
|
+
created = await destination.reparentPage({
|
|
714
|
+
page: created,
|
|
715
|
+
parent,
|
|
716
|
+
...(created.fingerprint ? { expectedFingerprint: created.fingerprint } : {}),
|
|
717
|
+
});
|
|
718
|
+
created = await verifyConfirmedDestinationPage(destination, created, this.consistencyOptions);
|
|
719
|
+
if (created.parentId !== parent.id) {
|
|
720
|
+
throw new Error(`Destination hierarchy verification failed for ${page.title}: expected parent ${parent.id}, received ${created.parentId ?? 'none'}`);
|
|
721
|
+
}
|
|
722
|
+
report.addHierarchyRepair({
|
|
723
|
+
sourceCanonicalId: page.canonicalId,
|
|
724
|
+
mode: 'moved',
|
|
725
|
+
...(oldPage.parentId ? { oldParentId: oldPage.parentId } : {}),
|
|
726
|
+
newParentId: parent.id,
|
|
727
|
+
oldDestinationId: oldPage.id,
|
|
728
|
+
newDestinationId: created.id,
|
|
729
|
+
verified: true,
|
|
730
|
+
});
|
|
731
|
+
report.issue({
|
|
732
|
+
code: 'destination-hierarchy-repaired',
|
|
733
|
+
severity: 'normalization',
|
|
734
|
+
phase: 'page',
|
|
735
|
+
pageId: page.canonicalId,
|
|
736
|
+
retryable: false,
|
|
737
|
+
message: `${page.title}: moved destination page ${created.id} from parent ${oldPage.parentId ?? 'none'} to ${parent.id} and verified the hierarchy`,
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
else {
|
|
741
|
+
let replacement = await destination.findChild(parent, title);
|
|
742
|
+
const replacementMatches = replacement
|
|
743
|
+
&& sourceDerivedTextMatches(bootstrap.body, replacement.content, destination.kind, cachedPage);
|
|
744
|
+
const titleConflict = replacementMatches ? undefined : replacement ?? await findTitleConflict(title);
|
|
745
|
+
if (titleConflict) {
|
|
746
|
+
const base = collisionTitle(page);
|
|
747
|
+
const hash = sha256(page.canonicalId).slice(0, 8);
|
|
748
|
+
title = base;
|
|
749
|
+
let sequence = 0;
|
|
750
|
+
while (await findTitleConflict(title)) {
|
|
751
|
+
sequence += 1;
|
|
752
|
+
title = `${base} [${hash}${sequence > 1 ? `-${sequence}` : ''}]`;
|
|
753
|
+
}
|
|
754
|
+
replacement = undefined;
|
|
755
|
+
}
|
|
756
|
+
created = replacement ?? await destination.createPage({ container: parent, title, body: bootstrap.body });
|
|
757
|
+
created = await verifyConfirmedDestinationPage(destination, created, this.consistencyOptions);
|
|
758
|
+
assertDestinationContent(bootstrap.body, created.content, destination.kind, cachedPage);
|
|
759
|
+
if (created.parentId !== parent.id) {
|
|
760
|
+
throw new Error(`Destination hierarchy verification failed for recreated page ${page.title}: expected parent ${parent.id}, received ${created.parentId ?? 'none'}`);
|
|
761
|
+
}
|
|
762
|
+
previousDestinationIds = [...new Set([...previousDestinationIds, oldPage.id])];
|
|
763
|
+
resourcesForState = resetResourceDestinations(saved.resources);
|
|
764
|
+
mappingRecovered = true;
|
|
765
|
+
report.addHierarchyRepair({
|
|
766
|
+
sourceCanonicalId: page.canonicalId,
|
|
767
|
+
mode: 'recreated',
|
|
768
|
+
...(oldPage.parentId ? { oldParentId: oldPage.parentId } : {}),
|
|
769
|
+
newParentId: parent.id,
|
|
770
|
+
oldDestinationId: oldPage.id,
|
|
771
|
+
newDestinationId: created.id,
|
|
772
|
+
verified: true,
|
|
773
|
+
});
|
|
774
|
+
report.issue({
|
|
775
|
+
code: 'destination-hierarchy-recreated',
|
|
776
|
+
severity: 'normalization',
|
|
777
|
+
phase: 'page',
|
|
778
|
+
pageId: page.canonicalId,
|
|
779
|
+
retryable: false,
|
|
780
|
+
message: `${page.title}: created verified replacement ${created.id} below ${parent.id}; previous page ${oldPage.id} was left untouched`,
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
}
|
|
581
784
|
}
|
|
582
785
|
else if (options.dryRun) {
|
|
583
786
|
created = {
|
|
@@ -588,25 +791,45 @@ export class Migrator {
|
|
|
588
791
|
title,
|
|
589
792
|
url: container.url,
|
|
590
793
|
locator: `dry-run:${page.id}`,
|
|
794
|
+
parentId: parent.id,
|
|
591
795
|
content: bootstrap.body,
|
|
592
796
|
contentLength: Buffer.byteLength(bootstrap.body),
|
|
593
797
|
...(container.space ? { space: container.space } : {}),
|
|
594
798
|
};
|
|
595
799
|
}
|
|
596
800
|
else {
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
801
|
+
const existingChild = await destination.findChild(parent, title);
|
|
802
|
+
if (existingChild && sourceDerivedTextMatches(bootstrap.body, existingChild.content, destination.kind, cachedPage)) {
|
|
803
|
+
created = existingChild;
|
|
804
|
+
mappingRecovered = true;
|
|
805
|
+
report.issue({
|
|
806
|
+
code: 'destination-unmapped-page-recovered',
|
|
807
|
+
severity: 'normalization',
|
|
808
|
+
phase: 'page',
|
|
809
|
+
pageId: page.canonicalId,
|
|
810
|
+
retryable: false,
|
|
811
|
+
message: `${page.title}: adopted an existing source-derived page below the expected parent`,
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
else {
|
|
815
|
+
const titleConflict = existingChild ?? await findTitleConflict(title);
|
|
816
|
+
if (titleConflict) {
|
|
817
|
+
const base = collisionTitle(page);
|
|
818
|
+
const hash = sha256(page.canonicalId).slice(0, 8);
|
|
819
|
+
title = base;
|
|
820
|
+
let sequence = 0;
|
|
821
|
+
while (await findTitleConflict(title)) {
|
|
822
|
+
sequence += 1;
|
|
823
|
+
title = `${base} [${hash}${sequence > 1 ? `-${sequence}` : ''}]`;
|
|
824
|
+
}
|
|
605
825
|
}
|
|
826
|
+
created = await destination.createPage({ container: parent, title, body: bootstrap.body });
|
|
827
|
+
created = await verifyConfirmedDestinationPage(destination, created, this.consistencyOptions);
|
|
828
|
+
assertDestinationContent(bootstrap.body, created.content, destination.kind, cachedPage);
|
|
606
829
|
}
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
830
|
+
}
|
|
831
|
+
if (!options.dryRun && created.parentId !== parent.id) {
|
|
832
|
+
throw new Error(`Destination hierarchy verification failed for ${page.title}: expected parent ${parent.id}, received ${created.parentId ?? 'none'}`);
|
|
610
833
|
}
|
|
611
834
|
destinationPages.set(page.canonicalId, created);
|
|
612
835
|
const next = {
|
|
@@ -632,10 +855,20 @@ export class Migrator {
|
|
|
632
855
|
}
|
|
633
856
|
catch (error) {
|
|
634
857
|
const message = `${page.title}: ${error instanceof Error ? error.message : String(error)}`;
|
|
635
|
-
|
|
858
|
+
const failureStatus = /conflict/i.test(message) ? 'conflict' : 'failed';
|
|
859
|
+
stagingFailures.set(page.canonicalId, { status: failureStatus, message });
|
|
860
|
+
report.updatePage(page.canonicalId, { status: failureStatus, error: message });
|
|
861
|
+
report.issue({
|
|
862
|
+
code: failureStatus === 'conflict' ? 'destination-conflict' : 'destination-page-staging-failed',
|
|
863
|
+
severity: 'error',
|
|
864
|
+
phase: 'page',
|
|
865
|
+
pageId: page.canonicalId,
|
|
866
|
+
retryable: failureStatus !== 'conflict',
|
|
867
|
+
message,
|
|
868
|
+
});
|
|
636
869
|
const savedState = store.value.pages[page.canonicalId];
|
|
637
870
|
if (savedState)
|
|
638
|
-
await store.setPage({ ...savedState, status:
|
|
871
|
+
await store.setPage({ ...savedState, status: failureStatus, lastError: message, updatedAt: new Date().toISOString() });
|
|
639
872
|
}
|
|
640
873
|
finally {
|
|
641
874
|
progress.staged += 1;
|
|
@@ -651,13 +884,16 @@ export class Migrator {
|
|
|
651
884
|
const cachedPage = await loadCachedPage(options.cacheDirectory, page.canonicalId);
|
|
652
885
|
const resources = resourcesByPage.get(page.canonicalId) ?? [];
|
|
653
886
|
if (!destinationPage) {
|
|
887
|
+
const currentFailure = stagingFailures.get(page.canonicalId);
|
|
654
888
|
const failedState = store.value.pages[page.canonicalId];
|
|
655
889
|
report.addPage(reportPage({
|
|
656
890
|
page,
|
|
657
|
-
status:
|
|
891
|
+
status: currentFailure?.status ?? 'blocked',
|
|
658
892
|
resources,
|
|
659
893
|
document,
|
|
660
|
-
|
|
894
|
+
...(failedState?.resources ? { resourceStates: failedState.resources } : {}),
|
|
895
|
+
...(options.dryRun ? { resourceStatusOverride: 'planned' } : {}),
|
|
896
|
+
error: currentFailure?.message ?? failedState?.lastError ?? 'Destination page with source-derived content was not created',
|
|
661
897
|
}));
|
|
662
898
|
progress.pages += 1;
|
|
663
899
|
updateProgress(`blocked ${page.title}`);
|
|
@@ -700,7 +936,9 @@ export class Migrator {
|
|
|
700
936
|
await store.setPage({ ...state, status: 'partial', lastError: detail, updatedAt: new Date().toISOString() });
|
|
701
937
|
};
|
|
702
938
|
try {
|
|
703
|
-
let remoteResources = options.dryRun
|
|
939
|
+
let remoteResources = options.dryRun || transferableResources.length === 0
|
|
940
|
+
? []
|
|
941
|
+
: await destination.listUploadedResources(destinationPage);
|
|
704
942
|
const nameAllocator = new DestinationNameAllocator(remoteResources.map((resource) => resource.name));
|
|
705
943
|
for (const resourceState of Object.values(state.resources)) {
|
|
706
944
|
if (resourceState.status === 'complete')
|
|
@@ -899,6 +1137,9 @@ export class Migrator {
|
|
|
899
1137
|
};
|
|
900
1138
|
const rendered = await renderRemoteDocument({ destination, container, document, page: cachedPage, context });
|
|
901
1139
|
if (rendered.degraded && !options.dryRun) {
|
|
1140
|
+
if (transferableResources.length === 0) {
|
|
1141
|
+
remoteResources = await destination.listUploadedResources(destinationPage);
|
|
1142
|
+
}
|
|
902
1143
|
const extension = page.rawFormat === 'yfm' ? 'yfm' : 'storage.xml';
|
|
903
1144
|
const snapshotName = `ywtc-source-${sha256(page.canonicalId).slice(0, 8)}.${extension}`;
|
|
904
1145
|
const snapshotPath = path.join(options.cacheDirectory, 'snapshots', snapshotName);
|
|
@@ -906,28 +1147,48 @@ export class Migrator {
|
|
|
906
1147
|
await writeFile(snapshotPath, cachedPage.rawContent, { mode: 0o600 });
|
|
907
1148
|
const snapshotKey = `snapshot:${page.canonicalId}`;
|
|
908
1149
|
const existingSnapshot = state.resources[snapshotKey];
|
|
1150
|
+
const snapshotFile = {
|
|
1151
|
+
path: snapshotPath,
|
|
1152
|
+
size: Buffer.byteLength(cachedPage.rawContent),
|
|
1153
|
+
sha256: sha256(cachedPage.rawContent),
|
|
1154
|
+
};
|
|
909
1155
|
try {
|
|
910
|
-
const
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
1156
|
+
const knownSnapshot = remoteResources.find((item) => item.id === existingSnapshot?.destinationId)
|
|
1157
|
+
?? remoteResources.find((item) => item.name.toLocaleLowerCase() === snapshotName.toLocaleLowerCase());
|
|
1158
|
+
let snapshot = knownSnapshot && await remoteMatches(knownSnapshot, snapshotFile, true)
|
|
1159
|
+
? knownSnapshot
|
|
1160
|
+
: undefined;
|
|
1161
|
+
if (!snapshot) {
|
|
1162
|
+
try {
|
|
1163
|
+
snapshot = await destination.uploadResource({
|
|
1164
|
+
page: destinationPage,
|
|
1165
|
+
name: snapshotName,
|
|
1166
|
+
filePath: snapshotPath,
|
|
1167
|
+
size: snapshotFile.size,
|
|
1168
|
+
mimeType: page.rawFormat === 'yfm' ? 'text/markdown' : 'application/xml',
|
|
1169
|
+
...(knownSnapshot ? { existingId: knownSnapshot.id } : {}),
|
|
1170
|
+
});
|
|
915
1171
|
}
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
name
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
}
|
|
1172
|
+
catch (uploadError) {
|
|
1173
|
+
remoteResources = await destination.listUploadedResources(destinationPage);
|
|
1174
|
+
const uncertain = remoteResources.find((item) => item.name.toLocaleLowerCase() === snapshotName.toLocaleLowerCase());
|
|
1175
|
+
if (uncertain && await remoteMatches(uncertain, snapshotFile, true))
|
|
1176
|
+
snapshot = uncertain;
|
|
1177
|
+
else
|
|
1178
|
+
throw uploadError;
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
if (!remoteResources.some((item) => item.id === snapshot.id))
|
|
1182
|
+
remoteResources.push(snapshot);
|
|
924
1183
|
state.resources[snapshotKey] = {
|
|
925
1184
|
sourceId: snapshotKey,
|
|
926
1185
|
sourceName: snapshotName,
|
|
927
1186
|
destinationId: snapshot.id,
|
|
928
1187
|
destinationName: snapshot.name,
|
|
929
1188
|
...(snapshot.size !== undefined ? { size: snapshot.size } : {}),
|
|
930
|
-
sha256: sha256
|
|
1189
|
+
sha256: snapshotFile.sha256,
|
|
1190
|
+
cachePath: snapshotPath,
|
|
1191
|
+
mimeType: page.rawFormat === 'yfm' ? 'text/markdown' : 'application/xml',
|
|
931
1192
|
status: 'complete',
|
|
932
1193
|
updatedAt: new Date().toISOString(),
|
|
933
1194
|
};
|
|
@@ -935,7 +1196,15 @@ export class Migrator {
|
|
|
935
1196
|
catch (error) {
|
|
936
1197
|
const detail = `Raw source snapshot was not transferred: ${error instanceof Error ? error.message : String(error)}`;
|
|
937
1198
|
resourceProblems.push(detail);
|
|
938
|
-
report.
|
|
1199
|
+
report.issue({
|
|
1200
|
+
code: 'source-snapshot-transfer-failed',
|
|
1201
|
+
severity: 'error',
|
|
1202
|
+
phase: 'resource',
|
|
1203
|
+
pageId: page.canonicalId,
|
|
1204
|
+
resourceId: snapshotKey,
|
|
1205
|
+
retryable: true,
|
|
1206
|
+
message: `${page.title}: ${detail}`,
|
|
1207
|
+
});
|
|
939
1208
|
state.resources[snapshotKey] = {
|
|
940
1209
|
sourceId: snapshotKey,
|
|
941
1210
|
sourceName: snapshotName,
|
|
@@ -954,7 +1223,11 @@ export class Migrator {
|
|
|
954
1223
|
if (destinationPage.contentFingerprint
|
|
955
1224
|
&& current.contentFingerprint
|
|
956
1225
|
&& current.contentFingerprint !== destinationPage.contentFingerprint) {
|
|
957
|
-
|
|
1226
|
+
const normalization = verifyDestinationContent(destinationPage.content ?? bootstrap.body, current.content, destination.kind, cachedPage);
|
|
1227
|
+
if (normalization.status !== 'complete') {
|
|
1228
|
+
throw new Error(`Destination conflict for ${page.title}: content changed while resources were transferred`);
|
|
1229
|
+
}
|
|
1230
|
+
rendered.normalizations.push('Destination normalized page markup without semantic content changes');
|
|
958
1231
|
}
|
|
959
1232
|
verified = await destination.updatePage({
|
|
960
1233
|
page: current,
|
|
@@ -999,7 +1272,8 @@ export class Migrator {
|
|
|
999
1272
|
status: finalState.status,
|
|
1000
1273
|
resources,
|
|
1001
1274
|
document,
|
|
1002
|
-
|
|
1275
|
+
resourceStates: finalState.resources,
|
|
1276
|
+
...(options.dryRun ? { resourceStatusOverride: 'planned' } : {}),
|
|
1003
1277
|
resourceFallbacks: fallbackDetails,
|
|
1004
1278
|
warnings: [...rendered.warnings, ...verificationDegradations],
|
|
1005
1279
|
normalizations: rendered.normalizations,
|
|
@@ -1019,7 +1293,14 @@ export class Migrator {
|
|
|
1019
1293
|
failureStatus = 'failed';
|
|
1020
1294
|
}
|
|
1021
1295
|
}
|
|
1022
|
-
report.
|
|
1296
|
+
report.issue({
|
|
1297
|
+
code: failureStatus === 'conflict' ? 'destination-conflict' : 'page-transfer-failed',
|
|
1298
|
+
severity: 'error',
|
|
1299
|
+
phase: 'page',
|
|
1300
|
+
pageId: page.canonicalId,
|
|
1301
|
+
retryable: failureStatus !== 'conflict',
|
|
1302
|
+
message,
|
|
1303
|
+
});
|
|
1023
1304
|
await store.setPage({
|
|
1024
1305
|
...state,
|
|
1025
1306
|
...(actual?.fingerprint ? { destinationFingerprint: actual.fingerprint } : {}),
|
|
@@ -1039,9 +1320,9 @@ export class Migrator {
|
|
|
1039
1320
|
status: failureStatus,
|
|
1040
1321
|
resources,
|
|
1041
1322
|
document,
|
|
1042
|
-
|
|
1323
|
+
resourceStates: state.resources,
|
|
1324
|
+
...(options.dryRun ? { resourceStatusOverride: 'planned' } : {}),
|
|
1043
1325
|
resourceFallbacks: fallbackDetails,
|
|
1044
|
-
warnings: resourceProblems,
|
|
1045
1326
|
error: message,
|
|
1046
1327
|
}), actual?.content ? { source: normalizedPreviewHtml(document), destination: renderedPreviewHtml(actual.content, destination.kind) } : undefined);
|
|
1047
1328
|
}
|