@iwonz/ywtc 4.0.0 → 4.1.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 (66) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +47 -45
  3. package/dist/adapters/confluence.js +19 -4
  4. package/dist/adapters/confluence.js.map +1 -1
  5. package/dist/adapters/yandex.d.ts +7 -8
  6. package/dist/adapters/yandex.js +68 -22
  7. package/dist/adapters/yandex.js.map +1 -1
  8. package/dist/cli-arguments.d.ts +3 -2
  9. package/dist/cli-arguments.js +42 -37
  10. package/dist/cli-arguments.js.map +1 -1
  11. package/dist/cli.js +19 -142
  12. package/dist/cli.js.map +1 -1
  13. package/dist/clients/yandex-wiki.js +2 -6
  14. package/dist/clients/yandex-wiki.js.map +1 -1
  15. package/dist/consistency.d.ts +1 -1
  16. package/dist/consistency.js +6 -1
  17. package/dist/consistency.js.map +1 -1
  18. package/dist/content-model.d.ts +1 -0
  19. package/dist/content-model.js +118 -37
  20. package/dist/content-model.js.map +1 -1
  21. package/dist/conversion.d.ts +2 -0
  22. package/dist/conversion.js +42 -17
  23. package/dist/conversion.js.map +1 -1
  24. package/dist/domain.d.ts +22 -2
  25. package/dist/domain.js +10 -0
  26. package/dist/domain.js.map +1 -1
  27. package/dist/exclusive-lease.d.ts +14 -0
  28. package/dist/exclusive-lease.js +87 -0
  29. package/dist/exclusive-lease.js.map +1 -0
  30. package/dist/filesystem-export.d.ts +2 -0
  31. package/dist/filesystem-export.js +188 -167
  32. package/dist/filesystem-export.js.map +1 -1
  33. package/dist/graph.js +4 -4
  34. package/dist/graph.js.map +1 -1
  35. package/dist/http.js +1 -1
  36. package/dist/http.js.map +1 -1
  37. package/dist/interactive-config.js +55 -3
  38. package/dist/interactive-config.js.map +1 -1
  39. package/dist/interactive-init.d.ts +3 -3
  40. package/dist/interactive-init.js +7 -5
  41. package/dist/interactive-init.js.map +1 -1
  42. package/dist/interactive-migrate.d.ts +23 -0
  43. package/dist/interactive-migrate.js +578 -0
  44. package/dist/interactive-migrate.js.map +1 -0
  45. package/dist/issues.d.ts +1 -1
  46. package/dist/migration-command.d.ts +19 -0
  47. package/dist/migration-command.js +160 -0
  48. package/dist/migration-command.js.map +1 -0
  49. package/dist/migrator.js +439 -311
  50. package/dist/migrator.js.map +1 -1
  51. package/dist/report-catalog.d.ts +4 -0
  52. package/dist/report-catalog.js +4 -0
  53. package/dist/report-catalog.js.map +1 -1
  54. package/dist/report-contract.d.ts +6 -0
  55. package/dist/report-contract.js.map +1 -1
  56. package/dist/report-ui/index.html +19 -25
  57. package/dist/report.js +33 -22
  58. package/dist/report.js.map +1 -1
  59. package/dist/state.d.ts +16 -0
  60. package/dist/state.js +58 -32
  61. package/dist/state.js.map +1 -1
  62. package/dist/{init-prompts.d.ts → wizard-prompts.d.ts} +2 -2
  63. package/dist/{init-prompts.js → wizard-prompts.js} +3 -3
  64. package/dist/wizard-prompts.js.map +1 -0
  65. package/package.json +3 -7
  66. package/dist/init-prompts.js.map +0 -1
package/dist/migrator.js CHANGED
@@ -8,7 +8,7 @@ import { secretValues } from './config.js';
8
8
  import { appendGridSnapshots, assertDestinationContent, emptyRendererFallbackReason, normalizePageContent, normalizedPreviewHtml, rawContentFallback, renderedPreviewHtml, renderConfluenceStorage, renderYfm, setSourceResourceBinding, sourceResourceBindings, verifyDestinationContent, } from './content-model.js';
9
9
  import { commentAttachmentUses, createCommentArchiveZip, renderCommentArchive, renderCommentArchiveIndex, resolveCommentAttachmentReferences, verifyCommentArchive, } from './comments.js';
10
10
  import { ConfirmedDestinationMissingError, verifyConfirmedDestinationPage } from './consistency.js';
11
- import { DestinationRevisionConflictError, DestinationTitleConflictError } from './domain.js';
11
+ import { DestinationOwnershipConflictError, DestinationRevisionConflictError, DestinationTitleConflictError } from './domain.js';
12
12
  import { assertTransferCapabilities } from './domain.js';
13
13
  import { FilesystemExporter } from './filesystem-export.js';
14
14
  import { discoverPageGraph } from './graph.js';
@@ -115,13 +115,15 @@ async function revisionDelay(attempt, options) {
115
115
  }
