@iwonz/ywtc 3.0.0 → 3.2.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 (62) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +36 -12
  3. package/dist/adapters/confluence.js +12 -3
  4. package/dist/adapters/confluence.js.map +1 -1
  5. package/dist/adapters/yandex.js +8 -3
  6. package/dist/adapters/yandex.js.map +1 -1
  7. package/dist/cli-arguments.d.ts +12 -2
  8. package/dist/cli-arguments.js +23 -3
  9. package/dist/cli-arguments.js.map +1 -1
  10. package/dist/cli.js +70 -1
  11. package/dist/cli.js.map +1 -1
  12. package/dist/clients/confluence-cloud.d.ts +1 -0
  13. package/dist/clients/confluence-cloud.js +6 -0
  14. package/dist/clients/confluence-cloud.js.map +1 -1
  15. package/dist/clients/confluence.d.ts +1 -0
  16. package/dist/clients/confluence.js +9 -0
  17. package/dist/clients/confluence.js.map +1 -1
  18. package/dist/clients/yandex-wiki.d.ts +2 -1
  19. package/dist/clients/yandex-wiki.js +12 -0
  20. package/dist/clients/yandex-wiki.js.map +1 -1
  21. package/dist/config.d.ts +2 -0
  22. package/dist/config.js +15 -2
  23. package/dist/config.js.map +1 -1
  24. package/dist/connection-verifier.d.ts +17 -0
  25. package/dist/connection-verifier.js +94 -0
  26. package/dist/connection-verifier.js.map +1 -0
  27. package/dist/content-model.d.ts +7 -1
  28. package/dist/content-model.js +188 -16
  29. package/dist/content-model.js.map +1 -1
  30. package/dist/conversion.d.ts +1 -0
  31. package/dist/conversion.js +17 -0
  32. package/dist/conversion.js.map +1 -1
  33. package/dist/domain.d.ts +15 -4
  34. package/dist/domain.js.map +1 -1
  35. package/dist/filesystem-export.js +1 -1
  36. package/dist/filesystem-export.js.map +1 -1
  37. package/dist/http.d.ts +2 -1
  38. package/dist/http.js +4 -2
  39. package/dist/http.js.map +1 -1
  40. package/dist/init-prompts.d.ts +49 -0
  41. package/dist/init-prompts.js +63 -0
  42. package/dist/init-prompts.js.map +1 -0
  43. package/dist/interactive-config.d.ts +37 -0
  44. package/dist/interactive-config.js +226 -0
  45. package/dist/interactive-config.js.map +1 -0
  46. package/dist/interactive-init.d.ts +20 -0
  47. package/dist/interactive-init.js +450 -0
  48. package/dist/interactive-init.js.map +1 -0
  49. package/dist/migrator.d.ts +5 -0
  50. package/dist/migrator.js +283 -114
  51. package/dist/migrator.js.map +1 -1
  52. package/dist/report-catalog.d.ts +65 -0
  53. package/dist/report-catalog.js +117 -0
  54. package/dist/report-catalog.js.map +1 -0
  55. package/dist/report.d.ts +4 -0
  56. package/dist/report.js +1 -0
  57. package/dist/report.js.map +1 -1
  58. package/dist/types.d.ts +13 -0
  59. package/dist/workspace.d.ts +5 -0
  60. package/dist/workspace.js +10 -1
  61. package/dist/workspace.js.map +1 -1
  62. package/package.json +3 -1
package/dist/migrator.js CHANGED
@@ -3,7 +3,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import PQueue from 'p-queue';
5
5
  import { createProviderAdapter } from './adapters/factory.js';
6
- import { appendGridSnapshots, normalizePageContent, normalizedPreviewHtml, renderedPreviewHtml, renderConfluenceStorage, renderYfm, } from './content-model.js';
6
+ import { appendGridSnapshots, assertDestinationContent, isLegacyTransferPlaceholder, normalizePageContent, normalizedPreviewHtml, rawContentFallback, renderedPreviewHtml, renderConfluenceStorage, renderYfm, sourceResourceBindings, } from './content-model.js';
7
7
  import { assertTransferCapabilities } from './domain.js';
8
8
  import { FilesystemExporter } from './filesystem-export.js';
9
9
  import { discoverPageGraph } from './graph.js';
