@iwonz/ywtc 4.0.0 → 4.1.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.
- package/CHANGELOG.md +27 -0
- package/README.md +47 -45
- package/dist/adapters/confluence.js +19 -4
- package/dist/adapters/confluence.js.map +1 -1
- package/dist/adapters/yandex.d.ts +7 -8
- package/dist/adapters/yandex.js +68 -22
- package/dist/adapters/yandex.js.map +1 -1
- package/dist/cli-arguments.d.ts +3 -2
- package/dist/cli-arguments.js +42 -37
- package/dist/cli-arguments.js.map +1 -1
- package/dist/cli.js +19 -142
- package/dist/cli.js.map +1 -1
- package/dist/clients/yandex-wiki.js +2 -6
- package/dist/clients/yandex-wiki.js.map +1 -1
- package/dist/consistency.d.ts +1 -1
- package/dist/consistency.js +6 -1
- package/dist/consistency.js.map +1 -1
- package/dist/content-model.d.ts +1 -0
- package/dist/content-model.js +118 -37
- package/dist/content-model.js.map +1 -1
- package/dist/conversion.d.ts +2 -0
- package/dist/conversion.js +42 -17
- package/dist/conversion.js.map +1 -1
- package/dist/domain.d.ts +22 -2
- package/dist/domain.js +10 -0
- package/dist/domain.js.map +1 -1
- package/dist/exclusive-lease.d.ts +14 -0
- package/dist/exclusive-lease.js +87 -0
- package/dist/exclusive-lease.js.map +1 -0
- package/dist/filesystem-export.d.ts +2 -0
- package/dist/filesystem-export.js +188 -167
- package/dist/filesystem-export.js.map +1 -1
- package/dist/graph.js +4 -4
- package/dist/graph.js.map +1 -1
- package/dist/http.js +1 -1
- package/dist/http.js.map +1 -1
- package/dist/interactive-config.js +55 -3
- package/dist/interactive-config.js.map +1 -1
- package/dist/interactive-init.d.ts +3 -3
- package/dist/interactive-init.js +7 -5
- package/dist/interactive-init.js.map +1 -1
- package/dist/interactive-migrate.d.ts +23 -0
- package/dist/interactive-migrate.js +578 -0
- package/dist/interactive-migrate.js.map +1 -0
- package/dist/issues.d.ts +1 -1
- package/dist/migration-command.d.ts +19 -0
- package/dist/migration-command.js +160 -0
- package/dist/migration-command.js.map +1 -0
- package/dist/migrator.js +439 -311
- package/dist/migrator.js.map +1 -1
- package/dist/report-catalog.d.ts +4 -0
- package/dist/report-catalog.js +4 -0
- package/dist/report-catalog.js.map +1 -1
- package/dist/report-contract.d.ts +6 -0
- package/dist/report-contract.js.map +1 -1
- package/dist/report-ui/index.html +19 -25
- package/dist/report.js +33 -22
- package/dist/report.js.map +1 -1
- package/dist/state.d.ts +16 -0
- package/dist/state.js +58 -32
- package/dist/state.js.map +1 -1
- package/dist/{init-prompts.d.ts → wizard-prompts.d.ts} +2 -2
- package/dist/{init-prompts.js → wizard-prompts.js} +3 -3
- package/dist/wizard-prompts.js.map +1 -0
- package/package.json +3 -7
- package/dist/init-prompts.js.map +0 -1
package/dist/migrator.js
CHANGED
|
@@ -8,7 +8,7 @@ import { secretValues } from './config.js';
|
|
|
8
8
|
import { appendGridSnapshots, assertDestinationContent, emptyRendererFallbackReason, normalizePageContent, normalizedPreviewHtml, rawContentFallback, renderedPreviewHtml, renderConfluenceStorage, renderYfm, setSourceResourceBinding, sourceResourceBindings, verifyDestinationContent, } from './content-model.js';
|
|
9
9
|
import { commentAttachmentUses, createCommentArchiveZip, renderCommentArchive, renderCommentArchiveIndex, resolveCommentAttachmentReferences, verifyCommentArchive, } from './comments.js';
|
|
10
10
|
import { ConfirmedDestinationMissingError, verifyConfirmedDestinationPage } from './consistency.js';
|
|
11
|
-
import { DestinationRevisionConflictError, DestinationTitleConflictError } from './domain.js';
|
|
11
|
+
import { DestinationOwnershipConflictError, DestinationRevisionConflictError, DestinationTitleConflictError } from './domain.js';
|
|
12
12
|
import { assertTransferCapabilities } from './domain.js';
|
|
13
13
|
import { FilesystemExporter } from './filesystem-export.js';
|
|
14
14
|
import { discoverPageGraph } from './graph.js';
|
|
@@ -115,13 +115,15 @@ async function revisionDelay(attempt, options) {
|
|
|
115
115
|
}
|
|
116
116
|
async function updateMappedPage(input) {
|
|
117
117
|
let current = input.current;
|
|
118
|
+
let title = input.title;
|
|
119
|
+
let retainedMappedTitle = false;
|
|
118
120
|
for (let attempt = 0;; attempt += 1) {
|
|
119
121
|
const verification = verifyDestinationContent(input.body, current.content, input.destination.kind, input.sourcePage);
|
|
120
|
-
if (verification.status === 'complete' && current.title ===
|
|
122
|
+
if (verification.status === 'complete' && current.title === title) {
|
|
121
123
|
return { page: current, updated: false, normalized: current.content !== input.body, verification };
|
|
122
124
|
}
|
|
123
125
|
try {
|
|
124
|
-
const result = await input.destination.updatePage({ page: current, title
|
|
126
|
+
const result = await input.destination.updatePage({ page: current, title, body: input.body });
|
|
125
127
|
const confirmed = await verifyConfirmedDestinationPage(input.destination, result, input.consistencyOptions);
|
|
126
128
|
return {
|
|
127
129
|
page: confirmed,
|
|
@@ -131,6 +133,19 @@ async function updateMappedPage(input) {
|
|
|
131
133
|
};
|
|
132
134
|
}
|
|
133
135
|
catch (error) {
|
|
136
|
+
if (error instanceof DestinationTitleConflictError && !retainedMappedTitle && current.title !== title) {
|
|
137
|
+
title = current.title;
|
|
138
|
+
retainedMappedTitle = true;
|
|
139
|
+
input.report.issue({
|
|
140
|
+
code: 'mapped-title-retained',
|
|
141
|
+
severity: 'normalization',
|
|
142
|
+
phase: 'page',
|
|
143
|
+
pageId: input.sourcePage.canonicalId,
|
|
144
|
+
retryable: false,
|
|
145
|
+
message: `${input.sourcePage.title}: retained mapped destination title "${current.title}" because the source title is occupied`,
|
|
146
|
+
});
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
134
149
|
if (!(error instanceof DestinationRevisionConflictError) || attempt >= REVISION_RETRY_DELAYS_MS.length)
|
|
135
150
|
throw error;
|
|
136
151
|
const delay = await revisionDelay(attempt, input.consistencyOptions);
|
|
@@ -241,22 +256,23 @@ async function createPageWithAllocatedTitle(input) {
|
|
|
241
256
|
throw new Error(`Confluence rejected all ${candidates.length} deterministic destination titles for ${input.sourcePage.title} because they are occupied`);
|
|
242
257
|
}
|
|
243
258
|
function destinationFromState(state, adapter) {
|
|
244
|
-
if (!state.destinationId
|
|
259
|
+
if (!state.destinationId) {
|
|
245
260
|
throw new Error(`Incomplete mapped destination for ${state.sourceTitle}`);
|
|
246
261
|
}
|
|
247
262
|
return {
|
|
248
263
|
provider: adapter.kind,
|
|
249
264
|
profile: adapter.profile.name,
|
|
250
265
|
id: state.destinationId,
|
|
251
|
-
canonicalId: state.destinationCanonicalId
|
|
266
|
+
canonicalId: state.destinationCanonicalId ?? `${adapter.profile.name}:${adapter.kind}:${state.destinationId}`,
|
|
252
267
|
title: state.destinationTitle,
|
|
253
|
-
url: state.destinationLocator,
|
|
268
|
+
url: state.destinationLocator ?? state.destinationId,
|
|
254
269
|
locator: state.destinationId,
|
|
255
270
|
...(state.destinationFingerprint ? { fingerprint: state.destinationFingerprint } : {}),
|
|
256
271
|
...(state.destinationVersion ? { version: state.destinationVersion } : {}),
|
|
257
272
|
};
|
|
258
273
|
}
|
|
259
274
|
function reportPage(input) {
|
|
275
|
+
const effectiveWarnings = input.warnings ?? input.document.warnings;
|
|
260
276
|
const inventory = input.commentInventory;
|
|
261
277
|
const commentResourceIds = new Set();
|
|
262
278
|
for (const thread of inventory?.threads ?? []) {
|
|
@@ -359,11 +375,14 @@ function reportPage(input) {
|
|
|
359
375
|
tables: input.document.metrics.tables,
|
|
360
376
|
grids: input.resources.filter((resource) => resource.kind === 'grid').length,
|
|
361
377
|
codeBlocks: input.document.metrics.codeBlocks,
|
|
362
|
-
degraded:
|
|
378
|
+
degraded: effectiveWarnings.length > 0 || (input.resourceFallbacks?.length ?? 0) > 0,
|
|
363
379
|
resourceFallbacks: input.resourceFallbacks ?? [],
|
|
364
|
-
warnings: [...new Set(
|
|
380
|
+
warnings: [...new Set(effectiveWarnings)],
|
|
365
381
|
normalizations: [...new Set([...(input.document.normalizations ?? []), ...(input.normalizations ?? [])])],
|
|
382
|
+
adaptations: [...new Set([...(input.document.adaptations ?? []), ...(input.adaptations ?? [])])],
|
|
383
|
+
preservedExternalReferences: input.preservedExternalReferences ?? input.document.metrics.preservedExternalReferences,
|
|
366
384
|
...(input.error ? { error: input.error } : {}),
|
|
385
|
+
...(input.conflictEvidence ? { conflictEvidence: input.conflictEvidence } : {}),
|
|
367
386
|
...(input.status === 'blocked' && input.blockedBy ? input.blockedBy : {}),
|
|
368
387
|
comments: {
|
|
369
388
|
status: commentStatus,
|
|
@@ -423,13 +442,14 @@ function runStatus(errors, warnings, degradations) {
|
|
|
423
442
|
return 'partial';
|
|
424
443
|
return warnings + degradations > 0 ? 'completed-with-warnings' : 'completed';
|
|
425
444
|
}
|
|
426
|
-
function attentionCount(summary) {
|
|
427
|
-
return summary.warnings + summary.degradations;
|
|
428
|
-
}
|
|
429
445
|
function runOutcomeCounts(summary, pages) {
|
|
430
446
|
return {
|
|
431
447
|
pages,
|
|
432
|
-
warnings:
|
|
448
|
+
warnings: summary.warnings,
|
|
449
|
+
degradations: summary.degradations,
|
|
450
|
+
adaptations: summary.adaptations,
|
|
451
|
+
normalizations: summary.normalizations,
|
|
452
|
+
preservedExternalReferences: summary.preservedExternalReferences,
|
|
433
453
|
errors: summary.errors,
|
|
434
454
|
rootErrors: summary.rootErrors,
|
|
435
455
|
dependentPages: summary.dependentPages,
|
|
@@ -501,277 +521,282 @@ export class Migrator {
|
|
|
501
521
|
});
|
|
502
522
|
const store = new StateStore(stateLocation.filename);
|
|
503
523
|
await store.load(identity, { fresh: options.fresh ?? false, dryRun: options.dryRun });
|
|
504
|
-
if (!options.dryRun)
|
|
505
|
-
await store.save();
|
|
506
|
-
const run = await store.beginRun(options.scope, options.dryRun, sourceStatus, commentsEnabled);
|
|
507
|
-
const report = new TransferReport({
|
|
508
|
-
secrets: secretValues(this.config),
|
|
509
|
-
dryRun: options.dryRun,
|
|
510
|
-
commentsEnabled,
|
|
511
|
-
scope: options.scope,
|
|
512
|
-
sourceStatus,
|
|
513
|
-
direction: `${source.kind} → ${options.destination.type === 'directory' ? options.destination.format : destination.kind}`,
|
|
514
|
-
source: { provider: source.kind, profile: source.profile.name, rootId: root.id, title: root.title, url: root.url },
|
|
515
|
-
destination: options.destination.type === 'directory'
|
|
516
|
-
? { provider: 'filesystem', id: options.destination.path, format: options.destination.format }
|
|
517
|
-
: { provider: destination.kind, profile: destination.profile.name, id: container.id, url: container.url },
|
|
518
|
-
state: {
|
|
519
|
-
id: stateLocation.id,
|
|
520
|
-
path: stateLocation.filename,
|
|
521
|
-
mode: stateLocation.mode,
|
|
522
|
-
source: store.loadInfo.source,
|
|
523
|
-
...(store.loadInfo.backupFilename ? { backupPath: store.loadInfo.backupFilename } : {}),
|
|
524
|
-
},
|
|
525
|
-
});
|
|
526
|
-
report.registerPages([{ ...root, depth: 0 }]);
|
|
527
|
-
let graph;
|
|
528
|
-
let sourceFingerprints;
|
|
529
524
|
try {
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
525
|
+
if (!options.dryRun)
|
|
526
|
+
await store.save();
|
|
527
|
+
const run = await store.beginRun(options.scope, options.dryRun, sourceStatus, commentsEnabled);
|
|
528
|
+
const report = new TransferReport({
|
|
529
|
+
secrets: secretValues(this.config),
|
|
530
|
+
dryRun: options.dryRun,
|
|
531
|
+
commentsEnabled,
|
|
534
532
|
scope: options.scope,
|
|
535
533
|
sourceStatus,
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
&& graph.pages.some((page) => page.id === container.id)) {
|
|
549
|
-
throw new Error('The destination page belongs to the selected source graph. Choose a destination outside the transferred graph.');
|
|
550
|
-
}
|
|
551
|
-
status(`Source tree: root ${root.title} · ${graph.selection.returnedDescendants} descendants returned · ${graph.selection.selectedDescendants} selected · ${graph.selection.excluded.length} excluded · depth ${graph.selection.maximumDepth}`);
|
|
552
|
-
status(`Caching source content for ${graph.pages.length} pages`);
|
|
553
|
-
sourceFingerprints = await cacheDiscoveredPages(options.cacheDirectory, graph);
|
|
554
|
-
await store.setGraph(graph.rootId, graph.edges);
|
|
555
|
-
}
|
|
556
|
-
catch (error) {
|
|
557
|
-
const finalStatus = options.signal?.aborted ? 'cancelled' : 'failed';
|
|
558
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
559
|
-
report.updatePage(root.canonicalId, { status: 'failed', inventoryStatus: 'failed', error: message });
|
|
560
|
-
report.issue({
|
|
561
|
-
code: 'source-discovery-failed',
|
|
562
|
-
severity: 'error',
|
|
563
|
-
phase: 'discovery',
|
|
564
|
-
pageId: root.canonicalId,
|
|
565
|
-
retryable: true,
|
|
566
|
-
message,
|
|
534
|
+
direction: `${source.kind} → ${options.destination.type === 'directory' ? options.destination.format : destination.kind}`,
|
|
535
|
+
source: { provider: source.kind, profile: source.profile.name, rootId: root.id, title: root.title, url: root.url },
|
|
536
|
+
destination: options.destination.type === 'directory'
|
|
537
|
+
? { provider: 'filesystem', id: options.destination.path, format: options.destination.format }
|
|
538
|
+
: { provider: destination.kind, profile: destination.profile.name, id: container.id, url: container.url },
|
|
539
|
+
state: {
|
|
540
|
+
id: stateLocation.id,
|
|
541
|
+
path: stateLocation.filename,
|
|
542
|
+
mode: stateLocation.mode,
|
|
543
|
+
source: store.loadInfo.source,
|
|
544
|
+
...(store.loadInfo.backupFilename ? { backupPath: store.loadInfo.backupFilename } : {}),
|
|
545
|
+
},
|
|
567
546
|
});
|
|
568
|
-
report.
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, 0));
|
|
572
|
-
throw new MigrationRunError(message, reportPaths, stateLocation.filename, { cause: error });
|
|
573
|
-
}
|
|
574
|
-
const resourcesByPage = new Map();
|
|
575
|
-
const commentsByPage = new Map();
|
|
576
|
-
const inventoryQueue = new PQueue({ concurrency: options.concurrency });
|
|
577
|
-
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 };
|
|
578
|
-
status(`Inspecting content and resources for ${graph.pages.length} pages`);
|
|
579
|
-
const inventoryErrors = [];
|
|
580
|
-
await Promise.all(graph.pages.map((page) => inventoryQueue.add(async () => {
|
|
547
|
+
report.registerPages([{ ...root, depth: 0 }]);
|
|
548
|
+
let graph;
|
|
549
|
+
let sourceFingerprints;
|
|
581
550
|
try {
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
code: `comment-inventory-warning:${warning}`,
|
|
603
|
-
severity: 'warning',
|
|
604
|
-
phase: 'inventory',
|
|
605
|
-
pageId: page.canonicalId,
|
|
606
|
-
retryable: false,
|
|
607
|
-
message: `${page.title}: ${warning}`,
|
|
608
|
-
});
|
|
609
|
-
}
|
|
610
|
-
catch (error) {
|
|
611
|
-
const message = `${page.title}: comments could not be read: ${error instanceof Error ? error.message : String(error)}`;
|
|
612
|
-
commentInventory = { status: 'failed', threads: [], comments: 0, participants: 0, attachments: 0, knownBytes: 0, unknownSizes: 0, warnings: [], error: message };
|
|
613
|
-
commentsByPage.set(page.canonicalId, commentInventory);
|
|
614
|
-
report.issue({ code: 'comment-inventory-failed', severity: 'error', phase: 'inventory', pageId: page.canonicalId, retryable: true, message });
|
|
615
|
-
}
|
|
551
|
+
status(`Discovering ${options.scope} scope from ${root.title}`);
|
|
552
|
+
graph = await discoverPageGraph({
|
|
553
|
+
adapter: source,
|
|
554
|
+
root,
|
|
555
|
+
scope: options.scope,
|
|
556
|
+
sourceStatus,
|
|
557
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
558
|
+
onProgress: (progress) => status(`Discovery: ${progress.pages} pages, ${progress.edges} links${progress.active ? ` · ${progress.active}` : ''}`),
|
|
559
|
+
});
|
|
560
|
+
report.registerPages(graph.pages);
|
|
561
|
+
report.setEdges(graph.edges);
|
|
562
|
+
report.setSelection(graph.selection);
|
|
563
|
+
await store.setRunSelection(run.id, graph.selection);
|
|
564
|
+
for (const warning of graph.warnings)
|
|
565
|
+
report.warning(warning);
|
|
566
|
+
if (container
|
|
567
|
+
&& options.destination.type === 'remote'
|
|
568
|
+
&& sameRemoteInstance(options.source.profile, options.destination.profile)
|
|
569
|
+
&& graph.pages.some((page) => page.id === container.id)) {
|
|
570
|
+
throw new Error('The destination page belongs to the selected source graph. Choose a destination outside the transferred graph.');
|
|
616
571
|
}
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
inventory.unknownSizes += resources.filter((resource) => resource.kind !== 'grid' && resource.size === undefined).length;
|
|
622
|
-
inventory.grids += resources.filter((resource) => resource.kind === 'grid').length;
|
|
623
|
-
inventory.links += document.metrics.links;
|
|
624
|
-
inventory.images += document.metrics.images;
|
|
625
|
-
inventory.tables += document.metrics.tables;
|
|
626
|
-
inventory.codeBlocks += document.metrics.codeBlocks;
|
|
627
|
-
report.addPage(reportPage({
|
|
628
|
-
page,
|
|
629
|
-
status: 'planned',
|
|
630
|
-
resources,
|
|
631
|
-
document,
|
|
632
|
-
commentsEnabled,
|
|
633
|
-
...(commentInventory ? { commentInventory } : {}),
|
|
634
|
-
}), undefined, false);
|
|
635
|
-
status(`Inspected ${page.title} · ${resources.length} resources`);
|
|
572
|
+
status(`Source tree: root ${root.title} · ${graph.selection.returnedDescendants} descendants returned · ${graph.selection.selectedDescendants} selected · ${graph.selection.excluded.length} excluded · depth ${graph.selection.maximumDepth}`);
|
|
573
|
+
status(`Caching source content for ${graph.pages.length} pages`);
|
|
574
|
+
sourceFingerprints = await cacheDiscoveredPages(options.cacheDirectory, graph);
|
|
575
|
+
await store.setGraph(graph.rootId, graph.edges);
|
|
636
576
|
}
|
|
637
577
|
catch (error) {
|
|
638
|
-
const
|
|
639
|
-
|
|
640
|
-
report.updatePage(
|
|
578
|
+
const finalStatus = options.signal?.aborted ? 'cancelled' : 'failed';
|
|
579
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
580
|
+
report.updatePage(root.canonicalId, { status: 'failed', inventoryStatus: 'failed', error: message });
|
|
641
581
|
report.issue({
|
|
642
|
-
code: 'source-
|
|
582
|
+
code: 'source-discovery-failed',
|
|
643
583
|
severity: 'error',
|
|
644
|
-
phase: '
|
|
645
|
-
pageId:
|
|
584
|
+
phase: 'discovery',
|
|
585
|
+
pageId: root.canonicalId,
|
|
646
586
|
retryable: true,
|
|
647
587
|
message,
|
|
648
588
|
});
|
|
589
|
+
report.finish(finalStatus);
|
|
590
|
+
const reportPaths = await report.write(options.reportsDirectory);
|
|
591
|
+
if (!options.dryRun)
|
|
592
|
+
await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, 0));
|
|
593
|
+
throw new MigrationRunError(message, reportPaths, stateLocation.filename, { cause: error });
|
|
649
594
|
}
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
const
|
|
654
|
-
|
|
655
|
-
const
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
595
|
+
const resourcesByPage = new Map();
|
|
596
|
+
const commentsByPage = new Map();
|
|
597
|
+
const inventoryQueue = new PQueue({ concurrency: options.concurrency });
|
|
598
|
+
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 };
|
|
599
|
+
status(`Inspecting content and resources for ${graph.pages.length} pages`);
|
|
600
|
+
const inventoryErrors = [];
|
|
601
|
+
await Promise.all(graph.pages.map((page) => inventoryQueue.add(async () => {
|
|
602
|
+
try {
|
|
603
|
+
if (options.signal?.aborted)
|
|
604
|
+
throw options.signal.reason ?? new Error('Transfer cancelled');
|
|
605
|
+
const cachedPage = await loadCachedPage(options.cacheDirectory, page.canonicalId);
|
|
606
|
+
const resources = await source.listResources(page);
|
|
607
|
+
resourcesByPage.set(page.canonicalId, resources);
|
|
608
|
+
let commentInventory;
|
|
609
|
+
if (commentsEnabled) {
|
|
610
|
+
try {
|
|
611
|
+
if (!source.readComments)
|
|
612
|
+
throw new Error(`${source.profile.name} does not implement comment reads`);
|
|
613
|
+
commentInventory = await source.readComments(cachedPage);
|
|
614
|
+
resolveCommentAttachmentReferences(commentInventory, resources);
|
|
615
|
+
commentsByPage.set(page.canonicalId, commentInventory);
|
|
616
|
+
inventory.commentThreads += commentInventory.threads.length;
|
|
617
|
+
inventory.comments += commentInventory.comments;
|
|
618
|
+
inventory.commentParticipants += commentInventory.participants;
|
|
619
|
+
inventory.commentAttachments += commentInventory.attachments;
|
|
620
|
+
inventory.commentBytes += commentInventory.knownBytes;
|
|
621
|
+
for (const warning of commentInventory.warnings)
|
|
622
|
+
report.issue({
|
|
623
|
+
code: `comment-inventory-warning:${warning}`,
|
|
624
|
+
severity: 'warning',
|
|
625
|
+
phase: 'inventory',
|
|
626
|
+
pageId: page.canonicalId,
|
|
627
|
+
retryable: false,
|
|
628
|
+
message: `${page.title}: ${warning}`,
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
catch (error) {
|
|
632
|
+
const message = `${page.title}: comments could not be read: ${error instanceof Error ? error.message : String(error)}`;
|
|
633
|
+
commentInventory = { status: 'failed', threads: [], comments: 0, participants: 0, attachments: 0, knownBytes: 0, unknownSizes: 0, warnings: [], error: message };
|
|
634
|
+
commentsByPage.set(page.canonicalId, commentInventory);
|
|
635
|
+
report.issue({ code: 'comment-inventory-failed', severity: 'error', phase: 'inventory', pageId: page.canonicalId, retryable: true, message });
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
const document = appendGridSnapshots(normalizePageContent(cachedPage), resources);
|
|
639
|
+
await cacheDocument(options.cacheDirectory, page.canonicalId, document);
|
|
640
|
+
inventory.resources += resources.filter((resource) => resource.kind !== 'grid').length;
|
|
641
|
+
inventory.bytes += resources.reduce((total, resource) => total + (resource.size ?? 0), 0);
|
|
642
|
+
inventory.unknownSizes += resources.filter((resource) => resource.kind !== 'grid' && resource.size === undefined).length;
|
|
643
|
+
inventory.grids += resources.filter((resource) => resource.kind === 'grid').length;
|
|
644
|
+
inventory.links += document.metrics.links;
|
|
645
|
+
inventory.images += document.metrics.images;
|
|
646
|
+
inventory.tables += document.metrics.tables;
|
|
647
|
+
inventory.codeBlocks += document.metrics.codeBlocks;
|
|
648
|
+
report.addPage(reportPage({
|
|
649
|
+
page,
|
|
650
|
+
status: 'planned',
|
|
651
|
+
resources,
|
|
652
|
+
document,
|
|
653
|
+
commentsEnabled,
|
|
654
|
+
...(commentInventory ? { commentInventory } : {}),
|
|
655
|
+
}), undefined, false);
|
|
656
|
+
status(`Inspected ${page.title} · ${resources.length} resources`);
|
|
657
|
+
}
|
|
658
|
+
catch (error) {
|
|
659
|
+
const message = `${page.title}: ${error instanceof Error ? error.message : String(error)}`;
|
|
660
|
+
inventoryErrors.push(message);
|
|
661
|
+
report.updatePage(page.canonicalId, { status: 'failed', inventoryStatus: 'failed', error: message });
|
|
662
|
+
report.issue({
|
|
663
|
+
code: 'source-inventory-failed',
|
|
664
|
+
severity: 'error',
|
|
665
|
+
phase: 'inventory',
|
|
666
|
+
pageId: page.canonicalId,
|
|
667
|
+
retryable: true,
|
|
668
|
+
message,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
})));
|
|
672
|
+
await store.setRunComments(run.id, { threads: inventory.commentThreads, comments: inventory.comments, attachments: inventory.commentAttachments });
|
|
673
|
+
if (inventoryErrors.length > 0) {
|
|
674
|
+
const finalStatus = options.signal?.aborted ? 'cancelled' : 'failed';
|
|
675
|
+
report.finish(finalStatus);
|
|
676
|
+
const reportPaths = await report.write(options.reportsDirectory);
|
|
677
|
+
if (!options.dryRun)
|
|
678
|
+
await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, graph.pages.length));
|
|
679
|
+
throw new MigrationRunError('Resource preflight failed before destination mutation', reportPaths, stateLocation.filename);
|
|
680
|
+
}
|
|
681
|
+
if (!options.dryRun) {
|
|
682
|
+
for (const page of graph.pages) {
|
|
683
|
+
if (store.value.pages[page.canonicalId])
|
|
684
|
+
continue;
|
|
685
|
+
const commentInventory = commentsByPage.get(page.canonicalId);
|
|
686
|
+
const commentState = !commentsEnabled
|
|
687
|
+
? { status: 'skipped', threads: 0, comments: 0, participants: 0, attachments: 0, bytes: 0, updatedAt: new Date().toISOString() }
|
|
688
|
+
: commentInventory?.status === 'empty'
|
|
689
|
+
? { status: 'complete', threads: 0, comments: 0, participants: 0, attachments: 0, bytes: 0, updatedAt: new Date().toISOString() }
|
|
690
|
+
: commentInventory?.status === 'failed'
|
|
675
691
|
? {
|
|
676
|
-
status: '
|
|
677
|
-
|
|
678
|
-
participants: commentInventory.participants, attachments: commentInventory.attachments,
|
|
679
|
-
bytes: commentInventory.knownBytes, updatedAt: new Date().toISOString(),
|
|
692
|
+
status: 'failed', threads: 0, comments: 0, participants: 0, attachments: 0, bytes: 0,
|
|
693
|
+
...(commentInventory.error ? { lastError: commentInventory.error } : {}), updatedAt: new Date().toISOString(),
|
|
680
694
|
}
|
|
681
|
-
:
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
695
|
+
: commentInventory
|
|
696
|
+
? {
|
|
697
|
+
status: 'planned', ...(commentInventory.fingerprint ? { fingerprint: commentInventory.fingerprint } : {}),
|
|
698
|
+
threads: commentInventory.threads.length, comments: commentInventory.comments,
|
|
699
|
+
participants: commentInventory.participants, attachments: commentInventory.attachments,
|
|
700
|
+
bytes: commentInventory.knownBytes, updatedAt: new Date().toISOString(),
|
|
701
|
+
}
|
|
702
|
+
: undefined;
|
|
703
|
+
const resourceStates = Object.fromEntries((resourcesByPage.get(page.canonicalId) ?? [])
|
|
704
|
+
.filter((resource) => resource.kind !== 'grid')
|
|
705
|
+
.map((resource) => [resource.id, {
|
|
706
|
+
sourceId: resource.id,
|
|
707
|
+
sourceName: resource.name,
|
|
708
|
+
destinationName: preferredDestinationName(resource),
|
|
709
|
+
...(resource.size !== undefined ? { size: resource.size } : {}),
|
|
710
|
+
...(resource.mimeType ? { mimeType: resource.mimeType } : {}),
|
|
711
|
+
status: 'planned',
|
|
712
|
+
updatedAt: new Date().toISOString(),
|
|
713
|
+
}]));
|
|
714
|
+
await store.setPage({
|
|
715
|
+
sourceId: page.id,
|
|
716
|
+
sourceCanonicalId: page.canonicalId,
|
|
717
|
+
sourceLocator: page.locator,
|
|
718
|
+
sourceTitle: page.title,
|
|
719
|
+
...(page.projectedParentId ? { projectedParentId: page.projectedParentId } : {}),
|
|
720
|
+
destinationTitle: page.title,
|
|
721
|
+
sourceFingerprint: sourceFingerprints.get(page.canonicalId),
|
|
722
|
+
status: 'discovered',
|
|
723
|
+
resources: resourceStates,
|
|
724
|
+
...(commentState ? { comments: commentState } : {}),
|
|
725
|
+
degradations: [],
|
|
691
726
|
updatedAt: new Date().toISOString(),
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
sourceId: page.id,
|
|
695
|
-
sourceCanonicalId: page.canonicalId,
|
|
696
|
-
sourceLocator: page.locator,
|
|
697
|
-
sourceTitle: page.title,
|
|
698
|
-
...(page.projectedParentId ? { projectedParentId: page.projectedParentId } : {}),
|
|
699
|
-
destinationTitle: page.title,
|
|
700
|
-
sourceFingerprint: sourceFingerprints.get(page.canonicalId),
|
|
701
|
-
status: 'discovered',
|
|
702
|
-
resources: resourceStates,
|
|
703
|
-
...(commentState ? { comments: commentState } : {}),
|
|
704
|
-
degradations: [],
|
|
705
|
-
updatedAt: new Date().toISOString(),
|
|
706
|
-
});
|
|
727
|
+
});
|
|
728
|
+
}
|
|
707
729
|
}
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
730
|
+
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'}`;
|
|
731
|
+
status(planSummary);
|
|
732
|
+
this.logger.info(planSummary);
|
|
733
|
+
let output;
|
|
734
|
+
let archive;
|
|
735
|
+
try {
|
|
736
|
+
if (options.destination.type === 'directory') {
|
|
737
|
+
const result = await this.exportFilesystem(source, graph, options.destination, options, stateLocation.id, store, report, sourceFingerprints, resourcesByPage, commentsByPage);
|
|
738
|
+
output = result.directory;
|
|
739
|
+
archive = result.archive;
|
|
740
|
+
const exportEntry = path.join(result.directory, options.destination.format === 'html' ? 'index.html' : 'index.md');
|
|
741
|
+
report.setDestinationUrl(pathToFileURL(exportEntry).toString());
|
|
742
|
+
report.output('directory', result.directory);
|
|
743
|
+
if (result.archive)
|
|
744
|
+
report.output('archive', result.archive);
|
|
745
|
+
if (result.backup)
|
|
746
|
+
report.output('backup', result.backup);
|
|
747
|
+
}
|
|
748
|
+
else {
|
|
749
|
+
await this.transferRemote({
|
|
750
|
+
source,
|
|
751
|
+
destination: destination,
|
|
752
|
+
container: container,
|
|
753
|
+
graph,
|
|
754
|
+
options,
|
|
755
|
+
store,
|
|
756
|
+
report,
|
|
757
|
+
sourceFingerprints,
|
|
758
|
+
resourcesByPage,
|
|
759
|
+
commentsByPage,
|
|
760
|
+
});
|
|
761
|
+
output = container.url;
|
|
762
|
+
report.output('destination', container.url);
|
|
763
|
+
}
|
|
764
|
+
report.finish('completed');
|
|
765
|
+
const finalStatus = runStatus(report.data.summary.errors, report.data.summary.warnings, report.data.summary.degradations);
|
|
766
|
+
await store.finalizeOutcomes(graph.pages.map((page) => page.canonicalId), finalStatus, options.dryRun);
|
|
767
|
+
report.finish(finalStatus);
|
|
768
|
+
const reportPaths = await report.write(options.reportsDirectory);
|
|
769
|
+
if (!options.dryRun)
|
|
770
|
+
await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, graph.pages.length));
|
|
771
|
+
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`);
|
|
772
|
+
return {
|
|
773
|
+
status: finalStatus,
|
|
774
|
+
hasErrors: report.data.summary.errors > 0,
|
|
775
|
+
reportPaths,
|
|
776
|
+
stateId: stateLocation.id,
|
|
777
|
+
stateFilename: stateLocation.filename,
|
|
778
|
+
...(store.loadInfo.backupFilename ? { stateBackupFilename: store.loadInfo.backupFilename } : {}),
|
|
779
|
+
...(output ? { output } : {}),
|
|
780
|
+
...(archive ? { archive } : {}),
|
|
781
|
+
summary: report.data.summary,
|
|
782
|
+
};
|
|
726
783
|
}
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
});
|
|
740
|
-
output = container.url;
|
|
741
|
-
report.output('destination', container.url);
|
|
784
|
+
catch (error) {
|
|
785
|
+
const cancelled = options.signal?.aborted;
|
|
786
|
+
const finalStatus = cancelled ? 'cancelled' : 'failed';
|
|
787
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
788
|
+
report.error(message);
|
|
789
|
+
await store.finalizeOutcomes(graph.pages.map((page) => page.canonicalId), finalStatus, options.dryRun);
|
|
790
|
+
report.finish(finalStatus);
|
|
791
|
+
const reportPaths = await report.write(options.reportsDirectory);
|
|
792
|
+
if (!options.dryRun)
|
|
793
|
+
await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, graph.pages.length));
|
|
794
|
+
this.logger.error(`${message}. Report: ${reportPaths.html}`);
|
|
795
|
+
throw new MigrationRunError(message, reportPaths, stateLocation.filename, { cause: error });
|
|
742
796
|
}
|
|
743
|
-
report.finish('completed');
|
|
744
|
-
const finalStatus = runStatus(report.data.summary.errors, report.data.summary.warnings, report.data.summary.degradations);
|
|
745
|
-
await store.finalizeOutcomes(graph.pages.map((page) => page.canonicalId), finalStatus, options.dryRun);
|
|
746
|
-
report.finish(finalStatus);
|
|
747
|
-
const reportPaths = await report.write(options.reportsDirectory);
|
|
748
|
-
if (!options.dryRun)
|
|
749
|
-
await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, graph.pages.length));
|
|
750
|
-
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`);
|
|
751
|
-
return {
|
|
752
|
-
status: finalStatus,
|
|
753
|
-
hasErrors: report.data.summary.errors > 0,
|
|
754
|
-
reportPaths,
|
|
755
|
-
stateId: stateLocation.id,
|
|
756
|
-
stateFilename: stateLocation.filename,
|
|
757
|
-
...(store.loadInfo.backupFilename ? { stateBackupFilename: store.loadInfo.backupFilename } : {}),
|
|
758
|
-
...(output ? { output } : {}),
|
|
759
|
-
...(archive ? { archive } : {}),
|
|
760
|
-
summary: report.data.summary,
|
|
761
|
-
};
|
|
762
797
|
}
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
const finalStatus = cancelled ? 'cancelled' : 'failed';
|
|
766
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
767
|
-
report.error(message);
|
|
768
|
-
await store.finalizeOutcomes(graph.pages.map((page) => page.canonicalId), finalStatus, options.dryRun);
|
|
769
|
-
report.finish(finalStatus);
|
|
770
|
-
const reportPaths = await report.write(options.reportsDirectory);
|
|
771
|
-
if (!options.dryRun)
|
|
772
|
-
await store.finishRun(run.id, finalStatus, runOutcomeCounts(report.data.summary, graph.pages.length));
|
|
773
|
-
this.logger.error(`${message}. Report: ${reportPaths.html}`);
|
|
774
|
-
throw new MigrationRunError(message, reportPaths, stateLocation.filename, { cause: error });
|
|
798
|
+
finally {
|
|
799
|
+
await store.release();
|
|
775
800
|
}
|
|
776
801
|
}
|
|
777
802
|
async exportFilesystem(source, graph, destination, options, identity, store, report, sourceFingerprints, resourcesByPage, commentsByPage) {
|
|
@@ -836,6 +861,7 @@ export class Migrator {
|
|
|
836
861
|
status: pageStatus,
|
|
837
862
|
resources,
|
|
838
863
|
degradations: manifestPage?.warnings ?? [],
|
|
864
|
+
adaptations: manifestPage?.adaptations ?? [],
|
|
839
865
|
...(manifestPage?.comments ? { comments: {
|
|
840
866
|
status: manifestPage.comments.status,
|
|
841
867
|
...(manifestPage.comments.fingerprint ? { fingerprint: manifestPage.comments.fingerprint } : {}),
|
|
@@ -867,6 +893,8 @@ export class Migrator {
|
|
|
867
893
|
resourceStates: resources,
|
|
868
894
|
...(options.dryRun ? { resourceStatusOverride: 'planned' } : {}),
|
|
869
895
|
...(manifestPage?.warnings ? { warnings: manifestPage.warnings } : {}),
|
|
896
|
+
...(manifestPage?.adaptations ? { adaptations: manifestPage.adaptations } : {}),
|
|
897
|
+
preservedExternalReferences: manifestPage?.preservedExternalReferences ?? 0,
|
|
870
898
|
commentsEnabled: options.comments ?? true,
|
|
871
899
|
...(commentsByPage.get(page.canonicalId) ? { commentInventory: commentsByPage.get(page.canonicalId) } : {}),
|
|
872
900
|
...(manifestPage?.comments ? { commentState: {
|
|
@@ -986,7 +1014,7 @@ export class Migrator {
|
|
|
986
1014
|
}
|
|
987
1015
|
}
|
|
988
1016
|
current ??= await destination.findArchiveComment?.(destinationPage, fingerprint);
|
|
989
|
-
|
|
1017
|
+
let remoteResources = await destination.listUploadedResources(destinationPage);
|
|
990
1018
|
const remoteMatches = async (remote, file, trustedMapping) => {
|
|
991
1019
|
if (remote.size !== undefined && remote.size !== file.size)
|
|
992
1020
|
return false;
|
|
@@ -1010,18 +1038,37 @@ export class Migrator {
|
|
|
1010
1038
|
if (reusable)
|
|
1011
1039
|
return reusable;
|
|
1012
1040
|
const extension = path.extname(file.name);
|
|
1013
|
-
const
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1041
|
+
const base = file.name.slice(0, file.name.length - extension.length);
|
|
1042
|
+
let lastError;
|
|
1043
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
1044
|
+
const uploadName = attempt === 0 && !mapped && !byName
|
|
1045
|
+
? file.name
|
|
1046
|
+
: `${base}-${file.sha256.slice(0, Math.min(file.sha256.length, 8 + attempt * 2))}${extension}`;
|
|
1047
|
+
const occupied = remoteResources.find((resource) => resource.name.toLocaleLowerCase() === uploadName.toLocaleLowerCase());
|
|
1048
|
+
if (occupied && await remoteMatches(occupied, file, false))
|
|
1049
|
+
return occupied;
|
|
1050
|
+
if (occupied)
|
|
1051
|
+
continue;
|
|
1052
|
+
try {
|
|
1053
|
+
const uploaded = await destination.uploadResource({
|
|
1054
|
+
page: destinationPage,
|
|
1055
|
+
name: uploadName,
|
|
1056
|
+
filePath: file.path,
|
|
1057
|
+
size: file.size,
|
|
1058
|
+
...(mimeType ? { mimeType } : {}),
|
|
1059
|
+
});
|
|
1060
|
+
remoteResources.push(uploaded);
|
|
1061
|
+
return uploaded;
|
|
1062
|
+
}
|
|
1063
|
+
catch (error) {
|
|
1064
|
+
lastError = error;
|
|
1065
|
+
remoteResources = await destination.listUploadedResources(destinationPage);
|
|
1066
|
+
const uncertain = remoteResources.find((resource) => resource.name.toLocaleLowerCase() === uploadName.toLocaleLowerCase());
|
|
1067
|
+
if (uncertain && await remoteMatches(uncertain, file, false))
|
|
1068
|
+
return uncertain;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
throw new Error(`Archive upload could not be reconciled after 10 deterministic names: ${lastError instanceof Error ? lastError.message : String(lastError ?? 'unknown provider response')}`);
|
|
1025
1072
|
};
|
|
1026
1073
|
const archiveResource = zip
|
|
1027
1074
|
? await uploadArchive(zip, previous?.archiveResourceId, 'application/zip')
|
|
@@ -1209,6 +1256,17 @@ export class Migrator {
|
|
|
1209
1256
|
}
|
|
1210
1257
|
if (!verification.ok)
|
|
1211
1258
|
throw new Error(`Comment verification failed: ${verification.differences.join('; ')}`);
|
|
1259
|
+
if (fallbackReason) {
|
|
1260
|
+
report.issue({
|
|
1261
|
+
code: 'comment-presentation-adapted',
|
|
1262
|
+
severity: 'adaptation',
|
|
1263
|
+
phase: 'comment',
|
|
1264
|
+
pageId: page.canonicalId,
|
|
1265
|
+
commentId: verified.id,
|
|
1266
|
+
retryable: false,
|
|
1267
|
+
message: `${page.title}: ${fallbackReason}`,
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1212
1270
|
const previousIds = result.id !== current?.id && current
|
|
1213
1271
|
? [...new Set([...(previous?.previousDestinationIds ?? []), current.id])]
|
|
1214
1272
|
: previous?.previousDestinationIds;
|
|
@@ -1221,10 +1279,9 @@ export class Migrator {
|
|
|
1221
1279
|
...(previousIds?.length ? { previousDestinationIds: previousIds } : {}),
|
|
1222
1280
|
...archiveState,
|
|
1223
1281
|
...fullArchiveState,
|
|
1224
|
-
status: inventory.warnings.length > 0
|
|
1282
|
+
status: inventory.warnings.length > 0 ? 'partial' : 'complete',
|
|
1225
1283
|
...counts,
|
|
1226
1284
|
preview: verified.body,
|
|
1227
|
-
...(fallbackReason ? { lastError: fallbackReason } : {}),
|
|
1228
1285
|
updatedAt: now(),
|
|
1229
1286
|
};
|
|
1230
1287
|
pageState.comments = state;
|
|
@@ -1250,6 +1307,16 @@ export class Migrator {
|
|
|
1250
1307
|
async transferRemote(input) {
|
|
1251
1308
|
const { source, destination, container, graph, options, store, report, sourceFingerprints, resourcesByPage, commentsByPage } = input;
|
|
1252
1309
|
const destinationPages = new Map();
|
|
1310
|
+
const mappedOwners = new Map();
|
|
1311
|
+
for (const page of Object.values(store.value.pages)) {
|
|
1312
|
+
const destinationId = page.destinationId;
|
|
1313
|
+
if (!destinationId)
|
|
1314
|
+
continue;
|
|
1315
|
+
mappedOwners.set(destinationId, [...(mappedOwners.get(destinationId) ?? []), page.sourceCanonicalId]);
|
|
1316
|
+
}
|
|
1317
|
+
const ownershipConflicts = new Map([...mappedOwners.entries()]
|
|
1318
|
+
.filter(([, owners]) => owners.length > 1)
|
|
1319
|
+
.flatMap(([destinationId, owners]) => owners.map((owner) => [owner, { destinationId, owners }])));
|
|
1253
1320
|
const stagingFailures = new Map();
|
|
1254
1321
|
const sourcePages = new Map(graph.pages.map((page) => [page.canonicalId, page]));
|
|
1255
1322
|
const bootstrapBodies = new Map();
|
|
@@ -1335,6 +1402,10 @@ export class Migrator {
|
|
|
1335
1402
|
continue;
|
|
1336
1403
|
}
|
|
1337
1404
|
try {
|
|
1405
|
+
const ownershipConflict = ownershipConflicts.get(page.canonicalId);
|
|
1406
|
+
if (ownershipConflict) {
|
|
1407
|
+
throw new DestinationOwnershipConflictError('duplicate-source-mapping', [ownershipConflict.destinationId, ...ownershipConflict.owners], `Multiple source pages are mapped to destination page ${ownershipConflict.destinationId}: ${ownershipConflict.owners.join(', ')}`);
|
|
1408
|
+
}
|
|
1338
1409
|
const cachedPage = await loadCachedPage(options.cacheDirectory, page.canonicalId);
|
|
1339
1410
|
const bootstrap = bootstrapBodies.get(page.canonicalId);
|
|
1340
1411
|
let created;
|
|
@@ -1533,8 +1604,9 @@ export class Migrator {
|
|
|
1533
1604
|
if (options.signal?.aborted)
|
|
1534
1605
|
throw options.signal.reason ?? error;
|
|
1535
1606
|
const message = `${page.title}: ${error instanceof Error ? error.message : String(error)}`;
|
|
1536
|
-
const
|
|
1537
|
-
const
|
|
1607
|
+
const ownershipConflict = error instanceof DestinationOwnershipConflictError ? error : undefined;
|
|
1608
|
+
const failureStatus = ownershipConflict ? 'conflict' : 'failed';
|
|
1609
|
+
const failureCode = ownershipConflict ? 'destination-ownership-ambiguity' : 'destination-page-staging-failed';
|
|
1538
1610
|
stagingFailures.set(page.canonicalId, {
|
|
1539
1611
|
status: failureStatus,
|
|
1540
1612
|
message,
|
|
@@ -1552,13 +1624,14 @@ export class Migrator {
|
|
|
1552
1624
|
});
|
|
1553
1625
|
const savedState = store.value.pages[page.canonicalId];
|
|
1554
1626
|
if (!options.dryRun) {
|
|
1627
|
+
const { conflictEvidence: _staleConflictEvidence, ...baseState } = savedState ?? {};
|
|
1555
1628
|
const cause = { blockedByPageId: page.canonicalId, blockedByCode: failureCode, blockedReason: message };
|
|
1556
1629
|
const resources = resourcesByPage.get(page.canonicalId) ?? [];
|
|
1557
1630
|
const comments = (options.comments ?? true)
|
|
1558
1631
|
? blockedCommentState(commentsByPage.get(page.canonicalId), savedState?.comments, cause)
|
|
1559
1632
|
: undefined;
|
|
1560
1633
|
await store.setPage({
|
|
1561
|
-
...
|
|
1634
|
+
...baseState,
|
|
1562
1635
|
sourceId: page.id,
|
|
1563
1636
|
sourceCanonicalId: page.canonicalId,
|
|
1564
1637
|
sourceLocator: page.locator,
|
|
@@ -1570,6 +1643,8 @@ export class Migrator {
|
|
|
1570
1643
|
resources: blockedResourceStates(resources, savedState?.resources ?? {}, cause),
|
|
1571
1644
|
...(comments ? { comments } : {}),
|
|
1572
1645
|
degradations: savedState?.degradations ?? [],
|
|
1646
|
+
adaptations: savedState?.adaptations ?? [],
|
|
1647
|
+
...(ownershipConflict ? { conflictEvidence: { reason: ownershipConflict.reason, candidateIds: ownershipConflict.candidateIds } } : {}),
|
|
1573
1648
|
lastError: message,
|
|
1574
1649
|
updatedAt: new Date().toISOString(),
|
|
1575
1650
|
});
|
|
@@ -1609,6 +1684,7 @@ export class Migrator {
|
|
|
1609
1684
|
commentsEnabled: options.comments ?? true,
|
|
1610
1685
|
...(commentsByPage.get(page.canonicalId) ? { commentInventory: commentsByPage.get(page.canonicalId) } : {}),
|
|
1611
1686
|
...(commentState ? { commentState } : {}),
|
|
1687
|
+
...(failedState?.conflictEvidence ? { conflictEvidence: failedState.conflictEvidence } : {}),
|
|
1612
1688
|
blockedBy,
|
|
1613
1689
|
error: currentFailure?.message ?? failedState?.lastError ?? 'Destination page with source-derived content was not created',
|
|
1614
1690
|
}));
|
|
@@ -1747,8 +1823,8 @@ export class Migrator {
|
|
|
1747
1823
|
updateProgress(`reconciled ${resource.name}`);
|
|
1748
1824
|
continue;
|
|
1749
1825
|
}
|
|
1750
|
-
const updateExisting = existing?.status === 'complete' && mappedRemote ? mappedRemote : undefined;
|
|
1751
|
-
|
|
1826
|
+
const updateExisting = destination.kind === 'confluence' && existing?.status === 'complete' && mappedRemote ? mappedRemote : undefined;
|
|
1827
|
+
let destinationName = updateExisting?.name ?? nameAllocator.allocate(resource, existing?.status === 'complete' && !nameAllocator.isOccupied(existing.destinationName) ? existing.destinationName : undefined);
|
|
1752
1828
|
state.resources[resource.id] = {
|
|
1753
1829
|
sourceId: resource.id,
|
|
1754
1830
|
sourceName: resource.name,
|
|
@@ -1761,9 +1837,19 @@ export class Migrator {
|
|
|
1761
1837
|
updatedAt: new Date().toISOString(),
|
|
1762
1838
|
};
|
|
1763
1839
|
await store.setPage({ ...state, updatedAt: new Date().toISOString() });
|
|
1764
|
-
|
|
1765
|
-
let
|
|
1766
|
-
|
|
1840
|
+
let result;
|
|
1841
|
+
let lastUploadError;
|
|
1842
|
+
for (let attempt = 0; attempt < 10 && !result; attempt += 1) {
|
|
1843
|
+
const matchingName = remoteResources.find((item) => item.name.toLocaleLowerCase() === destinationName.toLocaleLowerCase());
|
|
1844
|
+
if (matchingName && await remoteMatches(matchingName, downloaded)) {
|
|
1845
|
+
result = matchingName;
|
|
1846
|
+
break;
|
|
1847
|
+
}
|
|
1848
|
+
if (matchingName) {
|
|
1849
|
+
nameAllocator.reserve(matchingName.name);
|
|
1850
|
+
destinationName = nameAllocator.allocate(resource);
|
|
1851
|
+
continue;
|
|
1852
|
+
}
|
|
1767
1853
|
try {
|
|
1768
1854
|
result = await destination.uploadResource({
|
|
1769
1855
|
page: destinationPage,
|
|
@@ -1771,26 +1857,25 @@ export class Migrator {
|
|
|
1771
1857
|
filePath: downloaded.path,
|
|
1772
1858
|
size: downloaded.size,
|
|
1773
1859
|
...(mimeType ? { mimeType } : {}),
|
|
1774
|
-
...(updateExisting ? { existingId: updateExisting.id } : {}),
|
|
1860
|
+
...(attempt === 0 && updateExisting ? { existingId: updateExisting.id } : {}),
|
|
1775
1861
|
});
|
|
1776
1862
|
}
|
|
1777
1863
|
catch (uploadError) {
|
|
1864
|
+
lastUploadError = uploadError;
|
|
1778
1865
|
remoteResources = await destination.listUploadedResources(destinationPage);
|
|
1866
|
+
for (const remote of remoteResources)
|
|
1867
|
+
nameAllocator.reserve(remote.name);
|
|
1779
1868
|
const uncertain = remoteResources.find((item) => item.name.toLocaleLowerCase() === destinationName.toLocaleLowerCase());
|
|
1780
|
-
if (uncertain && await remoteMatches(uncertain, downloaded))
|
|
1869
|
+
if (uncertain && await remoteMatches(uncertain, downloaded)) {
|
|
1781
1870
|
result = uncertain;
|
|
1782
|
-
|
|
1783
|
-
const retryName = nameAllocator.allocate(resource);
|
|
1784
|
-
result = await destination.uploadResource({
|
|
1785
|
-
page: destinationPage,
|
|
1786
|
-
name: retryName,
|
|
1787
|
-
filePath: downloaded.path,
|
|
1788
|
-
size: downloaded.size,
|
|
1789
|
-
...(mimeType ? { mimeType } : {}),
|
|
1790
|
-
}).catch((retryError) => { throw new Error(`${retryError instanceof Error ? retryError.message : String(retryError)} (initial upload: ${uploadError instanceof Error ? uploadError.message : String(uploadError)})`); });
|
|
1871
|
+
break;
|
|
1791
1872
|
}
|
|
1873
|
+
destinationName = nameAllocator.allocate(resource);
|
|
1792
1874
|
}
|
|
1793
1875
|
}
|
|
1876
|
+
if (!result) {
|
|
1877
|
+
throw new Error(`Resource upload could not be reconciled after 10 deterministic names: ${lastUploadError instanceof Error ? lastUploadError.message : String(lastUploadError ?? 'unknown provider response')}`);
|
|
1878
|
+
}
|
|
1794
1879
|
if (!remoteResources.some((item) => item.id === result.id))
|
|
1795
1880
|
remoteResources.push(result);
|
|
1796
1881
|
setSourceResourceBinding(bindings, cachedPage, resources, resource, { kind: 'uploaded', resource: result });
|
|
@@ -1873,31 +1958,63 @@ export class Migrator {
|
|
|
1873
1958
|
sha256: sha256(cachedPage.rawContent),
|
|
1874
1959
|
};
|
|
1875
1960
|
try {
|
|
1876
|
-
const
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
? knownSnapshot
|
|
1961
|
+
const mappedSnapshot = remoteResources.find((item) => item.id === existingSnapshot?.destinationId);
|
|
1962
|
+
let snapshot = mappedSnapshot && await remoteMatches(mappedSnapshot, snapshotFile, true)
|
|
1963
|
+
? mappedSnapshot
|
|
1880
1964
|
: undefined;
|
|
1881
|
-
|
|
1965
|
+
const sameNameSnapshot = remoteResources.find((item) => item.name.toLocaleLowerCase() === snapshotName.toLocaleLowerCase());
|
|
1966
|
+
if (!snapshot && sameNameSnapshot && await remoteMatches(sameNameSnapshot, snapshotFile, false)) {
|
|
1967
|
+
snapshot = sameNameSnapshot;
|
|
1968
|
+
}
|
|
1969
|
+
const snapshotResource = {
|
|
1970
|
+
provider: cachedPage.provider,
|
|
1971
|
+
id: snapshotKey,
|
|
1972
|
+
pageId: cachedPage.id,
|
|
1973
|
+
name: snapshotName,
|
|
1974
|
+
preferredName: snapshotName,
|
|
1975
|
+
kind: 'attachment',
|
|
1976
|
+
};
|
|
1977
|
+
let destinationName = mappedSnapshot && destination.kind === 'confluence'
|
|
1978
|
+
? mappedSnapshot.name
|
|
1979
|
+
: nameAllocator.allocate(snapshotResource);
|
|
1980
|
+
let lastUploadError;
|
|
1981
|
+
for (let attempt = 0; attempt < 10 && !snapshot; attempt += 1) {
|
|
1982
|
+
const occupied = remoteResources.find((item) => item.name.toLocaleLowerCase() === destinationName.toLocaleLowerCase());
|
|
1983
|
+
if (occupied && await remoteMatches(occupied, snapshotFile, occupied.id === mappedSnapshot?.id)) {
|
|
1984
|
+
snapshot = occupied;
|
|
1985
|
+
break;
|
|
1986
|
+
}
|
|
1987
|
+
if (occupied && occupied.id !== mappedSnapshot?.id) {
|
|
1988
|
+
nameAllocator.reserve(occupied.name);
|
|
1989
|
+
destinationName = nameAllocator.allocate(snapshotResource);
|
|
1990
|
+
continue;
|
|
1991
|
+
}
|
|
1882
1992
|
try {
|
|
1883
1993
|
snapshot = await destination.uploadResource({
|
|
1884
1994
|
page: destinationPage,
|
|
1885
|
-
name:
|
|
1995
|
+
name: destinationName,
|
|
1886
1996
|
filePath: snapshotPath,
|
|
1887
1997
|
size: snapshotFile.size,
|
|
1888
1998
|
mimeType: page.rawFormat === 'yfm' ? 'text/markdown' : 'application/xml',
|
|
1889
|
-
...(
|
|
1999
|
+
...(attempt === 0 && mappedSnapshot && destination.kind === 'confluence' ? { existingId: mappedSnapshot.id } : {}),
|
|
1890
2000
|
});
|
|
1891
2001
|
}
|
|
1892
2002
|
catch (uploadError) {
|
|
2003
|
+
lastUploadError = uploadError;
|
|
1893
2004
|
remoteResources = await destination.listUploadedResources(destinationPage);
|
|
1894
|
-
const
|
|
1895
|
-
|
|
2005
|
+
for (const remote of remoteResources)
|
|
2006
|
+
nameAllocator.reserve(remote.name);
|
|
2007
|
+
const uncertain = remoteResources.find((item) => item.name.toLocaleLowerCase() === destinationName.toLocaleLowerCase());
|
|
2008
|
+
if (uncertain && await remoteMatches(uncertain, snapshotFile, uncertain.id === mappedSnapshot?.id)) {
|
|
1896
2009
|
snapshot = uncertain;
|
|
1897
|
-
|
|
1898
|
-
|
|
2010
|
+
break;
|
|
2011
|
+
}
|
|
2012
|
+
destinationName = nameAllocator.allocate(snapshotResource);
|
|
1899
2013
|
}
|
|
1900
2014
|
}
|
|
2015
|
+
if (!snapshot) {
|
|
2016
|
+
throw new Error(`Raw snapshot upload could not be reconciled after 10 deterministic names: ${lastUploadError instanceof Error ? lastUploadError.message : String(lastUploadError ?? 'unknown provider response')}`);
|
|
2017
|
+
}
|
|
1901
2018
|
if (!remoteResources.some((item) => item.id === snapshot.id))
|
|
1902
2019
|
remoteResources.push(snapshot);
|
|
1903
2020
|
state.resources[snapshotKey] = {
|
|
@@ -1937,6 +2054,7 @@ export class Migrator {
|
|
|
1937
2054
|
state.degradations = [...new Set([...rendered.warnings, ...resourceProblems])];
|
|
1938
2055
|
}
|
|
1939
2056
|
let verified = destinationPage;
|
|
2057
|
+
let verificationAdaptations = [];
|
|
1940
2058
|
let verificationDegradations = [];
|
|
1941
2059
|
if (!options.dryRun) {
|
|
1942
2060
|
const current = await verifyConfirmedDestinationPage(destination, destinationPage, this.consistencyOptions);
|
|
@@ -1963,6 +2081,7 @@ export class Migrator {
|
|
|
1963
2081
|
});
|
|
1964
2082
|
}
|
|
1965
2083
|
const verification = assertDestinationContent(rendered.body, verified.content, destination.kind, cachedPage);
|
|
2084
|
+
verificationAdaptations = verification.adaptations;
|
|
1966
2085
|
verificationDegradations = verification.status === 'partial' ? verification.differences : [];
|
|
1967
2086
|
}
|
|
1968
2087
|
const commentInventory = commentsByPage.get(page.canonicalId);
|
|
@@ -2009,7 +2128,10 @@ export class Migrator {
|
|
|
2009
2128
|
});
|
|
2010
2129
|
}
|
|
2011
2130
|
const commentPartial = !['complete', 'skipped', 'planned'].includes(commentState.status);
|
|
2012
|
-
const
|
|
2131
|
+
const renderedDegradations = rendered.notices
|
|
2132
|
+
.filter((notice) => notice.kind === 'degradation')
|
|
2133
|
+
.map((notice) => notice.message);
|
|
2134
|
+
const partial = renderedDegradations.length > 0 || resourceProblems.length > 0 || verificationDegradations.length > 0 || commentPartial;
|
|
2013
2135
|
const lastResourceProblem = resourceProblems.at(-1);
|
|
2014
2136
|
const clearedState = { ...state };
|
|
2015
2137
|
delete clearedState.lastError;
|
|
@@ -2023,7 +2145,8 @@ export class Migrator {
|
|
|
2023
2145
|
...(verified.version ? { destinationVersion: verified.version } : {}),
|
|
2024
2146
|
status: options.dryRun ? 'planned' : partial ? 'partial' : 'complete',
|
|
2025
2147
|
comments: commentState,
|
|
2026
|
-
degradations: [...new Set([...
|
|
2148
|
+
degradations: [...new Set([...renderedDegradations, ...resourceProblems, ...verificationDegradations])],
|
|
2149
|
+
adaptations: [...new Set([...rendered.adaptations, ...verificationAdaptations])],
|
|
2027
2150
|
...(lastResourceProblem ? { lastError: lastResourceProblem } : {}),
|
|
2028
2151
|
updatedAt: new Date().toISOString(),
|
|
2029
2152
|
};
|
|
@@ -2045,6 +2168,8 @@ export class Migrator {
|
|
|
2045
2168
|
resourceFallbacks: fallbackDetails,
|
|
2046
2169
|
warnings: [...rendered.warnings, ...verificationDegradations],
|
|
2047
2170
|
normalizations: rendered.normalizations,
|
|
2171
|
+
adaptations: [...rendered.adaptations, ...verificationAdaptations],
|
|
2172
|
+
preservedExternalReferences: rendered.preservedExternalReferences,
|
|
2048
2173
|
commentsEnabled: options.comments ?? true,
|
|
2049
2174
|
...(commentInventory ? { commentInventory } : {}),
|
|
2050
2175
|
commentState,
|
|
@@ -2052,7 +2177,8 @@ export class Migrator {
|
|
|
2052
2177
|
}
|
|
2053
2178
|
catch (error) {
|
|
2054
2179
|
const message = `${page.title}: ${error instanceof Error ? error.message : String(error)}`;
|
|
2055
|
-
|
|
2180
|
+
const ownershipConflict = error instanceof DestinationOwnershipConflictError ? error : undefined;
|
|
2181
|
+
let failureStatus = ownershipConflict ? 'conflict' : 'failed';
|
|
2056
2182
|
let actual;
|
|
2057
2183
|
if (failureStatus !== 'conflict' && !options.dryRun) {
|
|
2058
2184
|
try {
|
|
@@ -2065,7 +2191,7 @@ export class Migrator {
|
|
|
2065
2191
|
}
|
|
2066
2192
|
}
|
|
2067
2193
|
report.issue({
|
|
2068
|
-
code:
|
|
2194
|
+
code: ownershipConflict ? 'destination-ownership-ambiguity' : 'page-transfer-failed',
|
|
2069
2195
|
severity: 'error',
|
|
2070
2196
|
phase: 'page',
|
|
2071
2197
|
pageId: page.canonicalId,
|
|
@@ -2077,6 +2203,7 @@ export class Migrator {
|
|
|
2077
2203
|
...(actual?.fingerprint ? { destinationFingerprint: actual.fingerprint } : {}),
|
|
2078
2204
|
...(actual?.version ? { destinationVersion: actual.version } : {}),
|
|
2079
2205
|
status: failureStatus,
|
|
2206
|
+
...(ownershipConflict ? { conflictEvidence: { reason: ownershipConflict.reason, candidateIds: ownershipConflict.candidateIds } } : {}),
|
|
2080
2207
|
lastError: message,
|
|
2081
2208
|
updatedAt: new Date().toISOString(),
|
|
2082
2209
|
});
|
|
@@ -2099,6 +2226,7 @@ export class Migrator {
|
|
|
2099
2226
|
commentsEnabled: options.comments ?? true,
|
|
2100
2227
|
...(commentsByPage.get(page.canonicalId) ? { commentInventory: commentsByPage.get(page.canonicalId) } : {}),
|
|
2101
2228
|
...(state.comments ? { commentState: state.comments } : {}),
|
|
2229
|
+
...(ownershipConflict ? { conflictEvidence: { reason: ownershipConflict.reason, candidateIds: ownershipConflict.candidateIds } } : {}),
|
|
2102
2230
|
}), actual?.content ? { source: normalizedPreviewHtml(document), destination: renderedPreviewHtml(actual.content, destination.kind) } : undefined);
|
|
2103
2231
|
}
|
|
2104
2232
|
finally {
|