@iwonz/ywtc 3.3.0 → 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 (49) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +40 -43
  3. package/dist/adapters/confluence.d.ts +15 -1
  4. package/dist/adapters/confluence.js +59 -0
  5. package/dist/adapters/confluence.js.map +1 -1
  6. package/dist/adapters/yandex.d.ts +11 -1
  7. package/dist/adapters/yandex.js +64 -0
  8. package/dist/adapters/yandex.js.map +1 -1
  9. package/dist/cli-arguments.d.ts +2 -0
  10. package/dist/cli-arguments.js +6 -0
  11. package/dist/cli-arguments.js.map +1 -1
  12. package/dist/cli.js +6 -1
  13. package/dist/cli.js.map +1 -1
  14. package/dist/clients/confluence-cloud.d.ts +16 -1
  15. package/dist/clients/confluence-cloud.js +109 -0
  16. package/dist/clients/confluence-cloud.js.map +1 -1
  17. package/dist/clients/confluence.d.ts +12 -1
  18. package/dist/clients/confluence.js +88 -0
  19. package/dist/clients/confluence.js.map +1 -1
  20. package/dist/clients/yandex-wiki.d.ts +10 -1
  21. package/dist/clients/yandex-wiki.js +44 -0
  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/doctor.d.ts +8 -1
  27. package/dist/doctor.js +20 -3
  28. package/dist/doctor.js.map +1 -1
  29. package/dist/domain.d.ts +69 -1
  30. package/dist/domain.js +3 -1
  31. package/dist/domain.js.map +1 -1
  32. package/dist/filesystem-export.d.ts +16 -1
  33. package/dist/filesystem-export.js +88 -2
  34. package/dist/filesystem-export.js.map +1 -1
  35. package/dist/issues.d.ts +1 -1
  36. package/dist/migrator.d.ts +2 -0
  37. package/dist/migrator.js +488 -13
  38. package/dist/migrator.js.map +1 -1
  39. package/dist/report-catalog.d.ts +13 -1
  40. package/dist/report-catalog.js +12 -0
  41. package/dist/report-catalog.js.map +1 -1
  42. package/dist/report.d.ts +25 -2
  43. package/dist/report.js +23 -1
  44. package/dist/report.js.map +1 -1
  45. package/dist/state.d.ts +35 -1
  46. package/dist/state.js +43 -1
  47. package/dist/state.js.map +1 -1
  48. package/dist/types.d.ts +75 -0
  49. 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) {
@@ -212,11 +236,12 @@ export class Migrator {
212
236
  }
213
237
  async run(options) {
214
238
  const sourceStatus = options.sourceStatus ?? 'all';
239
+ const commentsEnabled = options.comments ?? true;
215
240
  const source = this.adapterFactory(options.source.profile, this.config);
216
241
  const destination = options.destination.type === 'remote'
217
242
  ? this.adapterFactory(options.destination.profile, this.config)
218
243
  : undefined;
219
- assertTransferCapabilities(source, destination, options.scope, sourceStatus);
244
+ assertTransferCapabilities(source, destination, options.scope, sourceStatus, commentsEnabled);
220
245
  const status = options.onStatus ?? (() => undefined);
221
246
  status('Checking source and destination capabilities');
222
247
  const [sourcePlatform, destinationPlatform, root, container] = await Promise.all([
@@ -240,9 +265,10 @@ export class Migrator {
240
265
  await store.load(identity, { fresh: options.fresh ?? false, dryRun: options.dryRun });
241
266
  if (!options.dryRun)
242
267
  await store.save();
243
- const run = await store.beginRun(options.scope, options.dryRun, sourceStatus);
268
+ const run = await store.beginRun(options.scope, options.dryRun, sourceStatus, commentsEnabled);
244
269
  const report = new TransferReport({
245
270
  dryRun: options.dryRun,
271
+ commentsEnabled,
246
272
  scope: options.scope,
247
273
  sourceStatus,
248
274
  direction: `${source.kind} → ${options.destination.type === 'directory' ? options.destination.format : destination.kind}`,
@@ -311,8 +337,9 @@ export class Migrator {
311
337
  throw new MigrationRunError(message, reportPaths, stateLocation.filename, { cause: error });
312
338
  }
313
339
  const resourcesByPage = new Map();
340
+ const commentsByPage = new Map();
314
341
  const inventoryQueue = new PQueue({ concurrency: options.concurrency });
315
- 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 };
316
343
  status(`Inspecting content and resources for ${graph.pages.length} pages`);
317
344
  const inventoryErrors = [];
318
345
  await Promise.all(graph.pages.map((page) => inventoryQueue.add(async () => {
@@ -322,6 +349,36 @@ export class Migrator {
322
349
  const cachedPage = await loadCachedPage(options.cacheDirectory, page.canonicalId);
323
350
  const resources = await source.listResources(page);
324
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
+ }
325
382
  const document = appendGridSnapshots(normalizePageContent(cachedPage), resources);
326
383
  await cacheDocument(options.cacheDirectory, page.canonicalId, document);
327
384
  inventory.resources += resources.filter((resource) => resource.kind !== 'grid').length;
@@ -337,6 +394,8 @@ export class Migrator {
337
394
  status: 'planned',
338
395
  resources,
339
396
  document,
397
+ commentsEnabled,
398
+ ...(commentInventory ? { commentInventory } : {}),
340
399
  }), undefined, false);
341
400
  status(`Inspected ${page.title} · ${resources.length} resources`);
342
401
  }
@@ -354,6 +413,7 @@ export class Migrator {
354
413
  });
355
414
  }
356
415
  })));
416
+ await store.setRunComments(run.id, { threads: inventory.commentThreads, comments: inventory.comments, attachments: inventory.commentAttachments });
357
417
  if (inventoryErrors.length > 0) {
358
418
  const finalStatus = options.signal?.aborted ? 'cancelled' : 'failed';
359
419
  report.finish(finalStatus);
@@ -366,14 +426,14 @@ export class Migrator {
366
426
  });
367
427
  throw new MigrationRunError('Resource preflight failed before destination mutation', reportPaths, stateLocation.filename);
368
428
  }
