@iwonz/ywtc 3.2.2 → 3.4.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 (54) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +48 -43
  3. package/dist/adapters/confluence.d.ts +37 -2
  4. package/dist/adapters/confluence.js +115 -2
  5. package/dist/adapters/confluence.js.map +1 -1
  6. package/dist/adapters/yandex.d.ts +26 -2
  7. package/dist/adapters/yandex.js +113 -4
  8. package/dist/adapters/yandex.js.map +1 -1
  9. package/dist/cli-arguments.d.ts +5 -1
  10. package/dist/cli-arguments.js +10 -0
  11. package/dist/cli-arguments.js.map +1 -1
  12. package/dist/cli.js +16 -6
  13. package/dist/cli.js.map +1 -1
  14. package/dist/clients/confluence-cloud.d.ts +17 -1
  15. package/dist/clients/confluence-cloud.js +116 -0
  16. package/dist/clients/confluence-cloud.js.map +1 -1
  17. package/dist/clients/confluence.d.ts +13 -1
  18. package/dist/clients/confluence.js +89 -0
  19. package/dist/clients/confluence.js.map +1 -1
  20. package/dist/clients/yandex-wiki.d.ts +13 -2
  21. package/dist/clients/yandex-wiki.js +67 -5
  22. package/dist/clients/yandex-wiki.js.map +1 -1
  23. package/dist/comments.d.ts +66 -0
  24. package/dist/comments.js +478 -0
  25. package/dist/comments.js.map +1 -0
  26. package/dist/content-model.js +3 -2
  27. package/dist/content-model.js.map +1 -1
  28. package/dist/doctor.d.ts +14 -2
  29. package/dist/doctor.js +26 -4
  30. package/dist/doctor.js.map +1 -1
  31. package/dist/domain.d.ts +116 -2
  32. package/dist/domain.js +7 -1
  33. package/dist/domain.js.map +1 -1
  34. package/dist/filesystem-export.d.ts +16 -1
  35. package/dist/filesystem-export.js +88 -2
  36. package/dist/filesystem-export.js.map +1 -1
  37. package/dist/graph.d.ts +2 -1
  38. package/dist/graph.js +105 -15
  39. package/dist/graph.js.map +1 -1
  40. package/dist/issues.d.ts +1 -1
  41. package/dist/migrator.d.ts +4 -1
  42. package/dist/migrator.js +638 -27
  43. package/dist/migrator.js.map +1 -1
  44. package/dist/report-catalog.d.ts +30 -1
  45. package/dist/report-catalog.js +26 -0
  46. package/dist/report-catalog.js.map +1 -1
  47. package/dist/report.d.ts +47 -3
  48. package/dist/report.js +66 -3
  49. package/dist/report.js.map +1 -1
  50. package/dist/state.d.ts +42 -2
  51. package/dist/state.js +67 -1
  52. package/dist/state.js.map +1 -1
  53. package/dist/types.d.ts +79 -1
  54. package/package.json +1 -1
package/dist/migrator.js CHANGED
@@ -4,10 +4,12 @@ import path from 'node:path';
4
4
  import PQueue from 'p-queue';
5
5
  import { createProviderAdapter } from './adapters/factory.js';
6
6
  import { appendGridSnapshots, assertDestinationContent, isLegacyTransferPlaceholder, normalizePageContent, normalizedPreviewHtml, rawContentFallback, renderedPreviewHtml, renderConfluenceStorage, renderYfm, setSourceResourceBinding, sourceResourceBindings, verifyDestinationContent, } from './content-model.js';
7
+ import { commentAttachmentUses, createCommentArchiveZip, renderCommentArchive, renderCommentArchiveIndex, resolveCommentAttachmentReferences, verifyCommentArchive, } from './comments.js';
7
8
  import { ConfirmedDestinationMissingError, verifyConfirmedDestinationPage } from './consistency.js';
8
9
  import { assertTransferCapabilities } from './domain.js';
9
10
  import { FilesystemExporter } from './filesystem-export.js';
10
11
  import { discoverPageGraph } from './graph.js';
12
+ import { safeFilename } from './input.js';
11
13
  import { profileBaseUrl, sameRemoteInstance } from './locators.js';
12
14
  import { TransferReport, } from './report.js';
13
15
  import { DestinationNameAllocator, preferredDestinationName, verifiedCacheFile } from './resource-reconciliation.js';
@@ -137,6 +139,14 @@ function reportPage(input) {
137
139
  });
138
140
  const knownBytes = resourceItems.reduce((total, resource) => total + (resource.sourceBytes ?? 0), 0);
139
141
  const transferredBytes = resourceItems.reduce((total, resource) => total + (resource.transferredBytes ?? 0), 0);
142
+ const commentsEnabled = input.commentsEnabled ?? true;
143
+ const inventory = input.commentInventory;
144
+ const commentState = input.commentState;
145
+ const commentStatus = !commentsEnabled
146
+ ? 'skipped'
147
+ : commentState?.status
148
+ ?? (inventory?.status === 'failed' ? 'failed' : inventory?.status === 'empty' ? 'complete' : 'planned');
149
+ const commentError = commentState?.lastError ?? inventory?.error;
140
150
  return {
141
151
  sourceCanonicalId: input.page.canonicalId,
142
152
  sourceId: input.page.id,
@@ -166,6 +176,20 @@ function reportPage(input) {
166
176
  warnings: [...new Set([...input.document.warnings, ...(input.warnings ?? [])])],
167
177
  normalizations: [...new Set([...(input.document.normalizations ?? []), ...(input.normalizations ?? [])])],
168
178
  ...(input.error ? { error: input.error } : {}),
179
+ comments: {
180
+ status: commentStatus,
181
+ threads: commentState?.threads ?? inventory?.threads.length ?? 0,
182
+ comments: commentState?.comments ?? inventory?.comments ?? 0,
183
+ participants: commentState?.participants ?? inventory?.participants ?? 0,
184
+ attachments: commentState?.attachments ?? inventory?.attachments ?? 0,
185
+ bytes: commentState?.bytes ?? inventory?.knownBytes ?? 0,
186
+ ...(commentState?.archiveName ? { archiveName: commentState.archiveName } : {}),
187
+ ...(commentState?.destinationId ? { destinationId: commentState.destinationId } : {}),
188
+ ...(commentState?.previousDestinationIds?.length ? { revision: true } : {}),
189
+ ...(commentState?.status === 'stale' ? { stale: true } : {}),
190
+ ...(commentError ? { error: commentError } : {}),
191
+ ...(commentState?.preview ? { preview: commentState.preview } : {}),
192
+ },
169
193
  };
170
194
  }
