@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +15 -7
  3. package/dist/adapters/confluence.d.ts +23 -2
  4. package/dist/adapters/confluence.js +64 -9
  5. package/dist/adapters/confluence.js.map +1 -1
  6. package/dist/adapters/yandex.d.ts +16 -2
  7. package/dist/adapters/yandex.js +49 -4
  8. package/dist/adapters/yandex.js.map +1 -1
  9. package/dist/cli-arguments.d.ts +3 -1
  10. package/dist/cli-arguments.js +6 -2
  11. package/dist/cli-arguments.js.map +1 -1
  12. package/dist/cli.js +31 -19
  13. package/dist/cli.js.map +1 -1
  14. package/dist/clients/confluence-cloud.d.ts +1 -0
  15. package/dist/clients/confluence-cloud.js +7 -0
  16. package/dist/clients/confluence-cloud.js.map +1 -1
  17. package/dist/clients/confluence.d.ts +2 -0
  18. package/dist/clients/confluence.js +21 -0
  19. package/dist/clients/confluence.js.map +1 -1
  20. package/dist/clients/yandex-wiki.d.ts +3 -1
  21. package/dist/clients/yandex-wiki.js +23 -5
  22. package/dist/clients/yandex-wiki.js.map +1 -1
  23. package/dist/content-model.js +3 -2
  24. package/dist/content-model.js.map +1 -1
  25. package/dist/doctor.d.ts +7 -2
  26. package/dist/doctor.js +8 -3
  27. package/dist/doctor.js.map +1 -1
  28. package/dist/domain.d.ts +48 -2
  29. package/dist/domain.js +5 -1
  30. package/dist/domain.js.map +1 -1
  31. package/dist/graph.d.ts +2 -1
  32. package/dist/graph.js +105 -15
  33. package/dist/graph.js.map +1 -1
  34. package/dist/migrator.d.ts +4 -14
  35. package/dist/migrator.js +349 -68
  36. package/dist/migrator.js.map +1 -1
  37. package/dist/report-catalog.d.ts +47 -2
  38. package/dist/report-catalog.js +71 -9
  39. package/dist/report-catalog.js.map +1 -1
  40. package/dist/report.d.ts +96 -24
  41. package/dist/report.js +303 -36
  42. package/dist/report.js.map +1 -1
  43. package/dist/state.d.ts +8 -2
  44. package/dist/state.js +25 -1
  45. package/dist/state.js.map +1 -1
  46. package/dist/types.d.ts +5 -1
  47. 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 sizes = transferable.map((resource) => input.resourceSizes?.[resource.id]?.size ?? resource.size);
123
- const knownBytes = sizes.reduce((total, size) => total + (size ?? 0), 0);
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
- ...(transferable.length === 0 || sizes.some((size) => size !== undefined) ? { bytes: knownBytes } : {}),
139
- bytesUnknown: sizes.filter((size) => size === undefined).length,
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(message);
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.warnings,
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(message);
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.warnings,
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.warnings,
407
+ warnings: attentionCount(report.data.summary),
355
408
  errors: report.data.summary.errors,
356
409
  });
357
- status(`${finalStatus}: ${graph.pages.length} pages · ${report.data.summary.errors} errors · ${report.data.summary.warnings} warnings`);
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.warnings,
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: options.dryRun ? 'planned' : manifestPage?.degraded ? 'partial' : 'complete',
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: options.dryRun ? 'planned' : result.errors.length ? 'partial' : 'complete',
502
+ status: pageStatus,
449
503
  resources: sourceResources,
450
504
  document,
451
- resourceSizes: resources,
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
- report.error(message);
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 candidate = await (destination.findRecoverablePage?.(parent, title) ?? destination.findChild(parent, title));
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
- const occupied = candidate ?? await destination.findChild(parent, recoveryTitle);
540
- if (occupied) {
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 (await destination.findChild(parent, recoveryTitle)) {
546
- sequence += 1;
547
- recoveryTitle = `${base} [${hash}${sequence > 1 ? `-${sequence}` : ''}]`;
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
- if (await destination.findChild(parent, title)) {
598
- const base = collisionTitle(page);
599
- const hash = sha256(page.canonicalId).slice(0, 8);
600
- title = base;
601
- let sequence = 0;
602
- while (await destination.findChild(parent, title)) {
603
- sequence += 1;
604
- title = `${base} [${hash}${sequence > 1 ? `-${sequence}` : ''}]`;
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
- created = await destination.createPage({ container: parent, title, body: bootstrap.body });
608
- created = await verifyConfirmedDestinationPage(destination, created, this.consistencyOptions);
609
- assertDestinationContent(bootstrap.body, created.content, destination.kind, cachedPage);
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
- report.error(message);
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: /conflict/i.test(message) ? 'conflict' : 'failed', lastError: message, updatedAt: new Date().toISOString() });
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: failedState?.status ?? 'blocked',
891
+ status: currentFailure?.status ?? 'blocked',
658
892
  resources,
659
893
  document,
660
- error: failedState?.lastError ?? 'Destination page with source-derived content was not created',
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 ? [] : await destination.listUploadedResources(destinationPage);
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 snapshot = existingSnapshot?.status === 'complete' && existingSnapshot.destinationId
911
- ? {
912
- id: existingSnapshot.destinationId,
913
- name: existingSnapshot.destinationName,
914
- ...(existingSnapshot.size !== undefined ? { size: existingSnapshot.size } : {}),
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
- : await destination.uploadResource({
917
- page: destinationPage,
918
- name: snapshotName,
919
- filePath: snapshotPath,
920
- size: Buffer.byteLength(cachedPage.rawContent),
921
- mimeType: page.rawFormat === 'yfm' ? 'text/markdown' : 'application/xml',
922
- ...(existingSnapshot?.destinationId ? { existingId: existingSnapshot.destinationId } : {}),
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(cachedPage.rawContent),
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.error(`${page.title}: ${detail}`);
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
- throw new Error(`Destination conflict for ${page.title}: content changed while resources were transferred`);
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
- resourceSizes: finalState.resources,
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.error(message);
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
- resourceSizes: state.resources,
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
  }