116
116
  async function updateMappedPage(input) {
117
117
  let current = input.current;
118
+ let title = input.title;
119
+ let retainedMappedTitle = false;
118
120
  for (let attempt = 0;; attempt += 1) {
119
121
  const verification = verifyDestinationContent(input.body, current.content, input.destination.kind, input.sourcePage);
120
- if (verification.status === 'complete' && current.title === input.title) {
122
+ if (verification.status === 'complete' && current.title === title) {
121
123
  return { page: current, updated: false, normalized: current.content !== input.body, verification };
122
124
  }
123
125
  try {
124
- const result = await input.destination.updatePage({ page: current, title: input.title, body: input.body });
126
+ const result = await input.destination.updatePage({ page: current, title, body: input.body });
125
127
  const confirmed = await verifyConfirmedDestinationPage(input.destination, result, input.consistencyOptions);
126
128
  return {
127
129
  page: confirmed,
@@ -131,6 +133,19 @@ async function updateMappedPage(input) {
131
133
  };
132
134
  }
133
135
  catch (error) {
136
+ if (error instanceof DestinationTitleConflictError && !retainedMappedTitle && current.title !== title) {
137
+ title = current.title;
138
+ retainedMappedTitle = true;
139
+ input.report.issue({
140
+ code: 'mapped-title-retained',
141
+ severity: 'normalization',
142
+ phase: 'page',
143
+ pageId: input.sourcePage.canonicalId,
144
+ retryable: false,
145
+ message: `${input.sourcePage.title}: retained mapped destination title "${current.title}" because the source title is occupied`,
146
+ });
147
+ continue;
148
+ }
134
149
  if (!(error instanceof DestinationRevisionConflictError) || attempt >= REVISION_RETRY_DELAYS_MS.length)
135
150
  throw error;
136
151
  const delay = await revisionDelay(attempt, input.consistencyOptions);
@@ -241,22 +256,23 @@ async function createPageWithAllocatedTitle(input) {
241
256
  throw new Error(`Confluence rejected all ${candidates.length} deterministic destination titles for ${input.sourcePage.title} because they are occupied`);
242
257
  }
243
258
  function destinationFromState(state, adapter) {
244
- if (!state.destinationId || !state.destinationCanonicalId || !state.destinationLocator) {
259
+ if (!state.destinationId) {
245
260
  throw new Error(`Incomplete mapped destination for ${state.sourceTitle}`);
246
261
  }
247
262
  return {
248
263
  provider: adapter.kind,
249
264
  profile: adapter.profile.name,
250
265
  id: state.destinationId,
251
- canonicalId: state.destinationCanonicalId,
266
+ canonicalId: state.destinationCanonicalId ?? `${adapter.profile.name}:${adapter.kind}:${state.destinationId}`,
252
267
  title: state.destinationTitle,
253
- url: state.destinationLocator,
268
+ url: state.destinationLocator ?? state.destinationId,
254
269
  locator: state.destinationId,
255
270
  ...(state.destinationFingerprint ? { fingerprint: state.destinationFingerprint } : {}),
256
271
  ...(state.destinationVersion ? { version: state.destinationVersion } : {}),
257
272
  };
258
273
  }
259
274
  function reportPage(input) {
275
+ const effectiveWarnings = input.warnings ?? input.document.warnings;
260
276
  const inventory = input.commentInventory;
261
277
  const commentResourceIds = new Set();
262
278
  for (const thread of inventory?.threads ?? []) {
@@ -359,11 +375,14 @@ function reportPage(input) {
359
375
  tables: input.document.metrics.tables,
360
376
  grids: input.resources.filter((resource) => resource.kind === 'grid').length,
361
377
  codeBlocks: input.document.metrics.codeBlocks,
362
- degraded: input.document.degraded || (input.resourceFallbacks?.length ?? 0) > 0,
378
+ degraded: effectiveWarnings.length > 0 || (input.resourceFallbacks?.length ?? 0) > 0,
363
379
  resourceFallbacks: input.resourceFallbacks ?? [],
364
- warnings: [...new Set([...input.document.warnings, ...(input.warnings ?? [])])],
380
+ warnings: [...new Set(effectiveWarnings)],
365
381
  normalizations: [...new Set([...(input.document.normalizations ?? []), ...(input.normalizations ?? [])])],
382
+ adaptations: [...new Set([...(input.document.adaptations ?? []), ...(input.adaptations ?? [])])],
383
+ preservedExternalReferences: input.preservedExternalReferences ?? input.document.metrics.preservedExternalReferences,
366
384
  ...(input.error ? { error: input.error } : {}),
385
+ ...(input.conflictEvidence ? { conflictEvidence: input.conflictEvidence } : {}),
367
386
  ...(input.status === 'blocked' && input.blockedBy ? input.blockedBy : {}),
368
387
  comments: {
369
388
  status: commentStatus,
@@ -423,13 +442,14 @@ function runStatus(errors, warnings, degradations) {
423
442
  return 'partial';
424
443
  return warnings + degradations > 0 ? 'completed-with-warnings' : 'completed';
425
444
  }
426
- function attentionCount(summary) {
427
- return summary.warnings + summary.degradations;
428
- }
429
445
  function runOutcomeCounts(summary, pages) {
430
446
  return {
431
447
  pages,
432
- warnings: attentionCount(summary),
448
+ warnings: summary.warnings,
449
+ degradations: summary.degradations,
450
+ adaptations: summary.adaptations,
451
+ normalizations: summary.normalizations,
452
+ preservedExternalReferences: summary.preservedExternalReferences,
433
453
  errors: summary.errors,
434
454
  rootErrors: summary.rootErrors,
435
455
  dependentPages: summary.dependentPages,
@@ -501,277 +521,282 @@ export class Migrator {
501
521
  });
502
522
  const store = new StateStore(stateLocation.filename);
503
523
  await store.load(identity, { fresh: options.fresh ?? false, dryRun: options.dryRun });
504
- if (!options.dryRun)
505
- await store.save();
506
- const run = await store.beginRun(options.scope, options.dryRun, sourceStatus, commentsEnabled);
507
- const report = new TransferReport({
508
- secrets: secretValues(this.config),
509
- dryRun: options.dryRun,
510
- commentsEnabled,
511
- scope: options.scope,
512
- sourceStatus,
513
- direction: `${source.kind} → ${options.destination.type === 'directory' ? options.destination.format : destination.kind}`,
514
- source: { provider: source.kind, profile: source.profile.name, rootId: root.id, title: root.title, url: root.url },
515
- destination: options.destination.type === 'directory'
516
- ? { provider: 'filesystem', id: options.destination.path, format: options.destination.format }
517
- : { provider: destination.kind, profile: destination.profile.name, id: container.id, url: container.url },
518
- state: {
519
- id: stateLocation.id,
520
- path: stateLocation.filename,
521
- mode: stateLocation.mode,
522
- source: store.loadInfo.source,
523
- ...(store.loadInfo.backupFilename ? { backupPath: store.loadInfo.backupFilename } : {}),
524
- },
525
- });
526
- report.registerPages([{ ...root, depth: 0 }]);
527
- let graph;
528
- let sourceFingerprints;
529
524
  try {
530
- status(`Discovering ${options.scope} scope from ${root.title}`);
531
- graph = await discoverPageGraph({
532
- adapter: source,
533
- root,
525
+ if (!options.dryRun)
526
+ await store.save();
527
+ const run = await store.beginRun(options.scope, options.dryRun, sourceStatus, commentsEnabled);
528
+ const report = new TransferReport({
529
+ secrets: secretValues(this.config),
530
+ dryRun: options.dryRun,
531
+ commentsEnabled,
534
532
  scope: options.scope,
535
533
  sourceStatus,
536
- ...(options.signal ? { signal: options.signal } : {}),
537
- onProgress: (progress) => status(`Discovery: ${progress.pages} pages, ${progress.edges} links${progress.active ? ` · ${progress.active}` : ''}`),
538
- });
539
- report.registerPages(graph.pages);
540
- report.setEdges(graph.edges);
541
- report.setSelection(graph.selection);
542
- await store.setRunSelection(run.id, graph.selection);
543
- for (const warning of graph.warnings)
544
- report.warning(warning);
545
- if (container
546
- && options.destination.type === 'remote'
547
- && sameRemoteInstance(options.source.profile, options.destination.profile)
548
- && graph.pages.some((page) => page.id === container.id)) {
549
- throw new Error('The destination page belongs to the selected source graph. Choose a destination outside the transferred graph.');
550
- }
551
- status(`Source tree: root ${root.title} · ${graph.selection.returnedDescendants} descendants returned · ${graph.selection.selectedDescendants} selected · ${graph.selection.excluded.length} excluded · depth ${graph.selection.maximumDepth}`);
552
- status(`Caching source content for ${graph.pages.length} pages`);
553
- sourceFingerprints = await cacheDiscoveredPages(options.cacheDirectory, graph);
554
- await store.setGraph(graph.rootId, graph.edges);
555
- }
556
- catch (error) {
557
- const finalStatus = options.signal?.aborted ? 'cancelled' : 'failed';
558
- const message = error instanceof Error ? error.message : String(error);
559
- report.updatePage(root.canonicalId, { status: 'failed', inventoryStatus: 'failed', error: message });
560
- report.issue({
561
- code: 'source-discovery-failed',
562
- severity: 'error',
563
- phase: 'discovery',
564
- pageId: root.canonicalId,
565
- retryable: true,
566
- message,
534
+ direction: `${source.kind} → ${options.destination.type === 'directory' ? options.destination.format : destination.kind}`,
535
+ source: { provider: source.kind, profile: source.profile.name, rootId: root.id, title: root.title, url: root.url },
536
+ destination: options.destination.type === 'directory'
537
+ ? { provider: 'filesystem', id: options.destination.path, format: options.destination.format }
538
+ : { provider: destination.kind, profile: destination.profile.name, id: container.id, url: container.url },
539
+ state: {
540
+ id: stateLocation.id,
541
+ path: stateLocation.filename,
542
+ mode: stateLocation.mode,
543
+ source: store.loadInfo.source,
544
+ ...(store.loadInfo.backupFilename ? { backupPath: store.loadInfo.backupFilename } : {}),
545
+ },
567
546
  });
568
- report.finish(finalStatus);
569
- const reportPaths = await report.write(options.reportsDirectory);
570
- if (!options.dryRun)
571
- await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, 0));
572
- throw new MigrationRunError(message, reportPaths, stateLocation.filename, { cause: error });
573
- }
574
- const resourcesByPage = new Map();
575
- const commentsByPage = new Map();
576
- const inventoryQueue = new PQueue({ concurrency: options.concurrency });
577
- const inventory = { resources: 0, bytes: 0, unknownSizes: 0, grids: 0, links: 0, images: 0, tables: 0, codeBlocks: 0, commentThreads: 0, comments: 0, commentParticipants: 0, commentAttachments: 0, commentBytes: 0 };
578
- status(`Inspecting content and resources for ${graph.pages.length} pages`);
579
- const inventoryErrors = [];
580
- await Promise.all(graph.pages.map((page) => inventoryQueue.add(async () => {
547
+ report.registerPages([{ ...root, depth: 0 }]);
548
+ let graph;
549
+ let sourceFingerprints;
581
550
  try {
582
- if (options.signal?.aborted)
583
- throw options.signal.reason ?? new Error('Transfer cancelled');
584
- const cachedPage = await loadCachedPage(options.cacheDirectory, page.canonicalId);
585
- const resources = await source.listResources(page);
586
- resourcesByPage.set(page.canonicalId, resources);
587
- let commentInventory;
588
- if (commentsEnabled) {
589
- try {
590
- if (!source.readComments)
591
- throw new Error(`${source.profile.name} does not implement comment reads`);
592
- commentInventory = await source.readComments(cachedPage);
593
- resolveCommentAttachmentReferences(commentInventory, resources);
594
- commentsByPage.set(page.canonicalId, commentInventory);
595
- inventory.commentThreads += commentInventory.threads.length;
596
- inventory.comments += commentInventory.comments;
597
- inventory.commentParticipants += commentInventory.participants;
598
- inventory.commentAttachments += commentInventory.attachments;
599
- inventory.commentBytes += commentInventory.knownBytes;
600
- for (const warning of commentInventory.warnings)
601
- report.issue({
602
- code: `comment-inventory-warning:${warning}`,
603
- severity: 'warning',
604
- phase: 'inventory',
605
- pageId: page.canonicalId,
606
- retryable: false,
607
- message: `${page.title}: ${warning}`,
608
- });
609
- }
610
- catch (error) {
611
- const message = `${page.title}: comments could not be read: ${error instanceof Error ? error.message : String(error)}`;
612
- commentInventory = { status: 'failed', threads: [], comments: 0, participants: 0, attachments: 0, knownBytes: 0, unknownSizes: 0, warnings: [], error: message };
613
- commentsByPage.set(page.canonicalId, commentInventory);
614
- report.issue({ code: 'comment-inventory-failed', severity: 'error', phase: 'inventory', pageId: page.canonicalId, retryable: true, message });
615
- }
551
+ status(`Discovering ${options.scope} scope from ${root.title}`);
552
+ graph = await discoverPageGraph({
553
+ adapter: source,
554
+ root,
555
+ scope: options.scope,
556
+ sourceStatus,
557
+ ...(options.signal ? { signal: options.signal } : {}),
558
+ onProgress: (progress) => status(`Discovery: ${progress.pages} pages, ${progress.edges} links${progress.active ? ` · ${progress.active}` : ''}`),
559
+ });
560
+ report.registerPages(graph.pages);
561
+ report.setEdges(graph.edges);
562
+ report.setSelection(graph.selection);
563
+ await store.setRunSelection(run.id, graph.selection);
564
+ for (const warning of graph.warnings)
565
+ report.warning(warning);
566
+ if (container
567
+ && options.destination.type === 'remote'
568
+ && sameRemoteInstance(options.source.profile, options.destination.profile)
569
+ && graph.pages.some((page) => page.id === container.id)) {
570
+ throw new Error('The destination page belongs to the selected source graph. Choose a destination outside the transferred graph.');
616
571
  }
617
- const document = appendGridSnapshots(normalizePageContent(cachedPage), resources);
618
- await cacheDocument(options.cacheDirectory, page.canonicalId, document);
619
- inventory.resources += resources.filter((resource) => resource.kind !== 'grid').length;
620
- inventory.bytes += resources.reduce((total, resource) => total + (resource.size ?? 0), 0);
621
- inventory.unknownSizes += resources.filter((resource) => resource.kind !== 'grid' && resource.size === undefined).length;
622
- inventory.grids += resources.filter((resource) => resource.kind === 'grid').length;
623
- inventory.links += document.metrics.links;
624
- inventory.images += document.metrics.images;
625
- inventory.tables += document.metrics.tables;
626
- inventory.codeBlocks += document.metrics.codeBlocks;
627
- report.addPage(reportPage({
628
- page,
629
- status: 'planned',
630
- resources,
631
- document,
632
- commentsEnabled,
633
- ...(commentInventory ? { commentInventory } : {}),
634
- }), undefined, false);
635
- status(`Inspected ${page.title} · ${resources.length} resources`);
572
+ status(`Source tree: root ${root.title} · ${graph.selection.returnedDescendants} descendants returned · ${graph.selection.selectedDescendants} selected · ${graph.selection.excluded.length} excluded · depth ${graph.selection.maximumDepth}`);
573
+ status(`Caching source content for ${graph.pages.length} pages`);
574
+ sourceFingerprints = await cacheDiscoveredPages(options.cacheDirectory, graph);
575
+ await store.setGraph(graph.rootId, graph.edges);
636
576
  }
637
577
  catch (error) {
638
- const message = `${page.title}: ${error instanceof Error ? error.message : String(error)}`;
639
- inventoryErrors.push(message);
640
- report.updatePage(page.canonicalId, { status: 'failed', inventoryStatus: 'failed', error: message });
578
+ const finalStatus = options.signal?.aborted ? 'cancelled' : 'failed';
579
+ const message = error instanceof Error ? error.message : String(error);
580
+ report.updatePage(root.canonicalId, { status: 'failed', inventoryStatus: 'failed', error: message });
641
581
  report.issue({
642
- code: 'source-inventory-failed',
582
+ code: 'source-discovery-failed',
643
583
  severity: 'error',
644
- phase: 'inventory',
645
- pageId: page.canonicalId,
584
+ phase: 'discovery',
585
+ pageId: root.canonicalId,
646
586
  retryable: true,
647
587
  message,
648
588
  });
589
+ report.finish(finalStatus);
590
+ const reportPaths = await report.write(options.reportsDirectory);
591
+ if (!options.dryRun)
592
+ await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, 0));
593
+ throw new MigrationRunError(message, reportPaths, stateLocation.filename, { cause: error });
649
594
  }
650
- })));
651
- await store.setRunComments(run.id, { threads: inventory.commentThreads, comments: inventory.comments, attachments: inventory.commentAttachments });
652
- if (inventoryErrors.length > 0) {
653
- const finalStatus = options.signal?.aborted ? 'cancelled' : 'failed';
654
- report.finish(finalStatus);
655
- const reportPaths = await report.write(options.reportsDirectory);
656
- if (!options.dryRun)
657
- await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, graph.pages.length));
658
- throw new MigrationRunError('Resource preflight failed before destination mutation', reportPaths, stateLocation.filename);
659
- }
660
- if (!options.dryRun) {
661
- for (const page of graph.pages) {
662
- if (store.value.pages[page.canonicalId])
663
- continue;
664
- const commentInventory = commentsByPage.get(page.canonicalId);
665
- const commentState = !commentsEnabled
666
- ? { status: 'skipped', threads: 0, comments: 0, participants: 0, attachments: 0, bytes: 0, updatedAt: new Date().toISOString() }
667
- : commentInventory?.status === 'empty'
668
- ? { status: 'complete', threads: 0, comments: 0, participants: 0, attachments: 0, bytes: 0, updatedAt: new Date().toISOString() }
669
- : commentInventory?.status === 'failed'
670
- ? {
671
- status: 'failed', threads: 0, comments: 0, participants: 0, attachments: 0, bytes: 0,
672
- ...(commentInventory.error ? { lastError: commentInventory.error } : {}), updatedAt: new Date().toISOString(),
673
- }
674
- : commentInventory
595
+ const resourcesByPage = new Map();
596
+ const commentsByPage = new Map();
597
+ const inventoryQueue = new PQueue({ concurrency: options.concurrency });
598
+ const inventory = { resources: 0, bytes: 0, unknownSizes: 0, grids: 0, links: 0, images: 0, tables: 0, codeBlocks: 0, commentThreads: 0, comments: 0, commentParticipants: 0, commentAttachments: 0, commentBytes: 0 };
599
+ status(`Inspecting content and resources for ${graph.pages.length} pages`);
600
+ const inventoryErrors = [];
601
+ await Promise.all(graph.pages.map((page) => inventoryQueue.add(async () => {
602
+ try {
603
+ if (options.signal?.aborted)
604
+ throw options.signal.reason ?? new Error('Transfer cancelled');
605
+ const cachedPage = await loadCachedPage(options.cacheDirectory, page.canonicalId);
606
+ const resources = await source.listResources(page);
607
+ resourcesByPage.set(page.canonicalId, resources);
608
+ let commentInventory;
609
+ if (commentsEnabled) {
610
+ try {
611
+ if (!source.readComments)
612
+ throw new Error(`${source.profile.name} does not implement comment reads`);
613
+ commentInventory = await source.readComments(cachedPage);
614
+ resolveCommentAttachmentReferences(commentInventory, resources);
615
+ commentsByPage.set(page.canonicalId, commentInventory);
616
+ inventory.commentThreads += commentInventory.threads.length;
617
+ inventory.comments += commentInventory.comments;
618
+ inventory.commentParticipants += commentInventory.participants;
619
+ inventory.commentAttachments += commentInventory.attachments;
620
+ inventory.commentBytes += commentInventory.knownBytes;
621
+ for (const warning of commentInventory.warnings)
622
+ report.issue({
623
+ code: `comment-inventory-warning:${warning}`,
624
+ severity: 'warning',
625
+ phase: 'inventory',
626
+ pageId: page.canonicalId,
627
+ retryable: false,
628
+ message: `${page.title}: ${warning}`,
629
+ });
630
+ }
631
+ catch (error) {
632
+ const message = `${page.title}: comments could not be read: ${error instanceof Error ? error.message : String(error)}`;
633
+ commentInventory = { status: 'failed', threads: [], comments: 0, participants: 0, attachments: 0, knownBytes: 0, unknownSizes: 0, warnings: [], error: message };
634
+ commentsByPage.set(page.canonicalId, commentInventory);
635
+ report.issue({ code: 'comment-inventory-failed', severity: 'error', phase: 'inventory', pageId: page.canonicalId, retryable: true, message });
636
+ }
637
+ }
638
+ const document = appendGridSnapshots(normalizePageContent(cachedPage), resources);
639
+ await cacheDocument(options.cacheDirectory, page.canonicalId, document);
640
+ inventory.resources += resources.filter((resource) => resource.kind !== 'grid').length;
641
+ inventory.bytes += resources.reduce((total, resource) => total + (resource.size ?? 0), 0);
642
+ inventory.unknownSizes += resources.filter((resource) => resource.kind !== 'grid' && resource.size === undefined).length;
643
+ inventory.grids += resources.filter((resource) => resource.kind === 'grid').length;
644
+ inventory.links += document.metrics.links;
645
+ inventory.images += document.metrics.images;
646
+ inventory.tables += document.metrics.tables;
647
+ inventory.codeBlocks += document.metrics.codeBlocks;
648
+ report.addPage(reportPage({
649
+ page,
650
+ status: 'planned',
651
+ resources,
652
+ document,
653
+ commentsEnabled,
654
+ ...(commentInventory ? { commentInventory } : {}),
655
+ }), undefined, false);
656
+ status(`Inspected ${page.title} · ${resources.length} resources`);
657
+ }
658
+ catch (error) {
659
+ const message = `${page.title}: ${error instanceof Error ? error.message : String(error)}`;
660
+ inventoryErrors.push(message);
661
+ report.updatePage(page.canonicalId, { status: 'failed', inventoryStatus: 'failed', error: message });
662
+ report.issue({
663
+ code: 'source-inventory-failed',
664
+ severity: 'error',
665
+ phase: 'inventory',
666
+ pageId: page.canonicalId,
667
+ retryable: true,
668
+ message,
669
+ });
670
+ }
671
+ })));
672
+ await store.setRunComments(run.id, { threads: inventory.commentThreads, comments: inventory.comments, attachments: inventory.commentAttachments });
673
+ if (inventoryErrors.length > 0) {
674
+ const finalStatus = options.signal?.aborted ? 'cancelled' : 'failed';
675
+ report.finish(finalStatus);
676
+ const reportPaths = await report.write(options.reportsDirectory);
677
+ if (!options.dryRun)
678
+ await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, graph.pages.length));
679
+ throw new MigrationRunError('Resource preflight failed before destination mutation', reportPaths, stateLocation.filename);
680
+ }
681
+ if (!options.dryRun) {
682
+ for (const page of graph.pages) {
683
+ if (store.value.pages[page.canonicalId])
684
+ continue;
685
+ const commentInventory = commentsByPage.get(page.canonicalId);
686
+ const commentState = !commentsEnabled
687
+ ? { status: 'skipped', threads: 0, comments: 0, participants: 0, attachments: 0, bytes: 0, updatedAt: new Date().toISOString() }
688
+ : commentInventory?.status === 'empty'
689
+ ? { status: 'complete', threads: 0, comments: 0, participants: 0, attachments: 0, bytes: 0, updatedAt: new Date().toISOString() }
690
+ : commentInventory?.status === 'failed'
675
691
  ? {
676
- status: 'planned', ...(commentInventory.fingerprint ? { fingerprint: commentInventory.fingerprint } : {}),
677
- threads: commentInventory.threads.length, comments: commentInventory.comments,
678
- participants: commentInventory.participants, attachments: commentInventory.attachments,
679
- bytes: commentInventory.knownBytes, updatedAt: new Date().toISOString(),
692
+ status: 'failed', threads: 0, comments: 0, participants: 0, attachments: 0, bytes: 0,
693
+ ...(commentInventory.error ? { lastError: commentInventory.error } : {}), updatedAt: new Date().toISOString(),
680
694
  }
681
- : undefined;
682
- const resourceStates = Object.fromEntries((resourcesByPage.get(page.canonicalId) ?? [])
683
- .filter((resource) => resource.kind !== 'grid')
684
- .map((resource) => [resource.id, {
685
- sourceId: resource.id,
686
- sourceName: resource.name,
687
- destinationName: preferredDestinationName(resource),
688
- ...(resource.size !== undefined ? { size: resource.size } : {}),
689
- ...(resource.mimeType ? { mimeType: resource.mimeType } : {}),
690
- status: 'planned',
695
+ : commentInventory
696
+ ? {
697
+ status: 'planned', ...(commentInventory.fingerprint ? { fingerprint: commentInventory.fingerprint } : {}),
698
+ threads: commentInventory.threads.length, comments: commentInventory.comments,
699
+ participants: commentInventory.participants, attachments: commentInventory.attachments,
700
+ bytes: commentInventory.knownBytes, updatedAt: new Date().toISOString(),
701
+ }
702
+ : undefined;
703
+ const resourceStates = Object.fromEntries((resourcesByPage.get(page.canonicalId) ?? [])
704
+ .filter((resource) => resource.kind !== 'grid')
705
+ .map((resource) => [resource.id, {
706
+ sourceId: resource.id,
707
+ sourceName: resource.name,
708
+ destinationName: preferredDestinationName(resource),
709
+ ...(resource.size !== undefined ? { size: resource.size } : {}),
710
+ ...(resource.mimeType ? { mimeType: resource.mimeType } : {}),
711
+ status: 'planned',
712
+ updatedAt: new Date().toISOString(),
713
+ }]));
714
+ await store.setPage({
715
+ sourceId: page.id,
716
+ sourceCanonicalId: page.canonicalId,
717
+ sourceLocator: page.locator,
718
+ sourceTitle: page.title,
719
+ ...(page.projectedParentId ? { projectedParentId: page.projectedParentId } : {}),
720
+ destinationTitle: page.title,
721
+ sourceFingerprint: sourceFingerprints.get(page.canonicalId),
722
+ status: 'discovered',
723
+ resources: resourceStates,
724
+ ...(commentState ? { comments: commentState } : {}),
725
+ degradations: [],
691
726
  updatedAt: new Date().toISOString(),
692
- }]));
693
- await store.setPage({
694
- sourceId: page.id,
695
- sourceCanonicalId: page.canonicalId,
696
- sourceLocator: page.locator,
697
- sourceTitle: page.title,
698
- ...(page.projectedParentId ? { projectedParentId: page.projectedParentId } : {}),
699
- destinationTitle: page.title,
700
- sourceFingerprint: sourceFingerprints.get(page.canonicalId),
701
- status: 'discovered',
702
- resources: resourceStates,
703
- ...(commentState ? { comments: commentState } : {}),
704
- degradations: [],
705
- updatedAt: new Date().toISOString(),
706
- });
727
+ });
728
+ }
707
729
  }
708
- }
709
- 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 · ${commentsEnabled ? `${inventory.commentThreads} comment threads / ${inventory.comments} entries / ${inventory.commentAttachments} files (${inventory.commentBytes} known bytes)` : 'comments skipped'}`;
710
- status(planSummary);
711
- this.logger.info(planSummary);
712
- let output;
713
- let archive;
714
- try {
715
- if (options.destination.type === 'directory') {
716
- const result = await this.exportFilesystem(source, graph, options.destination, options, stateLocation.id, store, report, sourceFingerprints, resourcesByPage, commentsByPage);
717
- output = result.directory;
718
- archive = result.archive;
719
- const exportEntry = path.join(result.directory, options.destination.format === 'html' ? 'index.html' : 'index.md');
720
- report.setDestinationUrl(pathToFileURL(exportEntry).toString());
721
- report.output('directory', result.directory);
722
- if (result.archive)
723
- report.output('archive', result.archive);
724
- if (result.backup)
725
- report.output('backup', result.backup);
730
+ 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 · ${commentsEnabled ? `${inventory.commentThreads} comment threads / ${inventory.comments} entries / ${inventory.commentAttachments} files (${inventory.commentBytes} known bytes)` : 'comments skipped'}`;
731
+ status(planSummary);
732
+ this.logger.info(planSummary);
733
+ let output;
734
+ let archive;
735
+ try {
736
+ if (options.destination.type === 'directory') {
737
+ const result = await this.exportFilesystem(source, graph, options.destination, options, stateLocation.id, store, report, sourceFingerprints, resourcesByPage, commentsByPage);
738
+ output = result.directory;
739
+ archive = result.archive;
740
+ const exportEntry = path.join(result.directory, options.destination.format === 'html' ? 'index.html' : 'index.md');
741
+ report.setDestinationUrl(pathToFileURL(exportEntry).toString());
742
+ report.output('directory', result.directory);
743
+ if (result.archive)
744
+ report.output('archive', result.archive);
745
+ if (result.backup)
746
+ report.output('backup', result.backup);
747
+ }
748
+ else {
749
+ await this.transferRemote({
750
+ source,
751
+ destination: destination,
752
+ container: container,
753
+ graph,
754
+ options,
755
+ store,
756
+ report,
757
+ sourceFingerprints,
758
+ resourcesByPage,
759
+ commentsByPage,
760
+ });
761
+ output = container.url;
762
+ report.output('destination', container.url);
763
+ }
764
+ report.finish('completed');
765
+ const finalStatus = runStatus(report.data.summary.errors, report.data.summary.warnings, report.data.summary.degradations);
766
+ await store.finalizeOutcomes(graph.pages.map((page) => page.canonicalId), finalStatus, options.dryRun);
767
+ report.finish(finalStatus);
768
+ const reportPaths = await report.write(options.reportsDirectory);
769
+ if (!options.dryRun)
770
+ await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, graph.pages.length));
771
+ 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`);
772
+ return {
773
+ status: finalStatus,
774
+ hasErrors: report.data.summary.errors > 0,
775
+ reportPaths,
776
+ stateId: stateLocation.id,
777
+ stateFilename: stateLocation.filename,
778
+ ...(store.loadInfo.backupFilename ? { stateBackupFilename: store.loadInfo.backupFilename } : {}),
779
+ ...(output ? { output } : {}),
780
+ ...(archive ? { archive } : {}),
781
+ summary: report.data.summary,
782
+ };
726
783
  }
727
- else {
728
- await this.transferRemote({
729
- source,
730
- destination: destination,
731
- container: container,
732
- graph,
733
- options,
734
- store,
735
- report,
736
- sourceFingerprints,
737
- resourcesByPage,
738
- commentsByPage,
739
- });
740
- output = container.url;
741
- report.output('destination', container.url);
784
+ catch (error) {
785
+ const cancelled = options.signal?.aborted;
786
+ const finalStatus = cancelled ? 'cancelled' : 'failed';
787
+ const message = error instanceof Error ? error.message : String(error);
788
+ report.error(message);
789
+ await store.finalizeOutcomes(graph.pages.map((page) => page.canonicalId), finalStatus, options.dryRun);
790
+ report.finish(finalStatus);
791
+ const reportPaths = await report.write(options.reportsDirectory);
792
+ if (!options.dryRun)
793
+ await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, graph.pages.length));
794
+ this.logger.error(`${message}. Report: ${reportPaths.html}`);
795
+ throw new MigrationRunError(message, reportPaths, stateLocation.filename, { cause: error });
742
796
  }
743
- report.finish('completed');
744
- const finalStatus = runStatus(report.data.summary.errors, report.data.summary.warnings, report.data.summary.degradations);
745
- await store.finalizeOutcomes(graph.pages.map((page) => page.canonicalId), finalStatus, options.dryRun);
746
- report.finish(finalStatus);
747
- const reportPaths = await report.write(options.reportsDirectory);
748
- if (!options.dryRun)
749
- await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, graph.pages.length));
750
- 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`);
751
- return {
752
- status: finalStatus,
753
- hasErrors: report.data.summary.errors > 0,
754
- reportPaths,
755
- stateId: stateLocation.id,
756
- stateFilename: stateLocation.filename,
757
- ...(store.loadInfo.backupFilename ? { stateBackupFilename: store.loadInfo.backupFilename } : {}),
758
- ...(output ? { output } : {}),
759
- ...(archive ? { archive } : {}),
760
- summary: report.data.summary,
761
- };
762
797
  }
763
- catch (error) {
764
- const cancelled = options.signal?.aborted;
765
- const finalStatus = cancelled ? 'cancelled' : 'failed';
766
- const message = error instanceof Error ? error.message : String(error);
767
- report.error(message);
768
- await store.finalizeOutcomes(graph.pages.map((page) => page.canonicalId), finalStatus, options.dryRun);
769
- report.finish(finalStatus);
770
- const reportPaths = await report.write(options.reportsDirectory);
771
- if (!options.dryRun)
772
- await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, graph.pages.length));
773
- this.logger.error(`${message}. Report: ${reportPaths.html}`);
774
- throw new MigrationRunError(message, reportPaths, stateLocation.filename, { cause: error });
798
+ finally {
799
+ await store.release();
775
800
  }
776
801
  }
777
802
  async exportFilesystem(source, graph, destination, options, identity, store, report, sourceFingerprints, resourcesByPage, commentsByPage) {
@@ -836,6 +861,7 @@ export class Migrator {
836
861
  status: pageStatus,
837
862
  resources,
838
863
  degradations: manifestPage?.warnings ?? [],
864
+ adaptations: manifestPage?.adaptations ?? [],
839
865
  ...(manifestPage?.comments ? { comments: {
840
866
  status: manifestPage.comments.status,
841
867
  ...(manifestPage.comments.fingerprint ? { fingerprint: manifestPage.comments.fingerprint } : {}),
@@ -867,6 +893,8 @@ export class Migrator {
867
893
  resourceStates: resources,
868
894
  ...(options.dryRun ? { resourceStatusOverride: 'planned' } : {}),
869
895
  ...(manifestPage?.warnings ? { warnings: manifestPage.warnings } : {}),
896
+ ...(manifestPage?.adaptations ? { adaptations: manifestPage.adaptations } : {}),
897
+ preservedExternalReferences: manifestPage?.preservedExternalReferences ?? 0,
870
898
  commentsEnabled: options.comments ?? true,
871
899
  ...(commentsByPage.get(page.canonicalId) ? { commentInventory: commentsByPage.get(page.canonicalId) } : {}),
872
900
  ...(manifestPage?.comments ? { commentState: {
@@ -986,7 +1014,7 @@ export class Migrator {
986
1014
  }
987
1015
  }
988
1016
  current ??= await destination.findArchiveComment?.(destinationPage, fingerprint);
989
- const remoteResources = await destination.listUploadedResources(destinationPage);
1017
+ let remoteResources = await destination.listUploadedResources(destinationPage);
990
1018
  const remoteMatches = async (remote, file, trustedMapping) => {
991
1019
  if (remote.size !== undefined && remote.size !== file.size)
992
1020
  return false;
@@ -1010,18 +1038,37 @@ export class Migrator {
1010
1038
  if (reusable)
1011
1039
  return reusable;
1012
1040
  const extension = path.extname(file.name);
1013
- const alternativeName = `${file.name.slice(0, file.name.length - extension.length)}-${file.sha256.slice(0, 8)}${extension}`;
1014
- const alternative = remoteResources.find((resource) => resource.name.toLocaleLowerCase() === alternativeName.toLocaleLowerCase());
1015
- if (alternative && await remoteMatches(alternative, file, false))
1016
- return alternative;
1017
- const uploadName = mapped || byName ? alternativeName : file.name;
1018
- return destination.uploadResource({
1019
- page: destinationPage,
1020
- name: uploadName,
1021
- filePath: file.path,
1022
- size: file.size,
1023
- ...(mimeType ? { mimeType } : {}),
1024
- });
1041
+ const base = file.name.slice(0, file.name.length - extension.length);
1042
+ let lastError;
1043
+ for (let attempt = 0; attempt < 10; attempt += 1) {
1044
+ const uploadName = attempt === 0 && !mapped && !byName
1045
+ ? file.name
1046
+ : `${base}-${file.sha256.slice(0, Math.min(file.sha256.length, 8 + attempt * 2))}${extension}`;
1047
+ const occupied = remoteResources.find((resource) => resource.name.toLocaleLowerCase() === uploadName.toLocaleLowerCase());
1048
+ if (occupied && await remoteMatches(occupied, file, false))
1049
+ return occupied;
1050
+ if (occupied)
1051
+ continue;
1052
+ try {
1053
+ const uploaded = await destination.uploadResource({
1054
+ page: destinationPage,
1055
+ name: uploadName,
1056
+ filePath: file.path,
1057
+ size: file.size,
1058
+ ...(mimeType ? { mimeType } : {}),
1059
+ });
1060
+ remoteResources.push(uploaded);
1061
+ return uploaded;
1062
+ }
1063
+ catch (error) {
1064
+ lastError = error;
1065
+ remoteResources = await destination.listUploadedResources(destinationPage);
1066
+ const uncertain = remoteResources.find((resource) => resource.name.toLocaleLowerCase() === uploadName.toLocaleLowerCase());
1067
+ if (uncertain && await remoteMatches(uncertain, file, false))
1068
+ return uncertain;
1069
+ }
1070
+ }
1071
+ throw new Error(`Archive upload could not be reconciled after 10 deterministic names: ${lastError instanceof Error ? lastError.message : String(lastError ?? 'unknown provider response')}`);
1025
1072
  };
1026
1073
  const archiveResource = zip
1027
1074
  ? await uploadArchive(zip, previous?.archiveResourceId, 'application/zip')
@@ -1209,6 +1256,17 @@ export class Migrator {
1209
1256
  }
1210
1257
  if (!verification.ok)
1211
1258
  throw new Error(`Comment verification failed: ${verification.differences.join('; ')}`);
1259
+ if (fallbackReason) {
1260
+ report.issue({
1261
+ code: 'comment-presentation-adapted',
1262
+ severity: 'adaptation',
1263
+ phase: 'comment',
1264
+ pageId: page.canonicalId,
1265
+ commentId: verified.id,
1266
+ retryable: false,
1267
+ message: `${page.title}: ${fallbackReason}`,
1268
+ });
1269
+ }
1212
1270
  const previousIds = result.id !== current?.id && current
1213
1271
  ? [...new Set([...(previous?.previousDestinationIds ?? []), current.id])]
1214
1272
  : previous?.previousDestinationIds;
@@ -1221,10 +1279,9 @@ export class Migrator {
1221
1279
  ...(previousIds?.length ? { previousDestinationIds: previousIds } : {}),
1222
1280
  ...archiveState,
1223
1281
  ...fullArchiveState,
1224
- status: inventory.warnings.length > 0 || fallbackReason ? 'partial' : 'complete',
1282
+ status: inventory.warnings.length > 0 ? 'partial' : 'complete',
1225
1283
  ...counts,
1226
1284
  preview: verified.body,
1227
- ...(fallbackReason ? { lastError: fallbackReason } : {}),
1228
1285
  updatedAt: now(),
1229
1286
  };
1230
1287
  pageState.comments = state;
@@ -1250,6 +1307,16 @@ export class Migrator {
1250
1307
  async transferRemote(input) {
1251
1308
  const { source, destination, container, graph, options, store, report, sourceFingerprints, resourcesByPage, commentsByPage } = input;
1252
1309
  const destinationPages = new Map();
1310
+ const mappedOwners = new Map();
1311
+ for (const page of Object.values(store.value.pages)) {
1312
+ const destinationId = page.destinationId;
1313
+ if (!destinationId)
1314
+ continue;
1315
+ mappedOwners.set(destinationId, [...(mappedOwners.get(destinationId) ?? []), page.sourceCanonicalId]);
1316
+ }
1317
+ const ownershipConflicts = new Map([...mappedOwners.entries()]
1318
+ .filter(([, owners]) => owners.length > 1)
1319
+ .flatMap(([destinationId, owners]) => owners.map((owner) => [owner, { destinationId, owners }])));
1253
1320
  const stagingFailures = new Map();
1254
1321
  const sourcePages = new Map(graph.pages.map((page) => [page.canonicalId, page]));
1255
1322
  const bootstrapBodies = new Map();
@@ -1335,6 +1402,10 @@ export class Migrator {
1335
1402
  continue;
1336
1403
  }
1337
1404
  try {
1405
+ const ownershipConflict = ownershipConflicts.get(page.canonicalId);
1406
+ if (ownershipConflict) {
1407
+ throw new DestinationOwnershipConflictError('duplicate-source-mapping', [ownershipConflict.destinationId, ...ownershipConflict.owners], `Multiple source pages are mapped to destination page ${ownershipConflict.destinationId}: ${ownershipConflict.owners.join(', ')}`);
1408
+ }
1338
1409
  const cachedPage = await loadCachedPage(options.cacheDirectory, page.canonicalId);
1339
1410
  const bootstrap = bootstrapBodies.get(page.canonicalId);
1340
1411
  let created;
@@ -1533,8 +1604,9 @@ export class Migrator {
1533
1604
  if (options.signal?.aborted)
1534
1605
  throw options.signal.reason ?? error;
1535
1606
  const message = `${page.title}: ${error instanceof Error ? error.message : String(error)}`;
1536
- const failureStatus = /conflict/i.test(message) ? 'conflict' : 'failed';
1537
- const failureCode = failureStatus === 'conflict' ? 'destination-conflict' : 'destination-page-staging-failed';
1607
+ const ownershipConflict = error instanceof DestinationOwnershipConflictError ? error : undefined;
1608
+ const failureStatus = ownershipConflict ? 'conflict' : 'failed';
1609
+ const failureCode = ownershipConflict ? 'destination-ownership-ambiguity' : 'destination-page-staging-failed';
1538
1610
  stagingFailures.set(page.canonicalId, {
1539
1611
  status: failureStatus,
1540
1612
  message,
@@ -1552,13 +1624,14 @@ export class Migrator {
1552
1624
  });
1553
1625
  const savedState = store.value.pages[page.canonicalId];
1554
1626
  if (!options.dryRun) {
1627
+ const { conflictEvidence: _staleConflictEvidence, ...baseState } = savedState ?? {};
1555
1628
  const cause = { blockedByPageId: page.canonicalId, blockedByCode: failureCode, blockedReason: message };
1556
1629
  const resources = resourcesByPage.get(page.canonicalId) ?? [];
1557
1630
  const comments = (options.comments ?? true)
1558
1631
  ? blockedCommentState(commentsByPage.get(page.canonicalId), savedState?.comments, cause)
1559
1632
  : undefined;
1560
1633
  await store.setPage({
1561
- ...(savedState ?? {}),
1634
+ ...baseState,
1562
1635
  sourceId: page.id,
1563
1636
  sourceCanonicalId: page.canonicalId,
1564
1637
  sourceLocator: page.locator,
@@ -1570,6 +1643,8 @@ export class Migrator {
1570
1643
  resources: blockedResourceStates(resources, savedState?.resources ?? {}, cause),
1571
1644
  ...(comments ? { comments } : {}),
1572
1645
  degradations: savedState?.degradations ?? [],
1646
+ adaptations: savedState?.adaptations ?? [],
1647
+ ...(ownershipConflict ? { conflictEvidence: { reason: ownershipConflict.reason, candidateIds: ownershipConflict.candidateIds } } : {}),
1573
1648
  lastError: message,
1574
1649
  updatedAt: new Date().toISOString(),
1575
1650
  });
@@ -1609,6 +1684,7 @@ export class Migrator {
1609
1684
  commentsEnabled: options.comments ?? true,
1610
1685
  ...(commentsByPage.get(page.canonicalId) ? { commentInventory: commentsByPage.get(page.canonicalId) } : {}),
1611
1686
  ...(commentState ? { commentState } : {}),
1687
+ ...(failedState?.conflictEvidence ? { conflictEvidence: failedState.conflictEvidence } : {}),
1612
1688
  blockedBy,
1613
1689
  error: currentFailure?.message ?? failedState?.lastError ?? 'Destination page with source-derived content was not created',
1614
1690
  }));
@@ -1747,8 +1823,8 @@ export class Migrator {
1747
1823
  updateProgress(`reconciled ${resource.name}`);
1748
1824
  continue;
1749
1825
  }
1750
- const updateExisting = existing?.status === 'complete' && mappedRemote ? mappedRemote : undefined;
1751
- const destinationName = updateExisting?.name ?? nameAllocator.allocate(resource, existing?.status === 'complete' && !nameAllocator.isOccupied(existing.destinationName) ? existing.destinationName : undefined);
1826
+ const updateExisting = destination.kind === 'confluence' && existing?.status === 'complete' && mappedRemote ? mappedRemote : undefined;
1827
+ let destinationName = updateExisting?.name ?? nameAllocator.allocate(resource, existing?.status === 'complete' && !nameAllocator.isOccupied(existing.destinationName) ? existing.destinationName : undefined);
1752
1828
  state.resources[resource.id] = {
1753
1829
  sourceId: resource.id,
1754
1830
  sourceName: resource.name,
@@ -1761,9 +1837,19 @@ export class Migrator {
1761
1837
  updatedAt: new Date().toISOString(),
1762
1838
  };
1763
1839
  await store.setPage({ ...state, updatedAt: new Date().toISOString() });
1764
- const matchingName = remoteResources.find((item) => item.name.toLocaleLowerCase() === destinationName.toLocaleLowerCase());
1765
- let result = matchingName && await remoteMatches(matchingName, downloaded) ? matchingName : undefined;
1766
- if (!result) {
1840
+ let result;
1841
+ let lastUploadError;
1842
+ for (let attempt = 0; attempt < 10 && !result; attempt += 1) {
1843
+ const matchingName = remoteResources.find((item) => item.name.toLocaleLowerCase() === destinationName.toLocaleLowerCase());
1844
+ if (matchingName && await remoteMatches(matchingName, downloaded)) {
1845
+ result = matchingName;
1846
+ break;
1847
+ }
1848
+ if (matchingName) {
1849
+ nameAllocator.reserve(matchingName.name);
1850
+ destinationName = nameAllocator.allocate(resource);
1851
+ continue;
1852
+ }
1767
1853
  try {
1768
1854
  result = await destination.uploadResource({
1769
1855
  page: destinationPage,
@@ -1771,26 +1857,25 @@ export class Migrator {
1771
1857
  filePath: downloaded.path,
1772
1858
  size: downloaded.size,
1773
1859
  ...(mimeType ? { mimeType } : {}),
1774
- ...(updateExisting ? { existingId: updateExisting.id } : {}),
1860
+ ...(attempt === 0 && updateExisting ? { existingId: updateExisting.id } : {}),
1775
1861
  });
1776
1862
  }
1777
1863
  catch (uploadError) {
1864
+ lastUploadError = uploadError;
1778
1865
  remoteResources = await destination.listUploadedResources(destinationPage);
1866
+ for (const remote of remoteResources)
1867
+ nameAllocator.reserve(remote.name);
1779
1868
  const uncertain = remoteResources.find((item) => item.name.toLocaleLowerCase() === destinationName.toLocaleLowerCase());
1780
- if (uncertain && await remoteMatches(uncertain, downloaded))
1869
+ if (uncertain && await remoteMatches(uncertain, downloaded)) {
1781
1870
  result = uncertain;
1782
- else {
1783
- const retryName = nameAllocator.allocate(resource);
1784
- result = await destination.uploadResource({
1785
- page: destinationPage,
1786
- name: retryName,
1787
- filePath: downloaded.path,
1788
- size: downloaded.size,
1789
- ...(mimeType ? { mimeType } : {}),
1790
- }).catch((retryError) => { throw new Error(`${retryError instanceof Error ? retryError.message : String(retryError)} (initial upload: ${uploadError instanceof Error ? uploadError.message : String(uploadError)})`); });
1871
+ break;
1791
1872
  }
1873
+ destinationName = nameAllocator.allocate(resource);
1792
1874
  }
1793
1875
  }
1876
+ if (!result) {
1877
+ throw new Error(`Resource upload could not be reconciled after 10 deterministic names: ${lastUploadError instanceof Error ? lastUploadError.message : String(lastUploadError ?? 'unknown provider response')}`);
1878
+ }
1794
1879
  if (!remoteResources.some((item) => item.id === result.id))
1795
1880
  remoteResources.push(result);
1796
1881
  setSourceResourceBinding(bindings, cachedPage, resources, resource, { kind: 'uploaded', resource: result });
@@ -1873,31 +1958,63 @@ export class Migrator {
1873
1958
  sha256: sha256(cachedPage.rawContent),
1874
1959
  };
1875
1960
  try {
1876
- const knownSnapshot = remoteResources.find((item) => item.id === existingSnapshot?.destinationId)
1877
- ?? remoteResources.find((item) => item.name.toLocaleLowerCase() === snapshotName.toLocaleLowerCase());
1878
- let snapshot = knownSnapshot && await remoteMatches(knownSnapshot, snapshotFile, true)
1879
- ? knownSnapshot
1961
+ const mappedSnapshot = remoteResources.find((item) => item.id === existingSnapshot?.destinationId);
1962
+ let snapshot = mappedSnapshot && await remoteMatches(mappedSnapshot, snapshotFile, true)
1963
+ ? mappedSnapshot
1880
1964
  : undefined;
1881
- if (!snapshot) {
1965
+ const sameNameSnapshot = remoteResources.find((item) => item.name.toLocaleLowerCase() === snapshotName.toLocaleLowerCase());
1966
+ if (!snapshot && sameNameSnapshot && await remoteMatches(sameNameSnapshot, snapshotFile, false)) {
1967
+ snapshot = sameNameSnapshot;
1968
+ }
1969
+ const snapshotResource = {
1970
+ provider: cachedPage.provider,
1971
+ id: snapshotKey,
1972
+ pageId: cachedPage.id,
1973
+ name: snapshotName,
1974
+ preferredName: snapshotName,
1975
+ kind: 'attachment',
1976
+ };
1977
+ let destinationName = mappedSnapshot && destination.kind === 'confluence'
1978
+ ? mappedSnapshot.name
1979
+ : nameAllocator.allocate(snapshotResource);
1980
+ let lastUploadError;
1981
+ for (let attempt = 0; attempt < 10 && !snapshot; attempt += 1) {
1982
+ const occupied = remoteResources.find((item) => item.name.toLocaleLowerCase() === destinationName.toLocaleLowerCase());
1983
+ if (occupied && await remoteMatches(occupied, snapshotFile, occupied.id === mappedSnapshot?.id)) {
1984
+ snapshot = occupied;
1985
+ break;
1986
+ }
1987
+ if (occupied && occupied.id !== mappedSnapshot?.id) {
1988
+ nameAllocator.reserve(occupied.name);
1989
+ destinationName = nameAllocator.allocate(snapshotResource);
1990
+ continue;
1991
+ }
1882
1992
  try {
1883
1993
  snapshot = await destination.uploadResource({
1884
1994
  page: destinationPage,
1885
- name: snapshotName,
1995
+ name: destinationName,
1886
1996
  filePath: snapshotPath,
1887
1997
  size: snapshotFile.size,
1888
1998
  mimeType: page.rawFormat === 'yfm' ? 'text/markdown' : 'application/xml',
1889
- ...(knownSnapshot ? { existingId: knownSnapshot.id } : {}),
1999
+ ...(attempt === 0 && mappedSnapshot && destination.kind === 'confluence' ? { existingId: mappedSnapshot.id } : {}),
1890
2000
  });
1891
2001
  }
1892
2002
  catch (uploadError) {
2003
+ lastUploadError = uploadError;
1893
2004
  remoteResources = await destination.listUploadedResources(destinationPage);
1894
- const uncertain = remoteResources.find((item) => item.name.toLocaleLowerCase() === snapshotName.toLocaleLowerCase());
1895
- if (uncertain && await remoteMatches(uncertain, snapshotFile, true))
2005
+ for (const remote of remoteResources)
2006
+ nameAllocator.reserve(remote.name);
2007
+ const uncertain = remoteResources.find((item) => item.name.toLocaleLowerCase() === destinationName.toLocaleLowerCase());
2008
+ if (uncertain && await remoteMatches(uncertain, snapshotFile, uncertain.id === mappedSnapshot?.id)) {
1896
2009
  snapshot = uncertain;
1897
- else
1898
- throw uploadError;
2010
+ break;
2011
+ }
2012
+ destinationName = nameAllocator.allocate(snapshotResource);
1899
2013
  }
1900
2014
  }
2015
+ if (!snapshot) {
2016
+ throw new Error(`Raw snapshot upload could not be reconciled after 10 deterministic names: ${lastUploadError instanceof Error ? lastUploadError.message : String(lastUploadError ?? 'unknown provider response')}`);
2017
+ }
1901
2018
  if (!remoteResources.some((item) => item.id === snapshot.id))
1902
2019
  remoteResources.push(snapshot);
1903
2020
  state.resources[snapshotKey] = {
@@ -1937,6 +2054,7 @@ export class Migrator {
1937
2054
  state.degradations = [...new Set([...rendered.warnings, ...resourceProblems])];
1938
2055
  }
1939
2056
  let verified = destinationPage;
2057
+ let verificationAdaptations = [];
1940
2058
  let verificationDegradations = [];
1941
2059
  if (!options.dryRun) {
1942
2060
  const current = await verifyConfirmedDestinationPage(destination, destinationPage, this.consistencyOptions);
@@ -1963,6 +2081,7 @@ export class Migrator {
1963
2081
  });
1964
2082
  }
1965
2083
  const verification = assertDestinationContent(rendered.body, verified.content, destination.kind, cachedPage);
2084
+ verificationAdaptations = verification.adaptations;
1966
2085
  verificationDegradations = verification.status === 'partial' ? verification.differences : [];
1967
2086
  }
1968
2087
  const commentInventory = commentsByPage.get(page.canonicalId);
@@ -2009,7 +2128,10 @@ export class Migrator {
2009
2128
  });
2010
2129
  }
2011
2130
  const commentPartial = !['complete', 'skipped', 'planned'].includes(commentState.status);
2012
- const partial = rendered.degraded || resourceProblems.length > 0 || verificationDegradations.length > 0 || commentPartial;
2131
+ const renderedDegradations = rendered.notices
2132
+ .filter((notice) => notice.kind === 'degradation')
2133
+ .map((notice) => notice.message);
2134
+ const partial = renderedDegradations.length > 0 || resourceProblems.length > 0 || verificationDegradations.length > 0 || commentPartial;
2013
2135
  const lastResourceProblem = resourceProblems.at(-1);
2014
2136
  const clearedState = { ...state };
2015
2137
  delete clearedState.lastError;
@@ -2023,7 +2145,8 @@ export class Migrator {
2023
2145
  ...(verified.version ? { destinationVersion: verified.version } : {}),
2024
2146
  status: options.dryRun ? 'planned' : partial ? 'partial' : 'complete',
2025
2147
  comments: commentState,
2026
- degradations: [...new Set([...rendered.warnings, ...resourceProblems, ...verificationDegradations])],
2148
+ degradations: [...new Set([...renderedDegradations, ...resourceProblems, ...verificationDegradations])],
2149
+ adaptations: [...new Set([...rendered.adaptations, ...verificationAdaptations])],
2027
2150
  ...(lastResourceProblem ? { lastError: lastResourceProblem } : {}),
2028
2151
  updatedAt: new Date().toISOString(),
2029
2152
  };
@@ -2045,6 +2168,8 @@ export class Migrator {
2045
2168
  resourceFallbacks: fallbackDetails,
2046
2169
  warnings: [...rendered.warnings, ...verificationDegradations],
2047
2170
  normalizations: rendered.normalizations,
2171
+ adaptations: [...rendered.adaptations, ...verificationAdaptations],
2172
+ preservedExternalReferences: rendered.preservedExternalReferences,
2048
2173
  commentsEnabled: options.comments ?? true,
2049
2174
  ...(commentInventory ? { commentInventory } : {}),
2050
2175
  commentState,
@@ -2052,7 +2177,8 @@ export class Migrator {
2052
2177
  }
2053
2178
  catch (error) {
2054
2179
  const message = `${page.title}: ${error instanceof Error ? error.message : String(error)}`;
2055
- let failureStatus = /conflict/i.test(message) ? 'conflict' : 'failed';
2180
+ const ownershipConflict = error instanceof DestinationOwnershipConflictError ? error : undefined;
2181
+ let failureStatus = ownershipConflict ? 'conflict' : 'failed';
2056
2182
  let actual;
2057
2183
  if (failureStatus !== 'conflict' && !options.dryRun) {
2058
2184
  try {
@@ -2065,7 +2191,7 @@ export class Migrator {
2065
2191
  }
2066
2192
  }
2067
2193
  report.issue({
2068
- code: failureStatus === 'conflict' ? 'destination-conflict' : 'page-transfer-failed',
2194
+ code: ownershipConflict ? 'destination-ownership-ambiguity' : 'page-transfer-failed',
2069
2195
  severity: 'error',
2070
2196
  phase: 'page',
2071
2197
  pageId: page.canonicalId,
@@ -2077,6 +2203,7 @@ export class Migrator {
2077
2203
  ...(actual?.fingerprint ? { destinationFingerprint: actual.fingerprint } : {}),
2078
2204
  ...(actual?.version ? { destinationVersion: actual.version } : {}),
2079
2205
  status: failureStatus,
2206
+ ...(ownershipConflict ? { conflictEvidence: { reason: ownershipConflict.reason, candidateIds: ownershipConflict.candidateIds } } : {}),
2080
2207
  lastError: message,
2081
2208
  updatedAt: new Date().toISOString(),
2082
2209
  });
@@ -2099,6 +2226,7 @@ export class Migrator {
2099
2226
  commentsEnabled: options.comments ?? true,
2100
2227
  ...(commentsByPage.get(page.canonicalId) ? { commentInventory: commentsByPage.get(page.canonicalId) } : {}),
2101
2228
  ...(state.comments ? { commentState: state.comments } : {}),
2229
+ ...(ownershipConflict ? { conflictEvidence: { reason: ownershipConflict.reason, candidateIds: ownershipConflict.candidateIds } } : {}),
2102
2230
  }), actual?.content ? { source: normalizedPreviewHtml(document), destination: renderedPreviewHtml(actual.content, destination.kind) } : undefined);
2103
2231
  }
2104
2232
  finally {