171
195
  function runStatus(errors, warnings, degradations) {
@@ -211,11 +235,13 @@ export class Migrator {
211
235
  this.consistencyOptions = consistencyOptions;
212
236
  }
213
237
  async run(options) {
238
+ const sourceStatus = options.sourceStatus ?? 'all';
239
+ const commentsEnabled = options.comments ?? true;
214
240
  const source = this.adapterFactory(options.source.profile, this.config);
215
241
  const destination = options.destination.type === 'remote'
216
242
  ? this.adapterFactory(options.destination.profile, this.config)
217
243
  : undefined;
218
- assertTransferCapabilities(source, destination, options.scope);
244
+ assertTransferCapabilities(source, destination, options.scope, sourceStatus, commentsEnabled);
219
245
  const status = options.onStatus ?? (() => undefined);
220
246
  status('Checking source and destination capabilities');
221
247
  const [sourcePlatform, destinationPlatform, root, container] = await Promise.all([
@@ -239,10 +265,12 @@ export class Migrator {
239
265
  await store.load(identity, { fresh: options.fresh ?? false, dryRun: options.dryRun });
240
266
  if (!options.dryRun)
241
267
  await store.save();
242
- const run = await store.beginRun(options.scope, options.dryRun);
268
+ const run = await store.beginRun(options.scope, options.dryRun, sourceStatus, commentsEnabled);
243
269
  const report = new TransferReport({
244
270
  dryRun: options.dryRun,
271
+ commentsEnabled,
245
272
  scope: options.scope,
273
+ sourceStatus,
246
274
  direction: `${source.kind} → ${options.destination.type === 'directory' ? options.destination.format : destination.kind}`,
247
275
  source: { provider: source.kind, profile: source.profile.name, rootId: root.id, title: root.title, url: root.url },
248
276
  destination: options.destination.type === 'directory'
@@ -265,11 +293,14 @@ export class Migrator {
265
293
  adapter: source,
266
294
  root,
267
295
  scope: options.scope,
296
+ sourceStatus,
268
297
  ...(options.signal ? { signal: options.signal } : {}),
269
298
  onProgress: (progress) => status(`Discovery: ${progress.pages} pages, ${progress.edges} links${progress.active ? ` · ${progress.active}` : ''}`),
270
299
  });
271
300
  report.registerPages(graph.pages);
272
301
  report.setEdges(graph.edges);
302
+ report.setSelection(graph.selection);
303
+ await store.setRunSelection(run.id, graph.selection);
273
304
  for (const warning of graph.warnings)
274
305
  report.warning(warning);
275
306
  if (container
@@ -278,6 +309,7 @@ export class Migrator {
278
309
  && graph.pages.some((page) => page.id === container.id)) {
279
310
  throw new Error('The destination page belongs to the selected source graph. Choose a destination outside the transferred graph.');
280
311
  }
312
+ status(`Source tree: root ${root.title} · ${graph.selection.returnedDescendants} descendants returned · ${graph.selection.selectedDescendants} selected · ${graph.selection.excluded.length} excluded · depth ${graph.selection.maximumDepth}`);
281
313
  status(`Caching source content for ${graph.pages.length} pages`);
282
314
  sourceFingerprints = await cacheDiscoveredPages(options.cacheDirectory, graph);
283
315
  await store.setGraph(graph.rootId, graph.edges);
@@ -305,8 +337,9 @@ export class Migrator {
305
337
  throw new MigrationRunError(message, reportPaths, stateLocation.filename, { cause: error });
306
338
  }
307
339
  const resourcesByPage = new Map();
340
+ const commentsByPage = new Map();
308
341
  const inventoryQueue = new PQueue({ concurrency: options.concurrency });
309
- const inventory = { resources: 0, bytes: 0, unknownSizes: 0, grids: 0, links: 0, images: 0, tables: 0, codeBlocks: 0 };
342
+ 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 };
310
343
  status(`Inspecting content and resources for ${graph.pages.length} pages`);
311
344
  const inventoryErrors = [];
312
345
  await Promise.all(graph.pages.map((page) => inventoryQueue.add(async () => {
@@ -316,6 +349,36 @@ export class Migrator {
316
349
  const cachedPage = await loadCachedPage(options.cacheDirectory, page.canonicalId);
317
350
  const resources = await source.listResources(page);
318
351
  resourcesByPage.set(page.canonicalId, resources);
352
+ let commentInventory;
353
+ if (commentsEnabled) {
354
+ try {
355
+ if (!source.readComments)
356
+ throw new Error(`${source.profile.name} does not implement comment reads`);
357
+ commentInventory = await source.readComments(cachedPage);
358
+ resolveCommentAttachmentReferences(commentInventory, resources);
359
+ commentsByPage.set(page.canonicalId, commentInventory);
360
+ inventory.commentThreads += commentInventory.threads.length;
361
+ inventory.comments += commentInventory.comments;
362
+ inventory.commentParticipants += commentInventory.participants;
363
+ inventory.commentAttachments += commentInventory.attachments;
364
+ inventory.commentBytes += commentInventory.knownBytes;
365
+ for (const warning of commentInventory.warnings)
366
+ report.issue({
367
+ code: `comment-inventory-warning:${warning}`,
368
+ severity: 'warning',
369
+ phase: 'inventory',
370
+ pageId: page.canonicalId,
371
+ retryable: false,
372
+ message: `${page.title}: ${warning}`,
373
+ });
374
+ }
375
+ catch (error) {
376
+ const message = `${page.title}: comments could not be read: ${error instanceof Error ? error.message : String(error)}`;
377
+ commentInventory = { status: 'failed', threads: [], comments: 0, participants: 0, attachments: 0, knownBytes: 0, unknownSizes: 0, warnings: [], error: message };
378
+ commentsByPage.set(page.canonicalId, commentInventory);
379
+ report.issue({ code: 'comment-inventory-failed', severity: 'error', phase: 'inventory', pageId: page.canonicalId, retryable: true, message });
380
+ }
381
+ }
319
382
  const document = appendGridSnapshots(normalizePageContent(cachedPage), resources);
320
383
  await cacheDocument(options.cacheDirectory, page.canonicalId, document);
321
384
  inventory.resources += resources.filter((resource) => resource.kind !== 'grid').length;
@@ -331,6 +394,8 @@ export class Migrator {
331
394
  status: 'planned',
332
395
  resources,
333
396
  document,
397
+ commentsEnabled,
398
+ ...(commentInventory ? { commentInventory } : {}),
334
399
  }), undefined, false);
335
400
  status(`Inspected ${page.title} · ${resources.length} resources`);
336
401
  }
@@ -348,6 +413,7 @@ export class Migrator {
348
413
  });
349
414
  }
350
415
  })));
416
+ await store.setRunComments(run.id, { threads: inventory.commentThreads, comments: inventory.comments, attachments: inventory.commentAttachments });
351
417
  if (inventoryErrors.length > 0) {
352
418
  const finalStatus = options.signal?.aborted ? 'cancelled' : 'failed';
353
419
  report.finish(finalStatus);
@@ -360,14 +426,14 @@ export class Migrator {
360
426
  });
361
427
  throw new MigrationRunError('Resource preflight failed before destination mutation', reportPaths, stateLocation.filename);
362
428
  }
363
- const planSummary = `Plan ready: ${graph.pages.length} pages · ${inventory.resources} files (${inventory.bytes} known bytes${inventory.unknownSizes > 0 ? ` · ${inventory.unknownSizes} sizes unknown` : ''}) · ${inventory.links} links · ${inventory.images} images · ${inventory.tables} tables (${inventory.grids} grids) · ${inventory.codeBlocks} code blocks`;
429
+ 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'}`;
364
430
  status(planSummary);
365
431
  this.logger.info(planSummary);
366
432
  let output;
367
433
  let archive;
368
434
  try {
369
435
  if (options.destination.type === 'directory') {
370
- const result = await this.exportFilesystem(source, graph, options.destination, options, stateLocation.id, store, report, sourceFingerprints, resourcesByPage);
436
+ const result = await this.exportFilesystem(source, graph, options.destination, options, stateLocation.id, store, report, sourceFingerprints, resourcesByPage, commentsByPage);
371
437
  output = result.directory;
372
438
  archive = result.archive;
373
439
  report.output('directory', result.directory);
@@ -387,6 +453,7 @@ export class Migrator {
387
453
  report,
388
454
  sourceFingerprints,
389
455
  resourcesByPage,
456
+ commentsByPage,
390
457
  });
391
458
  output = container.url;
392
459
  report.output('destination', container.url);
@@ -431,7 +498,7 @@ export class Migrator {
431
498
  throw new MigrationRunError(message, reportPaths, stateLocation.filename, { cause: error });
432
499
  }
433
500
  }
434
- async exportFilesystem(source, graph, destination, options, identity, store, report, sourceFingerprints, resourcesByPage) {
501
+ async exportFilesystem(source, graph, destination, options, identity, store, report, sourceFingerprints, resourcesByPage, commentsByPage) {
435
502
  const exporter = new FilesystemExporter(source, options.concurrency);
436
503
  const totalResources = [...resourcesByPage.values()].reduce((total, resources) => total + resources.filter((resource) => resource.kind !== 'grid').length, 0);
437
504
  const total = Math.max(1, graph.pages.length + totalResources);
@@ -450,6 +517,8 @@ export class Migrator {
450
517
  options.onStatus?.(`Progress ${Math.min(100, Math.floor((processed / total) * 100))}% · ${processed}/${total} · ${message}`);
451
518
  } } : {}),
452
519
  resourcesByPage,
520
+ commentsByPage,
521
+ commentsEnabled: options.comments ?? true,
453
522
  loadPage: (page) => loadCachedPage(options.cacheDirectory, page.canonicalId),
454
523
  loadDocument: (page) => loadCachedDocument(options.cacheDirectory, page.canonicalId),
455
524
  });