369
- const planSummary = `Plan ready: root + ${graph.selection.selectedDescendants}/${graph.selection.returnedDescendants} descendants (${graph.selection.excluded.length} excluded, depth ${graph.selection.maximumDepth}) · ${graph.pages.length} pages · ${inventory.resources} files (${inventory.bytes} known bytes${inventory.unknownSizes > 0 ? ` · ${inventory.unknownSizes} sizes unknown` : ''}) · ${inventory.links} links · ${inventory.images} images · ${inventory.tables} tables (${inventory.grids} grids) · ${inventory.codeBlocks} code blocks`;
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'}`;
370
430
  status(planSummary);
371
431
  this.logger.info(planSummary);
372
432
  let output;
373
433
  let archive;
374
434
  try {
375
435
  if (options.destination.type === 'directory') {
376
- 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);
377
437
  output = result.directory;
378
438
  archive = result.archive;
379
439
  report.output('directory', result.directory);
@@ -393,6 +453,7 @@ export class Migrator {
393
453
  report,
394
454
  sourceFingerprints,
395
455
  resourcesByPage,
456
+ commentsByPage,
396
457
  });
397
458
  output = container.url;
398
459
  report.output('destination', container.url);
@@ -437,7 +498,7 @@ export class Migrator {
437
498
  throw new MigrationRunError(message, reportPaths, stateLocation.filename, { cause: error });
438
499
  }
439
500
  }
440
- async exportFilesystem(source, graph, destination, options, identity, store, report, sourceFingerprints, resourcesByPage) {
501
+ async exportFilesystem(source, graph, destination, options, identity, store, report, sourceFingerprints, resourcesByPage, commentsByPage) {
441
502
  const exporter = new FilesystemExporter(source, options.concurrency);
442
503
  const totalResources = [...resourcesByPage.values()].reduce((total, resources) => total + resources.filter((resource) => resource.kind !== 'grid').length, 0);
443
504
  const total = Math.max(1, graph.pages.length + totalResources);
@@ -456,6 +517,8 @@ export class Migrator {
456
517
  options.onStatus?.(`Progress ${Math.min(100, Math.floor((processed / total) * 100))}% · ${processed}/${total} · ${message}`);
457
518
  } } : {}),
