@iwonz/ywtc 3.4.0 → 3.5.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 (42) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +31 -2
  3. package/dist/adapters/confluence.d.ts +8 -2
  4. package/dist/adapters/confluence.js +44 -6
  5. package/dist/adapters/confluence.js.map +1 -1
  6. package/dist/adapters/yandex.d.ts +18 -4
  7. package/dist/adapters/yandex.js +34 -6
  8. package/dist/adapters/yandex.js.map +1 -1
  9. package/dist/cli.js +6 -4
  10. package/dist/cli.js.map +1 -1
  11. package/dist/content-model.d.ts +1 -0
  12. package/dist/content-model.js +13 -0
  13. package/dist/content-model.js.map +1 -1
  14. package/dist/domain.d.ts +12 -0
  15. package/dist/domain.js +10 -0
  16. package/dist/domain.js.map +1 -1
  17. package/dist/graph.js +11 -2
  18. package/dist/graph.js.map +1 -1
  19. package/dist/issues.d.ts +10 -1
  20. package/dist/issues.js +9 -1
  21. package/dist/issues.js.map +1 -1
  22. package/dist/migrator.d.ts +1 -1
  23. package/dist/migrator.js +344 -155
  24. package/dist/migrator.js.map +1 -1
  25. package/dist/report-catalog.d.ts +14 -0
  26. package/dist/report-catalog.js +14 -0
  27. package/dist/report-catalog.js.map +1 -1
  28. package/dist/report-contract.d.ts +259 -0
  29. package/dist/report-contract.js +15 -0
  30. package/dist/report-contract.js.map +1 -0
  31. package/dist/report-ui/index.html +92 -0
  32. package/dist/report.d.ts +13 -192
  33. package/dist/report.js +273 -176
  34. package/dist/report.js.map +1 -1
  35. package/dist/state.d.ts +24 -6
  36. package/dist/state.js +54 -3
  37. package/dist/state.js.map +1 -1
  38. package/dist/title-allocation.d.ts +2 -0
  39. package/dist/title-allocation.js +43 -0
  40. package/dist/title-allocation.js.map +1 -0
  41. package/docs/assets/report-header-transparent.png +0 -0
  42. package/package.json +24 -3
package/dist/migrator.js CHANGED
@@ -1,18 +1,23 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
4
5
  import PQueue from 'p-queue';
5
6
  import { createProviderAdapter } from './adapters/factory.js';
6
- import { appendGridSnapshots, assertDestinationContent, isLegacyTransferPlaceholder, normalizePageContent, normalizedPreviewHtml, rawContentFallback, renderedPreviewHtml, renderConfluenceStorage, renderYfm, setSourceResourceBinding, sourceResourceBindings, verifyDestinationContent, } from './content-model.js';
7
+ import { secretValues } from './config.js';
8
+ import { appendGridSnapshots, assertDestinationContent, emptyRendererFallbackReason, isLegacyTransferPlaceholder, normalizePageContent, normalizedPreviewHtml, rawContentFallback, renderedPreviewHtml, renderConfluenceStorage, renderYfm, setSourceResourceBinding, sourceResourceBindings, verifyDestinationContent, } from './content-model.js';
7
9
  import { commentAttachmentUses, createCommentArchiveZip, renderCommentArchive, renderCommentArchiveIndex, resolveCommentAttachmentReferences, verifyCommentArchive, } from './comments.js';
8
10
  import { ConfirmedDestinationMissingError, verifyConfirmedDestinationPage } from './consistency.js';
11
+ import { DestinationTitleConflictError } from './domain.js';
9
12
  import { assertTransferCapabilities } from './domain.js';
10
13
  import { FilesystemExporter } from './filesystem-export.js';
11
14
  import { discoverPageGraph } from './graph.js';
15
+ import { HttpRequestError } from './http.js';
12
16
  import { safeFilename } from './input.js';
13
17
  import { profileBaseUrl, sameRemoteInstance } from './locators.js';
14
18
  import { TransferReport, } from './report.js';
15
19
  import { DestinationNameAllocator, preferredDestinationName, verifiedCacheFile } from './resource-reconciliation.js';
20
+ import { destinationTitleCandidates } from './title-allocation.js';
16
21
  import { resolveStateLocation, StateStore, } from './state.js';