@@ -473,7 +542,8 @@ export class Migrator {
473
542
  updatedAt: new Date().toISOString(),
474
543
  }];
475
544
  }));
476
- const pageStatus = options.dryRun ? 'planned' : manifestPage?.degraded ? 'partial' : 'complete';
545
+ const commentIncomplete = manifestPage?.comments && !['complete', 'skipped'].includes(manifestPage.comments.status);
546
+ const pageStatus = options.dryRun ? 'planned' : manifestPage?.degraded || commentIncomplete ? 'partial' : 'complete';
477
547
  await store.setPage({
478
548
  sourceId: page.id,
479
549
  sourceCanonicalId: page.canonicalId,
@@ -489,6 +559,19 @@ export class Migrator {
489
559
  status: pageStatus,
490
560
  resources,
491
561
  degradations: manifestPage?.warnings ?? [],
562
+ ...(manifestPage?.comments ? { comments: {
563
+ status: manifestPage.comments.status,
564
+ ...(manifestPage.comments.fingerprint ? { fingerprint: manifestPage.comments.fingerprint } : {}),
565
+ ...(manifestPage.comments.archivePath ? { archivePath: manifestPage.comments.archivePath } : {}),
566
+ ...(manifestPage.comments.archiveName ? { archiveName: manifestPage.comments.archiveName } : {}),
567
+ threads: manifestPage.comments.threads,
568
+ comments: manifestPage.comments.comments,
569
+ participants: manifestPage.comments.participants,
570
+ attachments: manifestPage.comments.attachments,
571
+ bytes: manifestPage.comments.bytes,
572
+ ...(manifestPage.comments.error ? { lastError: manifestPage.comments.error } : {}),
573
+ updatedAt: new Date().toISOString(),
574
+ } } : {}),
492
575
  updatedAt: new Date().toISOString(),
493
576
  });
494
577
  report.addPage(reportPage({
@@ -499,7 +582,35 @@ export class Migrator {
499
582
  resourceStates: resources,
500
583
  ...(options.dryRun ? { resourceStatusOverride: 'planned' } : {}),
501
584
  ...(manifestPage?.warnings ? { warnings: manifestPage.warnings } : {}),
585
+ commentsEnabled: options.comments ?? true,
586
+ ...(commentsByPage.get(page.canonicalId) ? { commentInventory: commentsByPage.get(page.canonicalId) } : {}),
587
+ ...(manifestPage?.comments ? { commentState: {
588
+ status: manifestPage.comments.status,
589
+ ...(manifestPage.comments.fingerprint ? { fingerprint: manifestPage.comments.fingerprint } : {}),
590
+ ...(manifestPage.comments.archivePath ? { archivePath: manifestPage.comments.archivePath } : {}),
591
+ ...(manifestPage.comments.archiveName ? { archiveName: manifestPage.comments.archiveName } : {}),
592
+ threads: manifestPage.comments.threads,
593
+ comments: manifestPage.comments.comments,
594
+ participants: manifestPage.comments.participants,
595
+ attachments: manifestPage.comments.attachments,
596
+ bytes: manifestPage.comments.bytes,
597
+ ...(manifestPage.comments.error ? { lastError: manifestPage.comments.error } : {}),
598
+ updatedAt: new Date().toISOString(),
599
+ } } : {}),
502
600
  }), { source: normalizedPreviewHtml(document), destination: normalizedPreviewHtml(document) });
601
+ if (manifestPage?.comments
602
+ && !['complete', 'skipped', 'planned'].includes(manifestPage.comments.status)
603
+ && !(manifestPage.comments.status === 'failed' && commentsByPage.get(page.canonicalId)?.status === 'failed')
604
+ && !(manifestPage.comments.status === 'partial' && (commentsByPage.get(page.canonicalId)?.warnings.length ?? 0) > 0)) {
605
+ report.issue({
606
+ code: `comment-${manifestPage.comments.status}`,
607
+ severity: manifestPage.comments.status === 'partial' || manifestPage.comments.status === 'stale' ? 'degradation' : 'error',
608
+ phase: 'comment',
609
+ pageId: page.canonicalId,
610
+ retryable: manifestPage.comments.status === 'failed',
611
+ message: `${page.title}: ${manifestPage.comments.error ?? `comment archive is ${manifestPage.comments.status}`}`,
612
+ });
613
+ }
503
614
  }
504
615
  for (const warning of result.warnings)
505
616
  report.warning(warning);
@@ -507,18 +618,328 @@ export class Migrator {
507
618
  report.error(error);
508
619
  return result;
509
620
  }
621
+ async transferRemoteComments(input) {
622
+ const { source, destination, page, destinationPage, inventory, resources, pageState, report, store } = input;
623
+ const now = () => new Date().toISOString();
624
+ const counts = {
625
+ threads: inventory?.threads.length ?? 0,
626
+ comments: inventory?.comments ?? 0,
627
+ participants: inventory?.participants ?? 0,
628
+ attachments: inventory?.attachments ?? 0,
629
+ bytes: inventory?.knownBytes ?? 0,
630
+ };
631
+ if (!inventory)
632
+ return { status: 'skipped', ...counts, updatedAt: now() };
633
+ if (inventory.status === 'failed') {
634
+ return { status: 'failed', ...counts, ...(inventory.error ? { lastError: inventory.error } : {}), updatedAt: now() };
635
+ }
636
+ if (inventory.status === 'empty') {
637
+ if (pageState.comments?.destinationId) {
638
+ return { ...pageState.comments, status: 'stale', lastError: 'Source no longer returns the previously archived comments; the destination archive was preserved.', updatedAt: now() };
639
+ }
640
+ return { status: 'complete', ...counts, updatedAt: now() };
641
+ }
642
+ const fingerprint = inventory.fingerprint;
643
+ if (!fingerprint)
644
+ return { status: 'failed', ...counts, lastError: 'Comment inventory fingerprint is missing', updatedAt: now() };
645
+ if (input.dryRun)
646
+ return { status: 'planned', fingerprint, ...counts, updatedAt: now() };
647
+ if (!destination.createArchiveComment || !destination.readArchiveComment) {
648
+ return { status: 'failed', fingerprint, ...counts, lastError: `${destination.profile.name} does not implement comment archive writes`, updatedAt: now() };
649
+ }
650
+ try {
651
+ if (input.signal?.aborted)
652
+ throw input.signal.reason ?? new Error('Transfer cancelled');
653
+ const resourceById = new Map(resources.map((resource) => [resource.id, resource]));
654
+ const downloads = new Map();
655
+ const archiveFiles = [];
656
+ for (const use of commentAttachmentUses(inventory)) {
657
+ if (input.signal?.aborted)
658
+ throw input.signal.reason ?? new Error('Transfer cancelled');
659
+ const resourceId = use.reference.resourceId;
660
+ if (!resourceId)
661
+ continue;
662
+ const resource = resourceById.get(resourceId);
663
+ if (!resource)
664
+ continue;
665
+ let downloaded = downloads.get(resourceId);
666
+ if (!downloaded) {
667
+ const resourceState = pageState.resources[resourceId];
668
+ const filename = containedCachePath(input.cacheDirectory, resourceState?.cachePath)
669
+ ?? cachePath(input.cacheDirectory, page, resource);
670
+ const cached = await verifiedCacheFile(filename, resourceState?.sha256 ? {
671
+ sha256: resourceState.sha256,
672
+ ...(resourceState.size !== undefined ? { size: resourceState.size } : {}),
673
+ } : undefined);
674
+ downloaded = cached ?? await source.downloadResource(resource, filename);
675
+ downloads.set(resourceId, downloaded);
676
+ }
677
+ archiveFiles.push({
678
+ commentId: use.commentId,
679
+ reference: use.reference.href,
680
+ originalName: resource.preferredName ?? resource.name,
681
+ ...downloaded,
682
+ });
683
+ }
684
+ const zip = await createCommentArchiveZip({
685
+ pageKey: page.id,
686
+ fingerprint,
687
+ files: archiveFiles,
688
+ destinationDirectory: path.join(input.cacheDirectory, 'comments', sha256(page.canonicalId).slice(0, 16)),
689
+ });
690
+ let current;
691
+ const previous = pageState.comments;
692
+ if (previous?.destinationId) {
693
+ try {
694
+ current = await destination.readArchiveComment({ page: destinationPage, commentId: previous.destinationId });
695
+ }
696
+ catch (error) {
697
+ report.issue({
698
+ code: 'comment-mapping-missing', severity: 'warning', phase: 'comment', pageId: page.canonicalId,
699
+ retryable: true, message: `${page.title}: mapped comment ${previous.destinationId} could not be read and will be recreated: ${error instanceof Error ? error.message : String(error)}`,
700
+ });
701
+ }
702
+ }
703
+ current ??= await destination.findArchiveComment?.(destinationPage, fingerprint);
704
+ if (current && previous?.destinationFingerprint && current.fingerprint !== previous.destinationFingerprint) {
705
+ const conflict = {
706
+ fingerprint,
707
+ destinationId: current.id,
708
+ destinationFingerprint: current.fingerprint,
709
+ ...counts,
710
+ ...(previous.archiveResourceId ? { archiveResourceId: previous.archiveResourceId } : {}),
711
+ ...(previous.archiveName ? { archiveName: previous.archiveName } : {}),
712
+ ...(previous.archivePath ? { archivePath: previous.archivePath } : {}),
713
+ ...(previous.archiveSha256 ? { archiveSha256: previous.archiveSha256 } : {}),
714
+ status: 'conflict',
715
+ lastError: 'Destination comment archive was changed manually and was preserved.',
716
+ updatedAt: now(),
717
+ };
718
+ return conflict;
719
+ }
720
+ const remoteResources = await destination.listUploadedResources(destinationPage);
721
+ const remoteMatches = async (remote, file, trustedMapping) => {
722
+ if (remote.size !== undefined && remote.size !== file.size)
723
+ return false;
724
+ if (!destination.downloadUploadedResource)
725
+ return trustedMapping && (remote.size === undefined || remote.size === file.size);
726
+ const comparison = `${file.path}.destination-${sha256(remote.id).slice(0, 8)}`;
727
+ try {
728
+ const actual = await destination.downloadUploadedResource({ page: destinationPage, resource: remote, destination: comparison });
729
+ return actual.size === file.size && actual.sha256 === file.sha256;
730
+ }
731
+ finally {
732
+ await rm(comparison, { force: true });
733
+ }
734
+ };
735
+ const uploadArchive = async (file, mappedId, mimeType) => {
736
+ const mapped = mappedId ? remoteResources.find((resource) => resource.id === mappedId) : undefined;
737
+ const byName = remoteResources.find((resource) => resource.name.toLocaleLowerCase() === file.name.toLocaleLowerCase());
738
+ const reusable = mapped && await remoteMatches(mapped, file, true)
739
+ ? mapped
740
+ : byName && await remoteMatches(byName, file, false) ? byName : undefined;
741
+ if (reusable)
742
+ return reusable;
743
+ const extension = path.extname(file.name);
744
+ const alternativeName = `${file.name.slice(0, file.name.length - extension.length)}-${file.sha256.slice(0, 8)}${extension}`;
745
+ const alternative = remoteResources.find((resource) => resource.name.toLocaleLowerCase() === alternativeName.toLocaleLowerCase());
746
+ if (alternative && await remoteMatches(alternative, file, false))
747
+ return alternative;
748
+ const uploadName = mapped || byName ? alternativeName : file.name;
749
+ return destination.uploadResource({
750
+ page: destinationPage,
751
+ name: uploadName,
752
+ filePath: file.path,
753
+ size: file.size,
754
+ ...(mimeType ? { mimeType } : {}),
755
+ });
756
+ };
757
+ const archiveResource = zip
758
+ ? await uploadArchive(zip, previous?.archiveResourceId, 'application/zip')
759
+ : undefined;
760
+ if (input.signal?.aborted)
761
+ throw input.signal.reason ?? new Error('Transfer cancelled');
762
+ const attachmentNames = new Map();
763
+ if (zip) {
764
+ const nameByHash = new Map(zip.files.map((entry) => [entry.sha256, entry.archiveName]));
765
+ for (const [resourceId, downloaded] of downloads) {
766
+ const name = nameByHash.get(downloaded.sha256);
767
+ if (name)
768
+ attachmentNames.set(resourceId, name);
769
+ }
770
+ }
771
+ const render = (presentation, replaces) => renderCommentArchive(inventory, destination.kind, {
772
+ sourceUrl: page.url,
773
+ ...(zip ? { archiveName: archiveResource?.name ?? zip.name, archiveHref: archiveResource?.url ?? archiveResource?.name ?? zip.name } : {}),
774
+ ...(replaces ? { archiveReplaces: replaces } : {}),
775
+ attachmentNames,
776
+ pages: input.destinationPages,
777
+ sourcePages: input.sourcePages,
778
+ presentation,
779
+ });
780
+ const archiveState = zip ? {
781
+ ...(archiveResource ? { archiveResourceId: archiveResource.id } : {}),
782
+ archiveName: archiveResource?.name ?? zip.name,
783
+ archivePath: zip.path,
784
+ archiveSha256: zip.sha256,
785
+ } : {};
786
+ const replacement = destination.capabilities.updateComments ? undefined : current?.id;
787
+ let expectedBody = render('native', replacement);
788
+ let fallbackReason;
789
+ let fullArchiveState = {};
790
+ const maximumInlineBytes = 1_000_000;
791
+ const prepareFullArchive = async (reason) => {
792
+ if (fullArchiveState.fullArchiveName)
793
+ return;
794
+ const fullBody = renderCommentArchive(inventory, 'markdown', {
795
+ sourceUrl: page.url,
796
+ ...(zip ? { archiveName: archiveResource?.name ?? zip.name, archiveHref: archiveResource?.url ?? archiveResource?.name ?? zip.name } : {}),
797
+ ...(replacement ? { archiveReplaces: replacement } : {}),
798
+ attachmentNames,
799
+ pages: input.destinationPages,
800
+ sourcePages: input.sourcePages,
801
+ });
802
+ const fullArchiveName = `ywtc-comments-${safeFilename(page.id)}-${fingerprint.slice(0, 12)}.md`;
803
+ const fullArchivePath = path.join(input.cacheDirectory, 'comments', sha256(page.canonicalId).slice(0, 16), fullArchiveName);
804
+ await mkdir(path.dirname(fullArchivePath), { recursive: true, mode: 0o700 });
805
+ await writeFile(fullArchivePath, `${fullBody}\n`, { mode: 0o600 });
806
+ const fullArchiveSha256 = sha256(`${fullBody}\n`);
807
+ const fullArchiveResource = await uploadArchive({
808
+ path: fullArchivePath,
809
+ name: fullArchiveName,
810
+ size: Buffer.byteLength(`${fullBody}\n`),
811
+ sha256: fullArchiveSha256,
812
+ }, previous?.fullArchiveResourceId, 'text/markdown');
813
+ const destinationFullArchiveName = fullArchiveResource.name;
814
+ expectedBody = renderCommentArchiveIndex(inventory, destination.kind, {
815
+ sourceUrl: page.url,
816
+ fullArchiveName: destinationFullArchiveName,
817
+ fullArchiveHref: fullArchiveResource.url ?? destinationFullArchiveName,
818
+ ...(zip ? { archiveName: archiveResource?.name ?? zip.name, archiveHref: archiveResource?.url ?? archiveResource?.name ?? zip.name } : {}),
819
+ });
820
+ fullArchiveState = {
821
+ fullArchiveResourceId: fullArchiveResource.id,
822
+ fullArchiveName: destinationFullArchiveName,
823
+ fullArchivePath,
824
+ fullArchiveSha256,
825
+ };
826
+ fallbackReason = reason;
827
+ };
828
+ if (Buffer.byteLength(expectedBody) > maximumInlineBytes) {
829
+ await prepareFullArchive(`Full comment archive exceeded ${maximumInlineBytes} inline bytes and was attached as Markdown.`);
830
+ }
831
+ let result = current;
832
+ const requiresWrite = !result || previous?.fingerprint !== fingerprint;
833
+ if (requiresWrite) {
834
+ const write = async (body) => {
835
+ if (input.signal?.aborted)
836
+ throw input.signal.reason ?? new Error('Transfer cancelled');
837
+ if (!result || !destination.capabilities.updateComments || !destination.updateArchiveComment) {
838
+ return destination.createArchiveComment({ page: destinationPage, body });
839
+ }
840
+ return destination.updateArchiveComment({ comment: result, body });
841
+ };
842
+ const presentations = fallbackReason ? [] : ['native', 'quote', 'code'];
843
+ let lastError;
844
+ if (fallbackReason) {
845
+ result = await write(expectedBody);
846
+ }
847
+ else {
848
+ for (const presentation of presentations) {
849
+ try {
850
+ expectedBody = render(presentation, replacement);
851
+ result = await write(expectedBody);
852
+ if (presentation !== 'native')
853
+ fallbackReason = `Destination rejected the native collapsible archive; ${presentation} fallback was used.`;
854
+ lastError = undefined;
855
+ break;
856
+ }
857
+ catch (error) {
858
+ lastError = error;
859
+ const status = error.status;
860
+ if (status === 413 || /(?:status[=: ]+413|too large|payload too large)/i.test(error instanceof Error ? error.message : String(error))) {
861
+ try {
862
+ await prepareFullArchive('Destination rejected the inline archive size; the full archive was attached as Markdown.');
863
+ result = await write(expectedBody);
864
+ lastError = undefined;
865
+ break;
866
+ }
867
+ catch (fallbackError) {
868
+ lastError = fallbackError;
869
+ }
870
+ }
871
+ const reconciled = await destination.findArchiveComment?.(destinationPage, fingerprint);
872
+ if (reconciled) {
873
+ result = reconciled;
874
+ expectedBody = reconciled.body;
875
+ fallbackReason = presentation === 'native' ? undefined : `Destination accepted the ${presentation} fallback after an ambiguous response.`;
876
+ lastError = undefined;
877
+ break;
878
+ }
879
+ }
880
+ }
881
+ if (lastError)
882
+ throw lastError;
883
+ }
884
+ }
885
+ if (!result)
886
+ throw new Error('Destination comment archive was not created');
887
+ const verified = await destination.readArchiveComment({ page: destinationPage, commentId: result.id });
888
+ const verification = verifyCommentArchive(fullArchiveState.fullArchiveName ? fingerprint : inventory, verified.body, archiveResource?.name);
889
+ if (fullArchiveState.fullArchiveName && !verified.body.includes(fullArchiveState.fullArchiveName)) {
890
+ verification.ok = false;
891
+ verification.differences.push('full Markdown comment archive reference is missing');
892
+ }
893
+ if (!verification.ok)
894
+ throw new Error(`Comment verification failed: ${verification.differences.join('; ')}`);
895
+ const previousIds = result.id !== current?.id && current
896
+ ? [...new Set([...(previous?.previousDestinationIds ?? []), current.id])]
897
+ : previous?.previousDestinationIds;
898
+ const state = {
899
+ fingerprint,
900
+ destinationId: verified.id,
901
+ destinationFingerprint: verified.fingerprint,
902
+ ...(previousIds?.length ? { previousDestinationIds: previousIds } : {}),
903
+ ...archiveState,
904
+ ...fullArchiveState,
905
+ status: inventory.warnings.length > 0 || fallbackReason ? 'partial' : 'complete',
906
+ ...counts,
907
+ preview: verified.body,
908
+ ...(fallbackReason ? { lastError: fallbackReason } : {}),
909
+ updatedAt: now(),
910
+ };
911
+ pageState.comments = state;
912
+ await store.setPage({ ...pageState, comments: state, updatedAt: now() });
913
+ return state;
914
+ }
915
+ catch (error) {
916
+ if (input.signal?.aborted)
917
+ throw input.signal.reason ?? error;
918
+ const message = error instanceof Error ? error.message : String(error);
919
+ return {
920
+ fingerprint,
921
+ ...counts,
922
+ ...(pageState.comments?.destinationId ? { destinationId: pageState.comments.destinationId } : {}),
923
+ ...(pageState.comments?.destinationFingerprint ? { destinationFingerprint: pageState.comments.destinationFingerprint } : {}),
924
+ status: /conflict/i.test(message) ? 'conflict' : 'failed',
925
+ lastError: message,
926
+ updatedAt: now(),
927
+ };
928
+ }
929
+ }
510
930
  async transferRemote(input) {
511
- const { source, destination, container, graph, options, store, report, sourceFingerprints, resourcesByPage } = input;
931
+ const { source, destination, container, graph, options, store, report, sourceFingerprints, resourcesByPage, commentsByPage } = input;
512
932
  const destinationPages = new Map();
513
933
  const stagingFailures = new Map();
514
934
  const sourcePages = new Map(graph.pages.map((page) => [page.canonicalId, page]));
515
935
  const bootstrapBodies = new Map();
516
936
  const totalResources = [...resourcesByPage.values()].reduce((total, resources) => total + resources.filter((resource) => resource.kind !== 'grid').length, 0);
517
- const progress = { staged: 0, pages: 0, resources: 0 };
518
- const totalUnits = Math.max(1, graph.pages.length * 2 + totalResources);
937
+ const totalCommentArchives = (options.comments ?? true) ? graph.pages.length : 0;
938
+ const progress = { staged: 0, pages: 0, resources: 0, comments: 0 };
939
+ const totalUnits = Math.max(1, graph.pages.length * 2 + totalResources + totalCommentArchives);
519
940
  const updateProgress = (active) => {
520
- const completed = progress.staged + progress.pages + progress.resources;
521
- options.onStatus?.(`Progress ${Math.min(100, Math.floor((completed / totalUnits) * 100))}% · pages ${progress.pages}/${graph.pages.length} · files ${progress.resources}/${totalResources} · ${active}`);
941
+ const completed = progress.staged + progress.pages + progress.resources + progress.comments;
942
+ options.onStatus?.(`Progress ${Math.min(100, Math.floor((completed / totalUnits) * 100))}% · pages ${progress.pages}/${graph.pages.length} · files ${progress.resources}/${totalResources} · comments ${progress.comments}/${totalCommentArchives} · ${active}`);
522
943
  };
523
944
  for (const page of graph.pages) {
524
945
  const [document, cachedPage] = await Promise.all([
@@ -566,8 +987,12 @@ export class Migrator {
566
987
  let created;
567
988
  let title = saved?.destinationTitle ?? page.title;
568
989
  let resourcesForState = saved?.resources ?? {};
990
+ let commentsForState = saved?.comments;
569
991
  let previousDestinationIds = saved?.previousDestinationIds ?? [];
570
992
  let mappingRecovered = false;
993
+ const findTitleConflict = (candidateTitle) => destination.findTitleConflict
994
+ ? destination.findTitleConflict(parent, candidateTitle)
995
+ : destination.findChild(parent, candidateTitle);
571
996
  if (saved?.destinationId) {
572
997
  const mapped = destinationFromState(saved, destination);
573
998
  try {
@@ -613,14 +1038,31 @@ export class Migrator {
613
1038
  }
614
1039
  else {
615
1040
  let recoveryTitle = title;
616
- if (candidate) {
1041
+ let titleConflict = candidate;
1042
+ if (!titleConflict && !recoveryLookupFailed) {
1043
+ try {
1044
+ titleConflict = await findTitleConflict(recoveryTitle);
1045
+ }
1046
+ catch (lookupError) {
1047
+ recoveryLookupFailed = true;
1048
+ report.issue({
1049
+ code: 'destination-title-lookup-failed',
1050
+ severity: 'warning',
1051
+ phase: 'page',
1052
+ pageId: page.canonicalId,
1053
+ retryable: true,
1054
+ message: `${page.title}: destination title lookup failed; bootstrap recreation will be attempted with a stable suffix: ${lookupError instanceof Error ? lookupError.message : String(lookupError)}`,
1055
+ });
1056
+ }
1057
+ }
1058
+ if (titleConflict || recoveryLookupFailed) {
617
1059
  const base = collisionTitle(page);
618
1060
  const hash = sha256(page.canonicalId).slice(0, 8);
619
1061
  recoveryTitle = base;
620
1062
  let sequence = 0;
621
1063
  while (!recoveryLookupFailed) {
622
1064
  try {
623
- if (!await findRecoveryChild(recoveryTitle))
1065
+ if (!await findTitleConflict(recoveryTitle))
624
1066
  break;
625
1067
  sequence += 1;
626
1068
  recoveryTitle = `${base} [${hash}${sequence > 1 ? `-${sequence}` : ''}]`;
@@ -645,6 +1087,7 @@ export class Migrator {
645
1087
  }
646
1088
  previousDestinationIds = [...new Set([...previousDestinationIds, mapped.id])];
647
1089
  resourcesForState = resetResourceDestinations(saved.resources);
1090
+ commentsForState = undefined;
648
1091
  mappingRecovered = true;
649
1092
  report.issue({
650
1093
  code: 'destination-mapping-recovered',
@@ -670,6 +1113,92 @@ export class Migrator {
670
1113
  created = await verifyConfirmedDestinationPage(destination, created, this.consistencyOptions);
671
1114
  assertDestinationContent(bootstrap.body, created.content, destination.kind, cachedPage);
672
1115
  }
1116
+ if (!mappingRecovered && created.parentId !== parent.id) {
1117
+ const oldPage = created;
1118
+ if (options.dryRun) {
1119
+ report.addHierarchyRepair({
1120
+ sourceCanonicalId: page.canonicalId,
1121
+ mode: destination.capabilities.reparentPages && destination.reparentPage ? 'moved' : 'recreated',
1122
+ ...(created.parentId ? { oldParentId: created.parentId } : {}),
1123
+ newParentId: parent.id,
1124
+ oldDestinationId: created.id,
1125
+ newDestinationId: created.id,
1126
+ verified: false,
1127
+ });
1128
+ }
1129
+ else if (destination.capabilities.reparentPages && destination.reparentPage) {
1130
+ created = await destination.reparentPage({
1131
+ page: created,
1132
+ parent,
1133
+ ...(created.fingerprint ? { expectedFingerprint: created.fingerprint } : {}),
1134
+ });
1135
+ created = await verifyConfirmedDestinationPage(destination, created, this.consistencyOptions);
1136
+ if (created.parentId !== parent.id) {
1137
+ throw new Error(`Destination hierarchy verification failed for ${page.title}: expected parent ${parent.id}, received ${created.parentId ?? 'none'}`);
1138
+ }
1139
+ report.addHierarchyRepair({
1140
+ sourceCanonicalId: page.canonicalId,
1141
+ mode: 'moved',
1142
+ ...(oldPage.parentId ? { oldParentId: oldPage.parentId } : {}),
1143
+ newParentId: parent.id,
1144
+ oldDestinationId: oldPage.id,
1145
+ newDestinationId: created.id,
1146
+ verified: true,
1147
+ });
1148
+ report.issue({
1149
+ code: 'destination-hierarchy-repaired',
1150
+ severity: 'normalization',
1151
+ phase: 'page',
1152
+ pageId: page.canonicalId,
1153
+ retryable: false,
1154
+ message: `${page.title}: moved destination page ${created.id} from parent ${oldPage.parentId ?? 'none'} to ${parent.id} and verified the hierarchy`,
1155
+ });
1156
+ }
1157
+ else {
1158
+ let replacement = await destination.findChild(parent, title);
1159
+ const replacementMatches = replacement
1160
+ && sourceDerivedTextMatches(bootstrap.body, replacement.content, destination.kind, cachedPage);
1161
+ const titleConflict = replacementMatches ? undefined : replacement ?? await findTitleConflict(title);
1162
+ if (titleConflict) {
1163
+ const base = collisionTitle(page);
1164
+ const hash = sha256(page.canonicalId).slice(0, 8);
1165
+ title = base;
1166
+ let sequence = 0;
1167
+ while (await findTitleConflict(title)) {
1168
+ sequence += 1;
1169
+ title = `${base} [${hash}${sequence > 1 ? `-${sequence}` : ''}]`;
1170
+ }
1171
+ replacement = undefined;
1172
+ }
1173
+ created = replacement ?? await destination.createPage({ container: parent, title, body: bootstrap.body });
1174
+ created = await verifyConfirmedDestinationPage(destination, created, this.consistencyOptions);
1175
+ assertDestinationContent(bootstrap.body, created.content, destination.kind, cachedPage);
1176
+ if (created.parentId !== parent.id) {
1177
+ throw new Error(`Destination hierarchy verification failed for recreated page ${page.title}: expected parent ${parent.id}, received ${created.parentId ?? 'none'}`);
1178
+ }
1179
+ previousDestinationIds = [...new Set([...previousDestinationIds, oldPage.id])];
1180
+ resourcesForState = resetResourceDestinations(saved.resources);
1181
+ commentsForState = undefined;
1182
+ mappingRecovered = true;
1183
+ report.addHierarchyRepair({
1184
+ sourceCanonicalId: page.canonicalId,
1185
+ mode: 'recreated',
1186
+ ...(oldPage.parentId ? { oldParentId: oldPage.parentId } : {}),
1187
+ newParentId: parent.id,
1188
+ oldDestinationId: oldPage.id,
1189
+ newDestinationId: created.id,
1190
+ verified: true,
1191
+ });
1192
+ report.issue({
1193
+ code: 'destination-hierarchy-recreated',
1194
+ severity: 'normalization',
1195
+ phase: 'page',
1196
+ pageId: page.canonicalId,
1197
+ retryable: false,
1198
+ message: `${page.title}: created verified replacement ${created.id} below ${parent.id}; previous page ${oldPage.id} was left untouched`,
1199
+ });
1200
+ }
1201
+ }
673
1202
  }
674
1203
  else if (options.dryRun) {
675
1204
  created = {
@@ -680,25 +1209,45 @@ export class Migrator {
680
1209
  title,
681
1210
  url: container.url,
682
1211
  locator: `dry-run:${page.id}`,
1212
+ parentId: parent.id,
683
1213
  content: bootstrap.body,
684
1214
  contentLength: Buffer.byteLength(bootstrap.body),
685
1215
  ...(container.space ? { space: container.space } : {}),
686
1216
  };
687
1217
  }
688
1218
  else {
689
- if (await destination.findChild(parent, title)) {
690
- const base = collisionTitle(page);
691
- const hash = sha256(page.canonicalId).slice(0, 8);
692
- title = base;
693
- let sequence = 0;
694
- while (await destination.findChild(parent, title)) {
695
- sequence += 1;
696
- title = `${base} [${hash}${sequence > 1 ? `-${sequence}` : ''}]`;
1219
+ const existingChild = await destination.findChild(parent, title);
1220
+ if (existingChild && sourceDerivedTextMatches(bootstrap.body, existingChild.content, destination.kind, cachedPage)) {
1221
+ created = existingChild;
1222
+ mappingRecovered = true;
1223
+ report.issue({
1224
+ code: 'destination-unmapped-page-recovered',
1225
+ severity: 'normalization',
1226
+ phase: 'page',
1227
+ pageId: page.canonicalId,
1228
+ retryable: false,
1229
+ message: `${page.title}: adopted an existing source-derived page below the expected parent`,
1230
+ });
1231
+ }
1232
+ else {
1233
+ const titleConflict = existingChild ?? await findTitleConflict(title);
1234
+ if (titleConflict) {
1235
+ const base = collisionTitle(page);
1236
+ const hash = sha256(page.canonicalId).slice(0, 8);
1237
+ title = base;
1238
+ let sequence = 0;
1239
+ while (await findTitleConflict(title)) {
1240
+ sequence += 1;
1241
+ title = `${base} [${hash}${sequence > 1 ? `-${sequence}` : ''}]`;
1242
+ }
697
1243
  }
1244
+ created = await destination.createPage({ container: parent, title, body: bootstrap.body });
1245
+ created = await verifyConfirmedDestinationPage(destination, created, this.consistencyOptions);
1246
+ assertDestinationContent(bootstrap.body, created.content, destination.kind, cachedPage);
698
1247
  }
699
- created = await destination.createPage({ container: parent, title, body: bootstrap.body });
700
- created = await verifyConfirmedDestinationPage(destination, created, this.consistencyOptions);
701
- assertDestinationContent(bootstrap.body, created.content, destination.kind, cachedPage);
1248
+ }
1249
+ if (!options.dryRun && created.parentId !== parent.id) {
1250
+ throw new Error(`Destination hierarchy verification failed for ${page.title}: expected parent ${parent.id}, received ${created.parentId ?? 'none'}`);
702
1251
  }
703
1252
  destinationPages.set(page.canonicalId, created);
704
1253
  const next = {
@@ -716,6 +1265,7 @@ export class Migrator {
716
1265
  ...(created.fingerprint ? { destinationFingerprint: created.fingerprint } : {}),
717
1266
  status: options.dryRun ? 'planned' : saved?.status ?? 'skeleton-created',
718
1267
  resources: resourcesForState,
1268
+ ...(commentsForState ? { comments: commentsForState } : {}),
719
1269
  degradations: saved?.degradations ?? [],
720
1270
  updatedAt: new Date().toISOString(),
721
1271
  };
@@ -723,6 +1273,8 @@ export class Migrator {
723
1273
  options.onStatus?.(`${options.dryRun ? 'Planned' : 'Staged content for'} ${page.title}`);
724
1274
  }
725
1275
  catch (error) {
1276
+ if (options.signal?.aborted)
1277
+ throw options.signal.reason ?? error;
726
1278
  const message = `${page.title}: ${error instanceof Error ? error.message : String(error)}`;
727
1279
  const failureStatus = /conflict/i.test(message) ? 'conflict' : 'failed';
728
1280
  stagingFailures.set(page.canonicalId, { status: failureStatus, message });
@@ -762,6 +1314,9 @@ export class Migrator {
762
1314
  document,
763
1315
  ...(failedState?.resources ? { resourceStates: failedState.resources } : {}),
764
1316
  ...(options.dryRun ? { resourceStatusOverride: 'planned' } : {}),
1317
+ commentsEnabled: options.comments ?? true,
1318
+ ...(commentsByPage.get(page.canonicalId) ? { commentInventory: commentsByPage.get(page.canonicalId) } : {}),
1319
+ ...(failedState?.comments ? { commentState: failedState.comments } : {}),
765
1320
  error: currentFailure?.message ?? failedState?.lastError ?? 'Destination page with source-derived content was not created',
766
1321
  }));
767
1322
  progress.pages += 1;
@@ -805,7 +1360,9 @@ export class Migrator {
805
1360
  await store.setPage({ ...state, status: 'partial', lastError: detail, updatedAt: new Date().toISOString() });
806
1361
  };
807
1362
  try {
808
- let remoteResources = options.dryRun ? [] : await destination.listUploadedResources(destinationPage);
1363
+ let remoteResources = options.dryRun || transferableResources.length === 0
1364
+ ? []
1365
+ : await destination.listUploadedResources(destinationPage);
809
1366
  const nameAllocator = new DestinationNameAllocator(remoteResources.map((resource) => resource.name));
810
1367
  for (const resourceState of Object.values(state.resources)) {
811
1368
  if (resourceState.status === 'complete')
@@ -1004,6 +1561,9 @@ export class Migrator {
1004
1561
  };
1005
1562
  const rendered = await renderRemoteDocument({ destination, container, document, page: cachedPage, context });
1006
1563
  if (rendered.degraded && !options.dryRun) {
1564
+ if (transferableResources.length === 0) {
1565
+ remoteResources = await destination.listUploadedResources(destinationPage);
1566
+ }
1007
1567
  const extension = page.rawFormat === 'yfm' ? 'yfm' : 'storage.xml';
1008
1568
  const snapshotName = `ywtc-source-${sha256(page.canonicalId).slice(0, 8)}.${extension}`;
1009
1569
  const snapshotPath = path.join(options.cacheDirectory, 'snapshots', snapshotName);
@@ -1107,7 +1667,51 @@ export class Migrator {
1107
1667
  const verification = assertDestinationContent(rendered.body, verified.content, destination.kind, cachedPage);
1108
1668
  verificationDegradations = verification.status === 'partial' ? verification.differences : [];
1109
1669
  }
1110
- const partial = rendered.degraded || resourceProblems.length > 0 || verificationDegradations.length > 0;
1670
+ const commentInventory = commentsByPage.get(page.canonicalId);
1671
+ const commentState = (options.comments ?? true)
1672
+ ? await this.transferRemoteComments({
1673
+ source,
1674
+ destination,
1675
+ page: cachedPage,
1676
+ destinationPage: verified,
1677
+ inventory: commentInventory,
1678
+ resources,
1679
+ pageState: state,
1680
+ cacheDirectory: options.cacheDirectory,
1681
+ dryRun: options.dryRun,
1682
+ destinationPages,
1683
+ sourcePages,
1684
+ report,
1685
+ store,
1686
+ ...(options.signal ? { signal: options.signal } : {}),
1687
+ })
1688
+ : {
1689
+ status: 'skipped',
1690
+ threads: 0,
1691
+ comments: 0,
1692
+ participants: 0,
1693
+ attachments: 0,
1694
+ bytes: 0,
1695
+ updatedAt: new Date().toISOString(),
1696
+ };
1697
+ if (options.comments ?? true) {
1698
+ progress.comments += 1;
1699
+ updateProgress(`comments ${commentState.status} → ${page.title}`);
1700
+ }
1701
+ if (['failed', 'conflict', 'stale', 'partial'].includes(commentState.status)
1702
+ && !(commentState.status === 'failed' && commentInventory?.status === 'failed')
1703
+ && !(commentState.status === 'partial' && (commentInventory?.warnings.length ?? 0) > 0 && !commentState.lastError)) {
1704
+ report.issue({
1705
+ code: `comment-${commentState.status}`,
1706
+ severity: commentState.status === 'partial' || commentState.status === 'stale' ? 'degradation' : 'error',
1707
+ phase: 'comment',
1708
+ pageId: page.canonicalId,
1709
+ retryable: commentState.status === 'failed',
1710
+ message: `${page.title}: ${commentState.lastError ?? `comment archive is ${commentState.status}`}`,
1711
+ });
1712
+ }
1713
+ const commentPartial = !['complete', 'skipped', 'planned'].includes(commentState.status);
1714
+ const partial = rendered.degraded || resourceProblems.length > 0 || verificationDegradations.length > 0 || commentPartial;
1111
1715
  const lastResourceProblem = resourceProblems.at(-1);
1112
1716
  const clearedState = { ...state };
1113
1717
  delete clearedState.lastError;
@@ -1119,6 +1723,7 @@ export class Migrator {
1119
1723
  destinationTitle: verified.title,
1120
1724
  ...(verified.fingerprint ? { destinationFingerprint: verified.fingerprint } : {}),
1121
1725
  status: options.dryRun ? 'planned' : partial ? 'partial' : 'complete',
1726
+ comments: commentState,
1122
1727
  degradations: [...new Set([...rendered.warnings, ...resourceProblems, ...verificationDegradations])],
1123
1728
  ...(lastResourceProblem ? { lastError: lastResourceProblem } : {}),
1124
1729
  updatedAt: new Date().toISOString(),
@@ -1141,6 +1746,9 @@ export class Migrator {
1141
1746
  resourceFallbacks: fallbackDetails,
1142
1747
  warnings: [...rendered.warnings, ...verificationDegradations],
1143
1748
  normalizations: rendered.normalizations,
1749
+ commentsEnabled: options.comments ?? true,
1750
+ ...(commentInventory ? { commentInventory } : {}),
1751
+ commentState,
1144
1752
  }), { source: normalizedPreviewHtml(document), destination: renderedPreviewHtml(verified.content ?? rendered.body, destination.kind) });
1145
1753
  }
1146
1754
  catch (error) {
@@ -1188,6 +1796,9 @@ export class Migrator {
1188
1796
  ...(options.dryRun ? { resourceStatusOverride: 'planned' } : {}),
1189
1797
  resourceFallbacks: fallbackDetails,
1190
1798
  error: message,
1799
+ commentsEnabled: options.comments ?? true,
1800
+ ...(commentsByPage.get(page.canonicalId) ? { commentInventory: commentsByPage.get(page.canonicalId) } : {}),
1801
+ ...(state.comments ? { commentState: state.comments } : {}),
1191
1802
  }), actual?.content ? { source: normalizedPreviewHtml(document), destination: renderedPreviewHtml(actual.content, destination.kind) } : undefined);
1192
1803
  }
1193
1804
  finally {