@iwonz/ywtc 3.3.0 → 3.4.1

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 (56) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +67 -43
  3. package/dist/adapters/confluence.d.ts +15 -1
  4. package/dist/adapters/confluence.js +87 -3
  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 +12 -5
  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/content-model.d.ts +1 -0
  27. package/dist/content-model.js +13 -0
  28. package/dist/content-model.js.map +1 -1
  29. package/dist/doctor.d.ts +8 -1
  30. package/dist/doctor.js +20 -3
  31. package/dist/doctor.js.map +1 -1
  32. package/dist/domain.d.ts +74 -1
  33. package/dist/domain.js +13 -1
  34. package/dist/domain.js.map +1 -1
  35. package/dist/filesystem-export.d.ts +16 -1
  36. package/dist/filesystem-export.js +88 -2
  37. package/dist/filesystem-export.js.map +1 -1
  38. package/dist/issues.d.ts +4 -2
  39. package/dist/issues.js.map +1 -1
  40. package/dist/migrator.d.ts +2 -0
  41. package/dist/migrator.js +756 -164
  42. package/dist/migrator.js.map +1 -1
  43. package/dist/report-catalog.d.ts +27 -1
  44. package/dist/report-catalog.js +26 -0
  45. package/dist/report-catalog.js.map +1 -1
  46. package/dist/report.d.ts +42 -2
  47. package/dist/report.js +109 -7
  48. package/dist/report.js.map +1 -1
  49. package/dist/state.d.ts +54 -5
  50. package/dist/state.js +93 -3
  51. package/dist/state.js.map +1 -1
  52. package/dist/title-allocation.d.ts +2 -0
  53. package/dist/title-allocation.js +43 -0
  54. package/dist/title-allocation.js.map +1 -0
  55. package/dist/types.d.ts +75 -0
  56. package/package.json +1 -1
package/dist/migrator.js CHANGED
@@ -3,14 +3,19 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import PQueue from 'p-queue';
5
5
  import { createProviderAdapter } from './adapters/factory.js';