@@ -62,11 +62,6 @@ async function cacheDocument(cacheDirectory, canonicalId, document) {
62
62
  async function loadCachedDocument(cacheDirectory, canonicalId) {
63
63
  return JSON.parse(await readFile(cachedDocumentPath(cacheDirectory, canonicalId), 'utf8'));
64
64
  }
65
- function placeholder(adapter, page) {
66
- return adapter.kind === 'confluence'
67
- ? `<p><em>ywtc transfer in progress from <a href="${page.url.replaceAll('&', '&amp;').replaceAll('"', '&quot;')}">${page.provider}</a>.</em></p>`
68
- : `_ywtc transfer in progress from [${page.provider}](${page.url})._`;
69
- }
70
65
  function collisionTitle(page) {
71
66
  const suffix = page.locator.split('/').filter(Boolean).at(-1) ?? page.id;
72
67
  return `${page.title} [${suffix}]`;
@@ -111,7 +106,8 @@ function reportPage(input) {
111
106
  images: input.document.metrics.images,
112
107
  tables: input.document.metrics.tables,
113
108
  codeBlocks: input.document.metrics.codeBlocks,
114
- degraded: input.document.degraded,
109
+ degraded: input.document.degraded || (input.resourceFallbacks?.length ?? 0) > 0,
110
+ resourceFallbacks: input.resourceFallbacks ?? [],
115
111
  warnings: [...input.document.warnings, ...(input.warnings ?? [])],
116
112
  ...(input.error ? { error: input.error } : {}),
117
113
  };
@@ -121,6 +117,29 @@ function runStatus(errors, warnings) {
121
117
  return 'partial';
122
118
  return warnings > 0 ? 'completed-with-warnings' : 'completed';
123
119
  }
120
+ export class MigrationRunError extends Error {
121
+ reportPaths;
122
+ stateFilename;
123
+ constructor(message, reportPaths, stateFilename, options) {
124
+ super(message, options);
125
+ this.reportPaths = reportPaths;
126
+ this.stateFilename = stateFilename;
127
+ this.name = 'MigrationRunError';
128
+ }
129
+ }
130
+ async function renderRemoteDocument(input) {
131
+ try {
132
+ const rendered = input.destination.kind === 'confluence'
133
+ ? await renderConfluenceStorage(input.document, input.page, input.context, input.container.space ?? '')
134
+ : await renderYfm(input.document, input.context);
135
+ if (!rendered.body.trim())
136
+ return rawContentFallback(input.page, input.destination.kind, 'renderer returned an empty body');
137
+ return rendered;
138
+ }
139
+ catch (error) {
140
+ return rawContentFallback(input.page, input.destination.kind, error instanceof Error ? error.message : String(error));
141
+ }
142
+ }
124
143
  export class Migrator {
125
144
  config;
126
145
  logger;
@@ -208,7 +227,7 @@ export class Migrator {
208
227
  const reportPaths = await report.write(options.reportsDirectory);
209
228
  if (!options.dryRun)
210
229
  await store.finishRun(run.id, finalStatus, { pages: 0, warnings: 0, errors: 1 });
211
- throw new Error(`${message}. State: ${stateLocation.filename}. Report: ${reportPaths.html}`, { cause: error });
230
+ throw new MigrationRunError(message, reportPaths, stateLocation.filename, { cause: error });
212
231
  }
213
232
  const resourcesByPage = new Map();
214
233
  const inventoryQueue = new PQueue({ concurrency: options.concurrency });
@@ -245,7 +264,7 @@ export class Migrator {
245
264
  const reportPaths = await report.write(options.reportsDirectory);
246
265
  if (!options.dryRun)
247
266
  await store.finishRun(run.id, finalStatus, { pages: graph.pages.length, warnings: graph.warnings.length, errors: inventoryErrors.length });
248
- throw new Error(`Resource preflight failed before destination mutation. State: ${stateLocation.filename}. Report: ${reportPaths.html}`);
267
+ throw new MigrationRunError('Resource preflight failed before destination mutation', reportPaths, stateLocation.filename);
249
268
  }
250
269
  const planSummary = `Plan ready: ${graph.pages.length} pages · ${inventory.resources} files (${inventory.bytes} bytes) · ${inventory.links} links · ${inventory.images} images · ${inventory.tables} tables (${inventory.grids} grids) · ${inventory.codeBlocks} code blocks`;
251
270
  status(planSummary);
@@ -312,7 +331,7 @@ export class Migrator {
312
331
  if (!options.dryRun)
313
332
  await store.finishRun(run.id, finalStatus, { pages: graph.pages.length, warnings: transferWarnings, errors: transferErrors + 1 });
314
333
  this.logger.error(`${message}. Report: ${reportPaths.html}`);
315
- throw error;
334
+ throw new MigrationRunError(message, reportPaths, stateLocation.filename, { cause: error });
316
335
  }
317
336
  }
318
337
  async exportFilesystem(source, graph, destination, options, identity, store, report, sourceFingerprints, resourcesByPage) {
@@ -385,15 +404,36 @@ export class Migrator {
385
404
  async transferRemote(input) {
386
405
  const { source, destination, container, graph, options, store, report, sourceFingerprints, resourcesByPage } = input;
387
406
  const destinationPages = new Map();
407
+ const sourcePages = new Map(graph.pages.map((page) => [page.canonicalId, page]));
408
+ const bootstrapBodies = new Map();
388
409
  let errors = 0;
389
410
  let warnings = 0;
390
411
  const totalResources = [...resourcesByPage.values()].reduce((total, resources) => total + resources.filter((resource) => resource.kind !== 'grid').length, 0);
391
- const progress = { skeletons: 0, pages: 0, resources: 0 };
412
+ const progress = { staged: 0, pages: 0, resources: 0 };
392
413
  const totalUnits = Math.max(1, graph.pages.length * 2 + totalResources);
393
414
  const updateProgress = (active) => {
394
- const completed = progress.skeletons + progress.pages + progress.resources;
415
+ const completed = progress.staged + progress.pages + progress.resources;
395
416
  options.onStatus?.(`Progress ${Math.min(100, Math.floor((completed / totalUnits) * 100))}% · pages ${progress.pages}/${graph.pages.length} · files ${progress.resources}/${totalResources} · ${active}`);
396
417
  };
418
+ for (const page of graph.pages) {
419
+ const [document, cachedPage] = await Promise.all([
420
+ loadCachedDocument(options.cacheDirectory, page.canonicalId),
421
+ loadCachedPage(options.cacheDirectory, page.canonicalId),
422
+ ]);
423
+ const resources = resourcesByPage.get(page.canonicalId) ?? [];
424
+ bootstrapBodies.set(page.canonicalId, await renderRemoteDocument({
425
+ destination,
426
+ container,
427
+ document,
428
+ page: cachedPage,
429
+ context: {
430
+ pages: new Map(),
431
+ resources: sourceResourceBindings(cachedPage, resources),
432
+ sourcePages,
433
+ currentPageId: page.canonicalId,
434
+ },
435
+ }));
436
+ }
397
437
  for (const page of graph.pages) {
398
438
  if (options.signal?.aborted)
399
439
  throw options.signal.reason ?? new Error('Transfer cancelled');
@@ -403,18 +443,32 @@ export class Migrator {
403
443
  const message = `Destination parent is unavailable for ${page.title}`;
404
444
  errors += 1;
405
445
  report.error(message);
406
- progress.skeletons += 1;
446
+ progress.staged += 1;
407
447
  updateProgress(message);
408
448
  continue;
409
449
  }
410
450
  try {
451
+ const cachedPage = await loadCachedPage(options.cacheDirectory, page.canonicalId);
452
+ const bootstrap = bootstrapBodies.get(page.canonicalId);
411
453
  let created;
412
454
  let title = saved?.destinationTitle ?? page.title;
413
455
  if (saved?.destinationId) {
414
456
  created = await destination.verifyPage(destinationFromState(saved, destination));
415
- if (['complete', 'partial'].includes(saved.status) && saved.destinationFingerprint && created.fingerprint !== saved.destinationFingerprint) {
457
+ const generatedPlaceholder = created.title === saved.destinationTitle
458
+ && isLegacyTransferPlaceholder(created.content, destination.kind, cachedPage);
459
+ if (['complete', 'partial'].includes(saved.status) && saved.destinationFingerprint && created.fingerprint !== saved.destinationFingerprint && !generatedPlaceholder) {
416
460
  throw new Error(`Destination conflict for ${page.title}: content changed since the last verified run`);
417
461
  }
462
+ if (generatedPlaceholder && !options.dryRun) {
463
+ created = await destination.updatePage({
464
+ page: created,
465
+ title: created.title,
466
+ body: bootstrap.body,
467
+ ...(created.fingerprint ? { expectedFingerprint: created.fingerprint } : {}),
468
+ });
469
+ created = await destination.verifyPage(created);
470
+ assertDestinationContent(bootstrap.body, created.content, destination.kind, cachedPage);
471
+ }
418
472
  }
419
473
  else if (options.dryRun) {
420
474
  created = {
@@ -425,6 +479,8 @@ export class Migrator {
425
479
  title,
426
480
  url: container.url,
427
481
  locator: `dry-run:${page.id}`,
482
+ content: bootstrap.body,
483
+ contentLength: Buffer.byteLength(bootstrap.body),
428
484
  ...(container.space ? { space: container.space } : {}),
429
485
  };
430
486
  }
@@ -439,7 +495,9 @@ export class Migrator {
439
495
  title = `${base} [${hash}${sequence > 1 ? `-${sequence}` : ''}]`;
440
496
  }
441
497
  }
442
- created = await destination.createPage({ container: parent, title, body: placeholder(destination, page) });
498
+ created = await destination.createPage({ container: parent, title, body: bootstrap.body });
499
+ created = await destination.verifyPage(created);
500
+ assertDestinationContent(bootstrap.body, created.content, destination.kind, cachedPage);
443
501
  }
444
502
  destinationPages.set(page.canonicalId, created);
445
503
  const next = {
@@ -460,7 +518,7 @@ export class Migrator {
460
518
  updatedAt: new Date().toISOString(),
461
519
  };
462
520
  await store.setPage(next);
463
- options.onStatus?.(`${options.dryRun ? 'Planned' : 'Created'} page ${page.title}`);
521
+ options.onStatus?.(`${options.dryRun ? 'Planned' : 'Staged content for'} ${page.title}`);
464
522
  }
465
523
  catch (error) {
466
524
  errors += 1;
@@ -471,8 +529,8 @@ export class Migrator {
471
529
  await store.setPage({ ...savedState, status: /conflict/i.test(message) ? 'conflict' : 'failed', lastError: message, updatedAt: new Date().toISOString() });
472
530
  }
473
531
  finally {
474
- progress.skeletons += 1;
475
- updateProgress(`structure ${page.title}`);
532
+ progress.staged += 1;
533
+ updateProgress(`content staged ${page.title}`);
476
534
  }
477
535
  }
478
536
  const contentQueue = new PQueue({ concurrency: options.concurrency });
@@ -490,73 +548,135 @@ export class Migrator {
490
548
  status: failedState?.status ?? 'blocked',
491
549
  resources,
492
550
  document,
493
- error: failedState?.lastError ?? 'Destination skeleton was not created',
551
+ error: failedState?.lastError ?? 'Destination page with source-derived content was not created',
494
552
  }));
495
553
  progress.pages += 1;
496
554
  updateProgress(`blocked ${page.title}`);
497
555
  return;
498
556
  }
499
557
  const state = store.value.pages[page.canonicalId];
500
- const uploaded = new Map();
558
+ const bootstrap = bootstrapBodies.get(page.canonicalId);
559
+ const bindings = sourceResourceBindings(cachedPage, resources);
560
+ const resourceProblems = [];
561
+ const failedResourceIds = new Set();
562
+ const recordResourceFailure = async (resource, detail) => {
563
+ if (!failedResourceIds.has(resource.id)) {
564
+ failedResourceIds.add(resource.id);
565
+ errors += 1;
566
+ resourceProblems.push(detail);
567
+ report.error(`${page.title}: ${detail}`);
568
+ }
569
+ const previous = state.resources[resource.id];
570
+ state.resources[resource.id] = {
571
+ sourceId: resource.id,
572
+ sourceName: resource.name,
573
+ ...(previous?.destinationId ? { destinationId: previous.destinationId } : {}),
574
+ destinationName: previous?.destinationName ?? safeFilename(resource.name),
575
+ ...(previous?.sha256 ? { sha256: previous.sha256 } : {}),
576
+ ...(previous?.size !== undefined ? { size: previous.size } : resource.size !== undefined ? { size: resource.size } : {}),
577
+ status: 'failed',
578
+ lastError: detail,
579
+ updatedAt: new Date().toISOString(),
580
+ };
581
+ await store.setPage({ ...state, status: 'partial', lastError: detail, updatedAt: new Date().toISOString() });
582
+ };
501
583
  try {
502
584
  for (const resource of resources.filter((item) => item.kind !== 'grid')) {
503
585
  const key = resource.name.toLocaleLowerCase();
504
586
  const existing = state.resources[resource.id];
505
587
  if (existing?.status === 'complete' && existing.destinationId) {
506
- uploaded.set(key, {
507
- id: existing.destinationId,
508
- name: existing.destinationName,
509
- ...(existing.size !== undefined ? { size: existing.size } : {}),
510
- ...(existing.sha256 ? { fingerprint: existing.sha256 } : {}),
511
- });
588
+ bindings.set(key, { kind: 'uploaded', resource: {
589
+ id: existing.destinationId,
590
+ name: existing.destinationName,
591
+ ...(existing.size !== undefined ? { size: existing.size } : {}),
592
+ ...(existing.sha256 ? { fingerprint: existing.sha256 } : {}),
593
+ } });
512
594
  progress.resources += 1;
513
595
  updateProgress(`reused ${resource.name}`);
514
596
  continue;
515
597
  }
516
598
  if (options.dryRun) {
517
- uploaded.set(key, {
518
- id: `dry-run:${resource.id}`,
519
- name: safeFilename(resource.name),
520
- ...(resource.size !== undefined ? { size: resource.size } : {}),
521
- });
599
+ bindings.set(key, { kind: 'uploaded', resource: {
600
+ id: `dry-run:${resource.id}`,
601
+ name: safeFilename(resource.name),
602
+ ...(resource.size !== undefined ? { size: resource.size } : {}),
603
+ } });
522
604
  progress.resources += 1;
523
605
  updateProgress(`planned ${resource.name}`);
524
606
  continue;
525
607
  }
526
- const filename = cachePath(options.cacheDirectory, page, resource);
527
- const downloaded = await source.downloadResource(resource, filename);
528
- const result = await destination.uploadResource({
529
- page: destinationPage,
530
- name: safeFilename(resource.name),
531
- filePath: downloaded.path,
532
- size: downloaded.size,
533
- ...(downloaded.mimeType ?? resource.mimeType ? { mimeType: downloaded.mimeType ?? resource.mimeType } : {}),
534
- ...(existing?.destinationId ? { existingId: existing.destinationId } : {}),
535
- });
536
- uploaded.set(key, result);
537
- state.resources[resource.id] = {
538
- sourceId: resource.id,
539
- sourceName: resource.name,
540
- destinationId: result.id,
541
- destinationName: result.name,
542
- sha256: downloaded.sha256,
543
- size: downloaded.size,
544
- status: 'complete',
545
- updatedAt: new Date().toISOString(),
546
- };
547
- await store.setPage({ ...state, updatedAt: new Date().toISOString() });
548
- progress.resources += 1;
549
- updateProgress(`uploaded ${resource.name} → ${page.title}`);
608
+ try {
609
+ const filename = cachePath(options.cacheDirectory, page, resource);
610
+ const downloaded = await source.downloadResource(resource, filename);
611
+ const result = await destination.uploadResource({
612
+ page: destinationPage,
613
+ name: safeFilename(resource.name),
614
+ filePath: downloaded.path,
615
+ size: downloaded.size,
616
+ ...(downloaded.mimeType ?? resource.mimeType ? { mimeType: downloaded.mimeType ?? resource.mimeType } : {}),
617
+ ...(existing?.destinationId ? { existingId: existing.destinationId } : {}),
618
+ });
619
+ bindings.set(key, { kind: 'uploaded', resource: result });
620
+ state.resources[resource.id] = {
621
+ sourceId: resource.id,
622
+ sourceName: resource.name,
623
+ destinationId: result.id,
624
+ destinationName: result.name,
625
+ sha256: downloaded.sha256,
626
+ size: downloaded.size,
627
+ status: 'complete',
628
+ updatedAt: new Date().toISOString(),
629
+ };
630
+ await store.setPage({ ...state, updatedAt: new Date().toISOString() });
631
+ updateProgress(`uploaded ${resource.name} → ${page.title}`);
632
+ }
633
+ catch (error) {
634
+ await recordResourceFailure(resource, `Resource ${resource.name} was not transferred: ${error instanceof Error ? error.message : String(error)}`);
635
+ updateProgress(`fallback ${resource.name} → ${page.title}`);
636
+ }
637
+ finally {
638
+ progress.resources += 1;
639
+ }
640
+ }
641
+ if (!options.dryRun && resources.some((resource) => resource.kind !== 'grid')) {
642
+ try {
643
+ const remoteResources = await destination.listUploadedResources(destinationPage);
644
+ for (const resource of resources.filter((item) => item.kind !== 'grid')) {
645
+ const transferred = state.resources[resource.id];
646
+ if (transferred?.status !== 'complete')
647
+ continue;
648
+ const remote = remoteResources.find((item) => item.id === transferred.destinationId || item.name === transferred.destinationName);
649
+ const sizeMismatch = destination.kind === 'confluence'
650
+ && transferred.size !== undefined
651
+ && remote?.size !== undefined
652
+ && transferred.size !== remote.size;
653
+ if (!remote || sizeMismatch) {
654
+ bindings.set(resource.name.toLocaleLowerCase(), sourceResourceBindings(cachedPage, [resource]).get(resource.name.toLocaleLowerCase()));
655
+ await recordResourceFailure(resource, !remote
656
+ ? `Verification did not find destination resource for ${resource.name}`
657
+ : `Verification size mismatch for ${resource.name}: ${transferred.size} source bytes, ${remote.size} destination bytes`);
658
+ }
659
+ else {
660
+ bindings.set(resource.name.toLocaleLowerCase(), { kind: 'uploaded', resource: remote });
661
+ }
662
+ }
663
+ }
664
+ catch (error) {
665
+ for (const resource of resources.filter((item) => item.kind !== 'grid')) {
666
+ if (state.resources[resource.id]?.status !== 'complete')
667
+ continue;
668
+ bindings.set(resource.name.toLocaleLowerCase(), sourceResourceBindings(cachedPage, [resource]).get(resource.name.toLocaleLowerCase()));
669
+ await recordResourceFailure(resource, `Destination resource verification failed for ${resource.name}: ${error instanceof Error ? error.message : String(error)}`);
670
+ }
671
+ }
550
672
  }
551
673
  const context = {
552
674
  pages: destinationPages,
553
- resources: uploaded,
554
- sourcePages: new Map(graph.pages.map((item) => [item.canonicalId, item])),
675
+ resources: bindings,
676
+ sourcePages,
555
677
  currentPageId: page.canonicalId,
556
678
  };
557
- const rendered = destination.kind === 'confluence'
558
- ? await renderConfluenceStorage(document, cachedPage, context, container.space ?? '')
559
- : await renderYfm(document, context);
679
+ const rendered = await renderRemoteDocument({ destination, container, document, page: cachedPage, context });
560
680
  warnings += rendered.warnings.length;
561
681
  if (rendered.degraded && !options.dryRun) {
562
682
  const extension = page.rawFormat === 'yfm' ? 'yfm' : 'storage.xml';
@@ -566,92 +686,141 @@ export class Migrator {
566
686
  await writeFile(snapshotPath, cachedPage.rawContent, { mode: 0o600 });
567
687
  const snapshotKey = `snapshot:${page.canonicalId}`;
568
688
  const existingSnapshot = state.resources[snapshotKey];
569
- const snapshot = existingSnapshot?.status === 'complete' && existingSnapshot.destinationId
570
- ? {
571
- id: existingSnapshot.destinationId,
572
- name: existingSnapshot.destinationName,
573
- ...(existingSnapshot.size !== undefined ? { size: existingSnapshot.size } : {}),
574
- }
575
- : await destination.uploadResource({
576
- page: destinationPage,
577
- name: snapshotName,
578
- filePath: snapshotPath,
579
- size: Buffer.byteLength(cachedPage.rawContent),
580
- mimeType: page.rawFormat === 'yfm' ? 'text/markdown' : 'application/xml',
581
- ...(existingSnapshot?.destinationId ? { existingId: existingSnapshot.destinationId } : {}),
582
- });
583
- uploaded.set(snapshotName.toLocaleLowerCase(), snapshot);
584
- state.resources[snapshotKey] = {
585
- sourceId: snapshotKey,
586
- sourceName: snapshotName,
587
- destinationId: snapshot.id,
588
- destinationName: snapshot.name,
589
- ...(snapshot.size !== undefined ? { size: snapshot.size } : {}),
590
- sha256: sha256(cachedPage.rawContent),
591
- status: 'complete',
592
- updatedAt: new Date().toISOString(),
593
- };
594
- state.degradations = [...new Set([...state.degradations, ...rendered.warnings])];
689
+ try {
690
+ const snapshot = existingSnapshot?.status === 'complete' && existingSnapshot.destinationId
691
+ ? {
692
+ id: existingSnapshot.destinationId,
693
+ name: existingSnapshot.destinationName,
694
+ ...(existingSnapshot.size !== undefined ? { size: existingSnapshot.size } : {}),
695
+ }
696
+ : await destination.uploadResource({
697
+ page: destinationPage,
698
+ name: snapshotName,
699
+ filePath: snapshotPath,
700
+ size: Buffer.byteLength(cachedPage.rawContent),
701
+ mimeType: page.rawFormat === 'yfm' ? 'text/markdown' : 'application/xml',
702
+ ...(existingSnapshot?.destinationId ? { existingId: existingSnapshot.destinationId } : {}),
703
+ });
704
+ state.resources[snapshotKey] = {
705
+ sourceId: snapshotKey,
706
+ sourceName: snapshotName,
707
+ destinationId: snapshot.id,
708
+ destinationName: snapshot.name,
709
+ ...(snapshot.size !== undefined ? { size: snapshot.size } : {}),
710
+ sha256: sha256(cachedPage.rawContent),
711
+ status: 'complete',
712
+ updatedAt: new Date().toISOString(),
713
+ };
714
+ }
715
+ catch (error) {
716
+ const detail = `Raw source snapshot was not transferred: ${error instanceof Error ? error.message : String(error)}`;
717
+ errors += 1;
718
+ resourceProblems.push(detail);
719
+ report.error(`${page.title}: ${detail}`);
720
+ state.resources[snapshotKey] = {
721
+ sourceId: snapshotKey,
722
+ sourceName: snapshotName,
723
+ destinationName: snapshotName,
724
+ status: 'failed',
725
+ lastError: detail,
726
+ updatedAt: new Date().toISOString(),
727
+ };
728
+ }
729
+ state.degradations = [...new Set([...state.degradations, ...rendered.warnings, ...resourceProblems])];
595
730
  }
596
731
  let verified = destinationPage;
597
732
  if (!options.dryRun) {
733
+ const current = await destination.verifyPage(destinationPage);
734
+ if (destinationPage.contentFingerprint
735
+ && current.contentFingerprint
736
+ && current.contentFingerprint !== destinationPage.contentFingerprint) {
737
+ throw new Error(`Destination conflict for ${page.title}: content changed while resources were transferred`);
738
+ }
598
739
  verified = await destination.updatePage({
599
- page: destinationPage,
600
- title: destinationPage.title,
740
+ page: current,
741
+ title: current.title,
601
742
  body: rendered.body,
602
- ...(['complete', 'partial'].includes(state.status) && state.destinationFingerprint ? { expectedFingerprint: state.destinationFingerprint } : {}),
743
+ ...(current.fingerprint ? { expectedFingerprint: current.fingerprint } : {}),
603
744
  });
604
745
  if (verified.fingerprint) {
605
746
  state.destinationFingerprint = verified.fingerprint;
606
747
  await store.setPage({ ...state, updatedAt: new Date().toISOString() });
607
748
  }
608
749
  verified = await destination.verifyPage(verified);
609
- if ((verified.contentLength ?? 0) === 0) {
610
- throw new Error(`Verification found empty destination content for ${page.title}`);
611
- }
612
- const remoteResources = await destination.listUploadedResources(verified);
613
- for (const resource of resources.filter((item) => item.kind !== 'grid')) {
614
- const transferred = state.resources[resource.id];
615
- const remote = transferred && remoteResources.find((item) => (item.id === transferred.destinationId || item.name === transferred.destinationName));
616
- if (!transferred || !remote) {
617
- throw new Error(`Verification did not find destination resource for ${resource.name}`);
618
- }
619
- if (destination.kind === 'confluence'
620
- && transferred.size !== undefined
621
- && remote.size !== undefined
622
- && transferred.size !== remote.size) {
623
- throw new Error(`Verification size mismatch for ${resource.name}: ${transferred.size} source bytes, ${remote.size} destination bytes`);
624
- }
625
- }
750
+ assertDestinationContent(rendered.body, verified.content, destination.kind, cachedPage);
626
751
  }
752
+ const partial = rendered.degraded || resourceProblems.length > 0;
753
+ const lastResourceProblem = resourceProblems.at(-1);
754
+ const clearedState = { ...state };
755
+ delete clearedState.lastError;
627
756
  const finalState = {
628
- ...state,
757
+ ...clearedState,
629
758
  destinationId: verified.id,
630
759
  destinationCanonicalId: verified.canonicalId,
631
760
  destinationLocator: verified.url,
632
761
  destinationTitle: verified.title,
633
762
  ...(verified.fingerprint ? { destinationFingerprint: verified.fingerprint } : {}),
634
- status: options.dryRun ? 'planned' : rendered.degraded ? 'partial' : 'complete',
635
- degradations: [...new Set([...state.degradations, ...rendered.warnings])],
763
+ status: options.dryRun ? 'planned' : partial ? 'partial' : 'complete',
764
+ degradations: [...new Set([...state.degradations, ...rendered.warnings, ...resourceProblems])],
765
+ ...(lastResourceProblem ? { lastError: lastResourceProblem } : {}),
636
766
  updatedAt: new Date().toISOString(),
637
767
  };
638
768
  await store.setPage(finalState);
769
+ const fallbackDetails = resources.flatMap((resource) => {
770
+ const resourceState = finalState.resources[resource.id];
771
+ return resourceState?.status === 'failed'
772
+ ? [{ name: resource.name, error: resourceState.lastError ?? 'Resource was not transferred' }]
773
+ : [];
774
+ });
639
775
  report.addPage(reportPage({
640
776
  page,
641
777
  destination: verified,
642
778
  status: finalState.status,
643
779
  resources,
644
780
  document,
645
- warnings: rendered.warnings,
646
- }), { source: normalizedPreviewHtml(document), destination: renderedPreviewHtml(rendered.body, destination.kind) });
781
+ resourceFallbacks: fallbackDetails,
782
+ warnings: [...rendered.warnings, ...resourceProblems],
783
+ }), { source: normalizedPreviewHtml(document), destination: renderedPreviewHtml(verified.content ?? rendered.body, destination.kind) });
647
784
  }
648
785
  catch (error) {
649
786
  errors += 1;
650
787
  const message = `${page.title}: ${error instanceof Error ? error.message : String(error)}`;
651
- const failureStatus = /conflict/i.test(message) ? 'conflict' : 'partial';
788
+ let failureStatus = /conflict/i.test(message) ? 'conflict' : 'failed';
789
+ let actual;
790
+ if (failureStatus !== 'conflict' && !options.dryRun) {
791
+ try {
792
+ actual = await destination.verifyPage(destinationPage);
793
+ assertDestinationContent(bootstrap.body, actual.content, destination.kind, cachedPage);
794
+ failureStatus = 'partial';
795
+ }
796
+ catch {
797
+ failureStatus = 'failed';
798
+ }
799
+ }
652
800
  report.error(message);
653
- await store.setPage({ ...state, status: failureStatus, lastError: message, updatedAt: new Date().toISOString() });
654
- report.addPage(reportPage({ page, destination: destinationPage, status: failureStatus, resources, document, error: message }));
801
+ await store.setPage({
802
+ ...state,
803
+ ...(actual?.fingerprint ? { destinationFingerprint: actual.fingerprint } : {}),
804
+ status: failureStatus,
805
+ lastError: message,
806
+ updatedAt: new Date().toISOString(),
807
+ });
808
+ const fallbackDetails = resources.flatMap((resource) => {
809
+ const resourceState = state.resources[resource.id];
810
+ return resourceState?.status === 'failed'
811
+ ? [{ name: resource.name, error: resourceState.lastError ?? 'Resource was not transferred' }]
812
+ : [];
813
+ });
814
+ report.addPage(reportPage({
815
+ page,
816
+ destination: actual ?? destinationPage,
817
+ status: failureStatus,
818
+ resources,
819
+ document,
820
+ resourceFallbacks: fallbackDetails,
821
+ warnings: resourceProblems,
822
+ error: message,
823
+ }), actual?.content ? { source: normalizedPreviewHtml(document), destination: renderedPreviewHtml(actual.content, destination.kind) } : undefined);
655
824
  }
656
825
  finally {
657
826
  progress.pages += 1;