458
519
  resourcesByPage,
520
+ commentsByPage,
521
+ commentsEnabled: options.comments ?? true,
459
522
  loadPage: (page) => loadCachedPage(options.cacheDirectory, page.canonicalId),
460
523
  loadDocument: (page) => loadCachedDocument(options.cacheDirectory, page.canonicalId),
461
524
  });
@@ -479,7 +542,8 @@ export class Migrator {
479
542
  updatedAt: new Date().toISOString(),
480
543
  }];
481
544
  }));
482
- 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';
483
547
  await store.setPage({
484
548
  sourceId: page.id,
485
549
  sourceCanonicalId: page.canonicalId,
@@ -495,6 +559,19 @@ export class Migrator {
495
559
  status: pageStatus,
496
560
  resources,
497
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
+ } } : {}),
498
575
  updatedAt: new Date().toISOString(),
499
576
  });
500
577
  report.addPage(reportPage({
@@ -505,7 +582,35 @@ export class Migrator {
505
582
  resourceStates: resources,
506
583
  ...(options.dryRun ? { resourceStatusOverride: 'planned' } : {}),
507
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
+ } } : {}),
508
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
+ }
509
614
  }
510
615
  for (const warning of result.warnings)
511
616
  report.warning(warning);
@@ -513,18 +618,328 @@ export class Migrator {
513
618
  report.error(error);
514
619
  return result;
515
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
+ }
516
930
  async transferRemote(input) {
517
- const { source, destination, container, graph, options, store, report, sourceFingerprints, resourcesByPage } = input;
931
+ const { source, destination, container, graph, options, store, report, sourceFingerprints, resourcesByPage, commentsByPage } = input;
518
932
  const destinationPages = new Map();
519
933
  const stagingFailures = new Map();
520
934
  const sourcePages = new Map(graph.pages.map((page) => [page.canonicalId, page]));
521
935
  const bootstrapBodies = new Map();
522
936
  const totalResources = [...resourcesByPage.values()].reduce((total, resources) => total + resources.filter((resource) => resource.kind !== 'grid').length, 0);
523
- const progress = { staged: 0, pages: 0, resources: 0 };
524
- 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);
525
940
  const updateProgress = (active) => {
526
- const completed = progress.staged + progress.pages + progress.resources;
527
- 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}`);
528
943
  };
529
944
  for (const page of graph.pages) {
530
945
  const [document, cachedPage] = await Promise.all([
@@ -572,6 +987,7 @@ export class Migrator {
572
987
  let created;
573
988
  let title = saved?.destinationTitle ?? page.title;
574
989
  let resourcesForState = saved?.resources ?? {};
990
+ let commentsForState = saved?.comments;
575
991
  let previousDestinationIds = saved?.previousDestinationIds ?? [];
576
992
  let mappingRecovered = false;
577
993
  const findTitleConflict = (candidateTitle) => destination.findTitleConflict
@@ -671,6 +1087,7 @@ export class Migrator {
671
1087
  }
672
1088
  previousDestinationIds = [...new Set([...previousDestinationIds, mapped.id])];
673
1089
  resourcesForState = resetResourceDestinations(saved.resources);
1090
+ commentsForState = undefined;
674
1091
  mappingRecovered = true;
675
1092
  report.issue({
676
1093
  code: 'destination-mapping-recovered',
@@ -761,6 +1178,7 @@ export class Migrator {
761
1178
  }
762
1179
  previousDestinationIds = [...new Set([...previousDestinationIds, oldPage.id])];
763
1180
  resourcesForState = resetResourceDestinations(saved.resources);
1181
+ commentsForState = undefined;
764
1182
  mappingRecovered = true;
765
1183
  report.addHierarchyRepair({
766
1184
  sourceCanonicalId: page.canonicalId,
@@ -847,6 +1265,7 @@ export class Migrator {
847
1265
  ...(created.fingerprint ? { destinationFingerprint: created.fingerprint } : {}),
848
1266
  status: options.dryRun ? 'planned' : saved?.status ?? 'skeleton-created',
849
1267
  resources: resourcesForState,
1268
+ ...(commentsForState ? { comments: commentsForState } : {}),
850
1269
  degradations: saved?.degradations ?? [],
851
1270
  updatedAt: new Date().toISOString(),
852
1271
  };
@@ -854,6 +1273,8 @@ export class Migrator {
854
1273
  options.onStatus?.(`${options.dryRun ? 'Planned' : 'Staged content for'} ${page.title}`);
855
1274
  }
856
1275
  catch (error) {
1276
+ if (options.signal?.aborted)
1277
+ throw options.signal.reason ?? error;
857
1278
  const message = `${page.title}: ${error instanceof Error ? error.message : String(error)}`;
858
1279
  const failureStatus = /conflict/i.test(message) ? 'conflict' : 'failed';
859
1280
  stagingFailures.set(page.canonicalId, { status: failureStatus, message });
@@ -893,6 +1314,9 @@ export class Migrator {
893
1314
  document,
894
1315
  ...(failedState?.resources ? { resourceStates: failedState.resources } : {}),
895
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 } : {}),
896
1320
  error: currentFailure?.message ?? failedState?.lastError ?? 'Destination page with source-derived content was not created',
897
1321
  }));
898
1322
  progress.pages += 1;
@@ -1243,7 +1667,51 @@ export class Migrator {
1243
1667
  const verification = assertDestinationContent(rendered.body, verified.content, destination.kind, cachedPage);
1244
1668
  verificationDegradations = verification.status === 'partial' ? verification.differences : [];
1245
1669
  }
1246
- 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;
1247
1715
  const lastResourceProblem = resourceProblems.at(-1);
1248
1716
  const clearedState = { ...state };
1249
1717
  delete clearedState.lastError;
@@ -1255,6 +1723,7 @@ export class Migrator {
1255
1723
  destinationTitle: verified.title,
1256
1724
  ...(verified.fingerprint ? { destinationFingerprint: verified.fingerprint } : {}),
1257
1725
  status: options.dryRun ? 'planned' : partial ? 'partial' : 'complete',
1726
+ comments: commentState,
1258
1727
  degradations: [...new Set([...rendered.warnings, ...resourceProblems, ...verificationDegradations])],
1259
1728
  ...(lastResourceProblem ? { lastError: lastResourceProblem } : {}),
1260
1729
  updatedAt: new Date().toISOString(),
@@ -1277,6 +1746,9 @@ export class Migrator {
1277
1746
  resourceFallbacks: fallbackDetails,
1278
1747
  warnings: [...rendered.warnings, ...verificationDegradations],
1279
1748
  normalizations: rendered.normalizations,
1749
+ commentsEnabled: options.comments ?? true,
1750
+ ...(commentInventory ? { commentInventory } : {}),
1751
+ commentState,
1280
1752
  }), { source: normalizedPreviewHtml(document), destination: renderedPreviewHtml(verified.content ?? rendered.body, destination.kind) });
1281
1753
  }
1282
1754
  catch (error) {
@@ -1324,6 +1796,9 @@ export class Migrator {
1324
1796
  ...(options.dryRun ? { resourceStatusOverride: 'planned' } : {}),
1325
1797
  resourceFallbacks: fallbackDetails,
1326
1798
  error: message,
1799
+ commentsEnabled: options.comments ?? true,
1800
+ ...(commentsByPage.get(page.canonicalId) ? { commentInventory: commentsByPage.get(page.canonicalId) } : {}),
1801
+ ...(state.comments ? { commentState: state.comments } : {}),
1327
1802
  }), actual?.content ? { source: normalizedPreviewHtml(document), destination: renderedPreviewHtml(actual.content, destination.kind) } : undefined);
1328
1803
  }
1329
1804
  finally {