17
22
  function sourceIdentity(locator, root) {
18
23
  return {
@@ -65,10 +70,6 @@ async function cacheDocument(cacheDirectory, canonicalId, document) {
65
70
  async function loadCachedDocument(cacheDirectory, canonicalId) {
66
71
  return JSON.parse(await readFile(cachedDocumentPath(cacheDirectory, canonicalId), 'utf8'));
67
72
  }
68
- function collisionTitle(page) {
69
- const suffix = page.locator.split('/').filter(Boolean).at(-1) ?? page.id;
70
- return `${page.title} [${suffix}]`;
71
- }
72
73
  function cachePath(cacheDirectory, page, resource) {
73
74
  const pageKey = sha256(page.canonicalId).slice(0, 12);
74
75
  const resourceKey = sha256(`${resource.id}:${resource.name}`).slice(0, 12);
@@ -104,6 +105,73 @@ function sourceDerivedTextMatches(expectedBody, actualBody, provider, page) {
104
105
  const text = (value) => renderedPreviewHtml(value, provider).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
105
106
  return text(expectedBody) === text(actualBody);
106
107
  }
108
+ function ambiguousCreateOutcome(error) {
109
+ return error instanceof HttpRequestError
110
+ && (error.status === undefined || error.status >= 500);
111
+ }
112
+ async function createPageWithAllocatedTitle(input) {
113
+ const candidates = destinationTitleCandidates(input.sourcePage, input.preferredTitle);
114
+ for (const [index, title] of candidates.entries()) {
115
+ let child;
116
+ try {
117
+ child = input.destination.findRecoverablePage
118
+ ? await input.destination.findRecoverablePage(input.parent, title)
119
+ : await input.destination.findChild(input.parent, title);
120
+ }
121
+ catch (error) {
122
+ input.report.issue({
123
+ code: 'destination-recovery-lookup-failed',
124
+ severity: 'warning',
125
+ phase: 'page',
126
+ pageId: input.sourcePage.canonicalId,
127
+ retryable: true,
128
+ message: `${input.sourcePage.title}: parent-scoped recovery lookup failed for "${title}"; authoritative creation will be attempted: ${error instanceof Error ? error.message : String(error)}`,
129
+ });
130
+ }
131
+ if (child && sourceDerivedTextMatches(input.bootstrap.body, child.content, input.destination.kind, input.sourcePage)) {
132
+ return { page: child, reused: true };
133
+ }
134
+ const occupied = child ?? (input.destination.findTitleConflict
135
+ ? await input.destination.findTitleConflict(input.parent, title)
136
+ : undefined);
137
+ if (occupied)
138
+ continue;
139
+ let created;
140
+ try {
141
+ created = await input.destination.createPage({ container: input.parent, title, body: input.bootstrap.body });
142
+ }
143
+ catch (error) {
144
+ if (error instanceof DestinationTitleConflictError)
145
+ continue;
146
+ if (ambiguousCreateOutcome(error)) {
147
+ const recovered = input.destination.findRecoverablePage
148
+ ? await input.destination.findRecoverablePage(input.parent, title)
149
+ : await input.destination.findChild(input.parent, title);
150
+ if (recovered && sourceDerivedTextMatches(input.bootstrap.body, recovered.content, input.destination.kind, input.sourcePage)) {
151
+ return { page: recovered, reused: true };
152
+ }
153
+ }
154
+ throw error;
155
+ }
156
+ created = await verifyConfirmedDestinationPage(input.destination, created, input.consistencyOptions);
157
+ assertDestinationContent(input.bootstrap.body, created.content, input.destination.kind, input.sourcePage);
158
+ if (created.parentId !== input.parent.id) {
159
+ throw new Error(`Destination hierarchy verification failed for ${input.sourcePage.title}: expected parent ${input.parent.id}, received ${created.parentId ?? 'none'}`);
160
+ }
161
+ if (index > 0) {
162
+ input.report.issue({
163
+ code: 'destination-title-renamed',
164
+ severity: 'normalization',
165
+ phase: 'page',
166
+ pageId: input.sourcePage.canonicalId,
167
+ retryable: false,
168
+ message: `${input.sourcePage.title}: destination title was allocated as "${created.title}" because an earlier candidate was occupied`,
169
+ });
170
+ }
171
+ return { page: created, reused: false };
172
+ }
173
+ throw new Error(`Confluence rejected all ${candidates.length} deterministic destination titles for ${input.sourcePage.title} because they are occupied`);
174
+ }
107
175
  function destinationFromState(state, adapter) {
108
176
  if (!state.destinationId || !state.destinationCanonicalId || !state.destinationLocator) {
109
177
  throw new Error(`Incomplete mapped destination for ${state.sourceTitle}`);
@@ -120,6 +188,19 @@ function destinationFromState(state, adapter) {
120
188
  };
121
189
  }
122
190
  function reportPage(input) {
191
+ const inventory = input.commentInventory;
192
+ const commentResourceIds = new Set();
193
+ for (const thread of inventory?.threads ?? []) {
194
+ const stack = [thread.root];
195
+ while (stack.length > 0) {
196
+ const entry = stack.pop();
197
+ for (const attachment of entry.attachments) {
198
+ if (attachment.resourceId)
199
+ commentResourceIds.add(attachment.resourceId);
200
+ }
201
+ stack.push(...entry.replies);
202
+ }
203
+ }
123
204
  const transferable = input.resources.filter((resource) => resource.kind !== 'grid');
124
205
  const resourceItems = transferable.map((resource) => {
125
206
  const state = input.resourceStates?.[resource.id];
@@ -128,36 +209,74 @@ function reportPage(input) {
128
209
  return {
129
210
  sourceId: resource.id,
130
211
  sourceName: resource.name,
131
- kind: resource.kind,
212
+ ...(resource.downloadUrl ? { sourceUrl: resource.downloadUrl } : {}),
213
+ ...(resource.referenceAliases?.[0] ? { sourceReference: resource.referenceAliases[0] } : {}),
214
+ kind: commentResourceIds.has(resource.id) ? 'comment-attachment' : resource.kind,
132
215
  status,
133
216
  ...(sourceBytes !== undefined ? { sourceBytes } : {}),
134
217
  ...(status === 'complete' && sourceBytes !== undefined ? { transferredBytes: sourceBytes } : {}),
135
218
  ...(state?.destinationId ? { destinationId: state.destinationId } : {}),
136
219
  ...(state?.destinationName ? { destinationName: state.destinationName } : {}),
220
+ ...(state?.destinationUrl ? { destinationUrl: state.destinationUrl } : {}),
221
+ ...(state?.sha256 ? { sha256: state.sha256 } : {}),
137
222
  ...(state?.lastError ? { error: state.lastError } : {}),
223
+ ...(status === 'blocked' && input.blockedBy ? input.blockedBy : {}),
138
224
  };
139
225
  });
140
226
  const knownBytes = resourceItems.reduce((total, resource) => total + (resource.sourceBytes ?? 0), 0);
141
227
  const transferredBytes = resourceItems.reduce((total, resource) => total + (resource.transferredBytes ?? 0), 0);
142
228
  const commentsEnabled = input.commentsEnabled ?? true;
143
- const inventory = input.commentInventory;
144
229
  const commentState = input.commentState;
145
230
  const commentStatus = !commentsEnabled
146
231
  ? 'skipped'
147
232
  : commentState?.status
148
233
  ?? (inventory?.status === 'failed' ? 'failed' : inventory?.status === 'empty' ? 'complete' : 'planned');
149
234
  const commentError = commentState?.lastError ?? inventory?.error;
235
+ const commentItems = inventory?.threads.flatMap((thread) => {
236
+ const result = [];
237
+ const visit = (entry) => {
238
+ const author = entry.author.displayName
239
+ ?? entry.author.username
240
+ ?? entry.author.publicName
241
+ ?? entry.author.providerId;
242
+ result.push({
243
+ threadId: thread.id,
244
+ commentId: entry.id,
245
+ ...(entry.parentId ? { parentId: entry.parentId } : {}),
246
+ author,
247
+ ...(entry.createdAt ? { createdAt: entry.createdAt } : {}),
248
+ ...(entry.status ? { sourceStatus: entry.status } : {}),
249
+ outcome: commentStatus,
250
+ sourceUrl: `${input.page.url}#comment-${encodeURIComponent(entry.id)}`,
251
+ ...(commentState?.destinationUrl ? { destinationUrl: commentState.destinationUrl } : {}),
252
+ ...(commentError ? { error: commentError } : {}),
253
+ });
254
+ for (const reply of entry.replies)
255
+ visit(reply);
256
+ };
257
+ visit(thread.root);
258
+ return result;
259
+ });
150
260
  return {
151
261
  sourceCanonicalId: input.page.canonicalId,
152
262
  sourceId: input.page.id,
153
263
  sourceTitle: input.page.title,
154
264
  sourceUrl: input.page.url,
265
+ ...(input.page.parentId ? { sourceParentId: input.page.parentId } : {}),
266
+ ...(input.page.ancestorIds?.length ? { sourceAncestorIds: input.page.ancestorIds } : {}),
155
267
  ...(input.destination ? {
156
268
  destinationId: input.destination.id,
157
269
  destinationTitle: input.destination.title,
158
270
  destinationUrl: input.destination.url,
271
+ ...(input.destination.parentId ? { destinationParentId: input.destination.parentId } : {}),
159
272
  } : {}),
160
- ...(input.page.projectedParentId ? { parentId: input.page.projectedParentId } : {}),
273
+ ...(input.destinationFile ? {
274
+ destinationId: input.destinationFile.id,
275
+ destinationTitle: input.page.title,
276
+ destinationUrl: input.destinationFile.url,
277
+ ...(input.destinationFile.parentId ? { destinationParentId: input.destinationFile.parentId } : {}),
278
+ } : {}),
279
+ ...(input.page.projectedParentId ? { parentId: input.page.projectedParentId, projectedParentId: input.page.projectedParentId } : {}),
161
280
  depth: input.page.depth,
162
281
  status: input.status,
163
282
  inventoryStatus: input.inventoryStatus ?? 'complete',
@@ -176,6 +295,7 @@ function reportPage(input) {
176
295
  warnings: [...new Set([...input.document.warnings, ...(input.warnings ?? [])])],
177
296
  normalizations: [...new Set([...(input.document.normalizations ?? []), ...(input.normalizations ?? [])])],
178
297
  ...(input.error ? { error: input.error } : {}),
298
+ ...(input.status === 'blocked' && input.blockedBy ? input.blockedBy : {}),
179
299
  comments: {
180
300
  status: commentStatus,
181
301
  threads: commentState?.threads ?? inventory?.threads.length ?? 0,
@@ -184,14 +304,51 @@ function reportPage(input) {
184
304
  attachments: commentState?.attachments ?? inventory?.attachments ?? 0,
185
305
  bytes: commentState?.bytes ?? inventory?.knownBytes ?? 0,
186
306
  ...(commentState?.archiveName ? { archiveName: commentState.archiveName } : {}),
307
+ ...(commentState?.archiveUrl ? { archiveUrl: commentState.archiveUrl } : {}),
187
308
  ...(commentState?.destinationId ? { destinationId: commentState.destinationId } : {}),
309
+ ...(commentState?.destinationUrl ? { destinationUrl: commentState.destinationUrl } : {}),
188
310
  ...(commentState?.previousDestinationIds?.length ? { revision: true } : {}),
189
311
  ...(commentState?.status === 'stale' ? { stale: true } : {}),
190
312
  ...(commentError ? { error: commentError } : {}),
191
313
  ...(commentState?.preview ? { preview: commentState.preview } : {}),
314
+ ...(commentItems?.length ? { items: commentItems } : {}),
315
+ ...(commentStatus === 'blocked' && input.blockedBy ? input.blockedBy : {}),
192
316
  },
193
317
  };
194
318
  }
319
+ function blockedResourceStates(resources, previous, cause) {
320
+ return {
321
+ ...previous,
322
+ ...Object.fromEntries(resources.filter((resource) => resource.kind !== 'grid').map((resource) => {
323
+ const existing = previous[resource.id];
324
+ return [resource.id, {
325
+ ...(existing ?? {}),
326
+ sourceId: resource.id,
327
+ sourceName: resource.name,
328
+ destinationName: existing?.destinationName ?? preferredDestinationName(resource),
329
+ ...(existing?.size !== undefined ? { size: existing.size } : resource.size !== undefined ? { size: resource.size } : {}),
330
+ status: 'blocked',
331
+ ...cause,
332
+ lastError: cause.blockedReason,
333
+ updatedAt: new Date().toISOString(),
334
+ }];
335
+ })),
336
+ };
337
+ }
338
+ function blockedCommentState(inventory, previous, cause) {
339
+ return {
340
+ ...(previous ?? {}),
341
+ status: 'blocked',
342
+ threads: previous?.threads ?? inventory?.threads.length ?? 0,
343
+ comments: previous?.comments ?? inventory?.comments ?? 0,
344
+ participants: previous?.participants ?? inventory?.participants ?? 0,
345
+ attachments: previous?.attachments ?? inventory?.attachments ?? 0,
346
+ bytes: previous?.bytes ?? inventory?.knownBytes ?? 0,
347
+ ...cause,
348
+ lastError: cause.blockedReason,
349
+ updatedAt: new Date().toISOString(),
350
+ };
351
+ }
195
352
  function runStatus(errors, warnings, degradations) {
196
353
  if (errors > 0)
197
354
  return 'partial';
@@ -200,6 +357,17 @@ function runStatus(errors, warnings, degradations) {
200
357
  function attentionCount(summary) {
201
358
  return summary.warnings + summary.degradations;
202
359
  }
360
+ function runOutcomeCounts(summary, pages) {
361
+ return {
362
+ pages,
363
+ warnings: attentionCount(summary),
364
+ errors: summary.errors,
365
+ rootErrors: summary.rootErrors,
366
+ dependentPages: summary.dependentPages,
367
+ dependentFiles: summary.dependentFiles,
368
+ dependentComments: summary.dependentComments,
369
+ };
370
+ }
203
371
  export class MigrationRunError extends Error {
204
372
  reportPaths;
205
373
  stateFilename;
@@ -216,7 +384,7 @@ async function renderRemoteDocument(input) {
216
384
  ? await renderConfluenceStorage(input.document, input.page, input.context, input.container.space ?? '')
217
385
  : await renderYfm(input.document, input.context);
218
386
  if (!rendered.body.trim())
219
- return rawContentFallback(input.page, input.destination.kind, 'renderer returned an empty body');
387
+ return rawContentFallback(input.page, input.destination.kind, emptyRendererFallbackReason(input.document));
220
388
  return rendered;
221
389
  }
222
390
  catch (error) {
@@ -267,6 +435,7 @@ export class Migrator {
267
435
  await store.save();
268
436
  const run = await store.beginRun(options.scope, options.dryRun, sourceStatus, commentsEnabled);
269
437
  const report = new TransferReport({
438
+ secrets: secretValues(this.config),
270
439
  dryRun: options.dryRun,
271
440
  commentsEnabled,
272
441
  scope: options.scope,
@@ -329,11 +498,7 @@ export class Migrator {
329
498
  report.finish(finalStatus);
330
499
  const reportPaths = await report.write(options.reportsDirectory);
331
500
  if (!options.dryRun)
332
- await store.finishRun(run.id, finalStatus, {
333
- pages: 0,
334
- warnings: attentionCount(report.data.summary),
335
- errors: report.data.summary.errors,
336
- });
501
+ await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, 0));
337
502
  throw new MigrationRunError(message, reportPaths, stateLocation.filename, { cause: error });
338
503
  }
339
504
  const resourcesByPage = new Map();
@@ -419,13 +584,58 @@ export class Migrator {
419
584
  report.finish(finalStatus);
420
585
  const reportPaths = await report.write(options.reportsDirectory);
421
586
  if (!options.dryRun)
422
- await store.finishRun(run.id, finalStatus, {
423
- pages: graph.pages.length,
424
- warnings: attentionCount(report.data.summary),
425
- errors: report.data.summary.errors,
426
- });
587
+ await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, graph.pages.length));
427
588
  throw new MigrationRunError('Resource preflight failed before destination mutation', reportPaths, stateLocation.filename);
428
589
  }
590
+ if (!options.dryRun) {
591
+ for (const page of graph.pages) {
592
+ if (store.value.pages[page.canonicalId])
593
+ continue;
594
+ const commentInventory = commentsByPage.get(page.canonicalId);
595
+ const commentState = !commentsEnabled
596
+ ? { status: 'skipped', threads: 0, comments: 0, participants: 0, attachments: 0, bytes: 0, updatedAt: new Date().toISOString() }
597
+ : commentInventory?.status === 'empty'
598
+ ? { status: 'complete', threads: 0, comments: 0, participants: 0, attachments: 0, bytes: 0, updatedAt: new Date().toISOString() }
599
+ : commentInventory?.status === 'failed'
600
+ ? {
601
+ status: 'failed', threads: 0, comments: 0, participants: 0, attachments: 0, bytes: 0,
602
+ ...(commentInventory.error ? { lastError: commentInventory.error } : {}), updatedAt: new Date().toISOString(),
603
+ }
604
+ : commentInventory
605
+ ? {
606
+ status: 'planned', ...(commentInventory.fingerprint ? { fingerprint: commentInventory.fingerprint } : {}),
607
+ threads: commentInventory.threads.length, comments: commentInventory.comments,
608
+ participants: commentInventory.participants, attachments: commentInventory.attachments,
609
+ bytes: commentInventory.knownBytes, updatedAt: new Date().toISOString(),
610
+ }
611
+ : undefined;
612
+ const resourceStates = Object.fromEntries((resourcesByPage.get(page.canonicalId) ?? [])
613
+ .filter((resource) => resource.kind !== 'grid')
614
+ .map((resource) => [resource.id, {
615
+ sourceId: resource.id,
616
+ sourceName: resource.name,
617
+ destinationName: preferredDestinationName(resource),
618
+ ...(resource.size !== undefined ? { size: resource.size } : {}),
619
+ ...(resource.mimeType ? { mimeType: resource.mimeType } : {}),
620
+ status: 'planned',
621
+ updatedAt: new Date().toISOString(),
622
+ }]));
623
+ await store.setPage({
624
+ sourceId: page.id,
625
+ sourceCanonicalId: page.canonicalId,
626
+ sourceLocator: page.locator,
627
+ sourceTitle: page.title,
628
+ ...(page.projectedParentId ? { projectedParentId: page.projectedParentId } : {}),
629
+ destinationTitle: page.title,
630
+ sourceFingerprint: sourceFingerprints.get(page.canonicalId),
631
+ status: 'discovered',
632
+ resources: resourceStates,
633
+ ...(commentState ? { comments: commentState } : {}),
634
+ degradations: [],
635
+ updatedAt: new Date().toISOString(),
636
+ });
637
+ }
638
+ }
429
639
  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'}`;
430
640
  status(planSummary);
431
641
  this.logger.info(planSummary);
@@ -436,6 +646,8 @@ export class Migrator {
436
646
  const result = await this.exportFilesystem(source, graph, options.destination, options, stateLocation.id, store, report, sourceFingerprints, resourcesByPage, commentsByPage);
437
647
  output = result.directory;
438
648
  archive = result.archive;
649
+ const exportEntry = path.join(result.directory, options.destination.format === 'html' ? 'index.html' : 'index.md');
650
+ report.setDestinationUrl(pathToFileURL(exportEntry).toString());
439
651
  report.output('directory', result.directory);
440
652
  if (result.archive)
441
653
  report.output('archive', result.archive);
@@ -460,14 +672,11 @@ export class Migrator {
460
672
  }
461
673
  report.finish('completed');
462
674
  const finalStatus = runStatus(report.data.summary.errors, report.data.summary.warnings, report.data.summary.degradations);
675
+ await store.finalizeOutcomes(graph.pages.map((page) => page.canonicalId), finalStatus, options.dryRun);
463
676
  report.finish(finalStatus);
464
677
  const reportPaths = await report.write(options.reportsDirectory);
465
678
  if (!options.dryRun)
466
- await store.finishRun(run.id, finalStatus, {
467
- pages: graph.pages.length,
468
- warnings: attentionCount(report.data.summary),
469
- errors: report.data.summary.errors,
470
- });
679
+ await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, graph.pages.length));
471
680
  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`);
472
681
  return {
473
682
  status: finalStatus,
@@ -486,14 +695,11 @@ export class Migrator {
486
695
  const finalStatus = cancelled ? 'cancelled' : 'failed';
487
696
  const message = error instanceof Error ? error.message : String(error);
488
697
  report.error(message);
698
+ await store.finalizeOutcomes(graph.pages.map((page) => page.canonicalId), finalStatus, options.dryRun);
489
699
  report.finish(finalStatus);
490
700
  const reportPaths = await report.write(options.reportsDirectory);
491
701
  if (!options.dryRun)
492
- await store.finishRun(run.id, finalStatus, {
493
- pages: graph.pages.length,
494
- warnings: attentionCount(report.data.summary),
495
- errors: report.data.summary.errors,
496
- });
702
+ await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, graph.pages.length));
497
703
  this.logger.error(`${message}. Report: ${reportPaths.html}`);
498
704
  throw new MigrationRunError(message, reportPaths, stateLocation.filename, { cause: error });
499
705
  }
@@ -536,6 +742,7 @@ export class Migrator {
536
742
  sourceName: sourceResource?.name ?? resource.name,
537
743
  destinationId: resource.path,
538
744
  destinationName: resource.name,
745
+ destinationUrl: pathToFileURL(path.join(destination.path, resource.path)).toString(),
539
746
  ...(resource.sha256 ? { sha256: resource.sha256 } : {}),
540
747
  ...(resource.size !== undefined ? { size: resource.size } : {}),
541
748
  status: options.dryRun ? 'planned' : 'complete',
@@ -574,8 +781,16 @@ export class Migrator {
574
781
  } } : {}),
575
782
  updatedAt: new Date().toISOString(),
576
783
  });
784
+ const parentManifestPage = manifestPage?.parentId
785
+ ? result.manifest.pages.find((item) => item.sourceCanonicalId === manifestPage.parentId)
786
+ : undefined;
577
787
  report.addPage(reportPage({
578
788
  page,
789
+ ...(manifestPage ? { destinationFile: {
790
+ id: manifestPage.path,
791
+ url: pathToFileURL(path.join(destination.path, manifestPage.path)).toString(),
792
+ ...(parentManifestPage ? { parentId: parentManifestPage.path } : {}),
793
+ } } : {}),
579
794
  status: pageStatus,
580
795
  resources: sourceResources,
581
796
  document,
@@ -780,6 +995,7 @@ export class Migrator {
780
995
  const archiveState = zip ? {
781
996
  ...(archiveResource ? { archiveResourceId: archiveResource.id } : {}),
782
997
  archiveName: archiveResource?.name ?? zip.name,
998
+ ...(archiveResource?.url ? { archiveUrl: archiveResource.url } : {}),
783
999
  archivePath: zip.path,
784
1000
  archiveSha256: zip.sha256,
785
1001
  } : {};
@@ -899,6 +1115,7 @@ export class Migrator {
899
1115
  fingerprint,
900
1116
  destinationId: verified.id,
901
1117
  destinationFingerprint: verified.fingerprint,
1118
+ ...(verified.url ? { destinationUrl: verified.url } : {}),
902
1119
  ...(previousIds?.length ? { previousDestinationIds: previousIds } : {}),
903
1120
  ...archiveState,
904
1121
  ...fullArchiveState,
@@ -966,15 +1183,47 @@ export class Migrator {
966
1183
  const saved = store.value.pages[page.canonicalId];
967
1184
  const parent = page.projectedParentId ? destinationPages.get(page.projectedParentId) : container;
968
1185
  if (!parent) {
969
- const message = `Destination parent is unavailable for ${page.title}`;
970
- stagingFailures.set(page.canonicalId, { status: 'blocked', message });
971
- report.updatePage(page.canonicalId, { status: 'blocked', error: message });
1186
+ const upstream = page.projectedParentId ? stagingFailures.get(page.projectedParentId) : undefined;
1187
+ const blockedByPageId = upstream?.blockedByPageId ?? page.projectedParentId ?? page.canonicalId;
1188
+ const blockedByCode = upstream?.blockedByCode ?? 'destination-parent-unavailable';
1189
+ const message = `Destination parent is unavailable for ${page.title}; blocked by ${blockedByPageId} (${blockedByCode})`;
1190
+ const cause = { blockedByPageId, blockedByCode, blockedReason: message };
1191
+ stagingFailures.set(page.canonicalId, { status: 'blocked', message, blockedByPageId, blockedByCode });
1192
+ const previous = store.value.pages[page.canonicalId];
1193
+ const resources = resourcesByPage.get(page.canonicalId) ?? [];
1194
+ const comments = (options.comments ?? true)
1195
+ ? blockedCommentState(commentsByPage.get(page.canonicalId), previous?.comments, cause)
1196
+ : undefined;
1197
+ await store.setPage({
1198
+ sourceId: page.id,
1199
+ sourceCanonicalId: page.canonicalId,
1200
+ sourceLocator: page.locator,
1201
+ sourceTitle: page.title,
1202
+ ...(page.projectedParentId ? { projectedParentId: page.projectedParentId } : {}),
1203
+ destinationTitle: previous?.destinationTitle ?? page.title,
1204
+ ...(previous?.destinationId ? { destinationId: previous.destinationId } : {}),
1205
+ ...(previous?.destinationCanonicalId ? { destinationCanonicalId: previous.destinationCanonicalId } : {}),
1206
+ ...(previous?.destinationLocator ? { destinationLocator: previous.destinationLocator } : {}),
1207
+ ...(previous?.previousDestinationIds ? { previousDestinationIds: previous.previousDestinationIds } : {}),
1208
+ sourceFingerprint: sourceFingerprints.get(page.canonicalId),
1209
+ ...(previous?.destinationFingerprint ? { destinationFingerprint: previous.destinationFingerprint } : {}),
1210
+ status: 'blocked',
1211
+ resources: blockedResourceStates(resources, previous?.resources ?? {}, cause),
1212
+ ...(comments ? { comments } : {}),
1213
+ degradations: previous?.degradations ?? [],
1214
+ ...cause,
1215
+ lastError: message,
1216
+ updatedAt: new Date().toISOString(),
1217
+ });
1218
+ report.updatePage(page.canonicalId, { status: 'blocked', error: message, ...cause });
972
1219
  report.issue({
973
1220
  code: 'destination-parent-unavailable',
974
- severity: 'error',
1221
+ severity: 'blocked',
975
1222
  phase: 'page',
976
1223
  pageId: page.canonicalId,
977
- retryable: true,
1224
+ retryable: false,
1225
+ blockedByPageId,
1226
+ blockedByCode,
978
1227
  message,
979
1228
  });
980
1229
  progress.staged += 1;
@@ -985,14 +1234,20 @@ export class Migrator {
985
1234
  const cachedPage = await loadCachedPage(options.cacheDirectory, page.canonicalId);
986
1235
  const bootstrap = bootstrapBodies.get(page.canonicalId);
987
1236
  let created;
988
- let title = saved?.destinationTitle ?? page.title;
1237
+ const title = saved?.destinationTitle ?? page.title;
989
1238
  let resourcesForState = saved?.resources ?? {};
990
1239
  let commentsForState = saved?.comments;
991
1240
  let previousDestinationIds = saved?.previousDestinationIds ?? [];
992
1241
  let mappingRecovered = false;
993
- const findTitleConflict = (candidateTitle) => destination.findTitleConflict
994
- ? destination.findTitleConflict(parent, candidateTitle)
995
- : destination.findChild(parent, candidateTitle);
1242
+ const allocatePage = (preferredTitle) => createPageWithAllocatedTitle({
1243
+ destination,
1244
+ parent,
1245
+ sourcePage: cachedPage,
1246
+ bootstrap,
1247
+ ...(preferredTitle ? { preferredTitle } : {}),
1248
+ consistencyOptions: this.consistencyOptions,
1249
+ report,
1250
+ });
996
1251
  if (saved?.destinationId) {
997
1252
  const mapped = destinationFromState(saved, destination);
998
1253
  try {
@@ -1014,77 +1269,8 @@ export class Migrator {
1014
1269
  if (page.projectedParentId) {
1015
1270
  await verifyConfirmedDestinationPage(destination, parent, this.consistencyOptions);
1016
1271
  }
1017
- const findRecoveryChild = (candidateTitle) => destination.findRecoverablePage
1018
- ? destination.findRecoverablePage(parent, candidateTitle)
1019
- : destination.findChild(parent, candidateTitle);
1020
- let candidate;
1021
- let recoveryLookupFailed = false;
1022
- try {
1023
- candidate = await findRecoveryChild(title);
1024
- }
1025
- catch (lookupError) {
1026
- recoveryLookupFailed = true;
1027
- report.issue({
1028
- code: 'destination-recovery-lookup-failed',
1029
- severity: 'warning',
1030
- phase: 'page',
1031
- pageId: page.canonicalId,
1032
- retryable: true,
1033
- message: `${page.title}: recovery lookup failed; bootstrap recreation will be attempted: ${lookupError instanceof Error ? lookupError.message : String(lookupError)}`,
1034
- });
1035
- }
1036
- if (candidate && sourceDerivedTextMatches(bootstrap.body, candidate.content, destination.kind, cachedPage)) {
1037
- created = candidate;
1038
- }
1039
- else {
1040
- let recoveryTitle = title;
1041
- let titleConflict = candidate;
1042
- if (!titleConflict && !recoveryLookupFailed) {
1043
- try {
1044
- titleConflict = await findTitleConflict(recoveryTitle);
1045
- }
1046
- catch (lookupError) {
1047
- recoveryLookupFailed = true;
1048
- report.issue({
1049
- code: 'destination-title-lookup-failed',
1050
- severity: 'warning',
1051
- phase: 'page',
1052
- pageId: page.canonicalId,
1053
- retryable: true,
1054
- message: `${page.title}: destination title lookup failed; bootstrap recreation will be attempted with a stable suffix: ${lookupError instanceof Error ? lookupError.message : String(lookupError)}`,
1055
- });
1056
- }
1057
- }
1058
- if (titleConflict || recoveryLookupFailed) {
1059
- const base = collisionTitle(page);
1060
- const hash = sha256(page.canonicalId).slice(0, 8);
1061
- recoveryTitle = base;
1062
- let sequence = 0;
1063
- while (!recoveryLookupFailed) {
1064
- try {
1065
- if (!await findTitleConflict(recoveryTitle))
1066
- break;
1067
- sequence += 1;
1068
- recoveryTitle = `${base} [${hash}${sequence > 1 ? `-${sequence}` : ''}]`;
1069
- }
1070
- catch (lookupError) {
1071
- recoveryLookupFailed = true;
1072
- recoveryTitle = `${base} [${hash}]`;
1073
- report.issue({
1074
- code: 'destination-recovery-lookup-failed',
1075
- severity: 'warning',
1076
- phase: 'page',
1077
- pageId: page.canonicalId,
1078
- retryable: true,
1079
- message: `${page.title}: suffixed-title lookup failed; bootstrap recreation will be attempted: ${lookupError instanceof Error ? lookupError.message : String(lookupError)}`,
1080
- });
1081
- }
1082
- }
1083
- }
1084
- created = await destination.createPage({ container: parent, title: recoveryTitle, body: bootstrap.body });
1085
- created = await verifyConfirmedDestinationPage(destination, created, this.consistencyOptions);
1086
- assertDestinationContent(bootstrap.body, created.content, destination.kind, cachedPage);
1087
- }
1272
+ const allocation = await allocatePage(title);
1273
+ created = allocation.page;
1088
1274
  previousDestinationIds = [...new Set([...previousDestinationIds, mapped.id])];
1089
1275
  resourcesForState = resetResourceDestinations(saved.resources);
1090
1276
  commentsForState = undefined;
@@ -1155,27 +1341,7 @@ export class Migrator {
1155
1341
  });
1156
1342
  }
1157
1343
  else {
1158
- let replacement = await destination.findChild(parent, title);
1159
- const replacementMatches = replacement
1160
- && sourceDerivedTextMatches(bootstrap.body, replacement.content, destination.kind, cachedPage);
1161
- const titleConflict = replacementMatches ? undefined : replacement ?? await findTitleConflict(title);
1162
- if (titleConflict) {
1163
- const base = collisionTitle(page);
1164
- const hash = sha256(page.canonicalId).slice(0, 8);
1165
- title = base;
1166
- let sequence = 0;
1167
- while (await findTitleConflict(title)) {
1168
- sequence += 1;
1169
- title = `${base} [${hash}${sequence > 1 ? `-${sequence}` : ''}]`;
1170
- }
1171
- replacement = undefined;
1172
- }
1173
- created = replacement ?? await destination.createPage({ container: parent, title, body: bootstrap.body });
1174
- created = await verifyConfirmedDestinationPage(destination, created, this.consistencyOptions);
1175
- assertDestinationContent(bootstrap.body, created.content, destination.kind, cachedPage);
1176
- if (created.parentId !== parent.id) {
1177
- throw new Error(`Destination hierarchy verification failed for recreated page ${page.title}: expected parent ${parent.id}, received ${created.parentId ?? 'none'}`);
1178
- }
1344
+ created = (await allocatePage(title)).page;
1179
1345
  previousDestinationIds = [...new Set([...previousDestinationIds, oldPage.id])];
1180
1346
  resourcesForState = resetResourceDestinations(saved.resources);
1181
1347
  commentsForState = undefined;
@@ -1216,9 +1382,9 @@ export class Migrator {
1216
1382
  };
1217
1383
  }
1218
1384
  else {
1219
- const existingChild = await destination.findChild(parent, title);
1220
- if (existingChild && sourceDerivedTextMatches(bootstrap.body, existingChild.content, destination.kind, cachedPage)) {
1221
- created = existingChild;
1385
+ const allocation = await allocatePage(title);
1386
+ created = allocation.page;
1387
+ if (allocation.reused) {
1222
1388
  mappingRecovered = true;
1223
1389
  report.issue({
1224
1390
  code: 'destination-unmapped-page-recovered',
@@ -1229,22 +1395,6 @@ export class Migrator {
1229
1395
  message: `${page.title}: adopted an existing source-derived page below the expected parent`,
1230
1396
  });
1231
1397
  }
1232
- else {
1233
- const titleConflict = existingChild ?? await findTitleConflict(title);
1234
- if (titleConflict) {
1235
- const base = collisionTitle(page);
1236
- const hash = sha256(page.canonicalId).slice(0, 8);
1237
- title = base;
1238
- let sequence = 0;
1239
- while (await findTitleConflict(title)) {
1240
- sequence += 1;
1241
- title = `${base} [${hash}${sequence > 1 ? `-${sequence}` : ''}]`;
1242
- }
1243
- }
1244
- created = await destination.createPage({ container: parent, title, body: bootstrap.body });
1245
- created = await verifyConfirmedDestinationPage(destination, created, this.consistencyOptions);
1246
- assertDestinationContent(bootstrap.body, created.content, destination.kind, cachedPage);
1247
- }
1248
1398
  }
1249
1399
  if (!options.dryRun && created.parentId !== parent.id) {
1250
1400
  throw new Error(`Destination hierarchy verification failed for ${page.title}: expected parent ${parent.id}, received ${created.parentId ?? 'none'}`);
@@ -1277,10 +1427,16 @@ export class Migrator {
1277
1427
  throw options.signal.reason ?? error;
1278
1428
  const message = `${page.title}: ${error instanceof Error ? error.message : String(error)}`;
1279
1429
  const failureStatus = /conflict/i.test(message) ? 'conflict' : 'failed';
1280
- stagingFailures.set(page.canonicalId, { status: failureStatus, message });
1430
+ const failureCode = failureStatus === 'conflict' ? 'destination-conflict' : 'destination-page-staging-failed';
1431
+ stagingFailures.set(page.canonicalId, {
1432
+ status: failureStatus,
1433
+ message,
1434
+ blockedByPageId: page.canonicalId,
1435
+ blockedByCode: failureCode,
1436
+ });
1281
1437
  report.updatePage(page.canonicalId, { status: failureStatus, error: message });
1282
1438
  report.issue({
1283
- code: failureStatus === 'conflict' ? 'destination-conflict' : 'destination-page-staging-failed',
1439
+ code: failureCode,
1284
1440
  severity: 'error',
1285
1441
  phase: 'page',
1286
1442
  pageId: page.canonicalId,
@@ -1288,8 +1444,29 @@ export class Migrator {
1288
1444
  message,
1289
1445
  });
1290
1446
  const savedState = store.value.pages[page.canonicalId];
1291
- if (savedState)
1292
- await store.setPage({ ...savedState, status: failureStatus, lastError: message, updatedAt: new Date().toISOString() });
1447
+ if (!options.dryRun) {
1448
+ const cause = { blockedByPageId: page.canonicalId, blockedByCode: failureCode, blockedReason: message };
1449
+ const resources = resourcesByPage.get(page.canonicalId) ?? [];
1450
+ const comments = (options.comments ?? true)
1451
+ ? blockedCommentState(commentsByPage.get(page.canonicalId), savedState?.comments, cause)
1452
+ : undefined;
1453
+ await store.setPage({
1454
+ ...(savedState ?? {}),
1455
+ sourceId: page.id,
1456
+ sourceCanonicalId: page.canonicalId,
1457
+ sourceLocator: page.locator,
1458
+ sourceTitle: page.title,
1459
+ ...(page.projectedParentId ? { projectedParentId: page.projectedParentId } : {}),
1460
+ destinationTitle: savedState?.destinationTitle ?? page.title,
1461
+ sourceFingerprint: sourceFingerprints.get(page.canonicalId),
1462
+ status: failureStatus,
1463
+ resources: blockedResourceStates(resources, savedState?.resources ?? {}, cause),
1464
+ ...(comments ? { comments } : {}),
1465
+ degradations: savedState?.degradations ?? [],
1466
+ lastError: message,
1467
+ updatedAt: new Date().toISOString(),
1468
+ });
1469
+ }
1293
1470
  }
1294
1471
  finally {
1295
1472
  progress.staged += 1;
@@ -1307,16 +1484,25 @@ export class Migrator {
1307
1484
  if (!destinationPage) {
1308
1485
  const currentFailure = stagingFailures.get(page.canonicalId);
1309
1486
  const failedState = store.value.pages[page.canonicalId];
1487
+ const blockedBy = {
1488
+ blockedByPageId: currentFailure?.blockedByPageId ?? page.canonicalId,
1489
+ blockedByCode: currentFailure?.blockedByCode ?? 'destination-page-unavailable',
1490
+ blockedReason: currentFailure?.message ?? failedState?.lastError ?? 'Destination page with source-derived content was not created',
1491
+ };
1492
+ const commentState = failedState?.comments ?? ((options.comments ?? true)
1493
+ ? blockedCommentState(commentsByPage.get(page.canonicalId), undefined, blockedBy)
1494
+ : undefined);
1310
1495
  report.addPage(reportPage({
1311
1496
  page,
1312
1497
  status: currentFailure?.status ?? 'blocked',
1313
1498
  resources,
1314
1499
  document,
1315
1500
  ...(failedState?.resources ? { resourceStates: failedState.resources } : {}),
1316
- ...(options.dryRun ? { resourceStatusOverride: 'planned' } : {}),
1501
+ resourceStatusOverride: options.dryRun ? 'planned' : 'blocked',
1317
1502
  commentsEnabled: options.comments ?? true,
1318
1503
  ...(commentsByPage.get(page.canonicalId) ? { commentInventory: commentsByPage.get(page.canonicalId) } : {}),
1319
- ...(failedState?.comments ? { commentState: failedState.comments } : {}),
1504
+ ...(commentState ? { commentState } : {}),
1505
+ blockedBy,
1320
1506
  error: currentFailure?.message ?? failedState?.lastError ?? 'Destination page with source-derived content was not created',
1321
1507
  }));
1322
1508
  progress.pages += 1;
@@ -1418,6 +1604,7 @@ export class Migrator {
1418
1604
  sourceName: resource.name,
1419
1605
  destinationId: mappedRemote.id,
1420
1606
  destinationName: mappedRemote.name,
1607
+ ...(mappedRemote.url ? { destinationUrl: mappedRemote.url } : {}),
1421
1608
  sha256: downloaded.sha256,
1422
1609
  size: downloaded.size,
1423
1610
  cachePath: downloaded.path,
@@ -1441,6 +1628,7 @@ export class Migrator {
1441
1628
  sourceName: resource.name,
1442
1629
  destinationId: reconciled.id,
1443
1630
  destinationName: reconciled.name,
1631
+ ...(reconciled.url ? { destinationUrl: reconciled.url } : {}),
1444
1632
  sha256: downloaded.sha256,
1445
1633
  size: downloaded.size,
1446
1634
  cachePath: downloaded.path,
@@ -1504,6 +1692,7 @@ export class Migrator {
1504
1692
  sourceName: resource.name,
1505
1693
  destinationId: result.id,
1506
1694
  destinationName: result.name,
1695
+ ...(result.url ? { destinationUrl: result.url } : {}),
1507
1696
  sha256: downloaded.sha256,
1508
1697
  size: downloaded.size,
1509
1698
  cachePath: downloaded.path,