6
- import { appendGridSnapshots, assertDestinationContent, isLegacyTransferPlaceholder, normalizePageContent, normalizedPreviewHtml, rawContentFallback, renderedPreviewHtml, renderConfluenceStorage, renderYfm, setSourceResourceBinding, sourceResourceBindings, verifyDestinationContent, } from './content-model.js';
6
+ import { appendGridSnapshots, assertDestinationContent, emptyRendererFallbackReason, 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';
9
+ import { DestinationTitleConflictError } from './domain.js';
8
10
  import { assertTransferCapabilities } from './domain.js';
9
11
  import { FilesystemExporter } from './filesystem-export.js';
10
12
  import { discoverPageGraph } from './graph.js';
13
+ import { HttpRequestError } from './http.js';
14
+ import { safeFilename } from './input.js';
11
15
  import { profileBaseUrl, sameRemoteInstance } from './locators.js';
12
16
  import { TransferReport, } from './report.js';
13
17
  import { DestinationNameAllocator, preferredDestinationName, verifiedCacheFile } from './resource-reconciliation.js';
18
+ import { destinationTitleCandidates } from './title-allocation.js';
14
19
  import { resolveStateLocation, StateStore, } from './state.js';
15
20
  function sourceIdentity(locator, root) {
16
21
  return {
@@ -63,10 +68,6 @@ async function cacheDocument(cacheDirectory, canonicalId, document) {
63
68
  async function loadCachedDocument(cacheDirectory, canonicalId) {
64
69
  return JSON.parse(await readFile(cachedDocumentPath(cacheDirectory, canonicalId), 'utf8'));
65
70
  }
66
- function collisionTitle(page) {
67
- const suffix = page.locator.split('/').filter(Boolean).at(-1) ?? page.id;
68
- return `${page.title} [${suffix}]`;
69
- }
70
71
  function cachePath(cacheDirectory, page, resource) {
71
72
  const pageKey = sha256(page.canonicalId).slice(0, 12);
72
73
  const resourceKey = sha256(`${resource.id}:${resource.name}`).slice(0, 12);
@@ -102,6 +103,73 @@ function sourceDerivedTextMatches(expectedBody, actualBody, provider, page) {
102
103
  const text = (value) => renderedPreviewHtml(value, provider).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
103
104
  return text(expectedBody) === text(actualBody);
104
105
  }
106
+ function ambiguousCreateOutcome(error) {
107
+ return error instanceof HttpRequestError
108
+ && (error.status === undefined || error.status >= 500);
109
+ }
110
+ async function createPageWithAllocatedTitle(input) {
111
+ const candidates = destinationTitleCandidates(input.sourcePage, input.preferredTitle);
112
+ for (const [index, title] of candidates.entries()) {
113
+ let child;
114
+ try {
115
+ child = input.destination.findRecoverablePage
116
+ ? await input.destination.findRecoverablePage(input.parent, title)
117
+ : await input.destination.findChild(input.parent, title);
118
+ }
119
+ catch (error) {
120
+ input.report.issue({
121
+ code: 'destination-recovery-lookup-failed',
122
+ severity: 'warning',
123
+ phase: 'page',
124
+ pageId: input.sourcePage.canonicalId,
125
+ retryable: true,
126
+ message: `${input.sourcePage.title}: parent-scoped recovery lookup failed for "${title}"; authoritative creation will be attempted: ${error instanceof Error ? error.message : String(error)}`,
127
+ });
128
+ }
129
+ if (child && sourceDerivedTextMatches(input.bootstrap.body, child.content, input.destination.kind, input.sourcePage)) {
130
+ return { page: child, reused: true };
131
+ }
132
+ const occupied = child ?? (input.destination.findTitleConflict
133
+ ? await input.destination.findTitleConflict(input.parent, title)
134
+ : undefined);
135
+ if (occupied)
136
+ continue;
137
+ let created;
138
+ try {
139
+ created = await input.destination.createPage({ container: input.parent, title, body: input.bootstrap.body });
140
+ }
141
+ catch (error) {
142
+ if (error instanceof DestinationTitleConflictError)
143
+ continue;
144
+ if (ambiguousCreateOutcome(error)) {
145
+ const recovered = input.destination.findRecoverablePage
146
+ ? await input.destination.findRecoverablePage(input.parent, title)
147
+ : await input.destination.findChild(input.parent, title);
148
+ if (recovered && sourceDerivedTextMatches(input.bootstrap.body, recovered.content, input.destination.kind, input.sourcePage)) {
149
+ return { page: recovered, reused: true };
150
+ }
151
+ }
152
+ throw error;
153
+ }
154
+ created = await verifyConfirmedDestinationPage(input.destination, created, input.consistencyOptions);
155
+ assertDestinationContent(input.bootstrap.body, created.content, input.destination.kind, input.sourcePage);
156
+ if (created.parentId !== input.parent.id) {
157
+ throw new Error(`Destination hierarchy verification failed for ${input.sourcePage.title}: expected parent ${input.parent.id}, received ${created.parentId ?? 'none'}`);
158
+ }
159
+ if (index > 0) {
160
+ input.report.issue({
161
+ code: 'destination-title-renamed',
162
+ severity: 'normalization',
163
+ phase: 'page',
164
+ pageId: input.sourcePage.canonicalId,
165
+ retryable: false,
166
+ message: `${input.sourcePage.title}: destination title was allocated as "${created.title}" because an earlier candidate was occupied`,
167
+ });
168
+ }
169
+ return { page: created, reused: false };
170
+ }
171
+ throw new Error(`Confluence rejected all ${candidates.length} deterministic destination titles for ${input.sourcePage.title} because they are occupied`);
172
+ }
105
173
  function destinationFromState(state, adapter) {
106
174
  if (!state.destinationId || !state.destinationCanonicalId || !state.destinationLocator) {
107
175
  throw new Error(`Incomplete mapped destination for ${state.sourceTitle}`);
@@ -133,10 +201,19 @@ function reportPage(input) {
133
201
  ...(state?.destinationId ? { destinationId: state.destinationId } : {}),
134
202
  ...(state?.destinationName ? { destinationName: state.destinationName } : {}),
135
203
  ...(state?.lastError ? { error: state.lastError } : {}),
204
+ ...(status === 'blocked' && input.blockedBy ? input.blockedBy : {}),
136
205
  };
137
206
  });
138
207
  const knownBytes = resourceItems.reduce((total, resource) => total + (resource.sourceBytes ?? 0), 0);
139
208
  const transferredBytes = resourceItems.reduce((total, resource) => total + (resource.transferredBytes ?? 0), 0);
209
+ const commentsEnabled = input.commentsEnabled ?? true;
210
+ const inventory = input.commentInventory;
211
+ const commentState = input.commentState;
212
+ const commentStatus = !commentsEnabled
213
+ ? 'skipped'
214
+ : commentState?.status
215
+ ?? (inventory?.status === 'failed' ? 'failed' : inventory?.status === 'empty' ? 'complete' : 'planned');
216
+ const commentError = commentState?.lastError ?? inventory?.error;
140
217
  return {
141
218
  sourceCanonicalId: input.page.canonicalId,
142
219
  sourceId: input.page.id,
@@ -166,6 +243,55 @@ function reportPage(input) {
166
243
  warnings: [...new Set([...input.document.warnings, ...(input.warnings ?? [])])],
167
244
  normalizations: [...new Set([...(input.document.normalizations ?? []), ...(input.normalizations ?? [])])],
168
245
  ...(input.error ? { error: input.error } : {}),
246
+ ...(input.status === 'blocked' && input.blockedBy ? input.blockedBy : {}),
247
+ comments: {
248
+ status: commentStatus,
249
+ threads: commentState?.threads ?? inventory?.threads.length ?? 0,
250
+ comments: commentState?.comments ?? inventory?.comments ?? 0,
251
+ participants: commentState?.participants ?? inventory?.participants ?? 0,
252
+ attachments: commentState?.attachments ?? inventory?.attachments ?? 0,
253
+ bytes: commentState?.bytes ?? inventory?.knownBytes ?? 0,
254
+ ...(commentState?.archiveName ? { archiveName: commentState.archiveName } : {}),
255
+ ...(commentState?.destinationId ? { destinationId: commentState.destinationId } : {}),
256
+ ...(commentState?.previousDestinationIds?.length ? { revision: true } : {}),
257
+ ...(commentState?.status === 'stale' ? { stale: true } : {}),
258
+ ...(commentError ? { error: commentError } : {}),
259
+ ...(commentState?.preview ? { preview: commentState.preview } : {}),
260
+ ...(commentStatus === 'blocked' && input.blockedBy ? input.blockedBy : {}),
261
+ },
262
+ };
263
+ }
264
+ function blockedResourceStates(resources, previous, cause) {
265
+ return {
266
+ ...previous,
267
+ ...Object.fromEntries(resources.filter((resource) => resource.kind !== 'grid').map((resource) => {
268
+ const existing = previous[resource.id];
269
+ return [resource.id, {
270
+ ...(existing ?? {}),
271
+ sourceId: resource.id,
272
+ sourceName: resource.name,
273
+ destinationName: existing?.destinationName ?? preferredDestinationName(resource),
274
+ ...(existing?.size !== undefined ? { size: existing.size } : resource.size !== undefined ? { size: resource.size } : {}),
275
+ status: 'blocked',
276
+ ...cause,
277
+ lastError: cause.blockedReason,
278
+ updatedAt: new Date().toISOString(),
279
+ }];
280
+ })),
281
+ };
282
+ }
283
+ function blockedCommentState(inventory, previous, cause) {
284
+ return {
285
+ ...(previous ?? {}),
286
+ status: 'blocked',
287
+ threads: previous?.threads ?? inventory?.threads.length ?? 0,
288
+ comments: previous?.comments ?? inventory?.comments ?? 0,
289
+ participants: previous?.participants ?? inventory?.participants ?? 0,
290
+ attachments: previous?.attachments ?? inventory?.attachments ?? 0,
291
+ bytes: previous?.bytes ?? inventory?.knownBytes ?? 0,
292
+ ...cause,
293
+ lastError: cause.blockedReason,
294
+ updatedAt: new Date().toISOString(),
169
295
  };
170
296
  }
171
297
  function runStatus(errors, warnings, degradations) {
@@ -176,6 +302,17 @@ function runStatus(errors, warnings, degradations) {
176
302
  function attentionCount(summary) {
177
303
  return summary.warnings + summary.degradations;
178
304
  }
305
+ function runOutcomeCounts(summary, pages) {
306
+ return {
307
+ pages,
308
+ warnings: attentionCount(summary),
309
+ errors: summary.errors,
310
+ rootErrors: summary.rootErrors,
311
+ dependentPages: summary.dependentPages,
312
+ dependentFiles: summary.dependentFiles,
313
+ dependentComments: summary.dependentComments,
314
+ };
315
+ }
179
316
  export class MigrationRunError extends Error {
180
317
  reportPaths;
181
318
  stateFilename;
@@ -192,7 +329,7 @@ async function renderRemoteDocument(input) {
192
329
  ? await renderConfluenceStorage(input.document, input.page, input.context, input.container.space ?? '')
193
330
  : await renderYfm(input.document, input.context);
194
331
  if (!rendered.body.trim())
195
- return rawContentFallback(input.page, input.destination.kind, 'renderer returned an empty body');
332
+ return rawContentFallback(input.page, input.destination.kind, emptyRendererFallbackReason(input.document));
196
333
  return rendered;
197
334
  }
198
335
  catch (error) {
@@ -212,11 +349,12 @@ export class Migrator {
212
349
  }
213
350
  async run(options) {
214
351
  const sourceStatus = options.sourceStatus ?? 'all';
352
+ const commentsEnabled = options.comments ?? true;
215
353
  const source = this.adapterFactory(options.source.profile, this.config);
216
354
  const destination = options.destination.type === 'remote'
217
355
  ? this.adapterFactory(options.destination.profile, this.config)
218
356
  : undefined;
219
- assertTransferCapabilities(source, destination, options.scope, sourceStatus);
357
+ assertTransferCapabilities(source, destination, options.scope, sourceStatus, commentsEnabled);
220
358
  const status = options.onStatus ?? (() => undefined);
221
359
  status('Checking source and destination capabilities');
222
360
  const [sourcePlatform, destinationPlatform, root, container] = await Promise.all([
@@ -240,9 +378,10 @@ export class Migrator {
240
378
  await store.load(identity, { fresh: options.fresh ?? false, dryRun: options.dryRun });
241
379
  if (!options.dryRun)
242
380
  await store.save();
243
- const run = await store.beginRun(options.scope, options.dryRun, sourceStatus);
381
+ const run = await store.beginRun(options.scope, options.dryRun, sourceStatus, commentsEnabled);
244
382
  const report = new TransferReport({
245
383
  dryRun: options.dryRun,
384
+ commentsEnabled,
246
385
  scope: options.scope,
247
386
  sourceStatus,
248
387
  direction: `${source.kind} → ${options.destination.type === 'directory' ? options.destination.format : destination.kind}`,
@@ -303,16 +442,13 @@ export class Migrator {
303
442
  report.finish(finalStatus);
304
443
  const reportPaths = await report.write(options.reportsDirectory);
305
444
  if (!options.dryRun)
306
- await store.finishRun(run.id, finalStatus, {
307
- pages: 0,
308
- warnings: attentionCount(report.data.summary),
309
- errors: report.data.summary.errors,
310
- });
445
+ await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, 0));
311
446
  throw new MigrationRunError(message, reportPaths, stateLocation.filename, { cause: error });
312
447
  }
313
448
  const resourcesByPage = new Map();
449
+ const commentsByPage = new Map();
314
450
  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 };
451
+ 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
452
  status(`Inspecting content and resources for ${graph.pages.length} pages`);
317
453
  const inventoryErrors = [];
318
454
  await Promise.all(graph.pages.map((page) => inventoryQueue.add(async () => {
@@ -322,6 +458,36 @@ export class Migrator {
322
458
  const cachedPage = await loadCachedPage(options.cacheDirectory, page.canonicalId);
323
459
  const resources = await source.listResources(page);
324
460
  resourcesByPage.set(page.canonicalId, resources);
461
+ let commentInventory;
462
+ if (commentsEnabled) {
463
+ try {
464
+ if (!source.readComments)
465
+ throw new Error(`${source.profile.name} does not implement comment reads`);
466
+ commentInventory = await source.readComments(cachedPage);
467
+ resolveCommentAttachmentReferences(commentInventory, resources);
468
+ commentsByPage.set(page.canonicalId, commentInventory);
469
+ inventory.commentThreads += commentInventory.threads.length;
470
+ inventory.comments += commentInventory.comments;
471
+ inventory.commentParticipants += commentInventory.participants;
472
+ inventory.commentAttachments += commentInventory.attachments;
473
+ inventory.commentBytes += commentInventory.knownBytes;
474
+ for (const warning of commentInventory.warnings)
475
+ report.issue({
476
+ code: `comment-inventory-warning:${warning}`,
477
+ severity: 'warning',
478
+ phase: 'inventory',
479
+ pageId: page.canonicalId,
480
+ retryable: false,
481
+ message: `${page.title}: ${warning}`,
482
+ });
483
+ }
484
+ catch (error) {
485
+ const message = `${page.title}: comments could not be read: ${error instanceof Error ? error.message : String(error)}`;
486
+ commentInventory = { status: 'failed', threads: [], comments: 0, participants: 0, attachments: 0, knownBytes: 0, unknownSizes: 0, warnings: [], error: message };
487
+ commentsByPage.set(page.canonicalId, commentInventory);
488
+ report.issue({ code: 'comment-inventory-failed', severity: 'error', phase: 'inventory', pageId: page.canonicalId, retryable: true, message });
489
+ }
490
+ }
325
491
  const document = appendGridSnapshots(normalizePageContent(cachedPage), resources);
326
492
  await cacheDocument(options.cacheDirectory, page.canonicalId, document);
327
493
  inventory.resources += resources.filter((resource) => resource.kind !== 'grid').length;
@@ -337,6 +503,8 @@ export class Migrator {
337
503
  status: 'planned',
338
504
  resources,
339
505
  document,
506
+ commentsEnabled,
507
+ ...(commentInventory ? { commentInventory } : {}),
340
508
  }), undefined, false);
341
509
  status(`Inspected ${page.title} · ${resources.length} resources`);
342
510
  }
@@ -354,26 +522,72 @@ export class Migrator {
354
522
  });
355
523
  }
356
524
  })));
525
+ await store.setRunComments(run.id, { threads: inventory.commentThreads, comments: inventory.comments, attachments: inventory.commentAttachments });
357
526
  if (inventoryErrors.length > 0) {
358
527
  const finalStatus = options.signal?.aborted ? 'cancelled' : 'failed';
359
528
  report.finish(finalStatus);
360
529
  const reportPaths = await report.write(options.reportsDirectory);
361
530
  if (!options.dryRun)
362
- await store.finishRun(run.id, finalStatus, {
363
- pages: graph.pages.length,
364
- warnings: attentionCount(report.data.summary),
365
- errors: report.data.summary.errors,
366
- });
531
+ await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, graph.pages.length));
367
532
  throw new MigrationRunError('Resource preflight failed before destination mutation', reportPaths, stateLocation.filename);
368
533
  }
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`;
534
+ if (!options.dryRun) {
535
+ for (const page of graph.pages) {
536
+ if (store.value.pages[page.canonicalId])
537
+ continue;
538
+ const commentInventory = commentsByPage.get(page.canonicalId);
539
+ const commentState = !commentsEnabled
540
+ ? { status: 'skipped', threads: 0, comments: 0, participants: 0, attachments: 0, bytes: 0, updatedAt: new Date().toISOString() }
541
+ : commentInventory?.status === 'empty'
542
+ ? { status: 'complete', threads: 0, comments: 0, participants: 0, attachments: 0, bytes: 0, updatedAt: new Date().toISOString() }
543
+ : commentInventory?.status === 'failed'
544
+ ? {
545
+ status: 'failed', threads: 0, comments: 0, participants: 0, attachments: 0, bytes: 0,
546
+ ...(commentInventory.error ? { lastError: commentInventory.error } : {}), updatedAt: new Date().toISOString(),
547
+ }
548
+ : commentInventory
549
+ ? {
550
+ status: 'planned', ...(commentInventory.fingerprint ? { fingerprint: commentInventory.fingerprint } : {}),
551
+ threads: commentInventory.threads.length, comments: commentInventory.comments,
552
+ participants: commentInventory.participants, attachments: commentInventory.attachments,
553
+ bytes: commentInventory.knownBytes, updatedAt: new Date().toISOString(),
554
+ }
555
+ : undefined;
556
+ const resourceStates = Object.fromEntries((resourcesByPage.get(page.canonicalId) ?? [])
557
+ .filter((resource) => resource.kind !== 'grid')
558
+ .map((resource) => [resource.id, {
559
+ sourceId: resource.id,
560
+ sourceName: resource.name,
561
+ destinationName: preferredDestinationName(resource),
562
+ ...(resource.size !== undefined ? { size: resource.size } : {}),
563
+ ...(resource.mimeType ? { mimeType: resource.mimeType } : {}),
564
+ status: 'planned',
565
+ updatedAt: new Date().toISOString(),
566
+ }]));
567
+ await store.setPage({
568
+ sourceId: page.id,
569
+ sourceCanonicalId: page.canonicalId,
570
+ sourceLocator: page.locator,
571
+ sourceTitle: page.title,
572
+ ...(page.projectedParentId ? { projectedParentId: page.projectedParentId } : {}),
573
+ destinationTitle: page.title,
574
+ sourceFingerprint: sourceFingerprints.get(page.canonicalId),
575
+ status: 'discovered',
576
+ resources: resourceStates,
577
+ ...(commentState ? { comments: commentState } : {}),
578
+ degradations: [],
579
+ updatedAt: new Date().toISOString(),
580
+ });
581
+ }
582
+ }
583
+ 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
584
  status(planSummary);
371
585
  this.logger.info(planSummary);
372
586
  let output;
373
587
  let archive;
374
588
  try {
375
589
  if (options.destination.type === 'directory') {
376
- const result = await this.exportFilesystem(source, graph, options.destination, options, stateLocation.id, store, report, sourceFingerprints, resourcesByPage);
590
+ const result = await this.exportFilesystem(source, graph, options.destination, options, stateLocation.id, store, report, sourceFingerprints, resourcesByPage, commentsByPage);
377
591
  output = result.directory;
378
592
  archive = result.archive;
379
593
  report.output('directory', result.directory);
@@ -393,20 +607,18 @@ export class Migrator {
393
607
  report,
394
608
  sourceFingerprints,
395
609
  resourcesByPage,
610
+ commentsByPage,
396
611
  });
397
612
  output = container.url;
398
613
  report.output('destination', container.url);
399
614
  }
400
615
  report.finish('completed');
401
616
  const finalStatus = runStatus(report.data.summary.errors, report.data.summary.warnings, report.data.summary.degradations);
617
+ await store.finalizeOutcomes(graph.pages.map((page) => page.canonicalId), finalStatus, options.dryRun);
402
618
  report.finish(finalStatus);
403
619
  const reportPaths = await report.write(options.reportsDirectory);
404
620
  if (!options.dryRun)
405
- await store.finishRun(run.id, finalStatus, {
406
- pages: graph.pages.length,
407
- warnings: attentionCount(report.data.summary),
408
- errors: report.data.summary.errors,
409
- });
621
+ await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, graph.pages.length));
410
622
  status(`${finalStatus}: source ${report.data.summary.pages} pages · ${report.data.summary.complete} complete · ${report.data.summary.partial} partial · ${report.data.summary.failed + report.data.summary.blocked} failed/blocked · files ${report.data.summary.resourcesComplete}/${report.data.summary.resources} complete · ${report.data.summary.errors} errors · ${report.data.summary.warnings} warnings · ${report.data.summary.degradations} degradations`);
411
623
  return {
412
624
  status: finalStatus,
@@ -425,19 +637,16 @@ export class Migrator {
425
637
  const finalStatus = cancelled ? 'cancelled' : 'failed';
426
638
  const message = error instanceof Error ? error.message : String(error);
427
639
  report.error(message);
640
+ await store.finalizeOutcomes(graph.pages.map((page) => page.canonicalId), finalStatus, options.dryRun);
428
641
  report.finish(finalStatus);
429
642
  const reportPaths = await report.write(options.reportsDirectory);
430
643
  if (!options.dryRun)
431
- await store.finishRun(run.id, finalStatus, {
432
- pages: graph.pages.length,
433
- warnings: attentionCount(report.data.summary),
434
- errors: report.data.summary.errors,
435
- });
644
+ await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, graph.pages.length));
436
645
  this.logger.error(`${message}. Report: ${reportPaths.html}`);
437
646
  throw new MigrationRunError(message, reportPaths, stateLocation.filename, { cause: error });
438
647
  }
439
648
  }
440
- async exportFilesystem(source, graph, destination, options, identity, store, report, sourceFingerprints, resourcesByPage) {
649
+ async exportFilesystem(source, graph, destination, options, identity, store, report, sourceFingerprints, resourcesByPage, commentsByPage) {
441
650
  const exporter = new FilesystemExporter(source, options.concurrency);
442
651
  const totalResources = [...resourcesByPage.values()].reduce((total, resources) => total + resources.filter((resource) => resource.kind !== 'grid').length, 0);
443
652
  const total = Math.max(1, graph.pages.length + totalResources);
@@ -456,6 +665,8 @@ export class Migrator {
456
665
  options.onStatus?.(`Progress ${Math.min(100, Math.floor((processed / total) * 100))}% · ${processed}/${total} · ${message}`);
457
666
  } } : {}),
458
667
  resourcesByPage,
668
+ commentsByPage,
669
+ commentsEnabled: options.comments ?? true,
459
670
  loadPage: (page) => loadCachedPage(options.cacheDirectory, page.canonicalId),
460
671
  loadDocument: (page) => loadCachedDocument(options.cacheDirectory, page.canonicalId),
461
672
  });
@@ -479,7 +690,8 @@ export class Migrator {
479
690
  updatedAt: new Date().toISOString(),
480
691
  }];
481
692
  }));
482
- const pageStatus = options.dryRun ? 'planned' : manifestPage?.degraded ? 'partial' : 'complete';
693
+ const commentIncomplete = manifestPage?.comments && !['complete', 'skipped'].includes(manifestPage.comments.status);
694
+ const pageStatus = options.dryRun ? 'planned' : manifestPage?.degraded || commentIncomplete ? 'partial' : 'complete';
483
695
  await store.setPage({
484
696
  sourceId: page.id,
485
697
  sourceCanonicalId: page.canonicalId,
@@ -495,6 +707,19 @@ export class Migrator {
495
707
  status: pageStatus,
496
708
  resources,
497
709
  degradations: manifestPage?.warnings ?? [],
710
+ ...(manifestPage?.comments ? { comments: {
711
+ status: manifestPage.comments.status,
712
+ ...(manifestPage.comments.fingerprint ? { fingerprint: manifestPage.comments.fingerprint } : {}),
713
+ ...(manifestPage.comments.archivePath ? { archivePath: manifestPage.comments.archivePath } : {}),
714
+ ...(manifestPage.comments.archiveName ? { archiveName: manifestPage.comments.archiveName } : {}),
715
+ threads: manifestPage.comments.threads,
716
+ comments: manifestPage.comments.comments,
717
+ participants: manifestPage.comments.participants,
718
+ attachments: manifestPage.comments.attachments,
719
+ bytes: manifestPage.comments.bytes,
720
+ ...(manifestPage.comments.error ? { lastError: manifestPage.comments.error } : {}),
721
+ updatedAt: new Date().toISOString(),
722
+ } } : {}),
498
723
  updatedAt: new Date().toISOString(),
499
724
  });
500
725
  report.addPage(reportPage({
@@ -505,7 +730,35 @@ export class Migrator {
505
730
  resourceStates: resources,
506
731
  ...(options.dryRun ? { resourceStatusOverride: 'planned' } : {}),
507
732
  ...(manifestPage?.warnings ? { warnings: manifestPage.warnings } : {}),
733
+ commentsEnabled: options.comments ?? true,
734
+ ...(commentsByPage.get(page.canonicalId) ? { commentInventory: commentsByPage.get(page.canonicalId) } : {}),
735
+ ...(manifestPage?.comments ? { commentState: {
736
+ status: manifestPage.comments.status,
737
+ ...(manifestPage.comments.fingerprint ? { fingerprint: manifestPage.comments.fingerprint } : {}),
738
+ ...(manifestPage.comments.archivePath ? { archivePath: manifestPage.comments.archivePath } : {}),
739
+ ...(manifestPage.comments.archiveName ? { archiveName: manifestPage.comments.archiveName } : {}),
740
+ threads: manifestPage.comments.threads,
741
+ comments: manifestPage.comments.comments,
742
+ participants: manifestPage.comments.participants,
743
+ attachments: manifestPage.comments.attachments,
744
+ bytes: manifestPage.comments.bytes,
745
+ ...(manifestPage.comments.error ? { lastError: manifestPage.comments.error } : {}),
746
+ updatedAt: new Date().toISOString(),
747
+ } } : {}),
508
748
  }), { source: normalizedPreviewHtml(document), destination: normalizedPreviewHtml(document) });
749
+ if (manifestPage?.comments
750
+ && !['complete', 'skipped', 'planned'].includes(manifestPage.comments.status)
751
+ && !(manifestPage.comments.status === 'failed' && commentsByPage.get(page.canonicalId)?.status === 'failed')
752
+ && !(manifestPage.comments.status === 'partial' && (commentsByPage.get(page.canonicalId)?.warnings.length ?? 0) > 0)) {
753
+ report.issue({
754
+ code: `comment-${manifestPage.comments.status}`,
755
+ severity: manifestPage.comments.status === 'partial' || manifestPage.comments.status === 'stale' ? 'degradation' : 'error',
756
+ phase: 'comment',
757
+ pageId: page.canonicalId,
758
+ retryable: manifestPage.comments.status === 'failed',
759
+ message: `${page.title}: ${manifestPage.comments.error ?? `comment archive is ${manifestPage.comments.status}`}`,
760
+ });
761
+ }
509
762
  }
510
763
  for (const warning of result.warnings)
511
764
  report.warning(warning);
@@ -513,18 +766,328 @@ export class Migrator {
513
766
  report.error(error);
514
767
  return result;
515
768
  }
769
+ async transferRemoteComments(input) {
770
+ const { source, destination, page, destinationPage, inventory, resources, pageState, report, store } = input;
771
+ const now = () => new Date().toISOString();
772
+ const counts = {
773
+ threads: inventory?.threads.length ?? 0,
774
+ comments: inventory?.comments ?? 0,
775
+ participants: inventory?.participants ?? 0,
776
+ attachments: inventory?.attachments ?? 0,
777
+ bytes: inventory?.knownBytes ?? 0,
778
+ };
779
+ if (!inventory)
780
+ return { status: 'skipped', ...counts, updatedAt: now() };
781
+ if (inventory.status === 'failed') {
782
+ return { status: 'failed', ...counts, ...(inventory.error ? { lastError: inventory.error } : {}), updatedAt: now() };
783
+ }
784
+ if (inventory.status === 'empty') {
785
+ if (pageState.comments?.destinationId) {
786
+ return { ...pageState.comments, status: 'stale', lastError: 'Source no longer returns the previously archived comments; the destination archive was preserved.', updatedAt: now() };
787
+ }
788
+ return { status: 'complete', ...counts, updatedAt: now() };
789
+ }
790
+ const fingerprint = inventory.fingerprint;
791
+ if (!fingerprint)
792
+ return { status: 'failed', ...counts, lastError: 'Comment inventory fingerprint is missing', updatedAt: now() };
793
+ if (input.dryRun)
794
+ return { status: 'planned', fingerprint, ...counts, updatedAt: now() };
795
+ if (!destination.createArchiveComment || !destination.readArchiveComment) {
796
+ return { status: 'failed', fingerprint, ...counts, lastError: `${destination.profile.name} does not implement comment archive writes`, updatedAt: now() };
797
+ }
798
+ try {
799
+ if (input.signal?.aborted)
800
+ throw input.signal.reason ?? new Error('Transfer cancelled');
801
+ const resourceById = new Map(resources.map((resource) => [resource.id, resource]));
802
+ const downloads = new Map();
803
+ const archiveFiles = [];
804
+ for (const use of commentAttachmentUses(inventory)) {
805
+ if (input.signal?.aborted)
806
+ throw input.signal.reason ?? new Error('Transfer cancelled');
807
+ const resourceId = use.reference.resourceId;
808
+ if (!resourceId)
809
+ continue;
810
+ const resource = resourceById.get(resourceId);
811
+ if (!resource)
812
+ continue;
813
+ let downloaded = downloads.get(resourceId);
814
+ if (!downloaded) {
815
+ const resourceState = pageState.resources[resourceId];
816
+ const filename = containedCachePath(input.cacheDirectory, resourceState?.cachePath)
817
+ ?? cachePath(input.cacheDirectory, page, resource);
818
+ const cached = await verifiedCacheFile(filename, resourceState?.sha256 ? {
819
+ sha256: resourceState.sha256,
820
+ ...(resourceState.size !== undefined ? { size: resourceState.size } : {}),
821
+ } : undefined);
822
+ downloaded = cached ?? await source.downloadResource(resource, filename);
823
+ downloads.set(resourceId, downloaded);
824
+ }
825
+ archiveFiles.push({
826
+ commentId: use.commentId,
827
+ reference: use.reference.href,
828
+ originalName: resource.preferredName ?? resource.name,
829
+ ...downloaded,
830
+ });
831
+ }
832
+ const zip = await createCommentArchiveZip({
833
+ pageKey: page.id,
834
+ fingerprint,
835
+ files: archiveFiles,
836
+ destinationDirectory: path.join(input.cacheDirectory, 'comments', sha256(page.canonicalId).slice(0, 16)),
837
+ });
838
+ let current;
839
+ const previous = pageState.comments;
840
+ if (previous?.destinationId) {
841
+ try {
842
+ current = await destination.readArchiveComment({ page: destinationPage, commentId: previous.destinationId });
843
+ }
844
+ catch (error) {
845
+ report.issue({
846
+ code: 'comment-mapping-missing', severity: 'warning', phase: 'comment', pageId: page.canonicalId,
847
+ retryable: true, message: `${page.title}: mapped comment ${previous.destinationId} could not be read and will be recreated: ${error instanceof Error ? error.message : String(error)}`,
848
+ });
849
+ }
850
+ }
851
+ current ??= await destination.findArchiveComment?.(destinationPage, fingerprint);
852
+ if (current && previous?.destinationFingerprint && current.fingerprint !== previous.destinationFingerprint) {
853
+ const conflict = {
854
+ fingerprint,
855
+ destinationId: current.id,
856
+ destinationFingerprint: current.fingerprint,
857
+ ...counts,
858
+ ...(previous.archiveResourceId ? { archiveResourceId: previous.archiveResourceId } : {}),
859
+ ...(previous.archiveName ? { archiveName: previous.archiveName } : {}),
860
+ ...(previous.archivePath ? { archivePath: previous.archivePath } : {}),
861
+ ...(previous.archiveSha256 ? { archiveSha256: previous.archiveSha256 } : {}),
862
+ status: 'conflict',
863
+ lastError: 'Destination comment archive was changed manually and was preserved.',
864
+ updatedAt: now(),
865
+ };
866
+ return conflict;
867
+ }
868
+ const remoteResources = await destination.listUploadedResources(destinationPage);
869
+ const remoteMatches = async (remote, file, trustedMapping) => {
870
+ if (remote.size !== undefined && remote.size !== file.size)
871
+ return false;
872
+ if (!destination.downloadUploadedResource)
873
+ return trustedMapping && (remote.size === undefined || remote.size === file.size);
874
+ const comparison = `${file.path}.destination-${sha256(remote.id).slice(0, 8)}`;
875
+ try {
876
+ const actual = await destination.downloadUploadedResource({ page: destinationPage, resource: remote, destination: comparison });
877
+ return actual.size === file.size && actual.sha256 === file.sha256;
878
+ }
879
+ finally {
880
+ await rm(comparison, { force: true });
881
+ }
882
+ };
883
+ const uploadArchive = async (file, mappedId, mimeType) => {
884
+ const mapped = mappedId ? remoteResources.find((resource) => resource.id === mappedId) : undefined;
885
+ const byName = remoteResources.find((resource) => resource.name.toLocaleLowerCase() === file.name.toLocaleLowerCase());
886
+ const reusable = mapped && await remoteMatches(mapped, file, true)
887
+ ? mapped
888
+ : byName && await remoteMatches(byName, file, false) ? byName : undefined;
889
+ if (reusable)
890
+ return reusable;
891
+ const extension = path.extname(file.name);
892
+ const alternativeName = `${file.name.slice(0, file.name.length - extension.length)}-${file.sha256.slice(0, 8)}${extension}`;
893
+ const alternative = remoteResources.find((resource) => resource.name.toLocaleLowerCase() === alternativeName.toLocaleLowerCase());
894
+ if (alternative && await remoteMatches(alternative, file, false))
895
+ return alternative;
896
+ const uploadName = mapped || byName ? alternativeName : file.name;
897
+ return destination.uploadResource({
898
+ page: destinationPage,
899
+ name: uploadName,
900
+ filePath: file.path,
901
+ size: file.size,
902
+ ...(mimeType ? { mimeType } : {}),
903
+ });
904
+ };
905
+ const archiveResource = zip
906
+ ? await uploadArchive(zip, previous?.archiveResourceId, 'application/zip')
907
+ : undefined;
908
+ if (input.signal?.aborted)
909
+ throw input.signal.reason ?? new Error('Transfer cancelled');
910
+ const attachmentNames = new Map();
911
+ if (zip) {
912
+ const nameByHash = new Map(zip.files.map((entry) => [entry.sha256, entry.archiveName]));
913
+ for (const [resourceId, downloaded] of downloads) {
914
+ const name = nameByHash.get(downloaded.sha256);
915
+ if (name)
916
+ attachmentNames.set(resourceId, name);
917
+ }
918
+ }
919
+ const render = (presentation, replaces) => renderCommentArchive(inventory, destination.kind, {
920
+ sourceUrl: page.url,
921
+ ...(zip ? { archiveName: archiveResource?.name ?? zip.name, archiveHref: archiveResource?.url ?? archiveResource?.name ?? zip.name } : {}),
922
+ ...(replaces ? { archiveReplaces: replaces } : {}),
923
+ attachmentNames,
924
+ pages: input.destinationPages,
925
+ sourcePages: input.sourcePages,
926
+ presentation,
927
+ });
928
+ const archiveState = zip ? {
929
+ ...(archiveResource ? { archiveResourceId: archiveResource.id } : {}),
930
+ archiveName: archiveResource?.name ?? zip.name,
931
+ archivePath: zip.path,
932
+ archiveSha256: zip.sha256,
933
+ } : {};
934
+ const replacement = destination.capabilities.updateComments ? undefined : current?.id;
935
+ let expectedBody = render('native', replacement);
936
+ let fallbackReason;
937
+ let fullArchiveState = {};
938
+ const maximumInlineBytes = 1_000_000;
939
+ const prepareFullArchive = async (reason) => {
940
+ if (fullArchiveState.fullArchiveName)
941
+ return;
942
+ const fullBody = renderCommentArchive(inventory, 'markdown', {
943
+ sourceUrl: page.url,
944
+ ...(zip ? { archiveName: archiveResource?.name ?? zip.name, archiveHref: archiveResource?.url ?? archiveResource?.name ?? zip.name } : {}),
945
+ ...(replacement ? { archiveReplaces: replacement } : {}),
946
+ attachmentNames,
947
+ pages: input.destinationPages,
948
+ sourcePages: input.sourcePages,
949
+ });
950
+ const fullArchiveName = `ywtc-comments-${safeFilename(page.id)}-${fingerprint.slice(0, 12)}.md`;
951
+ const fullArchivePath = path.join(input.cacheDirectory, 'comments', sha256(page.canonicalId).slice(0, 16), fullArchiveName);
952
+ await mkdir(path.dirname(fullArchivePath), { recursive: true, mode: 0o700 });
953
+ await writeFile(fullArchivePath, `${fullBody}\n`, { mode: 0o600 });
954
+ const fullArchiveSha256 = sha256(`${fullBody}\n`);
955
+ const fullArchiveResource = await uploadArchive({
956
+ path: fullArchivePath,
957
+ name: fullArchiveName,
958
+ size: Buffer.byteLength(`${fullBody}\n`),
959
+ sha256: fullArchiveSha256,
960
+ }, previous?.fullArchiveResourceId, 'text/markdown');
961
+ const destinationFullArchiveName = fullArchiveResource.name;
962
+ expectedBody = renderCommentArchiveIndex(inventory, destination.kind, {
963
+ sourceUrl: page.url,
964
+ fullArchiveName: destinationFullArchiveName,
965
+ fullArchiveHref: fullArchiveResource.url ?? destinationFullArchiveName,
966
+ ...(zip ? { archiveName: archiveResource?.name ?? zip.name, archiveHref: archiveResource?.url ?? archiveResource?.name ?? zip.name } : {}),
967
+ });
968
+ fullArchiveState = {
969
+ fullArchiveResourceId: fullArchiveResource.id,
970
+ fullArchiveName: destinationFullArchiveName,
971
+ fullArchivePath,
972
+ fullArchiveSha256,
973
+ };
974
+ fallbackReason = reason;
975
+ };
976
+ if (Buffer.byteLength(expectedBody) > maximumInlineBytes) {
977
+ await prepareFullArchive(`Full comment archive exceeded ${maximumInlineBytes} inline bytes and was attached as Markdown.`);
978
+ }
979
+ let result = current;
980
+ const requiresWrite = !result || previous?.fingerprint !== fingerprint;
981
+ if (requiresWrite) {
982
+ const write = async (body) => {
983
+ if (input.signal?.aborted)
984
+ throw input.signal.reason ?? new Error('Transfer cancelled');
985
+ if (!result || !destination.capabilities.updateComments || !destination.updateArchiveComment) {
986
+ return destination.createArchiveComment({ page: destinationPage, body });
987
+ }
988
+ return destination.updateArchiveComment({ comment: result, body });
989
+ };
990
+ const presentations = fallbackReason ? [] : ['native', 'quote', 'code'];
991
+ let lastError;
992
+ if (fallbackReason) {
993
+ result = await write(expectedBody);
994
+ }
995
+ else {
996
+ for (const presentation of presentations) {
997
+ try {
998
+ expectedBody = render(presentation, replacement);
999
+ result = await write(expectedBody);
1000
+ if (presentation !== 'native')
1001
+ fallbackReason = `Destination rejected the native collapsible archive; ${presentation} fallback was used.`;
1002
+ lastError = undefined;
1003
+ break;
1004
+ }
1005
+ catch (error) {
1006
+ lastError = error;
1007
+ const status = error.status;
1008
+ if (status === 413 || /(?:status[=: ]+413|too large|payload too large)/i.test(error instanceof Error ? error.message : String(error))) {
1009
+ try {
1010
+ await prepareFullArchive('Destination rejected the inline archive size; the full archive was attached as Markdown.');
1011
+ result = await write(expectedBody);
1012
+ lastError = undefined;
1013
+ break;
1014
+ }
1015
+ catch (fallbackError) {
1016
+ lastError = fallbackError;
1017
+ }
1018
+ }
1019
+ const reconciled = await destination.findArchiveComment?.(destinationPage, fingerprint);
1020
+ if (reconciled) {
1021
+ result = reconciled;
1022
+ expectedBody = reconciled.body;
1023
+ fallbackReason = presentation === 'native' ? undefined : `Destination accepted the ${presentation} fallback after an ambiguous response.`;
1024
+ lastError = undefined;
1025
+ break;
1026
+ }
1027
+ }
1028
+ }
1029
+ if (lastError)
1030
+ throw lastError;
1031
+ }
1032
+ }
1033
+ if (!result)
1034
+ throw new Error('Destination comment archive was not created');
1035
+ const verified = await destination.readArchiveComment({ page: destinationPage, commentId: result.id });
1036
+ const verification = verifyCommentArchive(fullArchiveState.fullArchiveName ? fingerprint : inventory, verified.body, archiveResource?.name);
1037
+ if (fullArchiveState.fullArchiveName && !verified.body.includes(fullArchiveState.fullArchiveName)) {
1038
+ verification.ok = false;
1039
+ verification.differences.push('full Markdown comment archive reference is missing');
1040
+ }
1041
+ if (!verification.ok)
1042
+ throw new Error(`Comment verification failed: ${verification.differences.join('; ')}`);
1043
+ const previousIds = result.id !== current?.id && current
1044
+ ? [...new Set([...(previous?.previousDestinationIds ?? []), current.id])]
1045
+ : previous?.previousDestinationIds;
1046
+ const state = {
1047
+ fingerprint,
1048
+ destinationId: verified.id,
1049
+ destinationFingerprint: verified.fingerprint,
1050
+ ...(previousIds?.length ? { previousDestinationIds: previousIds } : {}),
1051
+ ...archiveState,
1052
+ ...fullArchiveState,
1053
+ status: inventory.warnings.length > 0 || fallbackReason ? 'partial' : 'complete',
1054
+ ...counts,
1055
+ preview: verified.body,
1056
+ ...(fallbackReason ? { lastError: fallbackReason } : {}),
1057
+ updatedAt: now(),
1058
+ };
1059
+ pageState.comments = state;
1060
+ await store.setPage({ ...pageState, comments: state, updatedAt: now() });
1061
+ return state;
1062
+ }
1063
+ catch (error) {
1064
+ if (input.signal?.aborted)
1065
+ throw input.signal.reason ?? error;
1066
+ const message = error instanceof Error ? error.message : String(error);
1067
+ return {
1068
+ fingerprint,
1069
+ ...counts,
1070
+ ...(pageState.comments?.destinationId ? { destinationId: pageState.comments.destinationId } : {}),
1071
+ ...(pageState.comments?.destinationFingerprint ? { destinationFingerprint: pageState.comments.destinationFingerprint } : {}),
1072
+ status: /conflict/i.test(message) ? 'conflict' : 'failed',
1073
+ lastError: message,
1074
+ updatedAt: now(),
1075
+ };
1076
+ }
1077
+ }
516
1078
  async transferRemote(input) {
517
- const { source, destination, container, graph, options, store, report, sourceFingerprints, resourcesByPage } = input;
1079
+ const { source, destination, container, graph, options, store, report, sourceFingerprints, resourcesByPage, commentsByPage } = input;
518
1080
  const destinationPages = new Map();
519
1081
  const stagingFailures = new Map();
520
1082
  const sourcePages = new Map(graph.pages.map((page) => [page.canonicalId, page]));
521
1083
  const bootstrapBodies = new Map();
522
1084
  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);
1085
+ const totalCommentArchives = (options.comments ?? true) ? graph.pages.length : 0;
1086
+ const progress = { staged: 0, pages: 0, resources: 0, comments: 0 };
1087
+ const totalUnits = Math.max(1, graph.pages.length * 2 + totalResources + totalCommentArchives);
525
1088
  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}`);
1089
+ const completed = progress.staged + progress.pages + progress.resources + progress.comments;
1090
+ 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
1091
  };
529
1092
  for (const page of graph.pages) {
530
1093
  const [document, cachedPage] = await Promise.all([
@@ -551,15 +1114,47 @@ export class Migrator {
551
1114
  const saved = store.value.pages[page.canonicalId];
552
1115
  const parent = page.projectedParentId ? destinationPages.get(page.projectedParentId) : container;
553
1116
  if (!parent) {
554
- const message = `Destination parent is unavailable for ${page.title}`;
555
- stagingFailures.set(page.canonicalId, { status: 'blocked', message });
556
- report.updatePage(page.canonicalId, { status: 'blocked', error: message });
1117
+ const upstream = page.projectedParentId ? stagingFailures.get(page.projectedParentId) : undefined;
1118
+ const blockedByPageId = upstream?.blockedByPageId ?? page.projectedParentId ?? page.canonicalId;
1119
+ const blockedByCode = upstream?.blockedByCode ?? 'destination-parent-unavailable';
1120
+ const message = `Destination parent is unavailable for ${page.title}; blocked by ${blockedByPageId} (${blockedByCode})`;
1121
+ const cause = { blockedByPageId, blockedByCode, blockedReason: message };
1122
+ stagingFailures.set(page.canonicalId, { status: 'blocked', message, blockedByPageId, blockedByCode });
1123
+ const previous = store.value.pages[page.canonicalId];
1124
+ const resources = resourcesByPage.get(page.canonicalId) ?? [];
1125
+ const comments = (options.comments ?? true)
1126
+ ? blockedCommentState(commentsByPage.get(page.canonicalId), previous?.comments, cause)
1127
+ : undefined;
1128
+ await store.setPage({
1129
+ sourceId: page.id,
1130
+ sourceCanonicalId: page.canonicalId,
1131
+ sourceLocator: page.locator,
1132
+ sourceTitle: page.title,
1133
+ ...(page.projectedParentId ? { projectedParentId: page.projectedParentId } : {}),
1134
+ destinationTitle: previous?.destinationTitle ?? page.title,
1135
+ ...(previous?.destinationId ? { destinationId: previous.destinationId } : {}),
1136
+ ...(previous?.destinationCanonicalId ? { destinationCanonicalId: previous.destinationCanonicalId } : {}),
1137
+ ...(previous?.destinationLocator ? { destinationLocator: previous.destinationLocator } : {}),
1138
+ ...(previous?.previousDestinationIds ? { previousDestinationIds: previous.previousDestinationIds } : {}),
1139
+ sourceFingerprint: sourceFingerprints.get(page.canonicalId),
1140
+ ...(previous?.destinationFingerprint ? { destinationFingerprint: previous.destinationFingerprint } : {}),
1141
+ status: 'blocked',
1142
+ resources: blockedResourceStates(resources, previous?.resources ?? {}, cause),
1143
+ ...(comments ? { comments } : {}),
1144
+ degradations: previous?.degradations ?? [],
1145
+ ...cause,
1146
+ lastError: message,
1147
+ updatedAt: new Date().toISOString(),
1148
+ });
1149
+ report.updatePage(page.canonicalId, { status: 'blocked', error: message, ...cause });
557
1150
  report.issue({
558
1151
  code: 'destination-parent-unavailable',
559
- severity: 'error',
1152
+ severity: 'blocked',
560
1153
  phase: 'page',
561
1154
  pageId: page.canonicalId,
562
- retryable: true,
1155
+ retryable: false,
1156
+ blockedByPageId,
1157
+ blockedByCode,
563
1158
  message,
564
1159
  });
565
1160
  progress.staged += 1;
@@ -570,13 +1165,20 @@ export class Migrator {
570
1165
  const cachedPage = await loadCachedPage(options.cacheDirectory, page.canonicalId);
571
1166
  const bootstrap = bootstrapBodies.get(page.canonicalId);
572
1167
  let created;
573
- let title = saved?.destinationTitle ?? page.title;
1168
+ const title = saved?.destinationTitle ?? page.title;
574
1169
  let resourcesForState = saved?.resources ?? {};
1170
+ let commentsForState = saved?.comments;
575
1171
  let previousDestinationIds = saved?.previousDestinationIds ?? [];
576
1172
  let mappingRecovered = false;
577
- const findTitleConflict = (candidateTitle) => destination.findTitleConflict
578
- ? destination.findTitleConflict(parent, candidateTitle)
579
- : destination.findChild(parent, candidateTitle);
1173
+ const allocatePage = (preferredTitle) => createPageWithAllocatedTitle({
1174
+ destination,
1175
+ parent,
1176
+ sourcePage: cachedPage,
1177
+ bootstrap,
1178
+ ...(preferredTitle ? { preferredTitle } : {}),
1179
+ consistencyOptions: this.consistencyOptions,
1180
+ report,
1181
+ });
580
1182
  if (saved?.destinationId) {
581
1183
  const mapped = destinationFromState(saved, destination);
582
1184
  try {
@@ -598,79 +1200,11 @@ export class Migrator {
598
1200
  if (page.projectedParentId) {
599
1201
  await verifyConfirmedDestinationPage(destination, parent, this.consistencyOptions);
600
1202
  }
601
- const findRecoveryChild = (candidateTitle) => destination.findRecoverablePage
602
- ? destination.findRecoverablePage(parent, candidateTitle)
603
- : destination.findChild(parent, candidateTitle);
604
- let candidate;
605
- let recoveryLookupFailed = false;
606
- try {
607
- candidate = await findRecoveryChild(title);
608
- }
609
- catch (lookupError) {
610
- recoveryLookupFailed = true;
611
- report.issue({
612
- code: 'destination-recovery-lookup-failed',
613
- severity: 'warning',
614
- phase: 'page',
615
- pageId: page.canonicalId,
616
- retryable: true,
617
- message: `${page.title}: recovery lookup failed; bootstrap recreation will be attempted: ${lookupError instanceof Error ? lookupError.message : String(lookupError)}`,
618
- });
619
- }
620
- if (candidate && sourceDerivedTextMatches(bootstrap.body, candidate.content, destination.kind, cachedPage)) {
621
- created = candidate;
622
- }
623
- else {
624
- let recoveryTitle = title;
625
- let titleConflict = candidate;
626
- if (!titleConflict && !recoveryLookupFailed) {
627
- try {
628
- titleConflict = await findTitleConflict(recoveryTitle);
629
- }
630
- catch (lookupError) {
631
- recoveryLookupFailed = true;
632
- report.issue({
633
- code: 'destination-title-lookup-failed',
634
- severity: 'warning',
635
- phase: 'page',
636
- pageId: page.canonicalId,
637
- retryable: true,
638
- message: `${page.title}: destination title lookup failed; bootstrap recreation will be attempted with a stable suffix: ${lookupError instanceof Error ? lookupError.message : String(lookupError)}`,
639
- });
640
- }
641
- }
642
- if (titleConflict || recoveryLookupFailed) {
643
- const base = collisionTitle(page);
644
- const hash = sha256(page.canonicalId).slice(0, 8);
645
- recoveryTitle = base;
646
- let sequence = 0;
647
- while (!recoveryLookupFailed) {
648
- try {
649
- if (!await findTitleConflict(recoveryTitle))
650
- break;
651
- sequence += 1;
652
- recoveryTitle = `${base} [${hash}${sequence > 1 ? `-${sequence}` : ''}]`;
653
- }
654
- catch (lookupError) {
655
- recoveryLookupFailed = true;
656
- recoveryTitle = `${base} [${hash}]`;
657
- report.issue({
658
- code: 'destination-recovery-lookup-failed',
659
- severity: 'warning',
660
- phase: 'page',
661
- pageId: page.canonicalId,
662
- retryable: true,
663
- message: `${page.title}: suffixed-title lookup failed; bootstrap recreation will be attempted: ${lookupError instanceof Error ? lookupError.message : String(lookupError)}`,
664
- });
665
- }
666
- }
667
- }
668
- created = await destination.createPage({ container: parent, title: recoveryTitle, body: bootstrap.body });
669
- created = await verifyConfirmedDestinationPage(destination, created, this.consistencyOptions);
670
- assertDestinationContent(bootstrap.body, created.content, destination.kind, cachedPage);
671
- }
1203
+ const allocation = await allocatePage(title);
1204
+ created = allocation.page;
672
1205
  previousDestinationIds = [...new Set([...previousDestinationIds, mapped.id])];
673
1206
  resourcesForState = resetResourceDestinations(saved.resources);
1207
+ commentsForState = undefined;
674
1208
  mappingRecovered = true;
675
1209
  report.issue({
676
1210
  code: 'destination-mapping-recovered',
@@ -738,29 +1272,10 @@ export class Migrator {
738
1272
  });
739
1273
  }
740
1274
  else {
741
- let replacement = await destination.findChild(parent, title);
742
- const replacementMatches = replacement
743
- && sourceDerivedTextMatches(bootstrap.body, replacement.content, destination.kind, cachedPage);
744
- const titleConflict = replacementMatches ? undefined : replacement ?? await findTitleConflict(title);
745
- if (titleConflict) {
746
- const base = collisionTitle(page);
747
- const hash = sha256(page.canonicalId).slice(0, 8);
748
- title = base;
749
- let sequence = 0;
750
- while (await findTitleConflict(title)) {
751
- sequence += 1;
752
- title = `${base} [${hash}${sequence > 1 ? `-${sequence}` : ''}]`;
753
- }
754
- replacement = undefined;
755
- }
756
- created = replacement ?? await destination.createPage({ container: parent, title, body: bootstrap.body });
757
- created = await verifyConfirmedDestinationPage(destination, created, this.consistencyOptions);
758
- assertDestinationContent(bootstrap.body, created.content, destination.kind, cachedPage);
759
- if (created.parentId !== parent.id) {
760
- throw new Error(`Destination hierarchy verification failed for recreated page ${page.title}: expected parent ${parent.id}, received ${created.parentId ?? 'none'}`);
761
- }
1275
+ created = (await allocatePage(title)).page;
762
1276
  previousDestinationIds = [...new Set([...previousDestinationIds, oldPage.id])];
763
1277
  resourcesForState = resetResourceDestinations(saved.resources);
1278
+ commentsForState = undefined;
764
1279
  mappingRecovered = true;
765
1280
  report.addHierarchyRepair({
766
1281
  sourceCanonicalId: page.canonicalId,
@@ -798,9 +1313,9 @@ export class Migrator {
798
1313
  };
799
1314
  }
800
1315
  else {
801
- const existingChild = await destination.findChild(parent, title);
802
- if (existingChild && sourceDerivedTextMatches(bootstrap.body, existingChild.content, destination.kind, cachedPage)) {
803
- created = existingChild;
1316
+ const allocation = await allocatePage(title);
1317
+ created = allocation.page;
1318
+ if (allocation.reused) {
804
1319
  mappingRecovered = true;
805
1320
  report.issue({
806
1321
  code: 'destination-unmapped-page-recovered',
@@ -811,22 +1326,6 @@ export class Migrator {
811
1326
  message: `${page.title}: adopted an existing source-derived page below the expected parent`,
812
1327
  });
813
1328
  }
814
- else {
815
- const titleConflict = existingChild ?? await findTitleConflict(title);
816
- if (titleConflict) {
817
- const base = collisionTitle(page);
818
- const hash = sha256(page.canonicalId).slice(0, 8);
819
- title = base;
820
- let sequence = 0;
821
- while (await findTitleConflict(title)) {
822
- sequence += 1;
823
- title = `${base} [${hash}${sequence > 1 ? `-${sequence}` : ''}]`;
824
- }
825
- }
826
- created = await destination.createPage({ container: parent, title, body: bootstrap.body });
827
- created = await verifyConfirmedDestinationPage(destination, created, this.consistencyOptions);
828
- assertDestinationContent(bootstrap.body, created.content, destination.kind, cachedPage);
829
- }
830
1329
  }
831
1330
  if (!options.dryRun && created.parentId !== parent.id) {
832
1331
  throw new Error(`Destination hierarchy verification failed for ${page.title}: expected parent ${parent.id}, received ${created.parentId ?? 'none'}`);
@@ -847,6 +1346,7 @@ export class Migrator {
847
1346
  ...(created.fingerprint ? { destinationFingerprint: created.fingerprint } : {}),
848
1347
  status: options.dryRun ? 'planned' : saved?.status ?? 'skeleton-created',
849
1348
  resources: resourcesForState,
1349
+ ...(commentsForState ? { comments: commentsForState } : {}),
850
1350
  degradations: saved?.degradations ?? [],
851
1351
  updatedAt: new Date().toISOString(),
852
1352
  };
@@ -854,12 +1354,20 @@ export class Migrator {
854
1354
  options.onStatus?.(`${options.dryRun ? 'Planned' : 'Staged content for'} ${page.title}`);
855
1355
  }
856
1356
  catch (error) {
1357
+ if (options.signal?.aborted)
1358
+ throw options.signal.reason ?? error;
857
1359
  const message = `${page.title}: ${error instanceof Error ? error.message : String(error)}`;
858
1360
  const failureStatus = /conflict/i.test(message) ? 'conflict' : 'failed';
859
- stagingFailures.set(page.canonicalId, { status: failureStatus, message });
1361
+ const failureCode = failureStatus === 'conflict' ? 'destination-conflict' : 'destination-page-staging-failed';
1362
+ stagingFailures.set(page.canonicalId, {
1363
+ status: failureStatus,
1364
+ message,
1365
+ blockedByPageId: page.canonicalId,
1366
+ blockedByCode: failureCode,
1367
+ });
860
1368
  report.updatePage(page.canonicalId, { status: failureStatus, error: message });
861
1369
  report.issue({
862
- code: failureStatus === 'conflict' ? 'destination-conflict' : 'destination-page-staging-failed',
1370
+ code: failureCode,
863
1371
  severity: 'error',
864
1372
  phase: 'page',
865
1373
  pageId: page.canonicalId,
@@ -867,8 +1375,29 @@ export class Migrator {
867
1375
  message,
868
1376
  });
869
1377
  const savedState = store.value.pages[page.canonicalId];
870
- if (savedState)
871
- await store.setPage({ ...savedState, status: failureStatus, lastError: message, updatedAt: new Date().toISOString() });
1378
+ if (!options.dryRun) {
1379
+ const cause = { blockedByPageId: page.canonicalId, blockedByCode: failureCode, blockedReason: message };
1380
+ const resources = resourcesByPage.get(page.canonicalId) ?? [];
1381
+ const comments = (options.comments ?? true)
1382
+ ? blockedCommentState(commentsByPage.get(page.canonicalId), savedState?.comments, cause)
1383
+ : undefined;
1384
+ await store.setPage({
1385
+ ...(savedState ?? {}),
1386
+ sourceId: page.id,
1387
+ sourceCanonicalId: page.canonicalId,
1388
+ sourceLocator: page.locator,
1389
+ sourceTitle: page.title,
1390
+ ...(page.projectedParentId ? { projectedParentId: page.projectedParentId } : {}),
1391
+ destinationTitle: savedState?.destinationTitle ?? page.title,
1392
+ sourceFingerprint: sourceFingerprints.get(page.canonicalId),
1393
+ status: failureStatus,
1394
+ resources: blockedResourceStates(resources, savedState?.resources ?? {}, cause),
1395
+ ...(comments ? { comments } : {}),
1396
+ degradations: savedState?.degradations ?? [],
1397
+ lastError: message,
1398
+ updatedAt: new Date().toISOString(),
1399
+ });
1400
+ }
872
1401
  }
873
1402
  finally {
874
1403
  progress.staged += 1;
@@ -886,13 +1415,25 @@ export class Migrator {
886
1415
  if (!destinationPage) {
887
1416
  const currentFailure = stagingFailures.get(page.canonicalId);
888
1417
  const failedState = store.value.pages[page.canonicalId];
1418
+ const blockedBy = {
1419
+ blockedByPageId: currentFailure?.blockedByPageId ?? page.canonicalId,
1420
+ blockedByCode: currentFailure?.blockedByCode ?? 'destination-page-unavailable',
1421
+ blockedReason: currentFailure?.message ?? failedState?.lastError ?? 'Destination page with source-derived content was not created',
1422
+ };
1423
+ const commentState = failedState?.comments ?? ((options.comments ?? true)
1424
+ ? blockedCommentState(commentsByPage.get(page.canonicalId), undefined, blockedBy)
1425
+ : undefined);
889
1426
  report.addPage(reportPage({
890
1427
  page,
891
1428
  status: currentFailure?.status ?? 'blocked',
892
1429
  resources,
893
1430
  document,
894
1431
  ...(failedState?.resources ? { resourceStates: failedState.resources } : {}),
895
- ...(options.dryRun ? { resourceStatusOverride: 'planned' } : {}),
1432
+ resourceStatusOverride: options.dryRun ? 'planned' : 'blocked',
1433
+ commentsEnabled: options.comments ?? true,
1434
+ ...(commentsByPage.get(page.canonicalId) ? { commentInventory: commentsByPage.get(page.canonicalId) } : {}),
1435
+ ...(commentState ? { commentState } : {}),
1436
+ blockedBy,
896
1437
  error: currentFailure?.message ?? failedState?.lastError ?? 'Destination page with source-derived content was not created',
897
1438
  }));
898
1439
  progress.pages += 1;
@@ -1243,7 +1784,51 @@ export class Migrator {
1243
1784
  const verification = assertDestinationContent(rendered.body, verified.content, destination.kind, cachedPage);
1244
1785
  verificationDegradations = verification.status === 'partial' ? verification.differences : [];
1245
1786
  }
1246
- const partial = rendered.degraded || resourceProblems.length > 0 || verificationDegradations.length > 0;
1787
+ const commentInventory = commentsByPage.get(page.canonicalId);
1788
+ const commentState = (options.comments ?? true)
1789
+ ? await this.transferRemoteComments({
1790
+ source,
1791
+ destination,
1792
+ page: cachedPage,
1793
+ destinationPage: verified,
1794
+ inventory: commentInventory,
1795
+ resources,
1796
+ pageState: state,
1797
+ cacheDirectory: options.cacheDirectory,
1798
+ dryRun: options.dryRun,
1799
+ destinationPages,
1800
+ sourcePages,
1801
+ report,
1802
+ store,
1803
+ ...(options.signal ? { signal: options.signal } : {}),
1804
+ })
1805
+ : {
1806
+ status: 'skipped',
1807
+ threads: 0,
1808
+ comments: 0,
1809
+ participants: 0,
1810
+ attachments: 0,
1811
+ bytes: 0,
1812
+ updatedAt: new Date().toISOString(),
1813
+ };
1814
+ if (options.comments ?? true) {
1815
+ progress.comments += 1;
1816
+ updateProgress(`comments ${commentState.status} → ${page.title}`);
1817
+ }
1818
+ if (['failed', 'conflict', 'stale', 'partial'].includes(commentState.status)
1819
+ && !(commentState.status === 'failed' && commentInventory?.status === 'failed')
1820
+ && !(commentState.status === 'partial' && (commentInventory?.warnings.length ?? 0) > 0 && !commentState.lastError)) {
1821
+ report.issue({
1822
+ code: `comment-${commentState.status}`,
1823
+ severity: commentState.status === 'partial' || commentState.status === 'stale' ? 'degradation' : 'error',
1824
+ phase: 'comment',
1825
+ pageId: page.canonicalId,
1826
+ retryable: commentState.status === 'failed',
1827
+ message: `${page.title}: ${commentState.lastError ?? `comment archive is ${commentState.status}`}`,
1828
+ });
1829
+ }
1830
+ const commentPartial = !['complete', 'skipped', 'planned'].includes(commentState.status);
1831
+ const partial = rendered.degraded || resourceProblems.length > 0 || verificationDegradations.length > 0 || commentPartial;
1247
1832
  const lastResourceProblem = resourceProblems.at(-1);
1248
1833
  const clearedState = { ...state };
1249
1834
  delete clearedState.lastError;
@@ -1255,6 +1840,7 @@ export class Migrator {
1255
1840
  destinationTitle: verified.title,
1256
1841
  ...(verified.fingerprint ? { destinationFingerprint: verified.fingerprint } : {}),
1257
1842
  status: options.dryRun ? 'planned' : partial ? 'partial' : 'complete',
1843
+ comments: commentState,
1258
1844
  degradations: [...new Set([...rendered.warnings, ...resourceProblems, ...verificationDegradations])],
1259
1845
  ...(lastResourceProblem ? { lastError: lastResourceProblem } : {}),
1260
1846
  updatedAt: new Date().toISOString(),
@@ -1277,6 +1863,9 @@ export class Migrator {
1277
1863
  resourceFallbacks: fallbackDetails,
1278
1864
  warnings: [...rendered.warnings, ...verificationDegradations],
1279
1865
  normalizations: rendered.normalizations,
1866
+ commentsEnabled: options.comments ?? true,
1867
+ ...(commentInventory ? { commentInventory } : {}),
1868
+ commentState,
1280
1869
  }), { source: normalizedPreviewHtml(document), destination: renderedPreviewHtml(verified.content ?? rendered.body, destination.kind) });
1281
1870
  }
1282
1871
  catch (error) {
@@ -1324,6 +1913,9 @@ export class Migrator {
1324
1913
  ...(options.dryRun ? { resourceStatusOverride: 'planned' } : {}),
1325
1914
  resourceFallbacks: fallbackDetails,
1326
1915
  error: message,
1916
+ commentsEnabled: options.comments ?? true,
1917
+ ...(commentsByPage.get(page.canonicalId) ? { commentInventory: commentsByPage.get(page.canonicalId) } : {}),
1918
+ ...(state.comments ? { commentState: state.comments } : {}),
1327
1919
  }), actual?.content ? { source: normalizedPreviewHtml(document), destination: renderedPreviewHtml(actual.content, destination.kind) } : undefined);
1328
1920
  }
1329
1921
  finally {